From f1c0570e3b51062f12c8ebf0fcefeb6fc81e22b7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:21:42 +1100 Subject: [PATCH] [image] Transparency changes; code refactor (#7908) --- CODEOWNERS | 2 +- esphome/components/animation/__init__.py | 284 +------ esphome/components/animation/animation.cpp | 4 +- esphome/components/animation/animation.h | 3 +- esphome/components/image/__init__.py | 691 +++++++++++------- esphome/components/image/image.cpp | 143 ++-- esphome/components/image/image.h | 39 +- esphome/components/online_image/__init__.py | 118 +-- .../components/online_image/image_decoder.h | 11 +- .../components/online_image/online_image.cpp | 92 +-- .../components/online_image/online_image.h | 3 +- esphome/components/online_image/png_image.h | 1 + script/ci-custom.py | 17 +- tests/components/animation/.gitattributes | 4 + tests/components/animation/anim.apng | Bin 0 -> 12626 bytes tests/components/animation/anim.gif | Bin 0 -> 9735 bytes tests/components/animation/anim.webp | Bin 0 -> 8244 bytes tests/components/animation/common.yaml | 23 + .../components/animation/test.esp32-ard.yaml | 10 +- .../animation/test.esp32-c3-ard.yaml | 11 +- .../animation/test.esp32-c3-idf.yaml | 11 +- .../components/animation/test.esp32-idf.yaml | 11 +- .../animation/test.esp8266-ard.yaml | 11 +- .../components/animation/test.rp2040-ard.yaml | 11 +- tests/components/image/common.yaml | 49 +- tests/components/image/test.host.yaml | 42 +- tests/components/online_image/common.yaml | 41 +- 27 files changed, 845 insertions(+), 787 deletions(-) create mode 100644 tests/components/animation/.gitattributes create mode 100644 tests/components/animation/anim.apng create mode 100644 tests/components/animation/anim.gif create mode 100644 tests/components/animation/anim.webp create mode 100644 tests/components/animation/common.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 404ad35efc..088e350f5d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -302,7 +302,7 @@ esphome/components/noblex/* @AGalfra esphome/components/npi19/* @bakerkj esphome/components/number/* @esphome/core esphome/components/one_wire/* @ssieb -esphome/components/online_image/* @guillempages +esphome/components/online_image/* @clydebarrow @guillempages esphome/components/opentherm/* @olegtarasov esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 21a82649f0..f73b8ef08f 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -1,28 +1,10 @@ import logging -from esphome import automation, core +from esphome import automation import esphome.codegen as cg import esphome.components.image as espImage -from esphome.components.image import ( - CONF_USE_TRANSPARENCY, - LOCAL_SCHEMA, - SOURCE_LOCAL, - SOURCE_WEB, - WEB_SCHEMA, -) import esphome.config_validation as cv -from esphome.const import ( - CONF_FILE, - CONF_ID, - CONF_PATH, - CONF_RAW_DATA_ID, - CONF_REPEAT, - CONF_RESIZE, - CONF_SOURCE, - CONF_TYPE, - CONF_URL, -) -from esphome.core import CORE, HexInt +from esphome.const import CONF_ID, CONF_REPEAT _LOGGER = logging.getLogger(__name__) @@ -30,6 +12,7 @@ AUTO_LOAD = ["image"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] MULTI_CONF = True +MULTI_CONF_NO_DEFAULT = True CONF_LOOP = "loop" CONF_START_FRAME = "start_frame" @@ -51,86 +34,19 @@ SetFrameAction = animation_ns.class_( "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) ) -TYPED_FILE_SCHEMA = cv.typed_schema( +CONFIG_SCHEMA = espImage.IMAGE_SCHEMA.extend( { - SOURCE_LOCAL: LOCAL_SCHEMA, - SOURCE_WEB: WEB_SCHEMA, - }, - key=CONF_SOURCE, -) - - -def _file_schema(value): - if isinstance(value, str): - return validate_file_shorthand(value) - return TYPED_FILE_SCHEMA(value) - - -FILE_SCHEMA = cv.Schema(_file_schema) - - -def validate_file_shorthand(value): - value = cv.string_strict(value) - if value.startswith("http://") or value.startswith("https://"): - return FILE_SCHEMA( + cv.Required(CONF_ID): cv.declare_id(Animation_), + cv.Optional(CONF_LOOP): cv.All( { - CONF_SOURCE: SOURCE_WEB, - CONF_URL: value, + cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, + cv.Optional(CONF_END_FRAME): cv.positive_int, + cv.Optional(CONF_REPEAT): cv.positive_int, } - ) - return FILE_SCHEMA( - { - CONF_SOURCE: SOURCE_LOCAL, - CONF_PATH: 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. - """ - 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.") - - return config - - -ANIMATION_SCHEMA = cv.Schema( - cv.All( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Required(CONF_FILE): FILE_SCHEMA, - cv.Optional(CONF_RESIZE): cv.dimensions, - cv.Optional(CONF_TYPE, default="BINARY"): cv.enum( - espImage.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_LOOP): cv.All( - { - cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, - cv.Optional(CONF_END_FRAME): cv.positive_int, - cv.Optional(CONF_REPEAT): cv.positive_int, - } - ), - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - }, - validate_cross_dependencies, - ) + ), + }, ) -CONFIG_SCHEMA = ANIMATION_SCHEMA NEXT_FRAME_SCHEMA = automation.maybe_simple_id( { @@ -164,180 +80,26 @@ async def animation_action_to_code(config, action_id, template_arg, args): async def to_code(config): - from PIL import Image + ( + prog_arr, + width, + height, + image_type, + trans_value, + frame_count, + ) = await espImage.write_image(config, all_frames=True) - 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_WEB: - path = espImage.compute_local_image_path(conf_file).as_posix() - else: - raise core.EsphomeError(f"Unknown animation source: {conf_file[CONF_SOURCE]}") - - try: - image = Image.open(path) - except Exception as e: - raise core.EsphomeError(f"Could not load image file {path}: {e}") - - width, height = image.size - frames = image.n_frames - if CONF_RESIZE in config: - new_width_max, new_height_max = config[CONF_RESIZE] - ratio = min(new_width_max / width, new_height_max / height) - width, height = int(width * ratio), int(height * ratio) - elif 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] - - if config[CONF_TYPE] == "GRAYSCALE": - data = [0 for _ in range(height * width * frames)] - pos = 0 - for frameIndex in range(frames): - image.seek(frameIndex) - frame = image.convert("LA", dither=Image.Dither.NONE) - if CONF_RESIZE in config: - frame = frame.resize([width, height]) - pixels = list(frame.getdata()) - if len(pixels) != height * width: - raise core.EsphomeError( - f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})" - ) - for pix, a in pixels: - if transparent: - if pix == 1: - pix = 0 - if a < 0x80: - pix = 1 - - data[pos] = pix - pos += 1 - - elif config[CONF_TYPE] == "RGBA": - data = [0 for _ in range(height * width * 4 * frames)] - pos = 0 - for frameIndex in range(frames): - image.seek(frameIndex) - frame = image.convert("RGBA") - if CONF_RESIZE in config: - frame = frame.resize([width, height]) - pixels = list(frame.getdata()) - if len(pixels) != height * width: - raise core.EsphomeError( - f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})" - ) - for pix in pixels: - data[pos] = pix[0] - pos += 1 - data[pos] = pix[1] - pos += 1 - data[pos] = pix[2] - pos += 1 - data[pos] = pix[3] - pos += 1 - - elif config[CONF_TYPE] == "RGB24": - data = [0 for _ in range(height * width * 3 * frames)] - pos = 0 - for frameIndex in range(frames): - image.seek(frameIndex) - frame = image.convert("RGBA") - if CONF_RESIZE in config: - frame = frame.resize([width, height]) - pixels = list(frame.getdata()) - if len(pixels) != height * width: - raise core.EsphomeError( - f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})" - ) - 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", "TRANSPARENT_IMAGE"]: - bytes_per_pixel = 3 if transparent else 2 - data = [0 for _ in range(height * width * bytes_per_pixel * frames)] - pos = 0 - for frameIndex in range(frames): - image.seek(frameIndex) - frame = image.convert("RGBA") - if CONF_RESIZE in config: - frame = frame.resize([width, height]) - pixels = list(frame.getdata()) - if len(pixels) != height * width: - raise core.EsphomeError( - f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})" - ) - 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"]: - width8 = ((width + 7) // 8) * 8 - data = [0 for _ in range((height * width8 // 8) * frames)] - for frameIndex in range(frames): - image.seek(frameIndex) - if transparent: - alpha = image.split()[-1] - has_alpha = alpha.getextrema()[0] < 0xFF - else: - has_alpha = False - frame = image.convert("1", dither=Image.Dither.NONE) - if CONF_RESIZE in config: - frame = frame.resize([width, height]) - if transparent: - alpha = alpha.resize([width, height]) - for x, y in [(i, j) for i in range(width) for j in range(height)]: - if transparent and has_alpha: - if not alpha.getpixel((x, y)): - continue - elif frame.getpixel((x, y)): - continue - - pos = x + y * width8 + (height * width8 * frameIndex) - data[pos // 8] |= 0x80 >> (pos % 8) - else: - raise core.EsphomeError( - f"Animation f{config[CONF_ID]} has not supported type {config[CONF_TYPE]}." - ) - - rhs = [HexInt(x) for x in data] - prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) var = cg.new_Pvariable( config[CONF_ID], prog_arr, width, height, - frames, - espImage.IMAGE_TYPE[config[CONF_TYPE]], + frame_count, + image_type, + trans_value, ) - cg.add(var.set_transparency(transparent)) if loop_config := config.get(CONF_LOOP): start = loop_config[CONF_START_FRAME] - end = loop_config.get(CONF_END_FRAME, frames) + end = loop_config.get(CONF_END_FRAME, frame_count) count = loop_config.get(CONF_REPEAT, -1) cg.add(var.set_loop(start, end, count)) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index 1375dfe07e..6db6f1a7bd 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -6,8 +6,8 @@ namespace esphome { namespace animation { Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, - image::ImageType type) - : Image(data_start, width, height, type), + image::ImageType type, image::Transparency transparent) + : Image(data_start, width, height, type, transparent), animation_data_start_(data_start), current_frame_(0), animation_frame_count_(animation_frame_count), diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index 272c5153d1..c44e0060af 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -8,7 +8,8 @@ namespace animation { class Animation : public image::Image { public: - Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type); + Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, + image::Transparency transparent); uint32_t get_animation_frame_count() const; int get_current_frame() const; diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 4669a3418a..801b05e160 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -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 " 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 + ) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index ca2f659fb0..e380112050 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -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 diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index 40370d18da..4024ab1357 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -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 diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index be1bfb4a00..d1915c7364 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -4,14 +4,18 @@ from esphome import automation import esphome.codegen as cg from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent from esphome.components.image import ( + CONF_INVERT_ALPHA, CONF_USE_TRANSPARENCY, - IMAGE_TYPE, + IMAGE_SCHEMA, Image_, - validate_cross_dependencies, + get_image_type_enum, + get_transparency_enum, ) import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_DITHER, + CONF_FILE, CONF_FORMAT, CONF_ID, CONF_ON_ERROR, @@ -23,7 +27,7 @@ from esphome.const import ( AUTO_LOAD = ["image"] DEPENDENCIES = ["display", "http_request"] -CODEOWNERS = ["@guillempages"] +CODEOWNERS = ["@guillempages", "@clydebarrow"] MULTI_CONF = True CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" @@ -35,9 +39,30 @@ online_image_ns = cg.esphome_ns.namespace("online_image") ImageFormat = online_image_ns.enum("ImageFormat") -FORMAT_PNG = "PNG" -IMAGE_FORMAT = {FORMAT_PNG: ImageFormat.PNG} # Add new supported formats here +class Format: + def __init__(self, image_type): + self.image_type = image_type + + @property + def enum(self): + return getattr(ImageFormat, self.image_type) + + def actions(self): + pass + + +class PNGFormat(Format): + def __init__(self): + super().__init__("PNG") + + def actions(self): + cg.add_define("USE_ONLINE_IMAGE_PNG_SUPPORT") + cg.add_library("pngle", "1.0.2") + + +# New formats can be added here. +IMAGE_FORMATS = {x.image_type: x for x in (PNGFormat(),)} OnlineImage = online_image_ns.class_("OnlineImage", cg.PollingComponent, Image_) @@ -57,48 +82,54 @@ DownloadErrorTrigger = online_image_ns.class_( "DownloadErrorTrigger", automation.Trigger.template() ) -ONLINE_IMAGE_SCHEMA = cv.Schema( - { - cv.Required(CONF_ID): cv.declare_id(OnlineImage), - cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), - # - # Common image options - # - cv.Optional(CONF_RESIZE): cv.dimensions, - cv.Optional(CONF_TYPE, default="BINARY"): 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, - # - # Online Image specific options - # - cv.Required(CONF_URL): cv.url, - cv.Required(CONF_FORMAT): cv.enum(IMAGE_FORMAT, upper=True), - cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), - cv.Optional(CONF_BUFFER_SIZE, default=2048): cv.int_range(256, 65536), - cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DownloadFinishedTrigger), - } - ), - cv.Optional(CONF_ON_ERROR): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DownloadErrorTrigger), - } - ), + +def remove_options(*options): + return { + cv.Optional(option): cv.invalid( + f"{option} is an invalid option for online_image" + ) + for option in options } -).extend(cv.polling_component_schema("never")) + + +ONLINE_IMAGE_SCHEMA = ( + IMAGE_SCHEMA.extend(remove_options(CONF_FILE, CONF_INVERT_ALPHA, CONF_DITHER)) + .extend( + { + cv.Required(CONF_ID): cv.declare_id(OnlineImage), + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + # Online Image specific options + cv.Required(CONF_URL): cv.url, + cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True), + cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), + cv.Optional(CONF_BUFFER_SIZE, default=2048): cv.int_range(256, 65536), + cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DownloadFinishedTrigger + ), + } + ), + cv.Optional(CONF_ON_ERROR): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DownloadErrorTrigger), + } + ), + } + ) + .extend(cv.polling_component_schema("never")) +) CONFIG_SCHEMA = cv.Schema( cv.All( ONLINE_IMAGE_SCHEMA, - validate_cross_dependencies, cv.require_framework_version( # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed # esp8266_arduino=cv.Version(2, 7, 0), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(4, 0, 0), rp2040_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), ), ) ) @@ -132,29 +163,26 @@ async def online_image_action_to_code(config, action_id, template_arg, args): async def to_code(config): - format = config[CONF_FORMAT] - if format in [FORMAT_PNG]: - cg.add_define("USE_ONLINE_IMAGE_PNG_SUPPORT") - cg.add_library("pngle", "1.0.2") + image_format = IMAGE_FORMATS[config[CONF_FORMAT]] + image_format.actions() url = config[CONF_URL] width, height = config.get(CONF_RESIZE, (0, 0)) - transparent = config[CONF_USE_TRANSPARENCY] + transparent = get_transparency_enum(config[CONF_USE_TRANSPARENCY]) var = cg.new_Pvariable( config[CONF_ID], url, width, height, - format, - config[CONF_TYPE], + image_format.enum, + get_image_type_enum(config[CONF_TYPE]), + transparent, config[CONF_BUFFER_SIZE], ) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_transparency(transparent)) - if placeholder_id := config.get(CONF_PLACEHOLDER): placeholder = await cg.get_variable(placeholder_id) cg.add(var.set_placeholder(placeholder)) diff --git a/esphome/components/online_image/image_decoder.h b/esphome/components/online_image/image_decoder.h index 908efab987..cde7f572e3 100644 --- a/esphome/components/online_image/image_decoder.h +++ b/esphome/components/online_image/image_decoder.h @@ -1,5 +1,4 @@ #pragma once -#include "esphome/core/defines.h" #include "esphome/core/color.h" namespace esphome { @@ -23,7 +22,7 @@ class ImageDecoder { /** * @brief Initialize the decoder. * - * @param download_size The total number of bytes that need to be download for the image. + * @param download_size The total number of bytes that need to be downloaded for the image. */ virtual void prepare(uint32_t download_size) { this->download_size_ = download_size; } @@ -38,7 +37,7 @@ class ImageDecoder { * @return int The amount of bytes read. It can be 0 if the buffer does not have enough content to meaningfully * decode anything, or negative in case of a decoding error. */ - virtual int decode(uint8_t *buffer, size_t size); + virtual int decode(uint8_t *buffer, size_t size) = 0; /** * @brief Request the image to be resized once the actual dimensions are known. @@ -50,7 +49,7 @@ class ImageDecoder { void set_size(int width, int height); /** - * @brief Draw a rectangle on the display_buffer using the defined color. + * @brief Fill a rectangle on the display_buffer using the defined color. * Will check the given coordinates for out-of-bounds, and clip the rectangle accordingly. * In case of binary displays, the color will be converted to binary as well. * Called by the callback functions, to be able to access the parent Image class. @@ -59,7 +58,7 @@ class ImageDecoder { * @param y The top-most coordinate of the rectangle. * @param w The width of the rectangle. * @param h The height of the rectangle. - * @param color The color to draw the rectangle with. + * @param color The fill color */ void draw(int x, int y, int w, int h, const Color &color); @@ -67,7 +66,7 @@ class ImageDecoder { protected: OnlineImage *image_; - // Initializing to 1, to ensure it is different than initial "decoded_bytes_". + // Initializing to 1, to ensure it is distinguishable from initial "decoded_bytes_". // Will be overwritten anyway once the download size is known. uint32_t download_size_ = 1; uint32_t decoded_bytes_ = 0; diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 8c4669cba5..93d070c6a9 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -25,8 +25,8 @@ inline bool is_color_on(const Color &color) { } OnlineImage::OnlineImage(const std::string &url, int width, int height, ImageFormat format, ImageType type, - uint32_t download_buffer_size) - : Image(nullptr, 0, 0, type), + image::Transparency transparency, uint32_t download_buffer_size) + : Image(nullptr, 0, 0, type, transparency), buffer_(nullptr), download_buffer_(download_buffer_size), format_(format), @@ -45,7 +45,7 @@ void OnlineImage::draw(int x, int y, display::Display *display, Color color_on, void OnlineImage::release() { if (this->buffer_) { - ESP_LOGD(TAG, "Deallocating old buffer..."); + ESP_LOGV(TAG, "Deallocating old buffer..."); this->allocator_.deallocate(this->buffer_, this->get_buffer_size_()); this->data_start_ = nullptr; this->buffer_ = nullptr; @@ -70,20 +70,19 @@ bool OnlineImage::resize_(int width_in, int height_in) { if (this->buffer_) { return false; } - auto new_size = this->get_buffer_size_(width, height); - ESP_LOGD(TAG, "Allocating new buffer of %d Bytes...", new_size); - delay_microseconds_safe(2000); + size_t new_size = this->get_buffer_size_(width, height); + ESP_LOGD(TAG, "Allocating new buffer of %zu bytes", new_size); this->buffer_ = this->allocator_.allocate(new_size); - if (this->buffer_) { - this->buffer_width_ = width; - this->buffer_height_ = height; - this->width_ = width; - ESP_LOGD(TAG, "New size: (%d, %d)", width, height); - } else { - ESP_LOGE(TAG, "allocation failed. Biggest block in heap: %zu Bytes", this->allocator_.get_max_free_block_size()); + if (this->buffer_ == nullptr) { + ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", new_size, + this->allocator_.get_max_free_block_size()); this->end_connection_(); return false; } + this->buffer_width_ = width; + this->buffer_height_ = height; + this->width_ = width; + ESP_LOGV(TAG, "New size: (%d, %d)", width, height); return true; } @@ -91,9 +90,8 @@ void OnlineImage::update() { if (this->decoder_) { ESP_LOGW(TAG, "Image already being updated."); return; - } else { - ESP_LOGI(TAG, "Updating image"); } + ESP_LOGI(TAG, "Updating image %s", this->url_.c_str()); this->downloader_ = this->parent_->get(this->url_); @@ -142,10 +140,11 @@ void OnlineImage::loop() { return; } if (!this->downloader_ || this->decoder_->is_finished()) { - ESP_LOGD(TAG, "Image fully downloaded"); this->data_start_ = buffer_; this->width_ = buffer_width_; this->height_ = buffer_height_; + ESP_LOGD(TAG, "Image fully downloaded, read %zu bytes, width/height = %d/%d", this->downloader_->get_bytes_read(), + this->width_, this->height_); this->end_connection_(); this->download_finished_callback_.call(); return; @@ -171,6 +170,19 @@ void OnlineImage::loop() { } } +void OnlineImage::map_chroma_key(Color &color) { + if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) { + if (color.g == 1 && color.r == 0 && color.b == 0) { + color.g = 0; + } + if (color.w < 0x80) { + color.r = 0; + color.g = this->type_ == ImageType::IMAGE_TYPE_RGB565 ? 4 : 1; + color.b = 0; + } + } +} + void OnlineImage::draw_pixel_(int x, int y, Color color) { if (!this->buffer_) { ESP_LOGE(TAG, "Buffer not allocated!"); @@ -184,57 +196,53 @@ void OnlineImage::draw_pixel_(int x, int y, Color color) { switch (this->type_) { case ImageType::IMAGE_TYPE_BINARY: { const uint32_t width_8 = ((this->width_ + 7u) / 8u) * 8u; - const uint32_t pos = x + y * width_8; - if ((this->has_transparency() && color.w > 127) || is_color_on(color)) { - this->buffer_[pos / 8u] |= (0x80 >> (pos % 8u)); + pos = x + y * width_8; + auto bitno = 0x80 >> (pos % 8u); + pos /= 8u; + auto on = is_color_on(color); + if (this->has_transparency() && color.w < 0x80) + on = false; + if (on) { + this->buffer_[pos] |= bitno; } else { - this->buffer_[pos / 8u] &= ~(0x80 >> (pos % 8u)); + this->buffer_[pos] &= ~bitno; } break; } case ImageType::IMAGE_TYPE_GRAYSCALE: { uint8_t gray = static_cast(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b); - if (this->has_transparency()) { + if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) { if (gray == 1) { gray = 0; } if (color.w < 0x80) { gray = 1; } + } else if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + if (color.w != 0xFF) + gray = color.w; } this->buffer_[pos] = gray; break; } case ImageType::IMAGE_TYPE_RGB565: { + this->map_chroma_key(color); uint16_t col565 = display::ColorUtil::color_to_565(color); this->buffer_[pos + 0] = static_cast((col565 >> 8) & 0xFF); this->buffer_[pos + 1] = static_cast(col565 & 0xFF); - if (this->has_transparency()) + if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { this->buffer_[pos + 2] = color.w; - break; - } - case ImageType::IMAGE_TYPE_RGBA: { - this->buffer_[pos + 0] = color.r; - this->buffer_[pos + 1] = color.g; - this->buffer_[pos + 2] = color.b; - this->buffer_[pos + 3] = color.w; - break; - } - case ImageType::IMAGE_TYPE_RGB24: - default: { - if (this->has_transparency()) { - if (color.b == 1 && color.r == 0 && color.g == 0) { - color.b = 0; - } - if (color.w < 0x80) { - color.r = 0; - color.g = 0; - color.b = 1; - } } + break; + } + case ImageType::IMAGE_TYPE_RGB: { + this->map_chroma_key(color); this->buffer_[pos + 0] = color.r; this->buffer_[pos + 1] = color.g; this->buffer_[pos + 2] = color.b; + if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + this->buffer_[pos + 3] = color.w; + } break; } } diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 017402a088..e044b4f390 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -48,12 +48,13 @@ class OnlineImage : public PollingComponent, * @param buffer_size Size of the buffer used to download the image. */ OnlineImage(const std::string &url, int width, int height, ImageFormat format, image::ImageType type, - uint32_t buffer_size); + image::Transparency transparency, uint32_t buffer_size); void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override; void update() override; void loop() override; + void map_chroma_key(Color &color); /** Set the URL to download the image from. */ void set_url(const std::string &url) { diff --git a/esphome/components/online_image/png_image.h b/esphome/components/online_image/png_image.h index a928276dcc..d82ff93149 100644 --- a/esphome/components/online_image/png_image.h +++ b/esphome/components/online_image/png_image.h @@ -1,6 +1,7 @@ #pragma once #include "image_decoder.h" +#include "esphome/core/defines.h" #ifdef USE_ONLINE_IMAGE_PNG_SUPPORT #include diff --git a/script/ci-custom.py b/script/ci-custom.py index 81e3da311a..d5d3ab88c8 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -58,7 +58,19 @@ file_types = ( ) cpp_include = ("*.h", "*.c", "*.cpp", "*.tcc") py_include = ("*.py",) -ignore_types = (".ico", ".png", ".woff", ".woff2", "", ".ttf", ".otf", ".pcf") +ignore_types = ( + ".ico", + ".png", + ".woff", + ".woff2", + "", + ".ttf", + ".otf", + ".pcf", + ".apng", + ".gif", + ".webp", +) LINT_FILE_CHECKS = [] LINT_CONTENT_CHECKS = [] @@ -669,8 +681,7 @@ def main(): ) args = parser.parse_args() - global EXECUTABLE_BIT - EXECUTABLE_BIT = git_ls_files() + EXECUTABLE_BIT.update(git_ls_files()) files = list(EXECUTABLE_BIT.keys()) # Match against re file_name_re = re.compile("|".join(args.files)) diff --git a/tests/components/animation/.gitattributes b/tests/components/animation/.gitattributes new file mode 100644 index 0000000000..ff9fc6f1f1 --- /dev/null +++ b/tests/components/animation/.gitattributes @@ -0,0 +1,4 @@ +*.apng -text +*.webp -text +*.gif -text + diff --git a/tests/components/animation/anim.apng b/tests/components/animation/anim.apng new file mode 100644 index 0000000000000000000000000000000000000000..927af5eb05a94ea8b1cdab493d2bfd8feffb7eac GIT binary patch literal 12626 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsI14-?iy0t*k)fqhyqJN3 zu_)8oIUqARnSr5VPU*zm-pq~y?e@a17dx87#KasIO%?1F*dpjNL4$?Uuxb6XqDsz6 znQ}qF=!0ep6mI>{`l5d!Y=an!tKboX zTYVdCnLB5)#olv&U!|$4R-2Gyh7oa6UMEQpWhlN26m)D)jSgdQSAQ{apO?Pi4`h z)m_(bIHk{3(fq{Iy-V@x<4tNV{ii)Pr+)pPAK!aq$MT@N51Xf%U;ZP}a?Msl)aUc> zD<FnGE+hE&XXJ2!HJOsM4X<>^&bX;r(O zZlq{?DX>Hy(Q@=WqJF_BOmK6+QhypCsV0jM@i286ML2<_lHSt%=NQ=wN3n6|NjYGsjE&+&8Yr*(1*e3YXjHR!`4Bz zD*cm$Oph^KH29QxkaynU8$oM76z|z7P-fawvUvMT{t2#}8edPiEq

o;kxmZTaNd1mhcgW+Dkv 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 L)z4*}Q$iB}JU+F8 literal 0 HcmV?d00001 diff --git a/tests/components/animation/anim.gif b/tests/components/animation/anim.gif new file mode 100644 index 0000000000000000000000000000000000000000..9932e774483eb3516bec26187591b997e6d41859 GIT binary patch literal 9735 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 WpJ(0v^UUJ>&U5AKJ~uEhSOWldK6Y9F literal 0 HcmV?d00001 diff --git a/tests/components/animation/anim.webp b/tests/components/animation/anim.webp new file mode 100644 index 0000000000000000000000000000000000000000..8958377afaf4b45f8247a92861735bbab858ce9b GIT binary patch literal 8244 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 literal 0 HcmV?d00001 diff --git a/tests/components/animation/common.yaml b/tests/components/animation/common.yaml new file mode 100644 index 0000000000..c0f04fe768 --- /dev/null +++ b/tests/components/animation/common.yaml @@ -0,0 +1,23 @@ +animation: + - id: rgb565_animation + file: $component_dir/anim.gif + type: RGB565 + use_transparency: opaque + resize: 50x50 + - id: rgb_animation + file: $component_dir/anim.apng + type: RGB + use_transparency: chroma_key + resize: 50x50 + - id: grayscale_animation + file: $component_dir/anim.apng + type: grayscale + +display: + lambda: |- + id(rgb565_animation).next_frame(); + id(rgb_animation1).next_frame(); + id(grayscale_animation2).next_frame(); + it.image(0, 0, rgb565_animation); + it.image(120, 0, rgb_animation1); + it.image(240, 0, grayscale_animation2); diff --git a/tests/components/animation/test.esp32-ard.yaml b/tests/components/animation/test.esp32-ard.yaml index af6cd202dd..5d330900df 100644 --- a/tests/components/animation/test.esp32-ard.yaml +++ b/tests/components/animation/test.esp32-ard.yaml @@ -13,12 +13,6 @@ display: reset_pin: 21 invert_colors: false -# Purposely test that `animation:` does auto-load `image:` -# Keep the `image:` undefined. -# image: +packages: + animation: !include common.yaml -animation: - - id: rgb565_animation - file: ../../pnglogo.png - type: RGB565 - use_transparency: false diff --git a/tests/components/animation/test.esp32-c3-ard.yaml b/tests/components/animation/test.esp32-c3-ard.yaml index 10e8ccb47e..18aa2a5b06 100644 --- a/tests/components/animation/test.esp32-c3-ard.yaml +++ b/tests/components/animation/test.esp32-c3-ard.yaml @@ -13,12 +13,5 @@ display: reset_pin: 10 invert_colors: false -# Purposely test that `animation:` does auto-load `image:` -# Keep the `image:` undefined. -# image: - -animation: - - id: rgb565_animation - file: ../../pnglogo.png - type: RGB565 - use_transparency: false +packages: + animation: !include common.yaml diff --git a/tests/components/animation/test.esp32-c3-idf.yaml b/tests/components/animation/test.esp32-c3-idf.yaml index 10e8ccb47e..18aa2a5b06 100644 --- a/tests/components/animation/test.esp32-c3-idf.yaml +++ b/tests/components/animation/test.esp32-c3-idf.yaml @@ -13,12 +13,5 @@ display: reset_pin: 10 invert_colors: false -# Purposely test that `animation:` does auto-load `image:` -# Keep the `image:` undefined. -# image: - -animation: - - id: rgb565_animation - file: ../../pnglogo.png - type: RGB565 - use_transparency: false +packages: + animation: !include common.yaml diff --git a/tests/components/animation/test.esp32-idf.yaml b/tests/components/animation/test.esp32-idf.yaml index af6cd202dd..7d9fe45bff 100644 --- a/tests/components/animation/test.esp32-idf.yaml +++ b/tests/components/animation/test.esp32-idf.yaml @@ -13,12 +13,5 @@ display: reset_pin: 21 invert_colors: false -# Purposely test that `animation:` does auto-load `image:` -# Keep the `image:` undefined. -# image: - -animation: - - id: rgb565_animation - file: ../../pnglogo.png - type: RGB565 - use_transparency: false +packages: + animation: !include common.yaml diff --git a/tests/components/animation/test.esp8266-ard.yaml b/tests/components/animation/test.esp8266-ard.yaml index ced4996f25..9548c7fbeb 100644 --- a/tests/components/animation/test.esp8266-ard.yaml +++ b/tests/components/animation/test.esp8266-ard.yaml @@ -13,12 +13,5 @@ display: reset_pin: 16 invert_colors: false -# Purposely test that `animation:` does auto-load `image:` -# Keep the `image:` undefined. -# image: - -animation: - - id: rgb565_animation - file: ../../pnglogo.png - type: RGB565 - use_transparency: false +packages: + animation: !include common.yaml diff --git a/tests/components/animation/test.rp2040-ard.yaml b/tests/components/animation/test.rp2040-ard.yaml index 0e33959cc6..efb3f2907c 100644 --- a/tests/components/animation/test.rp2040-ard.yaml +++ b/tests/components/animation/test.rp2040-ard.yaml @@ -13,12 +13,5 @@ display: reset_pin: 22 invert_colors: false -# Purposely test that `animation:` does auto-load `image:` -# Keep the `image:` undefined. -# image: - -animation: - - id: rgb565_animation - file: ../../pnglogo.png - type: RGB565 - use_transparency: false +packages: + animation: !include common.yaml diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 313da6bc0b..fdb0493d2a 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -5,32 +5,65 @@ image: dither: FloydSteinberg - id: transparent_transparent_image file: ../../pnglogo.png - type: TRANSPARENT_BINARY + type: BINARY + use_transparency: chroma_key + - id: rgba_image file: ../../pnglogo.png - type: RGBA + type: RGB + use_transparency: alpha_channel resize: 50x50 - id: rgb24_image file: ../../pnglogo.png - type: RGB24 - use_transparency: yes + type: RGB + use_transparency: chroma_key + - id: rgb_image + file: ../../pnglogo.png + type: RGB + use_transparency: opaque + - id: rgb565_image file: ../../pnglogo.png type: RGB565 - use_transparency: no + use_transparency: opaque + - id: rgb565_ck_image + file: ../../pnglogo.png + type: RGB565 + use_transparency: chroma_key + - id: rgb565_alpha_image + file: ../../pnglogo.png + type: RGB565 + use_transparency: alpha_channel + + - id: grayscale_alpha_image + file: ../../pnglogo.png + type: grayscale + use_transparency: alpha_channel + resize: 50x50 + - id: grayscale_ck_image + file: ../../pnglogo.png + type: grayscale + use_transparency: chroma_key + - id: grayscale_image + file: ../../pnglogo.png + type: grayscale + use_transparency: opaque + - id: web_svg_image file: https://raw.githubusercontent.com/esphome/esphome-docs/a62d7ab193c1a464ed791670170c7d518189109b/images/logo.svg resize: 256x48 - type: TRANSPARENT_BINARY + type: BINARY + use_transparency: chroma_key - id: web_tiff_image file: https://upload.wikimedia.org/wikipedia/commons/b/b6/SIPI_Jelly_Beans_4.1.07.tiff - type: RGB24 + type: RGB resize: 48x48 - id: web_redirect_image file: https://avatars.githubusercontent.com/u/3060199?s=48&v=4 - type: RGB24 + type: RGB resize: 48x48 - id: mdi_alert + type: BINARY file: mdi:alert-circle-outline resize: 50x50 - id: another_alert_icon diff --git a/tests/components/image/test.host.yaml b/tests/components/image/test.host.yaml index 29509db66c..61ecd5e374 100644 --- a/tests/components/image/test.host.yaml +++ b/tests/components/image/test.host.yaml @@ -5,4 +5,44 @@ display: width: 480 height: 480 -<<: !include common.yaml +image: + binary: + - id: binary_image + file: ../../pnglogo.png + dither: FloydSteinberg + - id: transparent_transparent_image + file: ../../pnglogo.png + use_transparency: chroma_key + rgb: + alpha_channel: + - id: rgba_image + file: ../../pnglogo.png + resize: 50x50 + chroma_key: + - id: rgb24_image + file: ../../pnglogo.png + type: RGB + opaque: + - id: rgb_image + file: ../../pnglogo.png + rgb565: + - id: rgb565_image + file: ../../pnglogo.png + use_transparency: opaque + - id: rgb565_ck_image + file: ../../pnglogo.png + use_transparency: chroma_key + - id: rgb565_alpha_image + file: ../../pnglogo.png + use_transparency: alpha_channel + grayscale: + - id: grayscale_alpha_image + file: ../../pnglogo.png + use_transparency: alpha_channel + resize: 50x50 + - id: grayscale_ck_image + file: ../../pnglogo.png + use_transparency: chroma_key + - id: grayscale_image + file: ../../pnglogo.png + use_transparency: opaque diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index 5c6feb4c81..81f43e9fdc 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -13,33 +13,32 @@ online_image: resize: 50x50 - id: online_binary_transparent_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png - type: TRANSPARENT_BINARY + type: BINARY + use_transparency: chroma_key format: png - id: online_rgba_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG - type: RGBA + type: RGB + use_transparency: alpha_channel - id: online_rgb24_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG - type: RGB24 - use_transparency: true + type: RGB + use_transparency: chroma_key # Check the set_url action -time: - - platform: sntp - on_time: - - at: "13:37:42" - then: - - online_image.set_url: - id: online_rgba_image - url: http://www.example.org/example.png - - online_image.set_url: - id: online_rgba_image - url: !lambda |- - return "http://www.example.org/example.png"; - - online_image.set_url: - id: online_rgba_image - url: !lambda |- - return str_sprintf("http://homeassistant.local:8123"); - +esphome: + on_boot: + then: + - online_image.set_url: + id: online_rgba_image + url: http://www.example.org/example.png + - online_image.set_url: + id: online_rgba_image + url: !lambda |- + return "http://www.example.org/example.png"; + - online_image.set_url: + id: online_rgba_image + url: !lambda |- + return str_sprintf("http://homeassistant.local:8123");