[image] Implement RGB111 format (#20114)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
guillempages
2026-10-12 22:16:44 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 7881dfc5d8
commit 25843c5242
9 changed files with 313 additions and 20 deletions
+73 -19
View File
@@ -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'"),
+35
View File
@@ -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;
+2
View File
@@ -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;
+17
View File
@@ -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])
@@ -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<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
+90
View File
@@ -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}"
)
+53 -1
View File
@@ -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"])])
+16
View File
@@ -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
+11
View File
@@ -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