diff --git a/android/filament-utils-android/src/main/cpp/ImageDiff.cpp b/android/filament-utils-android/src/main/cpp/ImageDiff.cpp index 338c92abb9..a365729fae 100644 --- a/android/filament-utils-android/src/main/cpp/ImageDiff.cpp +++ b/android/filament-utils-android/src/main/cpp/ImageDiff.cpp @@ -72,6 +72,8 @@ jobject createResult(JNIEnv* env, ImageDiffResult const& result, bool generateDi jfieldID statusField = env->GetFieldID(resultClass, "status", "Lcom/google/android/filament/utils/ImageDiff$Result$Status;"); jfieldID failingCountField = env->GetFieldID(resultClass, "failingPixelCount", "J"); jfieldID maxDiffField = env->GetFieldID(resultClass, "maxDiffFound", "[F"); + jfieldID avgErrorField = env->GetFieldID(resultClass, "averageError", "[F"); + jfieldID errHistField = env->GetFieldID(resultClass, "errorHistogram", "[I"); jfieldID diffImageField = env->GetFieldID(resultClass, "diffImage", "Landroid/graphics/Bitmap;"); // Map Status enum @@ -98,6 +100,24 @@ jobject createResult(JNIEnv* env, ImageDiffResult const& result, bool generateDi env->SetFloatArrayRegion(maxDiffArray, 0, 4, result.maxDiffFound); env->SetObjectField(resultObj, maxDiffField, maxDiffArray); + float avgErr[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; + if (result.failingPixelCount > 0) { + for (int i = 0; i < 4; ++i) { + avgErr[i] = result.errorSum[i] / (float) result.failingPixelCount; + } + } + jfloatArray avgErrorArray = env->NewFloatArray(4); + env->SetFloatArrayRegion(avgErrorArray, 0, 4, avgErr); + env->SetObjectField(resultObj, avgErrorField, avgErrorArray); + + jintArray errHistArray = env->NewIntArray(10); + jint hist[10]; + for (int i = 0; i < 10; ++i) { + hist[i] = (jint) result.errorHistogram[i]; + } + env->SetIntArrayRegion(errHistArray, 0, 10, hist); + env->SetObjectField(resultObj, errHistField, errHistArray); + if (generateDiff && result.diffImage.getWidth() > 0) { jclass bitmapClass = env->FindClass("android/graphics/Bitmap"); jmethodID createBitmap = env->GetStaticMethodID(bitmapClass, "createBitmap", diff --git a/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ImageDiff.java b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ImageDiff.java index 1b8e09a8a1..a894b3a1b5 100644 --- a/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ImageDiff.java +++ b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ImageDiff.java @@ -50,6 +50,8 @@ public class ImageDiff { public Status status; public long failingPixelCount; public float[] maxDiffFound; // [R, G, B, A] + public float[] averageError; // [R, G, B, A] for failing pixels + public int[] errorHistogram; // 10 bins of magnitude distribution public Bitmap diffImage; // Null if not generated } diff --git a/libs/imagediff/README.md b/libs/imagediff/README.md index 8f337aaaca..add0c36102 100644 --- a/libs/imagediff/README.md +++ b/libs/imagediff/README.md @@ -1,15 +1,27 @@ # 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. +`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. +- **Robustness Options**: Account for GPU rendering variations using positional tolerance + (`shiftRadius`) and local area averaging (`blurRadius`) to handle sub-pixel shifts and dithering + artifacts without complex distribution modeling. +- **Detailed Failure Metrics**: When a comparison fails, the result includes an `averageError` array + and a 10-bin `errorHistogram` to help diagnose whether the failure was a subtle precision shift or + a massive geometry error. +- **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 @@ -23,6 +35,10 @@ imagediff::ImageDiffConfig config; config.maxAbsDiff = 0.05f; // Allow 5% absolute difference config.swizzle = imagediff::ImageDiffConfig::Swizzle::RGBA; // Default +// Enable GPU rendering robustness: +config.shiftRadius = 1; // Allows a 1-pixel shift (absorbs rasterization/MSAA differences) +config.blurRadius = 0; // Set >0 to compare local averages (absorbs dithering) + // 2. Prepare Images imagediff::Bitmap ref = {width, height, stride, refData}; imagediff::Bitmap cand = {width, height, stride, candData}; @@ -37,7 +53,7 @@ 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. + // Inspect result.errorHistogram, result.averageError, diffImage, and maskImage. } ``` @@ -49,12 +65,24 @@ if (result.status == imagediff::ImageDiffResult::Status::PASSED) { "swizzle": "RGBA", "children": [ { - "maxAbsDiff": "0.01" // Strict pass + "description": "Strict check for exact matches (fastest)", + "maxAbsDiff": "0.01", + "shiftRadius": 0, + "blurRadius": 0 }, { - "maxFailingPixelsFraction": "0.05", // OR allow 5% failures - "maxAbsDiff": "0.1" + "description": "Positional check: Allow 1-pixel edge shift but require similar color", + "maxAbsDiff": "0.05", + "shiftRadius": 1, + "blurRadius": 0 + }, + { + "description": "Distribution check: Compare local 5x5 averages to allow dithering", + "maxAbsDiff": "0.02", + "shiftRadius": 0, + "blurRadius": 2, + "maxFailingPixelsFraction": "0.005" } ] } -``` +``` \ No newline at end of file diff --git a/libs/imagediff/include/imagediff/ImageDiff.h b/libs/imagediff/include/imagediff/ImageDiff.h index 79d74934d9..4bc3c7572c 100644 --- a/libs/imagediff/include/imagediff/ImageDiff.h +++ b/libs/imagediff/include/imagediff/ImageDiff.h @@ -35,6 +35,10 @@ struct ImageDiffConfig { uint8_t channelMask = (uint8_t) Channel::RGBA; float maxAbsDiff = 0.0f; // Max absolute difference allowed (e.g. 5/255) + // Robustness Parameters + uint8_t shiftRadius = 0; // Search a (2r+1) x (2r+1) window in the reference image + uint8_t blurRadius = 0; // Compare local (2r+1) x (2r+1) area averages + // For AND/OR mode: std::vector children; @@ -57,6 +61,11 @@ struct ImageDiffResult { float maxDiffFound[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; // [R, G, B, A] + float errorSum[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; // Sum of failing pixel errors + uint32_t errorHistogram[10] = { + 0 + }; // Decile buckets of max errors: (0-0.1], (0.1-0.2], ..., (0.9-1.0] + image::LinearImage diffImage; // Populated if generateDiffImage is true image::LinearImage maskImage; // Populated if generateDiffImage is true and mask is present diff --git a/libs/imagediff/src/ImageDiff.cpp b/libs/imagediff/src/ImageDiff.cpp index 18d2028d42..7abb561432 100644 --- a/libs/imagediff/src/ImageDiff.cpp +++ b/libs/imagediff/src/ImageDiff.cpp @@ -22,6 +22,7 @@ #include #include // strtof, strtol #include +#include #include // JSMN inclusion @@ -44,38 +45,108 @@ bool checkThresholds(float maxAbsDiff, float valRef, float valCand, float 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]; +bool checkPixelRecursive(ImageDiffConfig const& config, uint32_t x, uint32_t y, uint32_t width, + uint32_t height, float maskVal, float* outMaxDiffs, + std::function const& fetchRef, + std::function const& fetchCand) { - float const diffUnmasked = std::abs(valRef - valCand); - if (outMaxDiffs) { - outMaxDiffs[i] = std::max(outMaxDiffs[i], diffUnmasked); + if (config.mode == ImageDiffConfig::Mode::LEAF) { + bool passed = false; + + // 1. Positional Robustness (shiftRadius) + // Instead of strict pixel-to-pixel matching, we search a local neighborhood + // (2*sRad + 1)^2 window in the reference image. If ANY pixel in that window + // matches our candidate pixel (within the threshold), we consider it a pass. + // This absorbs 1-pixel shifts caused by rasterization rules or MSAA artifacts. + int const sRad = config.shiftRadius; + for (int dy = -sRad; dy <= sRad; ++dy) { + for (int dx = -sRad; dx <= sRad; ++dx) { + // Clamp search window to image boundaries + int const nx = std::max(0, std::min((int) width - 1, (int) x + dx)); + int const ny = std::max(0, std::min((int) height - 1, (int) y + dy)); + + float pxRef[4] = { 0, 0, 0, 0 }; + float pxCand[4] = { 0, 0, 0, 0 }; + + if (config.blurRadius > 0) { + // 2. Frequency Robustness (blurRadius) + // We apply a local box-blur (average) before comparing. This + // elegantly smooths out high-frequency noise like hardware dithering + // matrices or varying texture filtering weights without requiring + // complex image distribution metrics. + int const bRad = config.blurRadius; + float count = 0.0f; + for (int by = -bRad; by <= bRad; ++by) { + for (int bx = -bRad; bx <= bRad; ++bx) { + // Compute blur neighborhood around current shift position + int const bnx = std::max(0, std::min((int) width - 1, nx + bx)); + int const bny = std::max(0, std::min((int) height - 1, ny + by)); + // Compute blur neighborhood around candidate pixel + int const bcx = std::max(0, std::min((int) width - 1, (int) x + bx)); + int const bcy = std::max(0, std::min((int) height - 1, (int) y + by)); + + float tRef[4], tCand[4]; + fetchRef(bnx, bny, tRef); + fetchCand(bcx, bcy, tCand); + for (int c = 0; c < 4; ++c) { + pxRef[c] += tRef[c]; + pxCand[c] += tCand[c]; + } + count += 1.0f; + } + } + for (int c = 0; c < 4; ++c) { + pxRef[c] /= count; + pxCand[c] /= count; + } + } else { + // Fast path: strict fetching if no blur is required + fetchRef(nx, ny, pxRef); + fetchCand(x, y, pxCand); } - if (!checkThresholds(config.maxAbsDiff, valRef, valCand, maskVal)) { - passed = false; + bool match = true; + Channel const channels[] = { Channel::R, Channel::G, Channel::B, Channel::A }; + for (int c = 0; c < 4; ++c) { + if (config.channelMask & (uint8_t) channels[c]) { + float const valRef = pxRef[c]; + float const valCand = pxCand[c]; + float const diffUnmasked = std::abs(valRef - valCand); + + // We only record max diffs for the exact center pixel (dx=0, dy=0) + // to ensure failure statistics represent the true location, not the search + // space. + if (dx == 0 && dy == 0 && outMaxDiffs) { + outMaxDiffs[c] = std::max(outMaxDiffs[c], diffUnmasked); + } + + if (!checkThresholds(config.maxAbsDiff, valRef, valCand, maskVal)) { + match = false; + } + } + } + + // If we found a matching pixel in the shift radius, we can stop searching. + if (match) { + passed = true; + break; } } + if (passed) break; } return passed; } else if (config.mode == ImageDiffConfig::Mode::AND) { for (auto const& child: config.children) { - if (!checkPixelRecursive(child, pxRef, pxCand, maskVal, outMaxDiffs)) { + if (!checkPixelRecursive(child, x, y, width, height, maskVal, outMaxDiffs, fetchRef, + fetchCand)) { return false; } } return true; } else if (config.mode == ImageDiffConfig::Mode::OR) { for (auto const& child: config.children) { - if (checkPixelRecursive(child, pxRef, pxCand, maskVal, outMaxDiffs)) { + if (checkPixelRecursive(child, x, y, width, height, maskVal, outMaxDiffs, fetchRef, + fetchCand)) { return true; } } @@ -150,6 +221,14 @@ int parseJsonRecursive(char const* json, std::vector const& tokens, i utils::CString val = getTokenString(json, tokens[current]); outConfig->maxAbsDiff = strtof(val.c_str(), nullptr); current++; + } else if (key == "shiftRadius") { + utils::CString val = getTokenString(json, tokens[current]); + outConfig->shiftRadius = (uint8_t) strtol(val.c_str(), nullptr, 10); + current++; + } else if (key == "blurRadius") { + utils::CString val = getTokenString(json, tokens[current]); + outConfig->blurRadius = (uint8_t) strtol(val.c_str(), nullptr, 10); + current++; } else if (key == "children") { if (tokens[current].type == JSMN_ARRAY) { int const childCount = tokens[current].size; @@ -205,40 +284,72 @@ ImageDiffResult compare(LinearImage const& reference, LinearImage const& candida // 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 }; - + auto fetchRef = [dataRef, width, channels](uint32_t cx, uint32_t cy, float* out) { + size_t const idx = (cy * width + cx) * channels; for (uint32_t c = 0; c < channels && c < 4; ++c) { - pxRef[c] = dataRef[i * channels + c]; - pxCand[c] = dataCand[i * channels + c]; + out[c] = dataRef[idx + c]; } + for (uint32_t c = channels; c < 4; ++c) { + out[c] = (c == 3) ? 1.0f : 0.0f; + } + }; + auto fetchCand = [dataCand, width, channels](uint32_t cx, uint32_t cy, float* out) { + size_t const idx = (cy * width + cx) * channels; + for (uint32_t c = 0; c < channels && c < 4; ++c) { + out[c] = dataCand[idx + c]; + } + for (uint32_t c = channels; c < 4; ++c) { + out[c] = (c == 3) ? 1.0f : 0.0f; + } + }; + + for (size_t i = 0; i < totalPixels; ++i) { + uint32_t const x = i % width; + uint32_t const y = i / width; 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); + float localMaxDiff[4] = { 0, 0, 0, 0 }; + bool const pass = checkPixelRecursive(config, x, y, width, height, maskVal, localMaxDiff, + fetchRef, fetchCand); + + for (int c = 0; c < 4; ++c) { + result.maxDiffFound[c] = std::max(result.maxDiffFound[c], localMaxDiff[c]); + } if (!pass) { result.failingPixelCount++; + float maxError = 0.0f; + for (int c = 0; c < 4; ++c) { + result.errorSum[c] += localMaxDiff[c]; + maxError = std::max(maxError, localMaxDiff[c]); + } + int bin = (int) (maxError * 10.0f); + if (bin > 9) bin = 9; + if (bin < 0) bin = 0; + result.errorHistogram[bin]++; } else if (maskVal < 1.0f) { // Check if it would have failed without the mask - bool const passUnmasked = checkPixelRecursive(config, pxRef, pxCand, 1.0f, nullptr); + bool const passUnmasked = checkPixelRecursive(config, x, y, width, height, 1.0f, + nullptr, fetchRef, fetchCand); 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 (dataDiff || dataMaskOut) { + float pxRef[4], pxCand[4]; + fetchRef(x, y, pxRef); + fetchCand(x, y, pxCand); + if (dataDiff) { + for (uint32_t c = 0; c < channels && c < 4; ++c) { + dataDiff[i * channels + c] = std::abs(pxRef[c] - pxCand[c]); // Unmasked + } + } + if (dataMaskOut) { + dataMaskOut[i] = maskVal; } - } - if (dataMaskOut) { - dataMaskOut[i] = maskVal; } } @@ -295,7 +406,34 @@ utils::CString serializeResult(ImageDiffResult const& result) { s += std::to_string(result.maxDiffFound[2]).c_str(); s += ", "; s += std::to_string(result.maxDiffFound[3]).c_str(); + s += "], "; + + float const avgErrR = + result.failingPixelCount ? result.errorSum[0] / result.failingPixelCount : 0.0f; + float const avgErrG = + result.failingPixelCount ? result.errorSum[1] / result.failingPixelCount : 0.0f; + float const avgErrB = + result.failingPixelCount ? result.errorSum[2] / result.failingPixelCount : 0.0f; + float const avgErrA = + result.failingPixelCount ? result.errorSum[3] / result.failingPixelCount : 0.0f; + + s += "\"averageError\": ["; + s += std::to_string(avgErrR).c_str(); + s += ", "; + s += std::to_string(avgErrG).c_str(); + s += ", "; + s += std::to_string(avgErrB).c_str(); + s += ", "; + s += std::to_string(avgErrA).c_str(); + s += "], "; + + s += "\"errorHistogram\": ["; + for (int i = 0; i < 10; ++i) { + s += std::to_string(result.errorHistogram[i]).c_str(); + if (i < 9) s += ", "; + } s += "]"; + s += "}"; return s; } @@ -331,57 +469,79 @@ ImageDiffResult compare(Bitmap const& reference, Bitmap const& candidate, bool const isBGRA = (config.swizzle == ImageDiffConfig::Swizzle::BGRA); + 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; + } + }; + + auto fetchRef = [refBytes, &reference, unpackPixel](uint32_t cx, uint32_t cy, float* out) { + uint8_t const* src = refBytes + cy * reference.stride + cx * 4; + unpackPixel(src, out); + }; + auto fetchCand = [candBytes, &candidate, unpackPixel](uint32_t cx, uint32_t cy, float* out) { + uint8_t const* src = candBytes + cy * candidate.stride + cx * 4; + unpackPixel(src, out); + }; + 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); + float localMaxDiff[4] = { 0, 0, 0, 0 }; + bool const pass = checkPixelRecursive(config, x, y, width, height, maskVal, + localMaxDiff, fetchRef, fetchCand); + + for (int c = 0; c < 4; ++c) { + result.maxDiffFound[c] = std::max(result.maxDiffFound[c], localMaxDiff[c]); + } if (!pass) { result.failingPixelCount++; + float maxError = 0.0f; + for (int c = 0; c < 4; ++c) { + result.errorSum[c] += localMaxDiff[c]; + maxError = std::max(maxError, localMaxDiff[c]); + } + int bin = (int) (maxError * 10.0f); + if (bin > 9) bin = 9; + if (bin < 0) bin = 0; + result.errorHistogram[bin]++; } else if (maskVal < 1.0f) { // Check if it would have failed without the mask - bool const passUnmasked = checkPixelRecursive(config, pxRef, pxCand, 1.0f, nullptr); + bool const passUnmasked = checkPixelRecursive(config, x, y, width, height, 1.0f, + nullptr, fetchRef, fetchCand); 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 (dataDiff || dataMaskOut) { + float pxRef[4], pxCand[4]; + fetchRef(x, y, pxRef); + fetchCand(x, y, pxCand); + 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; } - } - if (dataMaskOut) { - dataMaskOut[y * width + x] = maskVal; } } } diff --git a/libs/imagediff/tests/test_ImageDiff.cpp b/libs/imagediff/tests/test_ImageDiff.cpp index 6398456743..7ada8bce77 100644 --- a/libs/imagediff/tests/test_ImageDiff.cpp +++ b/libs/imagediff/tests/test_ImageDiff.cpp @@ -136,6 +136,52 @@ TEST_F(ImageDiffTest, GlobalFailureFraction) { EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); } +TEST_F(ImageDiffTest, ShiftRadius) { + LinearImage img1 = createImage(10, 10, 0.5f); + LinearImage img2 = createImage(10, 10, 0.5f); + + // Shift the color by 1 pixel to the right in img2 + setPixel(img1, 5, 5, 1.0f, 0.0f, 0.0f); + setPixel(img2, 6, 5, 1.0f, 0.0f, 0.0f); + + ImageDiffConfig config; + config.maxAbsDiff = 0.05f; + + // Strict comparison should fail + auto result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PIXEL_DIFFERENCE); + + // With shiftRadius = 1, it should find the pixel nearby and pass + config.shiftRadius = 1; + result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); +} + +TEST_F(ImageDiffTest, BlurRadius) { + LinearImage img1 = createImage(10, 10, 0.5f); + LinearImage img2 = createImage(10, 10, 0.5f); + + // Dithering/Noise: add noise that averages out to 0 + // img1 has 0.5 everywhere + // img2 has 0.6 and 0.4 alternating (average is 0.5) + setPixel(img2, 5, 5, 0.6f, 0.5f, 0.5f); + setPixel(img2, 6, 5, 0.4f, 0.5f, 0.5f); + setPixel(img2, 5, 6, 0.4f, 0.5f, 0.5f); + setPixel(img2, 6, 6, 0.6f, 0.5f, 0.5f); + + ImageDiffConfig config; + config.maxAbsDiff = 0.05f; + + // Strict comparison should fail because pixel diff is 0.1 + auto result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PIXEL_DIFFERENCE); + + // With blurRadius = 1, it compares the 3x3 average, which is much closer to 0.5 + config.blurRadius = 1; + result = compare(img1, img2, config); + EXPECT_EQ(result.status, ImageDiffResult::Status::PASSED); +} + TEST_F(ImageDiffTest, JSONSerialization) { ImageDiffConfig config; config.mode = ImageDiffConfig::Mode::AND; diff --git a/tools/diffimg/tests/README.md b/tools/diffimg/tests/README.md new file mode 100644 index 0000000000..3c2b7280bd --- /dev/null +++ b/tools/diffimg/tests/README.md @@ -0,0 +1,51 @@ +# DiffImg Python Tests + +This directory contains a suite of synthetic image tests to validate the `diffimg` tool's robustness configurations (`shiftRadius` and `blurRadius`). + +## Files +- `gen_images.py`: A Python script that generates synthetic `PPM` images. + - `ref.ppm`: A reference image of a white circle. + - `cand_shift.ppm`: The same circle shifted by 1 pixel horizontally. + - `cand_blur.ppm`: The reference circle with high-frequency dithering noise added. +- `config_strict.json`: An exact-match configuration (`maxAbsDiff = 0.01`). +- `config_shift.json`: A configuration with a 1-pixel shift tolerance (`shiftRadius = 1`). +- `config_blur.json`: A configuration with a local area average check (`blurRadius = 1`). + +## How to Run + +Because `diffimg` depends on `libs/imageio`, which may use varying decoders based on OS capabilities, it is recommended to test with `PNG` files. + +**Prerequisites:** +- Python 3 +- `sips` (macOS native) or `ImageMagick` (for Linux/Windows) to convert PPM to PNG. +- A compiled `diffimg` binary. + +**Steps (macOS Example):** + +1. Navigate to this directory: + ```bash + cd tools/diffimg/tests/ + ``` + +2. Generate the PPM images: + ```bash + python3 gen_images.py + ``` + +3. Convert the generated PPM images to PNG (diffimg natively handles PNG cross-platform without needing LibTIFF or specific backends): + ```bash + sips -s format png ref.ppm --out ref.png + sips -s format png cand_shift.ppm --out cand_shift.png + sips -s format png cand_blur.ppm --out cand_blur.png + ``` + +4. Run the validation checks using the compiled binary (assuming it's built in `out/cmake-release` at the project root): + ```bash + # Test spatial shift (should FAIL with strict, PASS with shift) + ../../../out/cmake-release/tools/diffimg/diffimg -c config_strict.json ref.png cand_shift.png + ../../../out/cmake-release/tools/diffimg/diffimg -c config_shift.json ref.png cand_shift.png + + # Test high-frequency noise (should FAIL with strict, PASS with blur) + ../../../out/cmake-release/tools/diffimg/diffimg -c config_strict.json ref.png cand_blur.png + ../../../out/cmake-release/tools/diffimg/diffimg -c config_blur.json ref.png cand_blur.png + ``` \ No newline at end of file diff --git a/tools/diffimg/tests/config_blur.json b/tools/diffimg/tests/config_blur.json new file mode 100644 index 0000000000..7ec99f52ed --- /dev/null +++ b/tools/diffimg/tests/config_blur.json @@ -0,0 +1,6 @@ +{ + "mode": "LEAF", + "maxAbsDiff": "0.15", + "shiftRadius": 0, + "blurRadius": 1 +} \ No newline at end of file diff --git a/tools/diffimg/tests/config_shift.json b/tools/diffimg/tests/config_shift.json new file mode 100644 index 0000000000..910a0d324c --- /dev/null +++ b/tools/diffimg/tests/config_shift.json @@ -0,0 +1,6 @@ +{ + "mode": "LEAF", + "maxAbsDiff": "0.01", + "shiftRadius": 1, + "blurRadius": 0 +} \ No newline at end of file diff --git a/tools/diffimg/tests/config_strict.json b/tools/diffimg/tests/config_strict.json new file mode 100644 index 0000000000..4479dd085f --- /dev/null +++ b/tools/diffimg/tests/config_strict.json @@ -0,0 +1,6 @@ +{ + "mode": "LEAF", + "maxAbsDiff": "0.01", + "shiftRadius": 0, + "blurRadius": 0 +} \ No newline at end of file diff --git a/tools/diffimg/tests/gen_images.py b/tools/diffimg/tests/gen_images.py new file mode 100644 index 0000000000..3f218e1319 --- /dev/null +++ b/tools/diffimg/tests/gen_images.py @@ -0,0 +1,48 @@ +import math + +def write_ppm(filename, width, height, pixels): + with open(filename, 'w') as f: + f.write(f"P3\n{width} {height}\n255\n") + for y in range(height): + for x in range(width): + r, g, b = pixels[y][x] + f.write(f"{r} {g} {b} ") + f.write("\n") + +def create_ref(): + pixels = [[(0,0,0) for _ in range(64)] for _ in range(64)] + for y in range(64): + for x in range(64): + dx = x - 31.5 + dy = y - 31.5 + if math.sqrt(dx*dx + dy*dy) < 15: + pixels[y][x] = (255, 255, 255) + return pixels + +def create_shifted(): + pixels = [[(0,0,0) for _ in range(64)] for _ in range(64)] + for y in range(64): + for x in range(64): + dx = (x - 1) - 31.5 + dy = y - 31.5 + if math.sqrt(dx*dx + dy*dy) < 15: + pixels[y][x] = (255, 255, 255) + return pixels + +def create_dithered(): + pixels = create_ref() + for y in range(64): + for x in range(64): + r, g, b = pixels[y][x] + if r == 255: + if (x + y) % 2 == 0: + pixels[y][x] = (200, 200, 200) + else: + pixels[y][x] = (255, 255, 255) + return pixels + +if __name__ == '__main__': + write_ppm('ref.ppm', 64, 64, create_ref()) + write_ppm('cand_shift.ppm', 64, 64, create_shifted()) + write_ppm('cand_blur.ppm', 64, 64, create_dithered()) + print("PPM Images generated successfully.")