[runtime_image] Add support for QOI images (#16945)

This commit is contained in:
guillempages
2026-08-27 16:37:20 +12:00
committed by GitHub
parent cdd892a526
commit 20b38ac8b0
11 changed files with 338 additions and 11 deletions
+16 -1
View File
@@ -28,6 +28,7 @@ ImageDecoder = runtime_image_ns.class_("ImageDecoder")
BmpDecoder = runtime_image_ns.class_("BmpDecoder", ImageDecoder)
JpegDecoder = runtime_image_ns.class_("JpegDecoder", ImageDecoder)
PngDecoder = runtime_image_ns.class_("PngDecoder", ImageDecoder)
QoiDecoder = runtime_image_ns.class_("QoiDecoder", ImageDecoder)
# Runtime image class
RuntimeImage = runtime_image_ns.class_(
@@ -37,9 +38,10 @@ RuntimeImage = runtime_image_ns.class_(
# Image format enum
ImageFormat = runtime_image_ns.enum("ImageFormat")
IMAGE_FORMAT_AUTO = ImageFormat.AUTO
IMAGE_FORMAT_BMP = ImageFormat.BMP
IMAGE_FORMAT_JPEG = ImageFormat.JPEG
IMAGE_FORMAT_PNG = ImageFormat.PNG
IMAGE_FORMAT_BMP = ImageFormat.BMP
IMAGE_FORMAT_QOI = ImageFormat.QOI
# Export enum for decode errors
DecodeError = runtime_image_ns.enum("DecodeError")
@@ -115,14 +117,27 @@ class PNGFormat(Format):
cg.add_library("pngle", "1.1.0")
class QOIFormat(Format):
"""QOI format decoder configuration."""
def __init__(self):
super().__init__("QOI", QoiDecoder)
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_QOI")
# Decodable formats only; platforms that support runtime detection accept
# "AUTO" in their own schema and get_format() resolves it
_JPEG_FORMAT = JPEGFormat()
# Registry of available formats
IMAGE_FORMATS = {
"BMP": BMPFormat(),
"JPEG": _JPEG_FORMAT,
"JPG": _JPEG_FORMAT, # Alias for JPEG
"PNG": PNGFormat(),
"QOI": QOIFormat(),
}
FILTER_SOURCE_FILES = filter_source_files_from_defines(
@@ -21,6 +21,9 @@ static constexpr MimeLookup MIME_LOOKUP_TABLE[] = {
#ifdef USE_RUNTIME_IMAGE_PNG
{"image/png", ImageFormat::PNG}, {"image/x-png", ImageFormat::PNG},
#endif
#ifdef USE_RUNTIME_IMAGE_QOI
{"image/qoi", ImageFormat::QOI}, {"image/x-qoi", ImageFormat::QOI},
#endif
};
const char *get_mime_type_for_format(ImageFormat format) {
@@ -11,12 +11,14 @@ enum ImageFormat {
/** Format is supplied per decode, e.g. detected from the Content-Type header
* by online_image; sniffing the image data is not implemented. */
AUTO,
/** BMP format. */
BMP,
/** JPEG format. */
JPEG,
/** PNG format. */
PNG,
/** BMP format. */
BMP,
/** QOI format. */
QOI,
};
/// Canonical MIME type for a format; "image/*" for AUTO/unknown
@@ -0,0 +1,174 @@
#include "qoi_decoder.h"
#ifdef USE_RUNTIME_IMAGE_QOI
#include "esphome/components/display/display.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome::runtime_image {
static const char *const TAG = "image_decoder.qoi";
constexpr uint8_t QOI_OP_RGB = 0b11111110;
constexpr uint8_t QOI_OP_RGBA = 0b11111111;
constexpr uint8_t QOI_OP_INDEX = 0b00000000; // 00xxxxxx
constexpr uint8_t QOI_OP_DIFF = 0b01000000; // 01xxxxxx
constexpr uint8_t QOI_OP_LUMA = 0b10000000; // 10xxxxxx
constexpr uint8_t QOI_OP_RUN = 0b11000000; // 11xxxxxx
constexpr uint8_t QOI_RGB_CHUNK_SIZE = 4;
constexpr uint8_t QOI_RGBA_CHUNK_SIZE = 5;
constexpr uint8_t QOI_LUMA_CHUNK_SIZE = 2;
constexpr uint8_t QOI_MASK_OP = 0b11000000;
constexpr uint8_t QOI_MASK_VALUE = 0b00111111;
constexpr size_t QOI_HEADER_SIZE = 14;
constexpr size_t QOI_COLOR_TABLE_SIZE = 64;
inline size_t qoi_color_table_index(const Color &color) {
// QOI color hash function: (r * 3 + g * 5 + b * 7 + a * 11) % 64
return (color.r * 3 + color.g * 5 + color.b * 7 + color.w * 11) &
63; // modulo 64 is equivalent to bitwise AND with 63 (0b00111111)
}
void QoiDecoder::reset() {
ImageDecoder::reset();
this->current_index_ = 0;
this->paint_index_ = 0;
this->width_ = 0;
this->height_ = 0;
this->bits_per_pixel_ = 0;
this->last_pixel_ = Color(0, 0, 0, 255);
if (this->color_table_) {
std::fill_n(this->color_table_.get(), QOI_COLOR_TABLE_SIZE, Color());
}
}
int HOT QoiDecoder::decode(uint8_t *buffer, size_t size) {
size_t index = 0;
if (this->current_index_ == 0) {
if (size < QOI_HEADER_SIZE) {
return 0; // Need more data for file header
}
/** QOI Header definition, for reference:
char magic[4]; // magic bytes "qoif"
uint32_t width; // image width in pixels (BE)
uint32_t height; // image height in pixels (BE)
uint8_t channels; // 3 = RGB, 4 = RGBA
uint8_t colorspace; // 0 = sRGB with linear alpha, 1 = all channels linear
*/
// Check if the file is a QOI image
if (buffer[0] != 'q' || buffer[1] != 'o' || buffer[2] != 'i' || buffer[3] != 'f') {
ESP_LOGE(TAG, "Not a QOI file");
return DECODE_ERROR_INVALID_TYPE;
}
this->width_ = encode_uint32(buffer[4], buffer[5], buffer[6], buffer[7]);
this->height_ = encode_uint32(buffer[8], buffer[9], buffer[10], buffer[11]);
if (this->width_ == 0 || this->height_ == 0) {
ESP_LOGE(TAG, "Invalid image dimensions: (%zux%zu)", this->width_, this->height_);
return DECODE_ERROR_INVALID_TYPE;
}
uint8_t channels = buffer[12];
if (channels < 3 || channels > 4) {
ESP_LOGE(TAG, "Unsupported number of channels: %d", channels);
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
this->bits_per_pixel_ = channels * 8;
uint8_t colorspace = buffer[13];
if (colorspace > 1) {
ESP_LOGE(TAG, "Unsupported colorspace value: %d", colorspace);
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
ESP_LOGD(TAG, "QOI image header: width=%zu, height=%zu, channels=%d, colorspace=%d", this->width_, this->height_,
channels, colorspace);
if (!this->color_table_) {
this->color_table_ = std::make_unique<Color[]>(QOI_COLOR_TABLE_SIZE);
}
if (!this->set_size(this->width_, this->height_)) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
this->current_index_ = QOI_HEADER_SIZE;
index = QOI_HEADER_SIZE;
} // Current_index == 0
Color color;
const size_t total_pixels = this->width_ * this->height_;
while (index < size && this->paint_index_ < total_pixels) {
color = this->last_pixel_;
uint8_t byte = buffer[index];
if (byte == QOI_OP_RGB) {
if (size < index + QOI_RGB_CHUNK_SIZE) {
return index; // Need more data for RGB chunk
}
index++;
color.r = buffer[index++];
color.g = buffer[index++];
color.b = buffer[index++];
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
this->paint_index_++;
} else if (byte == QOI_OP_RGBA) {
if (size < index + QOI_RGBA_CHUNK_SIZE) {
return index; // Need more data for RGBA chunk
}
index++;
color.r = buffer[index++];
color.g = buffer[index++];
color.b = buffer[index++];
color.w = buffer[index++];
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
this->paint_index_++;
} else if ((byte & QOI_MASK_OP) == QOI_OP_RUN) {
// QOI run chunk
size_t run_length = (byte & QOI_MASK_VALUE) + 1; // run length is encoded in the lower 6 bits, plus one
for (size_t i = 0; i < run_length; i++) {
// TODO: optimize by drawing runs of pixels at once instead of one by one
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
this->paint_index_++;
}
index++;
} else if ((byte & QOI_MASK_OP) == QOI_OP_LUMA) {
if (size < index + QOI_LUMA_CHUNK_SIZE) {
return index; // Need more data for LUMA chunk
}
index++;
uint8_t byte2 = buffer[index++];
uint8_t delta_g = (byte & QOI_MASK_VALUE) - 32;
color.r += delta_g - 8 + ((byte2 >> 4) & 0x0f);
color.g += delta_g;
color.b += delta_g - 8 + (byte2 & 0x0f);
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
this->paint_index_++;
} else if ((byte & QOI_MASK_OP) == QOI_OP_DIFF) {
color.r += ((byte >> 4) & 0x03) - 2;
color.g += ((byte >> 2) & 0x03) - 2;
color.b += (byte & 0x03) - 2;
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
this->paint_index_++;
index++;
} else if ((byte & QOI_MASK_OP) == QOI_OP_INDEX) {
color = this->color_table_[byte];
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
this->paint_index_++;
index++;
}
this->last_pixel_ = color;
this->color_table_[qoi_color_table_index(color)] = color;
}
this->decoded_bytes_ += size;
return size;
}
} // namespace esphome::runtime_image
#endif // USE_RUNTIME_IMAGE_QOI
@@ -0,0 +1,49 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_RUNTIME_IMAGE_QOI
#include <memory>
#include "image_decoder.h"
#include "runtime_image.h"
namespace esphome::runtime_image {
/**
* @brief Image decoder specialization for QOI images.
*/
class QoiDecoder : public ImageDecoder {
public:
/**
* @brief Construct a new QOI decoder object.
*
* @param image The RuntimeImage to decode the stream into.
*/
QoiDecoder(RuntimeImage *image) : ImageDecoder(image, QOI) {}
void reset() override;
int HOT decode(uint8_t *buffer, size_t size) override;
bool is_finished() const override {
if (this->bits_per_pixel_ == 0) {
// header not yet received, so dimensions not yet determined
return false;
}
// QOI is finished when we've decoded all pixel data
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
}
protected:
std::unique_ptr<Color[]> color_table_;
size_t current_index_{0};
size_t paint_index_{0};
size_t width_{0};
size_t height_{0};
Color last_pixel_{0, 0, 0, 255}; // QOI spec defines initial previous pixel as opaque black
uint16_t bits_per_pixel_{0};
};
} // namespace esphome::runtime_image
#endif // USE_RUNTIME_IMAGE_QOI
@@ -15,6 +15,9 @@
#ifdef USE_RUNTIME_IMAGE_PNG
#include "png_decoder.h"
#endif
#ifdef USE_RUNTIME_IMAGE_QOI
#include "qoi_decoder.h"
#endif
namespace esphome::runtime_image {
@@ -367,6 +370,10 @@ std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_(ImageFormat format)
#ifdef USE_RUNTIME_IMAGE_PNG
case PNG:
return make_unique<PngDecoder>(this);
#endif
#ifdef USE_RUNTIME_IMAGE_QOI
case QOI:
return make_unique<QoiDecoder>(this);
#endif
case AUTO:
ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration");
+2 -1
View File
@@ -233,8 +233,9 @@
#endif
#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
#define USE_RUNTIME_IMAGE_BMP
#define USE_RUNTIME_IMAGE_PNG
#define USE_RUNTIME_IMAGE_JPEG
#define USE_RUNTIME_IMAGE_PNG
#define USE_RUNTIME_IMAGE_QOI
#define USE_RUNTIME_STATS
#define USE_OTA
#define USE_OTA_PASSWORD
@@ -62,6 +62,12 @@ image:
url: http://www.faqs.org/images/library.jpg
format: AUTO
type: RGB565
- platform: online_image
id: online_qoi_image
url: https://www.example.org/image.qoi
format: QOI
type: RGB
transparency: alpha_channel
# Check the set_url action
esphome:
+2 -1
View File
@@ -9,7 +9,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# tests have two decoder types and every retained decoder is under test.
async def to_code_testing(config: ConfigType) -> None:
enable_format("BMP")
enable_format("PNG")
enable_format("JPEG")
enable_format("PNG")
enable_format("QOI")
manifest.to_code = to_code_testing
@@ -70,11 +70,36 @@ static const uint8_t PNG_RGB_EXPECTED[4][4][3] = {
{{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}},
};
// 3x3 QOI, exercising all possible chunk types
static const uint8_t QOI_RGBA[] = {
0x71, 0x6F, 0x69, 0x66, // Header: 'qoif'
0x00, 0x00, 0x00, 0x03, // Width: 3
0x00, 0x00, 0x00, 0x03, // Height: 3
0x04, // Channels: 4 (RGBA)
0x00, // Colorspace: 0 (SRGB)
0xC1, // 1. QOI_OP_RUN
0x79, // 2. QOI_OP_DIFF
0xAA, 0x79, // 3. QOI_OP_LUMA
0xFE, 0xC8, 0x64, 0x32, // 4. QOI_OP_RGB
0xFF, 0x78, 0x50, 0x28,
0x64, // 5. QOI_OP_RGBA
0x31, // 6. QOI_OP_INDEX
0xC1, // 7. QOI_OP_RUN
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // End Marker
};
static const uint8_t QOI_EXPECTED_RGBA[3][3][4] = {
{{0x00, 0x00, 0x00, 0xFF}, {0x00, 0x00, 0x00, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}},
{{0x0A, 0x0A, 0x0A, 0xFF}, {0xC8, 0x64, 0x32, 0xFF}, {0x78, 0x50, 0x28, 0x64}},
{{0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}}
};
/// Exposes the protected decoder machinery so reuse and eviction can be observed directly.
class TestableRuntimeImage : public RuntimeImage {
public:
explicit TestableRuntimeImage(ImageFormat format)
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE)
: RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {}
ImageDecoder *decoder() { return this->decoder_.get(); }
};
@@ -132,6 +157,19 @@ template<size_t H, size_t W> static void expect_pixels(TestableRuntimeImage &img
}
}
template<size_t H, size_t W>
static void expect_pixels_rgba(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][4]) {
ASSERT_EQ(img.get_width(), static_cast<int>(W));
ASSERT_EQ(img.get_height(), static_cast<int>(H));
for (size_t y = 0; y < H; y++) {
for (size_t x = 0; x < W; x++) {
SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")");
Color color = img.get_pixel(x, y);
EXPECT_THAT((std::array<uint8_t, 4>{color.r, color.g, color.b, color.w}),
::testing::ElementsAreArray(expected[y][x]));
}
}
}
TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) {
TestableRuntimeImage img(BMP);
@@ -337,6 +375,33 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
}
#endif // USE_RUNTIME_IMAGE_JPEG
TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) {
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
ImageDecoder *first = img.decoder();
ASSERT_NE(first, nullptr);
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated";
}
TEST(RuntimeImageDecoder, QoiChunkedFeedDecodesLikeDownloadLoop) {
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
ImageDecoder *first = img.decoder();
// Chunked again on the warm decoder: the cross-call resume state
// (current_index_ / paint_index_) must have been fully reset.
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
EXPECT_EQ(img.decoder(), first);
}
TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) {
TestableRuntimeImage img(BMP);
std::vector<uint8_t> buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
@@ -10,12 +10,14 @@ TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
#endif // USE_RUNTIME_IMAGE_JPEG
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/qoi"), QOI);
EXPECT_EQ(get_format_for_mime_type("image/x-qoi"), QOI);
}
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
@@ -39,20 +41,22 @@ TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
#endif // USE_RUNTIME_IMAGE_JPEG
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
EXPECT_STREQ(get_mime_type_for_format(QOI), "image/qoi");
// AUTO has no single MIME type and falls back to the wildcard
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
// Every decodable format must resolve back to itself through its MIME type
for (ImageFormat format : {
BMP,
PNG,
#ifdef USE_RUNTIME_IMAGE_JPEG
JPEG,
#endif // USE_RUNTIME_IMAGE_JPEG
PNG,
QOI,
}) {
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
}