mirror of
https://github.com/esphome/esphome.git
synced 2025-09-12 00:02:21 +01:00
[image] Transparency changes; code refactor (#7908)
This commit is contained in:
@@ -6,7 +6,7 @@ import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import puremagic
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from esphome import core, external_files
|
||||
import esphome.codegen as cg
|
||||
@@ -29,21 +29,236 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "image"
|
||||
DEPENDENCIES = ["display"]
|
||||
MULTI_CONF = True
|
||||
MULTI_CONF_NO_DEFAULT = True
|
||||
|
||||
image_ns = cg.esphome_ns.namespace("image")
|
||||
|
||||
ImageType = image_ns.enum("ImageType")
|
||||
|
||||
CONF_OPAQUE = "opaque"
|
||||
CONF_CHROMA_KEY = "chroma_key"
|
||||
CONF_ALPHA_CHANNEL = "alpha_channel"
|
||||
CONF_INVERT_ALPHA = "invert_alpha"
|
||||
|
||||
TRANSPARENCY_TYPES = (
|
||||
CONF_OPAQUE,
|
||||
CONF_CHROMA_KEY,
|
||||
CONF_ALPHA_CHANNEL,
|
||||
)
|
||||
|
||||
|
||||
def get_image_type_enum(type):
|
||||
return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}")
|
||||
|
||||
|
||||
def get_transparency_enum(transparency):
|
||||
return getattr(TransparencyType, f"TRANSPARENCY_{transparency.upper()}")
|
||||
|
||||
|
||||
class ImageEncoder:
|
||||
"""
|
||||
Superclass of image type encoders
|
||||
"""
|
||||
|
||||
# Control which transparency options are available for a given type
|
||||
allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE}
|
||||
|
||||
# All imageencoder types are valid
|
||||
@staticmethod
|
||||
def validate(value):
|
||||
return value
|
||||
|
||||
def __init__(self, width, height, transparency, dither, invert_alpha):
|
||||
"""
|
||||
:param width: The image width in pixels
|
||||
:param height: The image height in pixels
|
||||
:param transparency: Transparency type
|
||||
:param dither: Dither method
|
||||
:param invert_alpha: True if the alpha channel should be inverted; for monochrome formats inverts the colours.
|
||||
"""
|
||||
self.transparency = transparency
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.data = [0 for _ in range(width * height)]
|
||||
self.dither = dither
|
||||
self.index = 0
|
||||
self.invert_alpha = invert_alpha
|
||||
|
||||
def convert(self, image):
|
||||
"""
|
||||
Convert the image format
|
||||
:param image: Input image
|
||||
:return: converted image
|
||||
"""
|
||||
return image
|
||||
|
||||
def encode(self, pixel):
|
||||
"""
|
||||
Encode a single pixel
|
||||
"""
|
||||
|
||||
def end_row(self):
|
||||
"""
|
||||
Marks the end of a pixel row
|
||||
:return:
|
||||
"""
|
||||
|
||||
|
||||
class ImageBinary(ImageEncoder):
|
||||
allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY}
|
||||
|
||||
def __init__(self, width, height, transparency, dither, invert_alpha):
|
||||
self.width8 = (width + 7) // 8
|
||||
super().__init__(self.width8, height, transparency, dither, invert_alpha)
|
||||
self.bitno = 0
|
||||
|
||||
def convert(self, image):
|
||||
return image.convert("1", dither=self.dither)
|
||||
|
||||
def encode(self, pixel):
|
||||
if self.invert_alpha:
|
||||
pixel = not pixel
|
||||
if pixel:
|
||||
self.data[self.index] |= 0x80 >> (self.bitno % 8)
|
||||
self.bitno += 1
|
||||
if self.bitno == 8:
|
||||
self.bitno = 0
|
||||
self.index += 1
|
||||
|
||||
def end_row(self):
|
||||
"""
|
||||
Pad rows to a byte boundary
|
||||
"""
|
||||
if self.bitno != 0:
|
||||
self.bitno = 0
|
||||
self.index += 1
|
||||
|
||||
|
||||
class ImageGrayscale(ImageEncoder):
|
||||
allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_INVERT_ALPHA, CONF_OPAQUE}
|
||||
|
||||
def convert(self, image):
|
||||
return image.convert("LA")
|
||||
|
||||
def encode(self, pixel):
|
||||
b, a = pixel
|
||||
if self.transparency == CONF_CHROMA_KEY:
|
||||
if b == 1:
|
||||
b = 0
|
||||
if a != 0xFF:
|
||||
b = 1
|
||||
if self.invert_alpha:
|
||||
b ^= 0xFF
|
||||
if self.transparency == CONF_ALPHA_CHANNEL:
|
||||
if a != 0xFF:
|
||||
b = a
|
||||
self.data[self.index] = b
|
||||
self.index += 1
|
||||
|
||||
|
||||
class ImageRGB565(ImageEncoder):
|
||||
def __init__(self, width, height, transparency, dither, invert_alpha):
|
||||
stride = 3 if transparency == CONF_ALPHA_CHANNEL else 2
|
||||
super().__init__(
|
||||
width * stride,
|
||||
height,
|
||||
transparency,
|
||||
dither,
|
||||
invert_alpha,
|
||||
)
|
||||
|
||||
def convert(self, image):
|
||||
return image.convert("RGBA")
|
||||
|
||||
def encode(self, pixel):
|
||||
r, g, b, a = pixel
|
||||
r = r >> 3
|
||||
g = g >> 2
|
||||
b = b >> 3
|
||||
if self.transparency == CONF_CHROMA_KEY:
|
||||
if r == 0 and g == 1 and b == 0:
|
||||
g = 0
|
||||
elif a < 128:
|
||||
r = 0
|
||||
g = 1
|
||||
b = 0
|
||||
rgb = (r << 11) | (g << 5) | b
|
||||
self.data[self.index] = rgb >> 8
|
||||
self.index += 1
|
||||
self.data[self.index] = rgb & 0xFF
|
||||
self.index += 1
|
||||
if self.transparency == CONF_ALPHA_CHANNEL:
|
||||
if self.invert_alpha:
|
||||
a ^= 0xFF
|
||||
self.data[self.index] = a
|
||||
self.index += 1
|
||||
|
||||
|
||||
class ImageRGB(ImageEncoder):
|
||||
def __init__(self, width, height, transparency, dither, invert_alpha):
|
||||
stride = 4 if transparency == CONF_ALPHA_CHANNEL else 3
|
||||
super().__init__(
|
||||
width * stride,
|
||||
height,
|
||||
transparency,
|
||||
dither,
|
||||
invert_alpha,
|
||||
)
|
||||
|
||||
def convert(self, image):
|
||||
return image.convert("RGBA")
|
||||
|
||||
def encode(self, pixel):
|
||||
r, g, b, a = pixel
|
||||
if self.transparency == CONF_CHROMA_KEY:
|
||||
if r == 0 and g == 1 and b == 0:
|
||||
g = 0
|
||||
elif a < 128:
|
||||
r = 0
|
||||
g = 1
|
||||
b = 0
|
||||
self.data[self.index] = r
|
||||
self.index += 1
|
||||
self.data[self.index] = g
|
||||
self.index += 1
|
||||
self.data[self.index] = b
|
||||
self.index += 1
|
||||
if self.transparency == CONF_ALPHA_CHANNEL:
|
||||
if self.invert_alpha:
|
||||
a ^= 0xFF
|
||||
self.data[self.index] = a
|
||||
self.index += 1
|
||||
|
||||
|
||||
class ReplaceWith:
|
||||
"""
|
||||
Placeholder class to provide feedback on deprecated features
|
||||
"""
|
||||
|
||||
allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE}
|
||||
|
||||
def __init__(self, replace_with):
|
||||
self.replace_with = replace_with
|
||||
|
||||
def validate(self, value):
|
||||
raise cv.Invalid(
|
||||
f"Image type {value} is removed; replace with {self.replace_with}"
|
||||
)
|
||||
|
||||
|
||||
IMAGE_TYPE = {
|
||||
"BINARY": ImageType.IMAGE_TYPE_BINARY,
|
||||
"TRANSPARENT_BINARY": ImageType.IMAGE_TYPE_BINARY,
|
||||
"GRAYSCALE": ImageType.IMAGE_TYPE_GRAYSCALE,
|
||||
"RGB565": ImageType.IMAGE_TYPE_RGB565,
|
||||
"RGB24": ImageType.IMAGE_TYPE_RGB24,
|
||||
"RGBA": ImageType.IMAGE_TYPE_RGBA,
|
||||
"BINARY": ImageBinary,
|
||||
"GRAYSCALE": ImageGrayscale,
|
||||
"RGB565": ImageRGB565,
|
||||
"RGB": ImageRGB,
|
||||
"TRANSPARENT_BINARY": ReplaceWith(
|
||||
"'type: BINARY' and 'use_transparency: chroma_key'"
|
||||
),
|
||||
"RGB24": ReplaceWith("'type: RGB'"),
|
||||
"RGBA": ReplaceWith("'type: RGB' and 'use_transparency: alpha_channel'"),
|
||||
}
|
||||
|
||||
TransparencyType = image_ns.enum("TransparencyType")
|
||||
|
||||
CONF_USE_TRANSPARENCY = "use_transparency"
|
||||
|
||||
# If the MDI file cannot be downloaded within this time, abort.
|
||||
@@ -53,17 +268,11 @@ SOURCE_LOCAL = "local"
|
||||
SOURCE_MDI = "mdi"
|
||||
SOURCE_WEB = "web"
|
||||
|
||||
|
||||
Image_ = image_ns.class_("Image")
|
||||
|
||||
|
||||
def _compute_local_icon_path(value: dict) -> Path:
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi"
|
||||
return base_dir / f"{value[CONF_ICON]}.svg"
|
||||
|
||||
|
||||
def compute_local_image_path(value: dict) -> Path:
|
||||
url = value[CONF_URL]
|
||||
def compute_local_image_path(value) -> Path:
|
||||
url = value[CONF_URL] if isinstance(value, dict) else value
|
||||
h = hashlib.new("sha256")
|
||||
h.update(url.encode())
|
||||
key = h.hexdigest()[:8]
|
||||
@@ -71,30 +280,38 @@ def compute_local_image_path(value: dict) -> Path:
|
||||
return base_dir / key
|
||||
|
||||
|
||||
def download_mdi(value):
|
||||
validate_cairosvg_installed(value)
|
||||
def local_path(value):
|
||||
value = value[CONF_PATH] if isinstance(value, dict) else value
|
||||
return str(CORE.relative_config_path(value))
|
||||
|
||||
mdi_id = value[CONF_ICON]
|
||||
path = _compute_local_icon_path(value)
|
||||
|
||||
def download_file(url, path):
|
||||
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
|
||||
return str(path)
|
||||
|
||||
|
||||
def download_mdi(value):
|
||||
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
|
||||
base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi"
|
||||
path = base_dir / f"{mdi_id}.svg"
|
||||
|
||||
url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg"
|
||||
|
||||
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
|
||||
|
||||
return value
|
||||
return download_file(url, path)
|
||||
|
||||
|
||||
def download_image(value):
|
||||
url = value[CONF_URL]
|
||||
path = compute_local_image_path(value)
|
||||
|
||||
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
|
||||
|
||||
return value
|
||||
value = value[CONF_URL] if isinstance(value, dict) else value
|
||||
return download_file(value, compute_local_image_path(value))
|
||||
|
||||
|
||||
def validate_cairosvg_installed(value):
|
||||
"""Validate that cairosvg is installed"""
|
||||
def is_svg_file(file):
|
||||
if not file:
|
||||
return False
|
||||
with open(file, "rb") as f:
|
||||
return "<svg " in str(f.read(1024))
|
||||
|
||||
|
||||
def validate_cairosvg_installed():
|
||||
try:
|
||||
import cairosvg
|
||||
except ImportError as err:
|
||||
@@ -110,73 +327,28 @@ def validate_cairosvg_installed(value):
|
||||
"(pip install -U cairosvg)"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def validate_cross_dependencies(config):
|
||||
"""
|
||||
Validate fields whose possible values depend on other fields.
|
||||
For example, validate that explicitly transparent image types
|
||||
have "use_transparency" set to True.
|
||||
Also set the default value for those kind of dependent fields.
|
||||
"""
|
||||
is_mdi = CONF_FILE in config and config[CONF_FILE][CONF_SOURCE] == SOURCE_MDI
|
||||
if CONF_TYPE not in config:
|
||||
if is_mdi:
|
||||
config[CONF_TYPE] = "TRANSPARENT_BINARY"
|
||||
else:
|
||||
config[CONF_TYPE] = "BINARY"
|
||||
|
||||
image_type = config[CONF_TYPE]
|
||||
is_transparent_type = image_type in ["TRANSPARENT_BINARY", "RGBA"]
|
||||
|
||||
# If the use_transparency option was not specified, set the default depending on the image type
|
||||
if CONF_USE_TRANSPARENCY not in config:
|
||||
config[CONF_USE_TRANSPARENCY] = is_transparent_type
|
||||
|
||||
if is_transparent_type and not config[CONF_USE_TRANSPARENCY]:
|
||||
raise cv.Invalid(f"Image type {image_type} must always be transparent.")
|
||||
|
||||
if is_mdi and config[CONF_TYPE] not in ["BINARY", "TRANSPARENT_BINARY"]:
|
||||
raise cv.Invalid("MDI images must be binary images.")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def validate_file_shorthand(value):
|
||||
value = cv.string_strict(value)
|
||||
if value.startswith("mdi:"):
|
||||
validate_cairosvg_installed(value)
|
||||
|
||||
match = re.search(r"mdi:([a-zA-Z0-9\-]+)", value)
|
||||
if match is None:
|
||||
raise cv.Invalid("Could not parse mdi icon name.")
|
||||
icon = match.group(1)
|
||||
return FILE_SCHEMA(
|
||||
{
|
||||
CONF_SOURCE: SOURCE_MDI,
|
||||
CONF_ICON: icon,
|
||||
}
|
||||
)
|
||||
return download_mdi(icon)
|
||||
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
return FILE_SCHEMA(
|
||||
{
|
||||
CONF_SOURCE: SOURCE_WEB,
|
||||
CONF_URL: value,
|
||||
}
|
||||
)
|
||||
return FILE_SCHEMA(
|
||||
{
|
||||
CONF_SOURCE: SOURCE_LOCAL,
|
||||
CONF_PATH: value,
|
||||
}
|
||||
)
|
||||
return download_image(value)
|
||||
|
||||
value = cv.file_(value)
|
||||
return local_path(value)
|
||||
|
||||
|
||||
LOCAL_SCHEMA = cv.Schema(
|
||||
LOCAL_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.file_,
|
||||
}
|
||||
},
|
||||
local_path,
|
||||
)
|
||||
|
||||
MDI_SCHEMA = cv.All(
|
||||
@@ -203,205 +375,202 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
def _file_schema(value):
|
||||
if isinstance(value, str):
|
||||
return validate_file_shorthand(value)
|
||||
return TYPED_FILE_SCHEMA(value)
|
||||
def validate_transparency(choices=TRANSPARENCY_TYPES):
|
||||
def validate(value):
|
||||
if isinstance(value, bool):
|
||||
value = str(value)
|
||||
return cv.one_of(*choices, lower=True)(value)
|
||||
|
||||
return validate
|
||||
|
||||
|
||||
FILE_SCHEMA = cv.Schema(_file_schema)
|
||||
def validate_type(image_types):
|
||||
def validate(value):
|
||||
value = cv.one_of(*image_types, upper=True)(value)
|
||||
return IMAGE_TYPE[value].validate(value)
|
||||
|
||||
IMAGE_SCHEMA = cv.Schema(
|
||||
cv.All(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(Image_),
|
||||
cv.Required(CONF_FILE): FILE_SCHEMA,
|
||||
cv.Optional(CONF_RESIZE): cv.dimensions,
|
||||
# Not setting default here on purpose; the default depends on the source type
|
||||
# (file or mdi), and will be set in the "validate_cross_dependencies" validator.
|
||||
cv.Optional(CONF_TYPE): cv.enum(IMAGE_TYPE, upper=True),
|
||||
# Not setting default here on purpose; the default depends on the image type,
|
||||
# and thus will be set in the "validate_cross_dependencies" validator.
|
||||
cv.Optional(CONF_USE_TRANSPARENCY): cv.boolean,
|
||||
cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
|
||||
"NONE", "FLOYDSTEINBERG", upper=True
|
||||
),
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
},
|
||||
validate_cross_dependencies,
|
||||
)
|
||||
return validate
|
||||
|
||||
|
||||
def validate_settings(value):
|
||||
type = value[CONF_TYPE]
|
||||
transparency = value[CONF_USE_TRANSPARENCY].lower()
|
||||
allow_config = IMAGE_TYPE[type].allow_config
|
||||
if transparency not in allow_config:
|
||||
raise cv.Invalid(
|
||||
f"Image format '{type}' cannot have transparency: {transparency}"
|
||||
)
|
||||
invert_alpha = value.get(CONF_INVERT_ALPHA, False)
|
||||
if (
|
||||
invert_alpha
|
||||
and transparency != CONF_ALPHA_CHANNEL
|
||||
and CONF_INVERT_ALPHA not in allow_config
|
||||
):
|
||||
raise cv.Invalid("No alpha channel to invert")
|
||||
if file := value.get(CONF_FILE):
|
||||
file = Path(file)
|
||||
if is_svg_file(file):
|
||||
validate_cairosvg_installed()
|
||||
else:
|
||||
try:
|
||||
Image.open(file)
|
||||
except UnidentifiedImageError as exc:
|
||||
raise cv.Invalid(f"File can't be opened as image: {file}") from exc
|
||||
return value
|
||||
|
||||
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(Image_),
|
||||
cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA),
|
||||
cv.Optional(CONF_RESIZE): cv.dimensions,
|
||||
cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
|
||||
"NONE", "FLOYDSTEINBERG", upper=True
|
||||
),
|
||||
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
}
|
||||
).add_extra(validate_settings)
|
||||
|
||||
IMAGE_SCHEMA = BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
|
||||
cv.Optional(
|
||||
CONF_USE_TRANSPARENCY, default=CONF_OPAQUE
|
||||
): validate_transparency(),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = IMAGE_SCHEMA
|
||||
|
||||
def typed_image_schema(image_type):
|
||||
"""
|
||||
Construct a schema for a specific image type, allowing transparency options
|
||||
"""
|
||||
return cv.Any(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(t.lower()): cv.ensure_list(
|
||||
BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_USE_TRANSPARENCY, default=t
|
||||
): validate_transparency((t,)),
|
||||
cv.Optional(CONF_TYPE, default=image_type): validate_type(
|
||||
(image_type,)
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
for t in IMAGE_TYPE[image_type].allow_config.intersection(
|
||||
TRANSPARENCY_TYPES
|
||||
)
|
||||
}
|
||||
),
|
||||
# Allow a default configuration with no transparency preselected
|
||||
cv.ensure_list(
|
||||
BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_USE_TRANSPARENCY, default=CONF_OPAQUE
|
||||
): validate_transparency(),
|
||||
cv.Optional(CONF_TYPE, default=image_type): validate_type(
|
||||
(image_type,)
|
||||
),
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_svg_image(file: bytes, resize: tuple[int, int]):
|
||||
# Local imports only to allow "validate_pillow_installed" to run *before* importing it
|
||||
# cairosvg is only needed in case of SVG images; adding it
|
||||
# to the top would force configurations not using SVG to also have it
|
||||
# installed for no reason.
|
||||
from cairosvg import svg2png
|
||||
from PIL import Image
|
||||
|
||||
if resize:
|
||||
req_width, req_height = resize
|
||||
svg_image = svg2png(
|
||||
file,
|
||||
output_width=req_width,
|
||||
output_height=req_height,
|
||||
)
|
||||
else:
|
||||
svg_image = svg2png(file)
|
||||
|
||||
return Image.open(io.BytesIO(svg_image))
|
||||
# The config schema can be a (possibly empty) single list of images,
|
||||
# or a dictionary of image types each with a list of images
|
||||
CONFIG_SCHEMA = cv.Any(
|
||||
cv.Schema({cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE}),
|
||||
cv.ensure_list(IMAGE_SCHEMA),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
# Local import only to allow "validate_pillow_installed" to run *before* importing it
|
||||
from PIL import Image
|
||||
|
||||
conf_file = config[CONF_FILE]
|
||||
|
||||
if conf_file[CONF_SOURCE] == SOURCE_LOCAL:
|
||||
path = CORE.relative_config_path(conf_file[CONF_PATH])
|
||||
|
||||
elif conf_file[CONF_SOURCE] == SOURCE_MDI:
|
||||
path = _compute_local_icon_path(conf_file).as_posix()
|
||||
|
||||
elif conf_file[CONF_SOURCE] == SOURCE_WEB:
|
||||
path = compute_local_image_path(conf_file).as_posix()
|
||||
|
||||
else:
|
||||
raise core.EsphomeError(f"Unknown image source: {conf_file[CONF_SOURCE]}")
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
file_contents = f.read()
|
||||
except Exception as e:
|
||||
raise core.EsphomeError(f"Could not load image file {path}: {e}")
|
||||
|
||||
file_type = puremagic.from_string(file_contents, mime=True)
|
||||
async def write_image(config, all_frames=False):
|
||||
path = Path(config[CONF_FILE])
|
||||
if not path.is_file():
|
||||
raise core.EsphomeError(f"Could not load image file {path}")
|
||||
|
||||
resize = config.get(CONF_RESIZE)
|
||||
if "svg" in file_type:
|
||||
image = load_svg_image(file_contents, resize)
|
||||
if is_svg_file(path):
|
||||
# Local import so use of non-SVG files needn't require cairosvg installed
|
||||
from cairosvg import svg2png
|
||||
|
||||
if not resize:
|
||||
resize = (None, None)
|
||||
with open(path, "rb") as file:
|
||||
image = svg2png(
|
||||
file_obj=file,
|
||||
output_width=resize[0],
|
||||
output_height=resize[1],
|
||||
)
|
||||
image = Image.open(io.BytesIO(image))
|
||||
width, height = image.size
|
||||
else:
|
||||
image = Image.open(io.BytesIO(file_contents))
|
||||
image = Image.open(path)
|
||||
width, height = image.size
|
||||
if resize:
|
||||
image.thumbnail(resize)
|
||||
# Preserve aspect ratio
|
||||
new_width_max = min(width, resize[0])
|
||||
new_height_max = min(height, resize[1])
|
||||
ratio = min(new_width_max / width, new_height_max / height)
|
||||
width, height = int(width * ratio), int(height * ratio)
|
||||
|
||||
width, height = image.size
|
||||
|
||||
if CONF_RESIZE not in config and (width > 500 or height > 500):
|
||||
if not resize and (width > 500 or height > 500):
|
||||
_LOGGER.warning(
|
||||
'The image "%s" you requested is very big. Please consider'
|
||||
" using the resize parameter.",
|
||||
path,
|
||||
)
|
||||
|
||||
transparent = config[CONF_USE_TRANSPARENCY]
|
||||
|
||||
dither = (
|
||||
Image.Dither.NONE
|
||||
if config[CONF_DITHER] == "NONE"
|
||||
else Image.Dither.FLOYDSTEINBERG
|
||||
)
|
||||
if config[CONF_TYPE] == "GRAYSCALE":
|
||||
image = image.convert("LA", dither=dither)
|
||||
pixels = list(image.getdata())
|
||||
data = [0 for _ in range(height * width)]
|
||||
pos = 0
|
||||
for g, a in pixels:
|
||||
if transparent:
|
||||
if g == 1:
|
||||
g = 0
|
||||
if a < 0x80:
|
||||
g = 1
|
||||
type = config[CONF_TYPE]
|
||||
transparency = config[CONF_USE_TRANSPARENCY]
|
||||
invert_alpha = config[CONF_INVERT_ALPHA]
|
||||
frame_count = 1
|
||||
if all_frames:
|
||||
try:
|
||||
frame_count = image.n_frames
|
||||
except AttributeError:
|
||||
pass
|
||||
if frame_count <= 1:
|
||||
_LOGGER.warning("Image file %s has no animation frames", path)
|
||||
|
||||
data[pos] = g
|
||||
pos += 1
|
||||
total_rows = height * frame_count
|
||||
encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha)
|
||||
for frame_index in range(frame_count):
|
||||
image.seek(frame_index)
|
||||
pixels = encoder.convert(image.resize((width, height))).getdata()
|
||||
for row in range(height):
|
||||
for col in range(width):
|
||||
encoder.encode(pixels[row * width + col])
|
||||
encoder.end_row()
|
||||
|
||||
elif config[CONF_TYPE] == "RGBA":
|
||||
image = image.convert("RGBA")
|
||||
pixels = list(image.getdata())
|
||||
data = [0 for _ in range(height * width * 4)]
|
||||
pos = 0
|
||||
for r, g, b, a in pixels:
|
||||
data[pos] = r
|
||||
pos += 1
|
||||
data[pos] = g
|
||||
pos += 1
|
||||
data[pos] = b
|
||||
pos += 1
|
||||
data[pos] = a
|
||||
pos += 1
|
||||
|
||||
elif config[CONF_TYPE] == "RGB24":
|
||||
image = image.convert("RGBA")
|
||||
pixels = list(image.getdata())
|
||||
data = [0 for _ in range(height * width * 3)]
|
||||
pos = 0
|
||||
for r, g, b, a in pixels:
|
||||
if transparent:
|
||||
if r == 0 and g == 0 and b == 1:
|
||||
b = 0
|
||||
if a < 0x80:
|
||||
r = 0
|
||||
g = 0
|
||||
b = 1
|
||||
|
||||
data[pos] = r
|
||||
pos += 1
|
||||
data[pos] = g
|
||||
pos += 1
|
||||
data[pos] = b
|
||||
pos += 1
|
||||
|
||||
elif config[CONF_TYPE] in ["RGB565"]:
|
||||
image = image.convert("RGBA")
|
||||
pixels = list(image.getdata())
|
||||
bytes_per_pixel = 3 if transparent else 2
|
||||
data = [0 for _ in range(height * width * bytes_per_pixel)]
|
||||
pos = 0
|
||||
for r, g, b, a in pixels:
|
||||
R = r >> 3
|
||||
G = g >> 2
|
||||
B = b >> 3
|
||||
rgb = (R << 11) | (G << 5) | B
|
||||
data[pos] = rgb >> 8
|
||||
pos += 1
|
||||
data[pos] = rgb & 0xFF
|
||||
pos += 1
|
||||
if transparent:
|
||||
data[pos] = a
|
||||
pos += 1
|
||||
|
||||
elif config[CONF_TYPE] in ["BINARY", "TRANSPARENT_BINARY"]:
|
||||
if transparent:
|
||||
alpha = image.split()[-1]
|
||||
has_alpha = alpha.getextrema()[0] < 0xFF
|
||||
_LOGGER.debug("%s Has alpha: %s", config[CONF_ID], has_alpha)
|
||||
image = image.convert("1", dither=dither)
|
||||
width8 = ((width + 7) // 8) * 8
|
||||
data = [0 for _ in range(height * width8 // 8)]
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if transparent and has_alpha:
|
||||
a = alpha.getpixel((x, y))
|
||||
if not a:
|
||||
continue
|
||||
elif image.getpixel((x, y)):
|
||||
continue
|
||||
pos = x + y * width8
|
||||
data[pos // 8] |= 0x80 >> (pos % 8)
|
||||
else:
|
||||
raise core.EsphomeError(
|
||||
f"Image f{config[CONF_ID]} has an unsupported type: {config[CONF_TYPE]}."
|
||||
)
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
rhs = [HexInt(x) for x in encoder.data]
|
||||
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID], prog_arr, width, height, IMAGE_TYPE[config[CONF_TYPE]]
|
||||
)
|
||||
cg.add(var.set_transparency(transparent))
|
||||
image_type = get_image_type_enum(type)
|
||||
trans_value = get_transparency_enum(transparency)
|
||||
|
||||
return prog_arr, width, height, image_type, trans_value, frame_count
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
if isinstance(config, list):
|
||||
for entry in config:
|
||||
await to_code(entry)
|
||||
elif CONF_ID not in config:
|
||||
for entry in config.values():
|
||||
await to_code(entry)
|
||||
else:
|
||||
prog_arr, width, height, image_type, trans_value, _ = await write_image(config)
|
||||
cg.new_Pvariable(
|
||||
config[CONF_ID], prog_arr, width, height, image_type, trans_value
|
||||
)
|
||||
|
@@ -12,7 +12,7 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color
|
||||
for (int img_y = 0; img_y < height_; img_y++) {
|
||||
if (this->get_binary_pixel_(img_x, img_y)) {
|
||||
display->draw_pixel_at(x + img_x, y + img_y, color_on);
|
||||
} else if (!this->transparent_) {
|
||||
} else if (!this->transparency_) {
|
||||
display->draw_pixel_at(x + img_x, y + img_y, color_off);
|
||||
}
|
||||
}
|
||||
@@ -39,20 +39,10 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color
|
||||
}
|
||||
}
|
||||
break;
|
||||
case IMAGE_TYPE_RGB24:
|
||||
case IMAGE_TYPE_RGB:
|
||||
for (int img_x = 0; img_x < width_; img_x++) {
|
||||
for (int img_y = 0; img_y < height_; img_y++) {
|
||||
auto color = this->get_rgb24_pixel_(img_x, img_y);
|
||||
if (color.w >= 0x80) {
|
||||
display->draw_pixel_at(x + img_x, y + img_y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case IMAGE_TYPE_RGBA:
|
||||
for (int img_x = 0; img_x < width_; img_x++) {
|
||||
for (int img_y = 0; img_y < height_; img_y++) {
|
||||
auto color = this->get_rgba_pixel_(img_x, img_y);
|
||||
auto color = this->get_rgb_pixel_(img_x, img_y);
|
||||
if (color.w >= 0x80) {
|
||||
display->draw_pixel_at(x + img_x, y + img_y, color);
|
||||
}
|
||||
@@ -61,20 +51,20 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color
|
||||
break;
|
||||
}
|
||||
}
|
||||
Color Image::get_pixel(int x, int y, Color color_on, Color color_off) const {
|
||||
Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off) const {
|
||||
if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_)
|
||||
return color_off;
|
||||
switch (this->type_) {
|
||||
case IMAGE_TYPE_BINARY:
|
||||
return this->get_binary_pixel_(x, y) ? color_on : color_off;
|
||||
if (this->get_binary_pixel_(x, y))
|
||||
return color_on;
|
||||
return color_off;
|
||||
case IMAGE_TYPE_GRAYSCALE:
|
||||
return this->get_grayscale_pixel_(x, y);
|
||||
case IMAGE_TYPE_RGB565:
|
||||
return this->get_rgb565_pixel_(x, y);
|
||||
case IMAGE_TYPE_RGB24:
|
||||
return this->get_rgb24_pixel_(x, y);
|
||||
case IMAGE_TYPE_RGBA:
|
||||
return this->get_rgba_pixel_(x, y);
|
||||
case IMAGE_TYPE_RGB:
|
||||
return this->get_rgb_pixel_(x, y);
|
||||
default:
|
||||
return color_off;
|
||||
}
|
||||
@@ -98,23 +88,40 @@ lv_img_dsc_t *Image::get_lv_img_dsc() {
|
||||
this->dsc_.header.cf = LV_IMG_CF_ALPHA_8BIT;
|
||||
break;
|
||||
|
||||
case IMAGE_TYPE_RGB24:
|
||||
this->dsc_.header.cf = LV_IMG_CF_RGB888;
|
||||
case IMAGE_TYPE_RGB:
|
||||
#if LV_COLOR_DEPTH == 32
|
||||
switch (this->transparent_) {
|
||||
case TRANSPARENCY_ALPHA_CHANNEL:
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA;
|
||||
break;
|
||||
case TRANSPARENCY_CHROMA_KEY:
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_CHROMA_KEYED;
|
||||
break;
|
||||
default:
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR;
|
||||
break;
|
||||
}
|
||||
#else
|
||||
this->dsc_.header.cf =
|
||||
this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGBA8888 : LV_IMG_CF_RGB888;
|
||||
#endif
|
||||
break;
|
||||
|
||||
case IMAGE_TYPE_RGB565:
|
||||
#if LV_COLOR_DEPTH == 16
|
||||
this->dsc_.header.cf = this->has_transparency() ? LV_IMG_CF_TRUE_COLOR_ALPHA : LV_IMG_CF_TRUE_COLOR;
|
||||
switch (this->transparency_) {
|
||||
case TRANSPARENCY_ALPHA_CHANNEL:
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA;
|
||||
break;
|
||||
case TRANSPARENCY_CHROMA_KEY:
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_CHROMA_KEYED;
|
||||
break;
|
||||
default:
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR;
|
||||
break;
|
||||
}
|
||||
#else
|
||||
this->dsc_.header.cf = LV_IMG_CF_RGB565;
|
||||
#endif
|
||||
break;
|
||||
|
||||
case IMAGE_TYPE_RGBA:
|
||||
#if LV_COLOR_DEPTH == 32
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR;
|
||||
#else
|
||||
this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA;
|
||||
this->dsc_.header.cf = this->transparent_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGB565A8 : LV_IMG_CF_RGB565;
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
@@ -128,51 +135,73 @@ bool Image::get_binary_pixel_(int x, int y) const {
|
||||
const uint32_t pos = x + y * width_8;
|
||||
return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u));
|
||||
}
|
||||
Color Image::get_rgba_pixel_(int x, int y) const {
|
||||
const uint32_t pos = (x + y * this->width_) * 4;
|
||||
return Color(progmem_read_byte(this->data_start_ + pos + 0), progmem_read_byte(this->data_start_ + pos + 1),
|
||||
progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 3));
|
||||
}
|
||||
Color Image::get_rgb24_pixel_(int x, int y) const {
|
||||
const uint32_t pos = (x + y * this->width_) * 3;
|
||||
Color Image::get_rgb_pixel_(int x, int y) const {
|
||||
const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8;
|
||||
Color color = Color(progmem_read_byte(this->data_start_ + pos + 0), progmem_read_byte(this->data_start_ + pos + 1),
|
||||
progmem_read_byte(this->data_start_ + pos + 2));
|
||||
if (color.b == 1 && color.r == 0 && color.g == 0 && transparent_) {
|
||||
// (0, 0, 1) has been defined as transparent color for non-alpha images.
|
||||
// putting blue == 1 as a first condition for performance reasons (least likely value to short-cut the if)
|
||||
color.w = 0;
|
||||
} else {
|
||||
color.w = 0xFF;
|
||||
progmem_read_byte(this->data_start_ + pos + 2), 0xFF);
|
||||
|
||||
switch (this->transparency_) {
|
||||
case TRANSPARENCY_CHROMA_KEY:
|
||||
if (color.g == 1 && color.r == 0 && color.b == 0) {
|
||||
// (0, 1, 0) has been defined as transparent color for non-alpha images.
|
||||
color.w = 0;
|
||||
}
|
||||
break;
|
||||
case TRANSPARENCY_ALPHA_CHANNEL:
|
||||
color.w = progmem_read_byte(this->data_start_ + (pos + 3));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
Color Image::get_rgb565_pixel_(int x, int y) const {
|
||||
const uint8_t *pos = this->data_start_;
|
||||
if (this->transparent_) {
|
||||
pos += (x + y * this->width_) * 3;
|
||||
} else {
|
||||
pos += (x + y * this->width_) * 2;
|
||||
}
|
||||
const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8;
|
||||
uint16_t rgb565 = encode_uint16(progmem_read_byte(pos), progmem_read_byte(pos + 1));
|
||||
auto r = (rgb565 & 0xF800) >> 11;
|
||||
auto g = (rgb565 & 0x07E0) >> 5;
|
||||
auto b = rgb565 & 0x001F;
|
||||
auto a = this->transparent_ ? progmem_read_byte(pos + 2) : 0xFF;
|
||||
Color color = Color((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2), a);
|
||||
return color;
|
||||
auto a = 0xFF;
|
||||
switch (this->transparency_) {
|
||||
case TRANSPARENCY_ALPHA_CHANNEL:
|
||||
a = progmem_read_byte(pos + 2);
|
||||
break;
|
||||
case TRANSPARENCY_CHROMA_KEY:
|
||||
if (rgb565 == 0x0020)
|
||||
a = 0;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return Color((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2), a);
|
||||
}
|
||||
|
||||
Color Image::get_grayscale_pixel_(int x, int y) const {
|
||||
const uint32_t pos = (x + y * this->width_);
|
||||
const uint8_t gray = progmem_read_byte(this->data_start_ + pos);
|
||||
uint8_t alpha = (gray == 1 && transparent_) ? 0 : 0xFF;
|
||||
uint8_t alpha = (gray == 1 && this->transparency_ == TRANSPARENCY_CHROMA_KEY) ? 0 : 0xFF;
|
||||
return Color(gray, gray, gray, alpha);
|
||||
}
|
||||
int Image::get_width() const { return this->width_; }
|
||||
int Image::get_height() const { return this->height_; }
|
||||
ImageType Image::get_type() const { return this->type_; }
|
||||
Image::Image(const uint8_t *data_start, int width, int height, ImageType type)
|
||||
: width_(width), height_(height), type_(type), data_start_(data_start) {}
|
||||
Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency)
|
||||
: width_(width), height_(height), type_(type), data_start_(data_start), transparency_(transparency) {
|
||||
switch (this->type_) {
|
||||
case IMAGE_TYPE_BINARY:
|
||||
this->bpp_ = 1;
|
||||
break;
|
||||
case IMAGE_TYPE_GRAYSCALE:
|
||||
this->bpp_ = 8;
|
||||
break;
|
||||
case IMAGE_TYPE_RGB565:
|
||||
this->bpp_ = transparency == TRANSPARENCY_ALPHA_CHANNEL ? 24 : 16;
|
||||
break;
|
||||
case IMAGE_TYPE_RGB:
|
||||
this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 32 : 24;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace image
|
||||
} // namespace esphome
|
||||
|
@@ -12,51 +12,40 @@ namespace image {
|
||||
enum ImageType {
|
||||
IMAGE_TYPE_BINARY = 0,
|
||||
IMAGE_TYPE_GRAYSCALE = 1,
|
||||
IMAGE_TYPE_RGB24 = 2,
|
||||
IMAGE_TYPE_RGB = 2,
|
||||
IMAGE_TYPE_RGB565 = 3,
|
||||
IMAGE_TYPE_RGBA = 4,
|
||||
};
|
||||
|
||||
enum Transparency {
|
||||
TRANSPARENCY_OPAQUE = 0,
|
||||
TRANSPARENCY_CHROMA_KEY = 1,
|
||||
TRANSPARENCY_ALPHA_CHANNEL = 2,
|
||||
};
|
||||
|
||||
class Image : public display::BaseImage {
|
||||
public:
|
||||
Image(const uint8_t *data_start, int width, int height, ImageType type);
|
||||
Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency);
|
||||
Color get_pixel(int x, int y, Color color_on = display::COLOR_ON, Color color_off = display::COLOR_OFF) const;
|
||||
int get_width() const override;
|
||||
int get_height() const override;
|
||||
const uint8_t *get_data_start() const { return this->data_start_; }
|
||||
ImageType get_type() const;
|
||||
|
||||
int get_bpp() const {
|
||||
switch (this->type_) {
|
||||
case IMAGE_TYPE_BINARY:
|
||||
return 1;
|
||||
case IMAGE_TYPE_GRAYSCALE:
|
||||
return 8;
|
||||
case IMAGE_TYPE_RGB565:
|
||||
return this->transparent_ ? 24 : 16;
|
||||
case IMAGE_TYPE_RGB24:
|
||||
return 24;
|
||||
case IMAGE_TYPE_RGBA:
|
||||
return 32;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int get_bpp() const { return this->bpp_; }
|
||||
|
||||
/// Return the stride of the image in bytes, that is, the distance in bytes
|
||||
/// between two consecutive rows of pixels.
|
||||
uint32_t get_width_stride() const { return (this->width_ * this->get_bpp() + 7u) / 8u; }
|
||||
size_t get_width_stride() const { return (this->width_ * this->get_bpp() + 7u) / 8u; }
|
||||
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
|
||||
|
||||
void set_transparency(bool transparent) { transparent_ = transparent; }
|
||||
bool has_transparency() const { return transparent_; }
|
||||
bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; }
|
||||
|
||||
#ifdef USE_LVGL
|
||||
lv_img_dsc_t *get_lv_img_dsc();
|
||||
#endif
|
||||
protected:
|
||||
bool get_binary_pixel_(int x, int y) const;
|
||||
Color get_rgb24_pixel_(int x, int y) const;
|
||||
Color get_rgba_pixel_(int x, int y) const;
|
||||
Color get_rgb_pixel_(int x, int y) const;
|
||||
Color get_rgb565_pixel_(int x, int y) const;
|
||||
Color get_grayscale_pixel_(int x, int y) const;
|
||||
|
||||
@@ -64,7 +53,9 @@ class Image : public display::BaseImage {
|
||||
int height_;
|
||||
ImageType type_;
|
||||
const uint8_t *data_start_;
|
||||
bool transparent_;
|
||||
Transparency transparency_;
|
||||
size_t bpp_{};
|
||||
size_t stride_{};
|
||||
#ifdef USE_LVGL
|
||||
lv_img_dsc_t dsc_{};
|
||||
#endif
|
||||
|
Reference in New Issue
Block a user