From 802c2633edb9634b9d47bde46648288677d5a2de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 21:38:58 -1000 Subject: [PATCH] [image] Draw rows in the outer loop and hand opaque RGB565 and RGB blocks to the display (#20653) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/animation/animation.cpp | 4 +- esphome/components/animation/animation.h | 2 +- esphome/components/animation/image.py | 3 +- esphome/components/file/image.py | 8 +- esphome/components/image/image.cpp | 211 ++++++++++------- esphome/components/image/image.h | 11 +- .../runtime_image/image_decoder.cpp | 2 +- .../runtime_image/runtime_image.cpp | 7 +- .../components/runtime_image/runtime_image.h | 5 - esphome/components/sendspin/image/__init__.py | 1 + tests/component_tests/image/test_init.py | 2 +- .../config/image_byte_order_big_endian.yaml | 35 +++ .../image_byte_order_little_endian.yaml | 35 +++ tests/component_tests/sendspin/test_image.py | 23 ++ tests/components/image/test_image_draw.cpp | 213 ++++++++++++++++++ 15 files changed, 466 insertions(+), 96 deletions(-) create mode 100644 tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml create mode 100644 tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml create mode 100644 tests/components/image/test_image_draw.cpp diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index 2a293adf1d8..1a2307520d3 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -5,8 +5,8 @@ namespace esphome::animation { Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, - image::ImageType type, image::Transparency transparent) - : Image(data_start, width, height, type, transparent), + image::ImageType type, image::Transparency transparent, bool big_endian) + : Image(data_start, width, height, type, transparent, big_endian), 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 4ee060c1213..0cee54c3607 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -6,7 +6,7 @@ namespace esphome::animation { class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, - image::Transparency transparent); + image::Transparency transparent, bool big_endian = false); uint32_t get_animation_frame_count() const; int get_current_frame() const; diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 09e0e8753cf..b8280f47cd9 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components.const import CONF_LOOP +from esphome.components.const import CONF_BYTE_ORDER, CONF_LOOP from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings @@ -90,6 +90,7 @@ async def setup_animation(config: ConfigType) -> None: frame_count, image_type, trans_value, + config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN", ) if loop_config := config.get(CONF_LOOP): start = loop_config[CONF_START_FRAME] diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 20eda9a3278..5e4fd145a44 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -254,7 +254,13 @@ async def new_image(config: ConfigType) -> MockObj: """ prog_arr, width, height, image_type, trans_value, _ = await write_image(config) var = cg.new_Pvariable( - config[CONF_ID], prog_arr, width, height, image_type, trans_value + config[CONF_ID], + prog_arr, + width, + height, + image_type, + trans_value, + config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN", ) add_metadata( config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY] diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index d27d110248c..60d81ad62f1 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -1,5 +1,7 @@ #include "image.h" +#include + #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -7,92 +9,137 @@ ESPHOME_LOG_TAG(TAG, "image"); namespace esphome::image { +// Walks the clipped range with rows (ROWS_OUTER) or columns outer and hands the +// body its coordinates and row major index. The body must be ESPHOME_ALWAYS_INLINE, +// at -Os GCC otherwise calls it once per pixel. +template +static void for_each_pixel(int x_start, int x_end, int y_start, int y_end, int width, F &&f) { + const int outer_start = ROWS_OUTER ? y_start : x_start; + const int outer_end = ROWS_OUTER ? y_end : x_end; + const int inner_start = ROWS_OUTER ? x_start : y_start; + const int inner_end = ROWS_OUTER ? x_end : y_end; + for (int outer = outer_start; outer < outer_end; outer++) { + const int outer_pos = ROWS_OUTER ? outer * width : outer; + for (int inner = inner_start; inner < inner_end; inner++) { + f(ROWS_OUTER ? inner : outer, ROWS_OUTER ? outer : inner, + ROWS_OUTER ? outer_pos + inner : outer_pos + inner * width); + } + } +} + void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { - int img_x0 = 0; - int img_y0 = 0; - int w = width_; - int h = height_; + int x_start = 0; + int y_start = 0; + int x_end = width_; + int y_end = height_; auto clipping = display->get_clipping(); if (clipping.is_set()) { if (clipping.x > x) - img_x0 += clipping.x - x; + x_start += clipping.x - x; if (clipping.y > y) - img_y0 += clipping.y - y; - if (w > clipping.x2() - x) - w = clipping.x2() - x; - if (h > clipping.y2() - y) - h = clipping.y2() - y; + y_start += clipping.y - y; + if (x_end > clipping.x2() - x) + x_end = clipping.x2() - x; + if (y_end > clipping.y2() - y) + y_end = clipping.y2() - y; } - switch (type_) { - case IMAGE_TYPE_BINARY: { - for (int img_x = img_x0; img_x < w; img_x++) { - for (int img_y = img_y0; img_y < h; 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->transparency_) { - display->draw_pixel_at(x + img_x, y + img_y, color_off); - } - } - } +#ifndef USE_ESP8266 + // Opaque RGB565 and RGB are already laid out for draw_pixels_at(), so the display + // gets the whole window. Not on the ESP8266, whose image bytes need progmem_read_byte(). + if (this->transparency_ == TRANSPARENCY_OPAQUE && + (this->type_ == IMAGE_TYPE_RGB565 || this->type_ == IMAGE_TYPE_RGB)) { + // Keep the block on screen + x_start = std::max(x_start, -x); + y_start = std::max(y_start, -y); + x_end = std::min(x_end, display->get_width() - x); + y_end = std::min(y_end, display->get_height() - y); + if (x_end <= x_start || y_end <= y_start) + return; + const bool rgb565 = this->type_ == IMAGE_TYPE_RGB565; + display->draw_pixels_at(x + x_start, y + y_start, x_end - x_start, y_end - y_start, this->data_start_, + display::COLOR_ORDER_RGB, rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_888, + this->big_endian_, x_start, y_start, this->width_ - x_end); + return; + } +#endif + + // Rows keep the read and the write sequential; when the display swaps the axes + // columns keep the write sequential instead, and a strided write costs more. +#ifdef USE_PSRAM + if (display->pixel_axes_swapped()) { + this->draw_(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end); + return; + } +#endif + // Without PSRAM a strided write is free, so one copy of the loops is enough + this->draw_(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end); +} + +template +void Image::draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end, + int y_start, int y_end) { + // Local copies, or the virtual draw_pixel_at() forces a reload every pixel + const Transparency transparency = this->transparency_; + const int width = this->width_; + switch (this->type_) { + case IMAGE_TYPE_BINARY: + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + if (this->get_binary_pixel_(img_x, img_y)) { + display->draw_pixel_at(x + img_x, y + img_y, color_on); + } else if (transparency == TRANSPARENCY_OPAQUE) { + display->draw_pixel_at(x + img_x, y + img_y, color_off); + } + }); + break; + case IMAGE_TYPE_RGB111: + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + 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: { + const uint8_t *const data = this->data_start_; + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + const uint8_t gray = progmem_read_byte(data + pos); + Color color = Color(gray, gray, gray, 0xFF); + if (transparency == TRANSPARENCY_CHROMA_KEY) { + if (gray == 1) { + return; // transparent pixel, skip drawing + } + } else if (transparency == TRANSPARENCY_ALPHA_CHANNEL) { + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); + } + display->draw_pixel_at(x + img_x, y + img_y, 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++) { - const uint32_t pos = (img_x + img_y * this->width_); - const uint8_t gray = progmem_read_byte(this->data_start_ + pos); - Color color = Color(gray, gray, gray, 0xFF); - switch (this->transparency_) { - case TRANSPARENCY_CHROMA_KEY: - if (gray == 1) { - continue; // skip drawing - } - break; - case TRANSPARENCY_ALPHA_CHANNEL: - // gray is the alpha: blend from color_off to color_on, drawn opaque - color = Color(Color::blend_channel(color_off.r, color_on.r, gray), - Color::blend_channel(color_off.g, color_on.g, gray), - Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); - break; - default: - break; - } - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - break; case IMAGE_TYPE_RGB565: - 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_rgb565_pixel_(img_x, img_y); - if (color.w >= 0x80) { - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - } + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + auto color = this->get_rgb565_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + }); break; case IMAGE_TYPE_RGB: - 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_rgb_pixel_(img_x, img_y); - if (color.w >= 0x80) { - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - } + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + 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); + } + }); break; } } @@ -185,7 +232,7 @@ Color Image::get_rgb111_pixel_(int x, int y) const { 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; + const uint32_t pos = (x + y * this->width_) * static_cast(this->bpp_) / 8; Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), progmem_read_byte(this->data_start_ + pos + 0), 0xFF); @@ -205,8 +252,10 @@ Color Image::get_rgb_pixel_(int x, int y) const { return color; } Color Image::get_rgb565_pixel_(int x, int y) const { - const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); + const uint8_t *pos = this->data_start_ + (x + y * this->width_) * static_cast(this->bpp_) / 8; + // High byte first when big endian + const uint8_t high = this->big_endian_ ? 0 : 1; + const uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + high), progmem_read_byte(pos + 1 - high)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; @@ -242,8 +291,14 @@ Color Image::get_grayscale_pixel_(int x, int y) const { 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, Transparency transparency) - : width_(width), height_(height), type_(type), data_start_(data_start), transparency_(transparency) { +Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency, + bool big_endian) + : width_(width), + height_(height), + type_(type), + data_start_(data_start), + transparency_(transparency), + big_endian_(big_endian) { switch (this->type_) { case IMAGE_TYPE_BINARY: this->bpp_ = 1; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index a94f8137f41..b03befcddb4 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -24,7 +24,9 @@ enum Transparency { class Image : public display::BaseImage { public: - Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency); + /// big_endian is the RGB565 byte order; codegen writes little endian unless configured otherwise. + Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency, + bool big_endian = false); 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; @@ -44,6 +46,10 @@ class Image : public display::BaseImage { lv_image_dsc_t *get_lv_image_dsc(); #endif protected: + /// Per-pixel draw of the clipped range, rows (true) or columns (false) outer. + template + void draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end, + int y_start, int y_end); 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; @@ -55,7 +61,8 @@ class Image : public display::BaseImage { ImageType type_; const uint8_t *data_start_; Transparency transparency_; - size_t bpp_{}; + bool big_endian_; + uint8_t bpp_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 55e25d76b72..d57d5d4e73d 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -30,6 +30,6 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { } uint8_t *ImageDecoder::image_buffer_() const { return this->image_->buffer_; } -bool ImageDecoder::image_is_big_endian_() const { return this->image_->is_big_endian_; } +bool ImageDecoder::image_is_big_endian_() const { return this->image_->big_endian_; } } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 79d2627a11c..14e0d5665a6 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -41,12 +41,11 @@ inline bool is_color_on(const Color &color) { RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency, image::Image *placeholder, bool is_big_endian, int fixed_width, int fixed_height) - : Image(nullptr, 0, 0, type, transparency), + : Image(nullptr, 0, 0, type, transparency, is_big_endian), format_(format), fixed_width_(fixed_width), fixed_height_(fixed_height), - placeholder_(placeholder), - is_big_endian_(is_big_endian) {} + placeholder_(placeholder) {} RuntimeImage::~RuntimeImage() { this->release(); } @@ -137,7 +136,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { Color mapped_color = color; this->map_chroma_key(mapped_color); uint16_t rgb565 = display::ColorUtil::color_to_565(mapped_color); - if (this->is_big_endian_) { + if (this->big_endian_) { this->buffer_[pos + 0] = static_cast((rgb565 >> 8) & 0xFF); this->buffer_[pos + 1] = static_cast(rgb565 & 0xFF); } else { diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 33bad52acf7..2a3ab40a5c1 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -227,11 +227,6 @@ class RuntimeImage : public image::Image { // Configuration bool progressive_display_{false}; - /** - * Whether the image is stored in big-endian format. - * This is used to determine how to store 16 bit colors in the buffer. - */ - bool is_big_endian_{false}; /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ bool external_buffer_{false}; diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index a5336d5e052..571f8aa47d8 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -173,6 +173,7 @@ async def to_code(config: ConfigType) -> None: settings.height, settings.image_type_enum, settings.transparent, + settings.byte_order_big_endian, ) add_metadata( view_id, diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 4d62bb52a19..602b923a1dd 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -745,7 +745,7 @@ def test_image_generation( in main_cpp ) assert ( - "new(cat_img) image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);" + "new(cat_img) image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, false);" in main_cpp ) diff --git a/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml b/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml new file mode 100644 index 00000000000..e58c546d2ee --- /dev/null +++ b/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml @@ -0,0 +1,35 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +spi: + clk_pin: 18 + mosi_pin: 23 + +display: + - platform: ili9xxx + model: ili9342 + cs_pin: 22 + dc_pin: 21 + invert_colors: false + +sendspin: + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + byte_order: BIG_ENDIAN + resize: 240x240 + current_image: + id: album_art + transition_image: + id: album_art_transition diff --git a/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml b/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml new file mode 100644 index 00000000000..b9c4fd91d21 --- /dev/null +++ b/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml @@ -0,0 +1,35 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +spi: + clk_pin: 18 + mosi_pin: 23 + +display: + - platform: ili9xxx + model: ili9342 + cs_pin: 22 + dc_pin: 21 + invert_colors: false + +sendspin: + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + byte_order: LITTLE_ENDIAN + resize: 240x240 + current_image: + id: album_art + transition_image: + id: album_art_transition diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py index dc9e4978c6b..7de2b9b4dba 100644 --- a/tests/component_tests/sendspin/test_image.py +++ b/tests/component_tests/sendspin/test_image.py @@ -4,6 +4,8 @@ These cover the rejection branches, which a compile test cannot reach: a `test*.yaml` can only assert that a configuration is accepted. """ +from collections.abc import Callable +from pathlib import Path from typing import Any import pytest @@ -120,3 +122,24 @@ def test_display_offset_accepted( config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) assert config["display_offset"].total_milliseconds == expected_ms + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("image_byte_order_big_endian.yaml", "true"), + ("image_byte_order_little_endian.yaml", "false"), + ], +) +def test_views_get_the_byte_order( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: str, +) -> None: + """Both views draw the frames the slot decodes, so they must read them in its byte order.""" + main_cpp = generate_main(component_config_path(config_file)) + + views = [line for line in main_cpp.splitlines() if "ArtworkImageView(" in line] + assert len(views) == 2 + assert all(line.endswith(f", {expected});") for line in views), views diff --git a/tests/components/image/test_image_draw.cpp b/tests/components/image/test_image_draw.cpp new file mode 100644 index 00000000000..80db8a4ad2a --- /dev/null +++ b/tests/components/image/test_image_draw.cpp @@ -0,0 +1,213 @@ +#include + +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::testing { + +class TestDisplay : public display::Display { + public: + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return 64; } + int get_height_internal() override { return 48; } +}; + +struct Blit { + int x, y, w, h; + const uint8_t *ptr; + display::ColorBitness bitness; + bool big_endian; + int x_offset, y_offset, x_pad; +}; + +// Records the bulk calls and counts the per pixel calls Image::draw() makes. +class RecordingDisplay : public TestDisplay { + public: + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->blits.push_back({x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad}); + } + void draw_pixel_at(int x, int y, Color color) override { this->pixels++; } + + std::vector blits; + int pixels{0}; +}; + +// Lets the bulk call fall through to the base class and keeps the pixels it produces. +class FallbackDisplay : public TestDisplay { + public: + void draw_pixel_at(int x, int y, Color color) override { this->pixels.push_back({x, y, color.raw_32}); } + + struct Pixel { + int x, y; + uint32_t raw; + }; + std::vector pixels; +}; + +static constexpr int W = 8, H = 6; + +static std::vector make_data(int bytes_per_pixel) { + std::vector data(W * H * bytes_per_pixel); + uint8_t value = 1; + for (auto &byte : data) + byte = value++; + return data; +} + +// Draws an opaque RGB565 image at (x, y) and returns the single bulk call it made. +static Blit draw_one(int x, int y, const std::vector &data, RecordingDisplay &display, + bool big_endian = false) { + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + image.draw(x, y, &display, Color::WHITE, Color::BLACK); + EXPECT_EQ(display.blits.size(), 1u); + EXPECT_EQ(display.pixels, 0); + return display.blits.at(0); +} + +static void expect_window(const Blit &blit, int x, int y, int w, int h, int x_offset, int y_offset, int x_pad) { + EXPECT_EQ(blit.x, x); + EXPECT_EQ(blit.y, y); + EXPECT_EQ(blit.w, w); + EXPECT_EQ(blit.h, h); + EXPECT_EQ(blit.x_offset, x_offset); + EXPECT_EQ(blit.y_offset, y_offset); + EXPECT_EQ(blit.x_pad, x_pad); +} + +TEST(ImageDraw, OpaqueRgb565IsOneBulkCall) { + const auto data = make_data(2); + RecordingDisplay display; + const Blit blit = draw_one(10, 20, data, display); + expect_window(blit, 10, 20, W, H, 0, 0, 0); + EXPECT_EQ(blit.ptr, data.data()); + EXPECT_EQ(blit.bitness, display::COLOR_BITNESS_565); + EXPECT_FALSE(blit.big_endian); +} + +TEST(ImageDraw, BigEndianRgb565IsPassedThrough) { + const auto data = make_data(2); + RecordingDisplay display; + EXPECT_TRUE(draw_one(0, 0, data, display, true).big_endian); +} + +TEST(ImageDraw, OpaqueRgbIsOneBulkCall) { + const auto data = make_data(3); + Image image(data.data(), W, H, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.blits.size(), 1u); + EXPECT_EQ(display.blits[0].bitness, display::COLOR_BITNESS_888); +} + +TEST(ImageDraw, ClippingShrinksTheWindow) { + const auto data = make_data(2); + RecordingDisplay display; + display.start_clipping(12, 22, 15, 24); // right and bottom are exclusive + expect_window(draw_one(10, 20, data, display), 12, 22, 3, 2, 2, 2, W - 5); +} + +TEST(ImageDraw, PartlyOffTheTopLeftIsClamped) { + const auto data = make_data(2); + RecordingDisplay display; + expect_window(draw_one(-3, -2, data, display), 0, 0, W - 3, H - 2, 3, 2, 0); +} + +TEST(ImageDraw, PartlyOffTheBottomRightIsClamped) { + const auto data = make_data(2); + RecordingDisplay display; + expect_window(draw_one(60, 44, data, display), 60, 44, 4, 4, 0, 0, W - 4); +} + +TEST(ImageDraw, FullyOffScreenDrawsNothing) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + image.draw(64, 0, &display, Color::WHITE, Color::BLACK); + image.draw(0, -H, &display, Color::WHITE, Color::BLACK); + EXPECT_TRUE(display.blits.empty()); + EXPECT_EQ(display.pixels, 0); +} + +TEST(ImageDraw, TransparentAndOtherFormatsStayPerPixel) { + const auto rgb565 = make_data(2); + const auto gray = make_data(1); + Image keyed(rgb565.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); + Image grayscale(gray.data(), W, H, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + keyed.draw(0, 0, &display, Color::WHITE, Color::BLACK); + grayscale.draw(0, 0, &display, Color::WHITE, Color::BLACK); + EXPECT_TRUE(display.blits.empty()); + EXPECT_GT(display.pixels, 0); +} + +// The base class fallback reads pixels in the image's byte order. +static void expect_fallback_decodes(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.pixels.size(), static_cast(W * H)); + for (const auto &pixel : display.pixels) { + const uint32_t rgb565 = + display::ColorUtil::read_packed(data.data(), pixel.x + pixel.y * W, big_endian); + const Color expected = display::ColorUtil::to_color(rgb565, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565); + EXPECT_EQ(pixel.raw, expected.raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +TEST(ImageDraw, FallbackDecodesLittleEndian) { expect_fallback_decodes(false); } +TEST(ImageDraw, FallbackDecodesBigEndian) { expect_fallback_decodes(true); } + +// The per pixel reader behind get_pixel() reads RGB565 in the image's byte order. +static void expect_reader_decodes(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + const uint32_t rgb565 = + display::ColorUtil::read_packed(data.data(), x + y * W, big_endian); + const Color expected(((rgb565 >> 11) << 3) | ((rgb565 >> 11) >> 2), + (((rgb565 >> 5) & 0x3F) << 2) | (((rgb565 >> 5) & 0x3F) >> 4), + ((rgb565 & 0x1F) << 3) | ((rgb565 & 0x1F) >> 2), 0xFF); + EXPECT_EQ(image.get_pixel(x, y).raw_32, expected.raw_32) << "at " << x << "," << y; + } + } +} + +TEST(ImageDraw, ReaderDecodesLittleEndian) { expect_reader_decodes(false); } +TEST(ImageDraw, ReaderDecodesBigEndian) { expect_reader_decodes(true); } + +// A chroma keyed RGB565 draw stays per pixel and must match get_pixel(). +static void expect_keyed_draw_matches_reader(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, big_endian); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_GT(display.pixels.size(), 0u); + for (const auto &pixel : display.pixels) { + EXPECT_EQ(pixel.raw, image.get_pixel(pixel.x, pixel.y).raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +TEST(ImageDraw, KeyedDrawMatchesReaderLittleEndian) { expect_keyed_draw_matches_reader(false); } +TEST(ImageDraw, KeyedDrawMatchesReaderBigEndian) { expect_keyed_draw_matches_reader(true); } + +// The fallback keeps red and blue in the order the image stores them. +TEST(ImageDraw, FallbackKeepsRgbChannelOrder) { + const auto data = make_data(3); + Image image(data.data(), W, H, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.pixels.size(), static_cast(W * H)); + for (const auto &pixel : display.pixels) { + // get_pixel() reports an opaque pixel with alpha set; the fallback decodes colour only + const Color expected = image.get_pixel(pixel.x, pixel.y); + EXPECT_EQ(pixel.raw, Color(expected.r, expected.g, expected.b).raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +} // namespace esphome::image::testing