From db0524d59b5c86ddec4d7cc77ed43de112aa2fd5 Mon Sep 17 00:00:00 2001 From: Powei Feng Date: Wed, 28 Jan 2026 10:35:36 -0800 Subject: [PATCH] imagediff: add image comparison library (#9640) A library for verifying rendering results against golden images. Key Features: - Hierarchical checks (AND, OR, LEAF). - Per-pixel masking. - Global failure tolerance (`maxFailingPixelsFraction`). - 8-bit `Bitmap` support. - JSON configuration. --- CMakeLists.txt | 1 + libs/imagediff/CMakeLists.txt | 22 + libs/imagediff/README.md | 60 +++ libs/imagediff/include/imagediff/ImageDiff.h | 100 +++++ libs/imagediff/src/ImageDiff.cpp | 397 +++++++++++++++++++ libs/imagediff/tests/test_ImageDiff.cpp | 304 ++++++++++++++ 6 files changed, 884 insertions(+) create mode 100644 libs/imagediff/CMakeLists.txt create mode 100644 libs/imagediff/README.md create mode 100644 libs/imagediff/include/imagediff/ImageDiff.h create mode 100644 libs/imagediff/src/ImageDiff.cpp create mode 100644 libs/imagediff/tests/test_ImageDiff.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b572d53071..221a7fc3fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -877,6 +877,7 @@ add_subdirectory(${LIBRARIES}/gltfio) add_subdirectory(${LIBRARIES}/ibl) add_subdirectory(${LIBRARIES}/iblprefilter) add_subdirectory(${LIBRARIES}/image) +add_subdirectory(${LIBRARIES}/imagediff) add_subdirectory(${LIBRARIES}/ktxreader) add_subdirectory(${LIBRARIES}/math) add_subdirectory(${LIBRARIES}/mathio) diff --git a/libs/imagediff/CMakeLists.txt b/libs/imagediff/CMakeLists.txt new file mode 100644 index 0000000000..aaf668e8fb --- /dev/null +++ b/libs/imagediff/CMakeLists.txt @@ -0,0 +1,22 @@ +add_library(imagediff STATIC + src/ImageDiff.cpp +) + +target_include_directories(imagediff PUBLIC + $ + $ +) + +target_link_libraries(imagediff PUBLIC + image + utils +) + +target_link_libraries(imagediff PRIVATE + jsmn +) + +if (NOT ANDROID AND NOT WEBGL AND NOT IOS) + add_executable(test_imagediff tests/test_ImageDiff.cpp) + target_link_libraries(test_imagediff PRIVATE imagediff gtest image utils) +endif() diff --git a/libs/imagediff/README.md b/libs/imagediff/README.md new file mode 100644 index 0000000000..8f337aaaca --- /dev/null +++ b/libs/imagediff/README.md @@ -0,0 +1,60 @@ +# ImageDiff Library + +`imagediff` is a flexible C++ library for comparing images with configurable tolerance logic. It supports both floating-point (`LinearImage`) and 8-bit packed (`Bitmap`) image formats, designed for verifying rendering results against golden images. + +## Features + +- **Flexible Comparison Logic**: Combine checks using `AND`, `OR`, and `LEAF` nodes. +- **Configurable Thresholds**: Support for absolute (`maxAbsDiff`) error tolerances. +- **Masking**: Optional per-pixel masking to ignore or weight specific regions. The library tracks pixels that passed specifically because of masking (`maskedIgnoredPixelCount`). +- **Global Failure Tolerance**: Allow a certain fraction of pixels (`maxFailingPixelsFraction`) to fail before rejecting the image. +- **8-bit Support**: Efficient comparison of raw 8-bit RGBA buffers with configurable swizzle (e.g., RGBA, BGRA). +- **Visualization**: Generate a `diffImage` (unmasked absolute difference) and a `maskImage` (copy of the applied mask) for debugging. + +## Usage + +### C++ API + +```cpp +#include + +// 1. Setup Configuration +imagediff::ImageDiffConfig config; +config.maxAbsDiff = 0.05f; // Allow 5% absolute difference +config.swizzle = imagediff::ImageDiffConfig::Swizzle::RGBA; // Default + +// 2. Prepare Images +imagediff::Bitmap ref = {width, height, stride, refData}; +imagediff::Bitmap cand = {width, height, stride, candData}; + +// Optional: Mask (single channel uint8_t for Bitmap) +imagediff::Bitmap mask = {width, height, maskStride, maskData}; + +// 3. Compare (enable visualization) +imagediff::ImageDiffResult result = imagediff::compare(ref, cand, config, &mask, true); + +if (result.status == imagediff::ImageDiffResult::Status::PASSED) { + // Success. Check result.maskedIgnoredPixelCount for info. +} else { + // Handle failure (SIZE_MISMATCH or PIXEL_DIFFERENCE) + // Inspect result.diffImage and result.maskImage. +} +``` + +### JSON Configuration + +```json +{ + "mode": "OR", + "swizzle": "RGBA", + "children": [ + { + "maxAbsDiff": "0.01" // Strict pass + }, + { + "maxFailingPixelsFraction": "0.05", // OR allow 5% failures + "maxAbsDiff": "0.1" + } + ] +} +``` diff --git a/libs/imagediff/include/imagediff/ImageDiff.h b/libs/imagediff/include/imagediff/ImageDiff.h new file mode 100644 index 0000000000..79d74934d9 --- /dev/null +++ b/libs/imagediff/include/imagediff/ImageDiff.h @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef IMAGEDIFF_IMAGEDIFF_H +#define IMAGEDIFF_IMAGEDIFF_H + +#include +#include + +#include +#include + +namespace imagediff { + +enum class Channel : uint8_t { R = 0x1, G = 0x2, B = 0x4, A = 0x8, RGB = 0x7, RGBA = 0xF }; + +struct ImageDiffConfig { + enum class Mode : uint8_t { LEAF, AND, OR } mode = Mode::LEAF; + enum class Swizzle : uint8_t { RGBA, BGRA } swizzle = Swizzle::RGBA; + + // For LEAF mode: + uint8_t channelMask = (uint8_t) Channel::RGBA; + float maxAbsDiff = 0.0f; // Max absolute difference allowed (e.g. 5/255) + + // For AND/OR mode: + std::vector children; + + // Global Logic (Checked at the root level mainly) + float maxFailingPixelsFraction = 0.0f; // 0.0 = strict, 0.01 = 1% pixels can fail +}; + +struct ImageDiffResult { + + enum class Status : uint8_t { + PASSED = 0, + SIZE_MISMATCH, + PIXEL_DIFFERENCE + } status = Status::PASSED; + + + size_t failingPixelCount = 0; + + size_t maskedIgnoredPixelCount = 0; // Pixels that passed only due to masking + + float maxDiffFound[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; // [R, G, B, A] + + image::LinearImage diffImage; // Populated if generateDiffImage is true + + image::LinearImage maskImage; // Populated if generateDiffImage is true and mask is present +}; + +/** + * Compares two images. + * @param reference The golden image (expected). + * @param candidate The image to test (actual). + * @param config The tolerance configuration. + * @param mask Optional: Grayscale image where 0=Ignore, 1=Strict (or vice versa). + * If null, no masking is applied. + * @param generateDiffImage If true, populates result.diffImage with visual diff. + */ +ImageDiffResult compare(image::LinearImage const& reference, image::LinearImage const& candidate, + ImageDiffConfig const& config, image::LinearImage const* mask = nullptr, + bool generateDiffImage = false); + +// 8-bit Support +struct Bitmap { + uint32_t width; + uint32_t height; + size_t stride; // in bytes + void const* data; +}; + +/** + * Overload for raw 8-bit RGBA buffers (packed uint32_t). + * Internal implementation will unpack to float for threshold consistency. + */ +ImageDiffResult compare(Bitmap const& reference, Bitmap const& candidate, + ImageDiffConfig const& config, Bitmap const* mask = nullptr, + bool generateDiffImage = false); + +// JSON Serialization +bool parseConfig(char const* json, size_t size, ImageDiffConfig* outConfig); +utils::CString serializeResult(ImageDiffResult const& result); + +} // namespace imagediff + +#endif // IMAGEDIFF_IMAGEDIFF_H diff --git a/libs/imagediff/src/ImageDiff.cpp b/libs/imagediff/src/ImageDiff.cpp new file mode 100644 index 0000000000..5aec2ee530 --- /dev/null +++ b/libs/imagediff/src/ImageDiff.cpp @@ -0,0 +1,397 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include + +#include +#include +#include // strtof, strtol +#include +#include + +// JSMN inclusion +#include + +namespace imagediff { + +using namespace image; + +// ------------------------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------------------------ +// Helper Functions +// ------------------------------------------------------------------------------------------------ + +namespace { + +bool checkThresholds(float maxAbsDiff, float valRef, float valCand, float mask) { + float const diff = std::abs(valRef - valCand) * mask; + return diff <= maxAbsDiff; +} + +bool checkPixelRecursive(ImageDiffConfig const& config, float const* pxRef, float const* pxCand, + float maskVal, float* outMaxDiffs) { + if (config.mode == ImageDiffConfig::Mode::LEAF) { + bool passed = true; + // Check channels using enum + Channel const channels[] = { Channel::R, Channel::G, Channel::B, Channel::A }; + for (int i = 0; i < 4; ++i) { + if (config.channelMask & (uint8_t) channels[i]) { + float const valRef = pxRef[i]; + float const valCand = pxCand[i]; + + float const diffUnmasked = std::abs(valRef - valCand); + if (outMaxDiffs) { + outMaxDiffs[i] = std::max(outMaxDiffs[i], diffUnmasked); + } + + if (!checkThresholds(config.maxAbsDiff, valRef, valCand, maskVal)) { + passed = false; + } + } + } + return passed; + } else if (config.mode == ImageDiffConfig::Mode::AND) { + for (auto const& child: config.children) { + if (!checkPixelRecursive(child, pxRef, pxCand, maskVal, outMaxDiffs)) { + return false; + } + } + return true; + } else if (config.mode == ImageDiffConfig::Mode::OR) { + for (auto const& child: config.children) { + if (checkPixelRecursive(child, pxRef, pxCand, maskVal, outMaxDiffs)) { + return true; + } + } + return false; + } + return false; +} + +// ------------------------------------------------------------------------------------------------ +// JSON Parsing Helpers +// ------------------------------------------------------------------------------------------------ + +utils::CString getTokenString(char const* json, jsmntok_t const& tok) { + if (tok.type == JSMN_STRING || tok.type == JSMN_PRIMITIVE) { + return utils::CString(json + tok.start, tok.end - tok.start); + } + return utils::CString(""); +} + +int parseJsonRecursive(char const* json, std::vector const& tokens, int idx, + ImageDiffConfig* outConfig, int depth) { + if (idx < 0 || idx >= tokens.size()) { + return idx; + } + + if (depth > 32) { + return -1; // Abort on excessive depth + } + + auto const& tok = tokens[idx]; + if (tok.type != JSMN_OBJECT) { + return idx + 1; // Expected object + } + + int const count = tok.size; // number of key-value pairs + int current = idx + 1; + + for (int i = 0; i < count; ++i) { + if (current < 0 || current >= tokens.size()) { + return -1; + } + utils::CString key = getTokenString(json, tokens[current]); + current++; // move to value + + if (key == "mode") { + utils::CString val = getTokenString(json, tokens[current]); + if (val == "AND") { + outConfig->mode = ImageDiffConfig::Mode::AND; + } else if (val == "OR") { + outConfig->mode = ImageDiffConfig::Mode::OR; + } else { + outConfig->mode = ImageDiffConfig::Mode::LEAF; + } + current++; + } else if (key == "swizzle") { + utils::CString val = getTokenString(json, tokens[current]); + if (val == "BGRA") { + outConfig->swizzle = ImageDiffConfig::Swizzle::BGRA; + } else { + outConfig->swizzle = ImageDiffConfig::Swizzle::RGBA; + } + current++; + } else if (key == "channelMask") { + utils::CString val = getTokenString(json, tokens[current]); + outConfig->channelMask = (uint8_t) strtol(val.c_str(), nullptr, 10); + current++; + } else if (key == "maxFailingPixelsFraction") { + utils::CString val = getTokenString(json, tokens[current]); + outConfig->maxFailingPixelsFraction = strtof(val.c_str(), nullptr); + current++; + } else if (key == "maxAbsDiff") { + utils::CString val = getTokenString(json, tokens[current]); + outConfig->maxAbsDiff = strtof(val.c_str(), nullptr); + current++; + } else if (key == "children") { + if (tokens[current].type == JSMN_ARRAY) { + int const childCount = tokens[current].size; + current++; + for (int check = 0; check < childCount; ++check) { + ImageDiffConfig child; + current = parseJsonRecursive(json, tokens, current, &child, depth + 1); + if (current < 0) return -1; + outConfig->children.push_back(child); + } + } else { + current++; + } + } else { + current++; + } + } + return current; +} +} // namespace + +// ------------------------------------------------------------------------------------------------ +// Core Compare +// ------------------------------------------------------------------------------------------------ + +ImageDiffResult compare(LinearImage const& reference, LinearImage const& candidate, + ImageDiffConfig const& config, LinearImage const* mask, bool generateDiffImage) { + ImageDiffResult result; + + if (reference.getWidth() != candidate.getWidth() || + reference.getHeight() != candidate.getHeight()) { + result.status = ImageDiffResult::Status::SIZE_MISMATCH; + return result; + } + + uint32_t const width = reference.getWidth(); + uint32_t const height = reference.getHeight(); + uint32_t const channels = reference.getChannels(); + + if (generateDiffImage) { + result.diffImage = LinearImage(width, height, channels); + if (mask) { + result.maskImage = LinearImage(width, height, 1); + } + } + + float const* dataRef = reference.getPixelRef(); + float const* dataCand = candidate.getPixelRef(); + float const* dataMask = mask ? mask->getPixelRef() : nullptr; + float* dataDiff = generateDiffImage ? result.diffImage.getPixelRef() : nullptr; + float* dataMaskOut = (generateDiffImage && mask) ? result.maskImage.getPixelRef() : nullptr; + + // We assume data is packed float, row-major. + size_t const totalPixels = width * height; + + for (size_t i = 0; i < totalPixels; ++i) { + // Extract pixel + // LinearImage channels can vary. we assume at least RGB(A). + float pxRef[4] = { 0, 0, 0, 1 }; + float pxCand[4] = { 0, 0, 0, 1 }; + + for (uint32_t c = 0; c < channels && c < 4; ++c) { + pxRef[c] = dataRef[i * channels + c]; + pxCand[c] = dataCand[i * channels + c]; + } + + uint32_t const maskChannels = mask ? mask->getChannels() : 0; + float const maskVal = dataMask ? dataMask[i * maskChannels + 0] : 1.0f; + + bool const pass = checkPixelRecursive(config, pxRef, pxCand, maskVal, result.maxDiffFound); + + if (!pass) { + result.failingPixelCount++; + } else if (maskVal < 1.0f) { + // Check if it would have failed without the mask + bool const passUnmasked = checkPixelRecursive(config, pxRef, pxCand, 1.0f, nullptr); + if (!passUnmasked) { + result.maskedIgnoredPixelCount++; + } + } + + if (dataDiff) { + for (uint32_t c = 0; c < channels && c < 4; ++c) { + float const d = std::abs(pxRef[c] - pxCand[c]); // Unmasked + dataDiff[i * channels + c] = d; + } + } + if (dataMaskOut) { + dataMaskOut[i] = maskVal; + } + } + + float const failFrac = (float) result.failingPixelCount / (float) totalPixels; + bool const passed = failFrac <= config.maxFailingPixelsFraction; + if (!passed) { + result.status = ImageDiffResult::Status::PIXEL_DIFFERENCE; + } + + return result; +} + + +bool parseConfig(char const* json, size_t size, ImageDiffConfig* outConfig) { + jsmn_parser parser; + jsmn_init(&parser); + + int const count = jsmn_parse(&parser, json, size, nullptr, 0); + if (count < 0) { + return false; + } + + std::vector tokens(count); + jsmn_init(&parser); + jsmn_parse(&parser, json, size, tokens.data(), count); + + if (count < 1 || tokens[0].type != JSMN_OBJECT) { + return false; + } + + return parseJsonRecursive(json, tokens, 0, outConfig, 0) >= 0; +} + +// ------------------------------------------------------------------------------------------------ +// JSON Serialization +// ------------------------------------------------------------------------------------------------ + +utils::CString serializeResult(ImageDiffResult const& result) { + utils::CString s = "{"; + s += "\"status\": "; + s += std::to_string((int) result.status).c_str(); + s += ", "; + s += "\"passed\": "; + s += (result.status == ImageDiffResult::Status::PASSED ? "true" : "false"); + s += ", "; + s += "\"failingPixelCount\": "; + s += std::to_string(result.failingPixelCount).c_str(); + s += ", "; + s += "\"maxDiffFound\": ["; + s += std::to_string(result.maxDiffFound[0]).c_str(); + s += ", "; + s += std::to_string(result.maxDiffFound[1]).c_str(); + s += ", "; + s += std::to_string(result.maxDiffFound[2]).c_str(); + s += ", "; + s += std::to_string(result.maxDiffFound[3]).c_str(); + s += "]"; + s += "}"; + return s; +} + +// ------------------------------------------------------------------------------------------------ +// 8-bit Support +// ------------------------------------------------------------------------------------------------ + +ImageDiffResult compare(Bitmap const& reference, Bitmap const& candidate, + ImageDiffConfig const& config, Bitmap const* mask, bool generateDiffImage) { + ImageDiffResult result; + + if (reference.width != candidate.width || reference.height != candidate.height) { + result.status = ImageDiffResult::Status::SIZE_MISMATCH; + return result; + } + + uint32_t const width = reference.width; + uint32_t const height = reference.height; + + if (generateDiffImage) { + result.diffImage = LinearImage(width, height, 4); + if (mask) { + result.maskImage = LinearImage(width, height, 1); + } + } + float* dataDiff = generateDiffImage ? result.diffImage.getPixelRef() : nullptr; + float* dataMaskOut = (generateDiffImage && mask) ? result.maskImage.getPixelRef() : nullptr; + + uint8_t const* refBytes = (uint8_t const*) reference.data; + uint8_t const* candBytes = (uint8_t const*) candidate.data; + uint8_t const* maskBytes = mask ? (uint8_t const*) mask->data : nullptr; + + bool const isBGRA = (config.swizzle == ImageDiffConfig::Swizzle::BGRA); + + for (uint32_t y = 0; y < height; ++y) { + uint8_t const* rowRef = refBytes + y * reference.stride; + uint8_t const* rowCand = candBytes + y * candidate.stride; + uint8_t const* rowMask = mask ? maskBytes + y * mask->stride : nullptr; + + for (uint32_t x = 0; x < width; ++x) { + float pxRef[4], pxCand[4]; + + auto unpackPixel = [isBGRA](uint8_t const* src, float* out) { + if (!isBGRA) { + out[0] = (float) src[0] / 255.0f; + out[1] = (float) src[1] / 255.0f; + out[2] = (float) src[2] / 255.0f; + out[3] = (float) src[3] / 255.0f; + } else { + out[0] = (float) src[2] / 255.0f; + out[1] = (float) src[1] / 255.0f; + out[2] = (float) src[0] / 255.0f; + out[3] = (float) src[3] / 255.0f; + } + }; + + unpackPixel(rowRef + x * 4, pxRef); + unpackPixel(rowCand + x * 4, pxCand); + + float maskVal = 1.0f; + if (rowMask) { + maskVal = (float) rowMask[x] / 255.0f; + } + + bool const pass = + checkPixelRecursive(config, pxRef, pxCand, maskVal, result.maxDiffFound); + + if (!pass) { + result.failingPixelCount++; + } else if (maskVal < 1.0f) { + // Check if it would have failed without the mask + bool const passUnmasked = checkPixelRecursive(config, pxRef, pxCand, 1.0f, nullptr); + if (!passUnmasked) { + result.maskedIgnoredPixelCount++; + } + } + + if (dataDiff) { + size_t const idx = (y * width + x) * 4; + for (int c = 0; c < 4; ++c) { + dataDiff[idx + c] = std::abs(pxRef[c] - pxCand[c]); + } + } + if (dataMaskOut) { + dataMaskOut[y * width + x] = maskVal; + } + } + } + + float const failFrac = (float) result.failingPixelCount / (float) (width * height); + bool const passed = failFrac <= config.maxFailingPixelsFraction; + if (!passed) { + result.status = ImageDiffResult::Status::PIXEL_DIFFERENCE; + } + + return result; +} + +} // namespace imagediff diff --git a/libs/imagediff/tests/test_ImageDiff.cpp b/libs/imagediff/tests/test_ImageDiff.cpp new file mode 100644 index 0000000000..6398456743 --- /dev/null +++ b/libs/imagediff/tests/test_ImageDiff.cpp @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include + +#include + +using namespace imagediff; +using namespace image; + +class ImageDiffTest : public testing::Test { +protected: + LinearImage createImage(uint32_t w, uint32_t h, float val) { + LinearImage img(w, h, 3); + float* p = img.getPixelRef(); + for (size_t i = 0; i < w * h * 3; ++i) { + p[i] = val; + } + return img; + } + + void setPixel(LinearImage& img, uint32_t x, uint32_t y, float r, float g, float b) { + float* p = img.getPixelRef(x, y); + p[0] = r; + p[1] = g; + p[2] = b; + } +}; + +TEST_F(ImageDiffTest, ExactMatch) { + LinearImage img1 = createImage(10, 10, 0.5f); + LinearImage img2 = createImage(10, 10, 0.5f); + + ImageDiffConfig config; + auto result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); + EXPECT_EQ(result.failingPixelCount, 0); +} + +TEST_F(ImageDiffTest, AbsThreshold) { + LinearImage img1 = createImage(10, 10, 0.5f); + LinearImage img2 = createImage(10, 10, 0.5f); + setPixel(img2, 5, 5, 0.6f, 0.5f, 0.5f); // 0.1 diff on Red + + ImageDiffConfig config; + config.maxAbsDiff = 0.05f; // Should fail + auto result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PIXEL_DIFFERENCE); + EXPECT_EQ(result.failingPixelCount, 1); + + config.maxAbsDiff = 0.15f; // Should pass + result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); +} + +TEST_F(ImageDiffTest, Masking) { + LinearImage img1 = createImage(10, 10, 0.5f); + LinearImage img2 = createImage(10, 10, 0.5f); + setPixel(img2, 5, 5, 1.0f, 0.5f, 0.5f); // Huge diff + + // Mask with 0 at 5,5 + LinearImage mask(10, 10, 1); + float* mp = mask.getPixelRef(); + for (int i = 0; i < 100; ++i) mp[i] = 1.0f; + *mask.getPixelRef(5, 5) = 0.0f; + + ImageDiffConfig config; + config.maxAbsDiff = 0.1f; + + auto result = compare(img1, img2, config, &mask); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); // Mask ignored the error +} + +TEST_F(ImageDiffTest, LogicAndOr) { + LinearImage img1 = createImage(1, 1, 0.5f); + LinearImage img2 = createImage(1, 1, 0.6f); // Diff 0.1 + + // OR Mode: Fail child 1, Pass child 2 -> Should PASS + ImageDiffConfig configOR; + configOR.mode = ImageDiffConfig::Mode::OR; + configOR.children.resize(2); + + // Child 1: Strict (Fail) + configOR.children[0].maxAbsDiff = 0.05f; + // Child 2: Relaxed (Pass) + configOR.children[1].maxAbsDiff = 0.15f; + + auto result = compare(img1, img2, configOR); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); + + // AND Mode: Pass child 1, Fail child 2 -> Should FAIL + ImageDiffConfig configAND; + configAND.mode = ImageDiffConfig::Mode::AND; + configAND.children.resize(2); + + // Child 1: Relaxed (Pass) + configAND.children[0].maxAbsDiff = 0.15f; + // Child 2: Strict (Fail) + configAND.children[1].maxAbsDiff = 0.05f; + + result = compare(img1, img2, configAND); + EXPECT_EQ(result.status, ImageDiffResult::Status::PIXEL_DIFFERENCE); +} + +TEST_F(ImageDiffTest, GlobalFailureFraction) { + LinearImage img1 = createImage(10, 10, 0.5f); + LinearImage img2 = createImage(10, 10, 0.5f); + + // Fail 2 pixels (2%) + setPixel(img2, 0, 0, 1.0f, 0.5f, 0.5f); + setPixel(img2, 1, 0, 1.0f, 0.5f, 0.5f); + + ImageDiffConfig config; + config.maxFailingPixelsFraction = 0.01f; // 1% allowed + auto result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PIXEL_DIFFERENCE); + + config.maxFailingPixelsFraction = 0.03f; // 3% allowed + result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); +} + +TEST_F(ImageDiffTest, JSONSerialization) { + ImageDiffConfig config; + config.mode = ImageDiffConfig::Mode::AND; + config.maxAbsDiff = 0.5f; + config.children.resize(1); + config.children[0].maxAbsDiff = 0.1f; + + // Test parsing + char const* json = R"({ + "mode": "OR", + "swizzle": "BGRA", + "maxAbsDiff": "0.2", + "children": [ + {"maxAbsDiff": "0.1"} + ] + })"; + + ImageDiffConfig parsed; + bool success = parseConfig(json, strlen(json), &parsed); + EXPECT_TRUE(success); + EXPECT_EQ(parsed.mode, ImageDiffConfig::Mode::OR); + EXPECT_EQ(parsed.swizzle, ImageDiffConfig::Swizzle::BGRA); + EXPECT_FLOAT_EQ(parsed.maxAbsDiff, 0.2f); + EXPECT_EQ(parsed.children.size(), 1); + EXPECT_FLOAT_EQ(parsed.children[0].maxAbsDiff, 0.1f); +} +TEST_F(ImageDiffTest, Uint8Test) { + uint32_t w = 2, h = 2; + // RGBA (Little Endian uint32 0xAABBGGRR) -> 0xFF0000FF is R=255, G=0, B=0, A=255 + std::vector b1(w * h, 0xFF0000FF); + std::vector b2(w * h, 0xFF0000FF); + + Bitmap bmp1 = { w, h, w * 4, b1.data() }; + Bitmap bmp2 = { w, h, w * 4, b2.data() }; + + ImageDiffConfig config; + config.swizzle = ImageDiffConfig::Swizzle::RGBA; + auto result = compare(bmp1, bmp2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); + + // Change one pixel in b2 to slight red change + // 0xFF0000FE -> R=254. Diff 1/255 ~= 0.0039 + b2[0] = 0xFF0000FE; + config.maxAbsDiff = 0.001f; // Should fail + result = compare(bmp1, bmp2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PIXEL_DIFFERENCE); + EXPECT_EQ(result.failingPixelCount, 1); + + config.maxAbsDiff = 0.005f; // Should pass + result = compare(bmp1, bmp2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); +} + +TEST_F(ImageDiffTest, BitmapMasking) { + uint32_t w = 2, h = 2; + std::vector b1(w * h, 0xFF0000FF); + std::vector b2(w * h, 0xFF0000FF); + b2[0] = 0xFF0000FE; // Slight diff (1/255 ~= 0.0039) + + std::vector mask = { 0, 255, 255, 255 }; // Mask out the diff at pixel 0 + + Bitmap bmp1 = { w, h, w * 4, b1.data() }; + Bitmap bmp2 = { w, h, w * 4, b2.data() }; + Bitmap bmpMask = { w, h, w, mask.data() }; + + ImageDiffConfig config; + config.maxAbsDiff = 0.001f; + + auto result = compare(bmp1, bmp2, config, &bmpMask); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); +} + +TEST_F(ImageDiffTest, SizeMismatch) { + LinearImage img1 = createImage(10, 10, 0.5f); + LinearImage img2 = createImage(11, 10, 0.5f); + + ImageDiffConfig config; + auto result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::SIZE_MISMATCH); + EXPECT_EQ(result.status, ImageDiffResult::Status::SIZE_MISMATCH); + + // Test 8-bit overload + uint32_t d1[4] = { 0 }; + uint32_t d2[6] = { 0 }; + Bitmap b1 = { 2, 2, 8, d1 }; + Bitmap b2 = { 3, 2, 12, d2 }; + result = compare(b1, b2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::SIZE_MISMATCH); + EXPECT_EQ(result.status, ImageDiffResult::Status::SIZE_MISMATCH); +} + +TEST_F(ImageDiffTest, DiffImageGeneration) { + // Use 4-channel images to test Alpha diff + LinearImage img1(2, 1, 4); + LinearImage img2(2, 1, 4); + + // Clear images (Black Transparent) + memset(img1.getPixelRef(), 0, 2 * 1 * 4 * sizeof(float)); + memset(img2.getPixelRef(), 0, 2 * 1 * 4 * sizeof(float)); + + // Make them opaque + img1.getPixelRef(0, 0)[3] = 1.0f; + img1.getPixelRef(1, 0)[3] = 1.0f; + img2.getPixelRef(0, 0)[3] = 1.0f; + img2.getPixelRef(1, 0)[3] = 1.0f; + + // Pixel 0: No diff (0,0,0,1 vs 0,0,0,1) + // Pixel 1: Diff 0.5 in R + float* p2 = img2.getPixelRef(1, 0); + p2[0] = 0.5f; + + // Mask for Pixel 1 = 0.5 + LinearImage mask(2, 1, 1); + *mask.getPixelRef(1, 0) = 0.5f; + + ImageDiffConfig config; + config.maxAbsDiff = 0.1f; + + // Enable diff generation + auto result = compare(img1, img2, config, &mask, true); + + // Check Status (Weighted diff = 0.5 * 0.5 = 0.25 > 0.1 -> Fail) + EXPECT_EQ(result.status, ImageDiffResult::Status::PIXEL_DIFFERENCE); + EXPECT_EQ(result.failingPixelCount, 1); + + // Verify MaskedIgnored count (Pixel 0: Pass, Pixel 1: Fail) + // Wait, let's make a pixel that passes ONLY due to mask. + // Pixel 0: Diff 0.2, Mask 0.2 -> Weighted 0.04 (Pass). Unmasked 0.2 (Fail). + float* p1_0 = img1.getPixelRef(0, 0); + p1_0[0] = 0.0f; + float* p2_0 = img2.getPixelRef(0, 0); + p2_0[0] = 0.2f; + *mask.getPixelRef(0, 0) = 0.2f; + + result = compare(img1, img2, config, &mask, true); + + // Pixel 0: Diff 0.2, Mask 0.2 -> 0.04 < 0.1 (Pass). Unmasked 0.2 > 0.1 (Fail). + // Pixel 1: Diff 0.5, Mask 0.5 -> 0.25 > 0.1 (Fail). + + EXPECT_EQ(result.failingPixelCount, 1); // Pixel 1 fails + EXPECT_EQ(result.maskedIgnoredPixelCount, 1); // Pixel 0 ignored + + // Verify Diff Image Content + // Diff Image should have unmasked diff in RGB, and alpha diff in A. + // Pixel 0: |0 - 0.2| = 0.2 in R. A should be 0 (no alpha diff). + float const* diffP0 = result.diffImage.getPixelRef(0, 0); + EXPECT_FLOAT_EQ(diffP0[0], 0.2f); // R + EXPECT_FLOAT_EQ(diffP0[3], 0.0f); // A (Mask is NOT here anymore) + + // Pixel 1: |0 - 0.5| = 0.5 in R. + float const* diffP1 = result.diffImage.getPixelRef(1, 0); + EXPECT_FLOAT_EQ(diffP1[0], 0.5f); + + // Verify Mask Image Content + ASSERT_EQ(result.maskImage.getWidth(), 2); + float const* maskP0 = result.maskImage.getPixelRef(0, 0); + EXPECT_FLOAT_EQ(maskP0[0], 0.2f); + + float const* maskP1 = result.maskImage.getPixelRef(1, 0); + EXPECT_FLOAT_EQ(maskP1[0], 0.5f); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}