diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index eaee31a1c7..668a78f3cb 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -145,13 +145,42 @@ def is_alpha_only(image: Image): return all(b.getextrema()[1] == 0 for b in image.split()[:-1]) -class ImageBinary(ImageEncoder): +class BitPackedEncoder(ImageEncoder): + """Packs pixels MSB first, with every row padded to a whole byte.""" + + def __init__( + self, + bpp: int, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + self.bpp = bpp + super().__init__( + (width * bpp + 7) // 8, height, transparency, dither, invert_alpha + ) + self.bitno = 0 + + def add_bit(self, bit: bool) -> None: + if bit: + self.data[self.index] |= 0x80 >> self.bitno + self.bitno += 1 + if self.bitno == 8: + self.end_row() + + def end_row(self) -> None: + if self.bitno != 0: + self.bitno = 0 + self.index += 1 + + +class ImageBinary(BitPackedEncoder): 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 + super().__init__(1, width, height, transparency, dither, invert_alpha) def convert(self, image, path): if is_alpha_only(image): @@ -159,22 +188,46 @@ class ImageBinary(ImageEncoder): 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 + self.add_bit(bool(pixel) != self.invert_alpha) - def end_row(self): - """ - Pad rows to a byte boundary - """ - if self.bitno != 0: - self.bitno = 0 - self.index += 1 + +# The eight colours one bit per channel can express, for dithering. +_RGB111_PALETTE = Image.new("P", (1, 1)) +_RGB111_PALETTE.putpalette([255 * int(x) for i in range(8) for x in f"{i:03b}"]) + + +class ImageRGB111(BitPackedEncoder): + allow_config = {CONF_OPAQUE, CONF_ALPHA_CHANNEL} + + def __init__( + self, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + bpp = 4 if transparency == CONF_ALPHA_CHANNEL else 3 + super().__init__(bpp, width, height, transparency, dither, invert_alpha) + + def convert(self, image: Image, path: str): + image = image.convert("RGBA") + if self.dither != Image.Dither.FLOYDSTEINBERG: + return image + alpha = image.getchannel("A") + image = image.convert("RGB").quantize( + palette=_RGB111_PALETTE, dither=Image.Dither.FLOYDSTEINBERG + ) + image = image.convert("RGB") + image.putalpha(alpha) + return image + + def encode(self, pixel: tuple[int, int, int, int]): + r, g, b, a = pixel + if self.invert_alpha: + a ^= 0xFF + for channel in (r, g, b, a)[: self.bpp]: + self.add_bit(channel > 127) class ImageGrayscale(ImageEncoder): @@ -315,6 +368,7 @@ class ReplaceWith: IMAGE_TYPE = { "BINARY": ImageBinary, "GRAYSCALE": ImageGrayscale, + "RGB111": ImageRGB111, "RGB565": ImageRGB565, "RGB": ImageRGB, "TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"), diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index bfe311be28..d27d110248 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -3,6 +3,8 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +ESPHOME_LOG_TAG(TAG, "image"); + namespace esphome::image { void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { @@ -36,6 +38,17 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color } break; } + case IMAGE_TYPE_RGB111: { + for (int img_x = img_x0; img_x < w; img_x++) { + for (int img_y = img_y0; img_y < h; img_y++) { + auto color = this->get_rgb111_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + } + } + break; + } case IMAGE_TYPE_GRAYSCALE: for (int img_x = img_x0; img_x < w; img_x++) { for (int img_y = img_y0; img_y < h; img_y++) { @@ -91,6 +104,8 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off if (this->get_binary_pixel_(x, y)) return color_on; return color_off; + case IMAGE_TYPE_RGB111: + return this->get_rgb111_pixel_(x, y); case IMAGE_TYPE_GRAYSCALE: return this->get_grayscale_pixel_(x, y); case IMAGE_TYPE_RGB565: @@ -120,6 +135,10 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { this->dsc_.header.cf = LV_COLOR_FORMAT_A8; break; + case IMAGE_TYPE_RGB111: + // Rejected by the lvgl config validation + ESP_LOGE(TAG, "RGB111 images are not supported in LVGL."); + break; case IMAGE_TYPE_RGB: switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: @@ -152,6 +171,19 @@ 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_rgb111_pixel_(int x, int y) const { + const uint32_t bitpos = y * this->get_width_stride() * 8 + x * this->bpp_; + const uint8_t *byte = this->data_start_ + bitpos / 8; + const uint8_t shift = bitpos % 8; + // A pixel can straddle two bytes; read the second one only when it does. + uint16_t window = progmem_read_byte(byte) << 8; + if (shift + this->bpp_ > 8) + window |= progmem_read_byte(byte + 1); + const uint8_t bits = (window >> (16 - this->bpp_ - shift)) & ((1u << this->bpp_) - 1); + const uint8_t rgb = this->bpp_ == 4 ? bits >> 1 : bits; + const bool opaque = this->bpp_ == 3 || (bits & 1); + return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00); +} 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 + 2), progmem_read_byte(this->data_start_ + pos + 1), @@ -216,6 +248,9 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T case IMAGE_TYPE_BINARY: this->bpp_ = 1; break; + case IMAGE_TYPE_RGB111: + this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 4 : 3; + break; case IMAGE_TYPE_GRAYSCALE: this->bpp_ = 8; break; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index fd9e92c21d..a94f8137f4 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -13,6 +13,7 @@ enum ImageType { IMAGE_TYPE_GRAYSCALE = 1, IMAGE_TYPE_RGB = 2, IMAGE_TYPE_RGB565 = 3, + IMAGE_TYPE_RGB111 = 4, // one bit per channel plus an optional alpha bit, for colour e-paper }; enum Transparency { @@ -44,6 +45,7 @@ class Image : public display::BaseImage { #endif protected: bool get_binary_pixel_(int x, int y) const; + Color get_rgb111_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; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9b2f41e063..6e3087ba9f 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -25,6 +25,7 @@ from esphome.components.image import ( ImageBinary, ImageGrayscale, ImageRGB, + ImageRGB111, ImageRGB565, get_image_metadata, ) @@ -43,6 +44,7 @@ from esphome.const import ( CONF_ROTATION, CONF_TIMEOUT, CONF_TRIGGER_ID, + CONF_TYPE, ) from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj @@ -293,6 +295,21 @@ def final_validation(config_list): if len(config_list) != 1: multi_conf_validate(config_list) + for img in get_lv_images_used(): + path = global_config.get_path_for_id(img.id)[:-1] + # An image nested in another platform (sendspin's current_image) takes its type from the parent + while path: + image_conf = global_config.get_config_for_path(path) + if isinstance(image_conf, dict) and CONF_TYPE in image_conf: + break + path = path[:-1] + else: + continue + if IMAGE_TYPE.get(image_conf[CONF_TYPE]) is ImageRGB111: + raise cv.FinalExternalInvalid( + f"Image '{img}': LVGL does not support RGB111 images, use RGB565 or RGB instead", + path + [CONF_TYPE], + ) for w in get_focused_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 6c10194574..79d2627a11 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -99,6 +99,22 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { } break; } + case image::IMAGE_TYPE_RGB111: { + uint8_t bits = (color.r >> 7) << 2 | (color.g >> 7) << 1 | (color.b >> 7); + if (this->bpp_ == 4) + bits = bits << 1 | (color.w >> 7); + const uint32_t stride = (this->buffer_width_ * this->bpp_ + 7u) / 8u; + const uint32_t bitpos = y * stride * 8 + x * this->bpp_; + uint8_t *byte = this->buffer_ + bitpos / 8; + // Place the pixel in a 16-bit window so it may straddle two bytes. + const uint8_t shift = 16 - this->bpp_ - bitpos % 8; + const uint16_t mask = ((1u << this->bpp_) - 1) << shift; + const uint16_t value = bits << shift; + byte[0] = (byte[0] & ~(mask >> 8)) | (value >> 8); + if (mask & 0xFF) + byte[1] = (byte[1] & ~(mask & 0xFF)) | (value & 0xFF); + break; + } case image::IMAGE_TYPE_GRAYSCALE: { uint32_t pos = this->get_position_(x, y); auto gray = static_cast(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b); diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index fad8b7df09..4d62bb52a1 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -102,6 +102,16 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb111", + "transparency": "chroma_key", + }, + "Image format 'RGB111' cannot have transparency: chroma_key", + id="rgb111_with_chroma_key_transparency", + ), ], ) def test_file_platform_configuration_errors( @@ -963,3 +973,83 @@ async def test_rgb565_alpha_animation_layout_per_frame( assert all(a == 0x00 for a in frame1_alpha), ( f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "mode", + "transparency", + "frame0_color", + "frame1_color", + "frame0_row", + "frame1_row", + ), + [ + pytest.param( + "RGB", + CONF_OPAQUE, + (255, 0, 0), + (0, 0, 255), + [0x92, 0x48], + [0x24, 0x92], + id="rgb111", + ), + pytest.param( + "RGBA", + CONF_ALPHA_CHANNEL, + (255, 0, 0, 255), + (0, 0, 255, 0), + [0x99, 0x99, 0x90], + [0x22, 0x22, 0x20], + id="rgb111_alpha", + ), + ], +) +async def test_rgb111_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, + mode: str, + transparency: str, + frame0_color: tuple[int, ...], + frame1_color: tuple[int, ...], + frame0_row: list[int], + frame1_row: list[int], +) -> None: + """RGB111 packs one bit per channel MSB first and pads every row to a byte, + so each frame is a self-contained block of row_stride * height bytes. + """ + # A width that is not a multiple of 8 leaves padding bits in every row. + width = 5 + height = 3 + frame0 = PILImage.new(mode, (width, height), frame0_color) + frame1 = PILImage.new(mode, (width, height), frame1_color) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB111", + CONF_TRANSPARENCY: transparency, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + frame_size = len(frame0_row) * height + assert data == frame0_row * height + frame1_row * height, ( + f"Expected {frame_size} row-padded bytes per frame, got {data}" + ) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py index 9a767c0dae..31cbccb9ff 100644 --- a/tests/component_tests/lvgl/test_validation.py +++ b/tests/component_tests/lvgl/test_validation.py @@ -2,14 +2,23 @@ from __future__ import annotations +from contextlib import nullcontext + import pytest from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER from esphome.components.display import add_metadata from esphome.components.lvgl import final_validation +from esphome.components.lvgl.defines import get_lv_images_used from esphome.config import Config from esphome.config_validation import Invalid -from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.const import ( + CONF_ID, + CONF_TYPE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE, ID from esphome.final_validate import full_config @@ -175,3 +184,46 @@ class TestDrawRoundingMerge: configs = [_make_lvgl_config(["my_disp"])] final_validation(configs) assert configs[0]["draw_rounding"] == 2 + + +class TestImageTypeValidation: + """Test the image types LVGL accepts.""" + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_image_type(self, image_type: str, expectation) -> None: + image_id = ID("my_image", True) + fc = full_config.get() + fc["image"] = [{CONF_ID: image_id, CONF_TYPE: image_type}] + fc.declare_ids.append((image_id, ["image", 0, CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_nested_image_takes_parent_type(self, image_type: str, expectation) -> None: + """A sendspin artwork image declares its ID below the slot that holds the type.""" + image_id = ID("album_art", True) + fc = full_config.get() + fc["image"] = [{CONF_TYPE: image_type, "current_image": {CONF_ID: image_id}}] + fc.declare_ids.append((image_id, ["image", 0, "current_image", CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 5a8f938319..6b4978fc54 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -10,6 +10,22 @@ image: type: BINARY transparency: chroma_key + - platform: file + id: rgb111_image + file: ../../pnglogo.png + type: RGB111 + - platform: file + id: rgb111_alpha_image + file: ../../pnglogo.png + type: RGB111 + transparency: alpha_channel + - platform: file + id: rgb111_dither_alpha_image + file: ../../pnglogo.png + type: RGB111 + dither: FloydSteinberg + transparency: alpha_channel + - platform: file id: rgba_image file: ../../pnglogo.png diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index df8f85596f..2a20fa2537 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -37,6 +37,17 @@ image: format: PNG type: RGB transparency: chroma_key + - platform: online_image + id: online_rgb111_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + format: png + - platform: online_image + id: online_rgb111_transparent_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + transparency: alpha_channel + format: png - platform: online_image id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp