From 20b38ac8b0c14f2cc3a9fbca9c4ffdd198ac800b Mon Sep 17 00:00:00 2001 From: guillempages Date: Thu, 27 Aug 2026 06:37:20 +0200 Subject: [PATCH] [runtime_image] Add support for QOI images (#16945) --- esphome/components/runtime_image/__init__.py | 17 +- .../components/runtime_image/image_format.cpp | 3 + .../components/runtime_image/image_format.h | 6 +- .../components/runtime_image/qoi_decoder.cpp | 174 ++++++++++++++++++ .../components/runtime_image/qoi_decoder.h | 49 +++++ .../runtime_image/runtime_image.cpp | 7 + esphome/core/defines.h | 3 +- tests/components/online_image/common.yaml | 6 + tests/components/runtime_image/__init__.py | 3 +- .../runtime_image/test_decoder_reuse.cpp | 69 ++++++- .../runtime_image/test_mime_types.cpp | 12 +- 11 files changed, 338 insertions(+), 11 deletions(-) create mode 100644 esphome/components/runtime_image/qoi_decoder.cpp create mode 100644 esphome/components/runtime_image/qoi_decoder.h diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index a220503045..e27fcadbee 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -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( diff --git a/esphome/components/runtime_image/image_format.cpp b/esphome/components/runtime_image/image_format.cpp index 9575b30887..9db8490415 100644 --- a/esphome/components/runtime_image/image_format.cpp +++ b/esphome/components/runtime_image/image_format.cpp @@ -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) { diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h index aff0c026b9..72bd81a06e 100644 --- a/esphome/components/runtime_image/image_format.h +++ b/esphome/components/runtime_image/image_format.h @@ -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 diff --git a/esphome/components/runtime_image/qoi_decoder.cpp b/esphome/components/runtime_image/qoi_decoder.cpp new file mode 100644 index 0000000000..b0fabb5233 --- /dev/null +++ b/esphome/components/runtime_image/qoi_decoder.cpp @@ -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 + +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(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 diff --git a/esphome/components/runtime_image/qoi_decoder.h b/esphome/components/runtime_image/qoi_decoder.h new file mode 100644 index 0000000000..c44afd83cf --- /dev/null +++ b/esphome/components/runtime_image/qoi_decoder.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_RUNTIME_IMAGE_QOI + +#include + +#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(this->width_ * this->height_); + } + + protected: + std::unique_ptr 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 diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index f7417c2c8e..ef92d0d707 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -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 RuntimeImage::create_decoder_(ImageFormat format) #ifdef USE_RUNTIME_IMAGE_PNG case PNG: return make_unique(this); +#endif +#ifdef USE_RUNTIME_IMAGE_QOI + case QOI: + return make_unique(this); #endif case AUTO: ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration"); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5b73c43ccd..993b9dce75 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index 85901287c7..d8d04850cf 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -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: diff --git a/tests/components/runtime_image/__init__.py b/tests/components/runtime_image/__init__.py index a8ff4bb68e..041db39128 100644 --- a/tests/components/runtime_image/__init__.py +++ b/tests/components/runtime_image/__init__.py @@ -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 diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp index 9c2d00b747..e62e2a98d9 100644 --- a/tests/components/runtime_image/test_decoder_reuse.cpp +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -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 static void expect_pixels(TestableRuntimeImage &img } } +template +static void expect_pixels_rgba(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][4]) { + ASSERT_EQ(img.get_width(), static_cast(W)); + ASSERT_EQ(img.get_height(), static_cast(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{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 buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP)); diff --git a/tests/components/runtime_image/test_mime_types.cpp b/tests/components/runtime_image/test_mime_types.cpp index de8bbc76be..22b825cff0 100644 --- a/tests/components/runtime_image/test_mime_types.cpp +++ b/tests/components/runtime_image/test_mime_types.cpp @@ -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; }