mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[image] Implement RGB111 format (#20114)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
J. Nick Koston
parent
7881dfc5d8
commit
25843c5242
@@ -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'"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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"])])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user