Implemented the Image::diff() optimization.

- Added exact RGB equality and alpha-only fast paths.
  - Added optional createDiffImage, defaulting to true.
  - Updated screenshot comparisons to compare-only first and generate visual output only on failure.
  - Flattened iteration into a pointer-walking loop.
  - Added a 256-entry sRGB-to-linear lookup table.
  - Preserved CIEDE2000, thresholds, alpha comparison, maxDeltaE, and visual rendering semantics.
  - Added six focused correctness tests in src/tests/unit_tests/image_diff_tests.cpp.
This commit is contained in:
Martín Lucas Golini
2026-08-22 01:33:07 -03:00
parent 36e54fe2a3
commit 405e84608f
4 changed files with 180 additions and 56 deletions

View File

@@ -432,7 +432,7 @@ class EE_API Image {
const FormatConfiguration& getImageFormatConfiguration() const;
struct DiffResult {
Image* diffImage{ nullptr }; ///< The visual diff image. Null if dimensions mismatched.
Image* diffImage{ nullptr }; ///< Null when visual output was disabled or cannot be created.
long long numDifferentPixels{ 0 }; ///< The number of pixels that exceeded the threshold.
double maxDeltaE{ 0.0 }; ///< The maximum perceptual difference (Delta E) found.
bool areSame() const { return numDifferentPixels == 0; }
@@ -450,10 +450,13 @@ class EE_API Image {
* A value of 1.0 is roughly the limit of human perception.
* A common default for tests is 2.3 (a "just noticeable difference").
* @param diffColor The color used to highlight differing pixels in the output image.
* @param createDiffImage Whether to create the visual diff image. Disable this for comparisons
* that only need statistics.
* @return A DiffResult struct containing the diff image and statistics.
*/
DiffResult diff( const Image& other, float threshold = 2.3f,
const Color& diffColor = Color( 255, 0, 255, 255 ) ) const;
const Color& diffColor = Color( 255, 0, 255, 255 ),
bool createDiffImage = true ) const;
protected:
Uint8* mPixels;

View File

@@ -11,6 +11,7 @@
#include <eepp/system/packregistry.hpp>
#include <algorithm>
#include <array>
#include <memory>
#include <imageresampler/resampler.h>
@@ -36,16 +37,24 @@ struct Lab {
double l, a, b;
};
// sRGB to Linear RGB conversion
static constexpr double srgbToLinear( double c ) {
return ( c > 0.04045 ) ? pow( ( c + 0.055 ) / 1.055, 2.4 ) : ( c / 12.92 );
static const std::array<double, 256>& getSRGBToLinearLUT() {
static const std::array<double, 256> lut = [] {
std::array<double, 256> values{};
for ( size_t i = 0; i < values.size(); ++i ) {
const double c = static_cast<double>( i ) / 255.0;
values[i] = c > 0.04045 ? pow( ( c + 0.055 ) / 1.055, 2.4 ) : c / 12.92;
}
return values;
}();
return lut;
}
// RGB to XYZ conversion (D65 illuminant)
static constexpr XYZ rgbToXyz( const Color& c ) {
double r = srgbToLinear( c.r / 255.0 );
double g = srgbToLinear( c.g / 255.0 );
double b = srgbToLinear( c.b / 255.0 );
static XYZ rgbToXyz( const Color& c ) {
const auto& lut = getSRGBToLinearLUT();
double r = lut[c.r];
double g = lut[c.g];
double b = lut[c.b];
return {
.x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375,
.y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750,
@@ -1510,7 +1519,8 @@ std::pair<std::vector<Image>, int> Image::loadGif( IOStream& stream ) {
return { std::move( gif ), delay ? delay : 100 };
}
Image::DiffResult Image::diff( const Image& other, float threshold, const Color& diffColor ) const {
Image::DiffResult Image::diff( const Image& other, float threshold, const Color& diffColor,
bool createDiffImage ) const {
DiffResult result;
if ( getWidth() != other.getWidth() || getHeight() != other.getHeight() ) {
@@ -1525,59 +1535,69 @@ Image::DiffResult Image::diff( const Image& other, float threshold, const Color&
return result;
}
// Create a 4-channel RGBA image for the diff output
result.diffImage = Image::New( getWidth(), getHeight(), 4 );
if ( !result.diffImage ) {
Log::error( "Image::diff: Failed to create diff image." );
return result;
if ( createDiffImage ) {
// Create a 4-channel RGBA image for the diff output
result.diffImage = Image::New( getWidth(), getHeight(), 4 );
if ( !result.diffImage ) {
Log::error( "Image::diff: Failed to create diff image." );
return result;
}
}
const Uint8* p1 = this->getPixelsPtr();
const Uint8* p2 = other.getPixelsPtr();
Uint8* pDiff = result.diffImage->getPixels();
const size_t pixelCount =
static_cast<size_t>( getWidth() ) * static_cast<size_t>( getHeight() );
const unsigned int channels1 = getChannels();
const unsigned int channels2 = other.getChannels();
const Uint8* pixel1 = getPixelsPtr();
const Uint8* pixel2 = other.getPixelsPtr();
Uint8* diffPixel = result.diffImage ? result.diffImage->getPixels() : nullptr;
unsigned int channels1 = this->getChannels();
unsigned int channels2 = other.getChannels();
unsigned int channelsDiff = result.diffImage->getChannels();
for ( size_t i = 0; i < pixelCount; ++i ) {
const Uint8 r1 = pixel1[0];
const Uint8 g1 = pixel1[1];
const Uint8 b1 = pixel1[2];
const Uint8 r2 = pixel2[0];
const Uint8 g2 = pixel2[1];
const Uint8 b2 = pixel2[2];
const Uint8 a1 = channels1 == 4 ? pixel1[3] : 255;
const Uint8 a2 = channels2 == 4 ? pixel2[3] : 255;
const bool rgbEqual = r1 == r2 && g1 == g2 && b1 == b2;
const bool alphaDiffers = a1 != a2;
double delta = 0.0;
for ( unsigned int y = 0; y < getHeight(); ++y ) {
for ( unsigned int x = 0; x < getWidth(); ++x ) {
size_t offset1 = ( y * getWidth() + x ) * channels1;
size_t offset2 = ( y * getWidth() + x ) * channels2;
size_t offsetDiff = ( y * getWidth() + x ) * channelsDiff;
if ( !rgbEqual ) {
const Color c1( r1, g1, b1, a1 );
const Color c2( r2, g2, b2, a2 );
const Lab lab1 = xyzToLab( rgbToXyz( c1 ) );
const Lab lab2 = xyzToLab( rgbToXyz( c2 ) );
delta = deltaECIEDE2000( lab1, lab2 );
result.maxDeltaE = eemax( result.maxDeltaE, delta );
}
Color c1( p1[offset1], p1[offset1 + 1], p1[offset1 + 2],
channels1 == 4 ? p1[offset1 + 3] : 255 );
Color c2( p2[offset2], p2[offset2 + 1], p2[offset2 + 2],
channels2 == 4 ? p2[offset2 + 3] : 255 );
const bool differs = delta > threshold || alphaDiffers;
if ( differs )
++result.numDifferentPixels;
// Also compare alpha channels directly, as perceptual diff is for color only
bool alphaDiffers = ( c1.a != c2.a );
Lab lab1 = xyzToLab( rgbToXyz( c1 ) );
Lab lab2 = xyzToLab( rgbToXyz( c2 ) );
double delta = deltaECIEDE2000( lab1, lab2 );
if ( delta > result.maxDeltaE ) {
result.maxDeltaE = delta;
}
if ( delta > threshold || alphaDiffers ) {
result.numDifferentPixels++;
pDiff[offsetDiff] = diffColor.r;
pDiff[offsetDiff + 1] = diffColor.g;
pDiff[offsetDiff + 2] = diffColor.b;
pDiff[offsetDiff + 3] = diffColor.a;
if ( diffPixel ) {
if ( differs ) {
diffPixel[0] = diffColor.r;
diffPixel[1] = diffColor.g;
diffPixel[2] = diffColor.b;
diffPixel[3] = diffColor.a;
} else {
// Blend original pixel with a transparent gray to fade it out
// This makes the highlighted differences stand out more.
Uint8 avg = ( c1.r + c1.g + c1.b ) / 3;
pDiff[offsetDiff] = avg;
pDiff[offsetDiff + 1] = avg;
pDiff[offsetDiff + 2] = avg;
pDiff[offsetDiff + 3] = 128; // Semi-transparent
const Uint8 avg = ( r1 + g1 + b1 ) / 3;
diffPixel[0] = avg;
diffPixel[1] = avg;
diffPixel[2] = avg;
diffPixel[3] = 128; // Semi-transparent
}
diffPixel += 4;
}
pixel1 += channels1;
pixel2 += channels2;
}
return result;

View File

@@ -40,9 +40,13 @@ static void compareImages( utest_state_s& utest_state, int* utest_result, EE::Wi
EXPECT_EQ_MSG( expectedImage.getWidth(), actualImage.getWidth(), "Images width not equal" );
EXPECT_EQ_MSG( expectedImage.getHeight(), actualImage.getHeight(), "Images height not equal" );
Image::DiffResult result = actualImage.diff( expectedImage );
constexpr float diffThreshold = 2.3f;
const Color diffColor( 255, 0, 255, 255 );
Image::DiffResult result = actualImage.diff( expectedImage, diffThreshold, diffColor, false );
EXPECT_LE( result.numDifferentPixels, allowedNumDifferentPixels );
if ( imageSizeMismatch || result.numDifferentPixels > allowedNumDifferentPixels ) {
Image::DiffResult visualResult =
actualImage.diff( expectedImage, diffThreshold, diffColor, true );
auto saveExt( Image::saveTypeToExtension( saveType ) );
std::string withTextShaper =
Text::TextShaperEnabled
@@ -112,11 +116,11 @@ static void compareImages( utest_state_s& utest_state, int* utest_result, EE::Wi
"output/" + imageName + "_actual_output" + withTextShaper + "." + saveExt;
actualImage.saveToFile( actualImagePath, saveType );
std::cerr << "Actual image saved to: " << actualImagePath << std::endl;
if ( result.diffImage ) {
if ( visualResult.diffImage ) {
std::string diffImagePath =
"output/" + imageName + "_diff_output" + withTextShaper + "." + saveExt;
result.diffImage->setImageFormatConfiguration( fconf );
result.diffImage->saveToFile( diffImagePath, saveType );
visualResult.diffImage->setImageFormatConfiguration( fconf );
visualResult.diffImage->saveToFile( diffImagePath, saveType );
std::cerr << "Visual diff saved to: " << diffImagePath << std::endl;
}
}

View File

@@ -0,0 +1,97 @@
#include "utest.h"
#include <eepp/graphics/image.hpp>
using namespace EE;
using namespace EE::Graphics;
UTEST( ImageDiff, ExactEqualityRGB ) {
const Uint8 pixels[] = { 12, 34, 56, 78, 90, 123 };
const Image image1( pixels, 2, 1, 3 );
const Image image2( pixels, 2, 1, 3 );
Image::DiffResult visualResult = image1.diff( image2 );
EXPECT_EQ( visualResult.numDifferentPixels, 0 );
EXPECT_EQ( visualResult.maxDeltaE, 0.0 );
ASSERT_TRUE( visualResult.diffImage != nullptr );
Image::DiffResult compareResult = image1.diff( image2, 2.3f, Color( 255, 0, 255, 255 ), false );
EXPECT_EQ( compareResult.numDifferentPixels, 0 );
EXPECT_EQ( compareResult.maxDeltaE, 0.0 );
EXPECT_TRUE( compareResult.diffImage == nullptr );
}
UTEST( ImageDiff, ExactEqualityRGBA ) {
const Uint8 pixels[] = { 12, 34, 56, 78 };
const Image image1( pixels, 1, 1, 4 );
const Image image2( pixels, 1, 1, 4 );
Image::DiffResult visualResult = image1.diff( image2 );
EXPECT_EQ( visualResult.numDifferentPixels, 0 );
EXPECT_EQ( visualResult.maxDeltaE, 0.0 );
ASSERT_TRUE( visualResult.diffImage != nullptr );
Image::DiffResult compareResult = image1.diff( image2, 2.3f, Color( 255, 0, 255, 255 ), false );
EXPECT_EQ( compareResult.numDifferentPixels, 0 );
EXPECT_EQ( compareResult.maxDeltaE, 0.0 );
EXPECT_TRUE( compareResult.diffImage == nullptr );
}
UTEST( ImageDiff, AlphaOnlyDifference ) {
const Uint8 pixels1[] = { 12, 34, 56, 78 };
const Uint8 pixels2[] = { 12, 34, 56, 79 };
const Image image1( pixels1, 1, 1, 4 );
const Image image2( pixels2, 1, 1, 4 );
Image::DiffResult result = image1.diff( image2, 2.3f, Color( 255, 0, 255, 255 ), false );
EXPECT_EQ( result.numDifferentPixels, 1 );
EXPECT_EQ( result.maxDeltaE, 0.0 );
EXPECT_TRUE( result.diffImage == nullptr );
}
UTEST( ImageDiff, RGBDifferenceThreshold ) {
const Uint8 pixels1[] = { 255, 0, 0 };
const Uint8 pixels2[] = { 0, 0, 255 };
const Image image1( pixels1, 1, 1, 3 );
const Image image2( pixels2, 1, 1, 3 );
Image::DiffResult belowThreshold =
image1.diff( image2, 1000.f, Color( 255, 0, 255, 255 ), false );
EXPECT_EQ( belowThreshold.numDifferentPixels, 0 );
EXPECT_TRUE( belowThreshold.maxDeltaE > 0.0 );
Image::DiffResult aboveThreshold = image1.diff( image2, 0.f, Color( 255, 0, 255, 255 ), false );
EXPECT_EQ( aboveThreshold.numDifferentPixels, 1 );
EXPECT_EQ( aboveThreshold.maxDeltaE, belowThreshold.maxDeltaE );
}
UTEST( ImageDiff, DiffImageRendering ) {
const Uint8 pixels1[] = { 30, 60, 90, 255, 255, 0, 0, 255 };
const Uint8 pixels2[] = { 30, 60, 90, 255, 0, 0, 255, 255 };
const Color diffColor( 1, 2, 3, 4 );
const Image image1( pixels1, 2, 1, 4 );
const Image image2( pixels2, 2, 1, 4 );
Image::DiffResult result = image1.diff( image2, 0.f, diffColor, true );
ASSERT_TRUE( result.diffImage != nullptr );
EXPECT_EQ( result.numDifferentPixels, 1 );
const Uint8* diffPixels = result.diffImage->getPixelsPtr();
EXPECT_EQ( diffPixels[0], 60 );
EXPECT_EQ( diffPixels[1], 60 );
EXPECT_EQ( diffPixels[2], 60 );
EXPECT_EQ( diffPixels[3], 128 );
EXPECT_EQ( diffPixels[4], diffColor.r );
EXPECT_EQ( diffPixels[5], diffColor.g );
EXPECT_EQ( diffPixels[6], diffColor.b );
EXPECT_EQ( diffPixels[7], diffColor.a );
}
UTEST( ImageDiff, DifferentDimensions ) {
const Image image1( 2, 3, 3 );
const Image image2( 1, 1, 3 );
Image::DiffResult result = image1.diff( image2 );
EXPECT_EQ( result.numDifferentPixels, 6 );
EXPECT_EQ( result.maxDeltaE, 0.0 );
EXPECT_TRUE( result.diffImage == nullptr );
}