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 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

m9s*zMUB&9i)Vj-aXg1Rr|O2;S{r^I>B3s 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