From 0d3572708604626245d7dd30571fbf30499ef218 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <3982806+meyfa@users.noreply.github.com> Date: Tue, 16 Apr 2024 19:24:06 +0800 Subject: [PATCH] [v3.x] feat: Add ability to define pixel difference calculators This is a backport of PR #17 to the v3.x release branch. Co-authored-by: Ben Thomson <15900351+bennothommo@users.noreply.github.com> --- README.md | 49 +++++++++++++++++++++++ src/DiffCalculator.php | 24 +++++++++++ src/DiffCalculator/RgbaChannels.php | 31 ++++++++++++++ src/DiffCalculator/ScaledRgbChannels.php | 45 +++++++++++++++++++++ src/GDAssertTrait.php | 38 +++++++++++++++--- src/GDSimilarityConstraint.php | 34 +++++----------- tests/GDAssertTraitTest.php | 20 +++++++++ tests/images/transparent-black.gif | Bin 0 -> 439 bytes tests/images/transparent-white.gif | Bin 0 -> 439 bytes 9 files changed, 210 insertions(+), 31 deletions(-) create mode 100644 src/DiffCalculator.php create mode 100644 src/DiffCalculator/RgbaChannels.php create mode 100644 src/DiffCalculator/ScaledRgbChannels.php create mode 100644 tests/images/transparent-black.gif create mode 100644 tests/images/transparent-white.gif diff --git a/README.md b/README.md index df51454..e9fb1c0 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,52 @@ $this->assertThat( $this->isSimilarGD('./tests/expected.png') ); ``` + +## Difference calculation + +By default, this library calculates the difference between two images by +comparing the RGBA color channel information at each pixel coordinate of the +source image and the test image, and averaging the difference between each +pixel to calculate the difference score. + +This will work for the majority of cases, but may give incorrect scoring +in certain circumstances, such as images that contain a lot of transparency. + +An alternative calculation method, which scales the RGB color channels +based on their alpha transparency - meaning more transparent pixels will +affect the difficulty score less to offset their less observable difference +on the image itself - can be enabled by adding a new `ScaledRgbChannels` +instance to the 5th parameter of the `assertSimilarGD` or `assertNotSimilarGD` +methods. + +```php +use AssertGD\DiffCalculator\ScaledRgbChannels; + +public function testImage() +{ + $this->assertSimilarGD( + 'expected.png', + 'actual.png', + '', + 0, + new ScaledRgbChannels() + ); +} +``` + +### Custom difference calculators + +If you wish to completely customise how calculations are done in this +library, you may also create your own calculation algorithm by creating +a class that implements the `AssertGd\DiffCalculator` interface. + +A class implementing this interface must provide a `calculate` method +that is provided two `GdImage` instances, and the X and Y co-ordinate +(as `ints`) of the pixel being compared in both images. + +The method should return a `float` between `0` and `1`, where 0 is +an exact match and 1 is the complete opposite. + +You may then provide an instance of the class as the 5th parameter of +the `assertSimilarGD` or `assertNotSimilarGD` method to use this +calculation method for determining the image difference. diff --git a/src/DiffCalculator.php b/src/DiffCalculator.php new file mode 100644 index 0000000..991a86a --- /dev/null +++ b/src/DiffCalculator.php @@ -0,0 +1,24 @@ +getPixel($pixelX, $pixelY); + $pixelB = $imageB->getPixel($pixelX, $pixelY); + + $diffR = abs($pixelA['red'] - $pixelB['red']) / 255; + $diffG = abs($pixelA['green'] - $pixelB['green']) / 255; + $diffB = abs($pixelA['blue'] - $pixelB['blue']) / 255; + $diffA = abs($pixelA['alpha'] - $pixelB['alpha']) / 127; + + return ($diffR + $diffG + $diffB + $diffA) / 4; + } +} diff --git a/src/DiffCalculator/ScaledRgbChannels.php b/src/DiffCalculator/ScaledRgbChannels.php new file mode 100644 index 0000000..1345a36 --- /dev/null +++ b/src/DiffCalculator/ScaledRgbChannels.php @@ -0,0 +1,45 @@ +premultiply($imageA->getPixel($pixelX, $pixelY)); + $pixelB = $this->premultiply($imageB->getPixel($pixelX, $pixelY)); + + $diffR = abs($pixelA['red'] - $pixelB['red']) / 255; + $diffG = abs($pixelA['green'] - $pixelB['green']) / 255; + $diffB = abs($pixelA['blue'] - $pixelB['blue']) / 255; + + return ($diffR + $diffG + $diffB) / 4; + } + + protected function premultiply(array $pixel) + { + $alpha = 1 - ($pixel['alpha'] / 127); + + return [ + 'red' => $pixel['red'] * $alpha, + 'green' => $pixel['green'] * $alpha, + 'blue' => $pixel['blue'] * $alpha, + ]; + } +} diff --git a/src/GDAssertTrait.php b/src/GDAssertTrait.php index 86887fe..3cd9d97 100644 --- a/src/GDAssertTrait.php +++ b/src/GDAssertTrait.php @@ -2,12 +2,19 @@ namespace AssertGD; +use AssertGD\DiffCalculator\RgbaChannels; + /** * Use this trait in a test case class to gain access to the image similarity * assertions. */ trait GDAssertTrait { + /** + * @var DiffCalculator The difference calculator to compare the images with. + */ + protected $diffCalculator; + /** * Asserts that the difference between $expected and $actual is AT MOST * $threshold. $expected and $actual can be GD image resources or paths to @@ -22,14 +29,15 @@ trait GDAssertTrait * @param string|resource $actual The actual image. * @param string $message The failure message. * @param float $threshold Error threshold between 0 and 1. + * @param DiffCalculator|null $diffCalculator The difference calculator to use. * * @return void * * @throws PHPUnit\Framework\AssertionFailedError */ - public function assertSimilarGD($expected, $actual, $message = '', $threshold = 0) + public function assertSimilarGD($expected, $actual, $message = '', $threshold = 0, $diffCalculator = null) { - $constraint = $this->isSimilarGD($expected, $threshold); + $constraint = $this->isSimilarGD($expected, $threshold, $diffCalculator); $this->assertThat($actual, $constraint, $message); } @@ -44,15 +52,16 @@ public function assertSimilarGD($expected, $actual, $message = '', $threshold = * @param string|resource $actual The actual image. * @param string $message The failure message. * @param float $threshold Error threshold between 0 and 1. + * @param DiffCalculator|null $diffCalculator The difference calculator to use. * * @return void * * @throws PHPUnit\Framework\AssertionFailedError */ - public function assertNotSimilarGD($expected, $actual, $message = '', $threshold = 0) + public function assertNotSimilarGD($expected, $actual, $message = '', $threshold = 0, $diffCalculator = null) { $constraint = $this->logicalNot( - $this->isSimilarGD($expected, $threshold) + $this->isSimilarGD($expected, $threshold, $diffCalculator) ); $this->assertThat($actual, $constraint, $message); } @@ -66,11 +75,28 @@ public function assertNotSimilarGD($expected, $actual, $message = '', $threshold * * @param string|resource $expected The expected image. * @param float $threshold Error threshold between 0 and 1. + * @param DiffCalculator|null $diffCalculator The difference calculator to use. * * @return GDSimilarityConstraint The constraint. */ - public function isSimilarGD($expected, $threshold = 0) + public function isSimilarGD($expected, $threshold = 0, $diffCalculator = null) { - return new GDSimilarityConstraint($expected, $threshold); + return new GDSimilarityConstraint($expected, $threshold, $diffCalculator ?? $this->diffCalculator ?? new RgbaChannels()); + } + + /** + * Sets the difference calculator to use for image comparisons in this test case. + * + * @var DiffCalculator $diffCalculator + */ + public function setDiffCalculator($diffCalculator): void + { + if (!($diffCalculator instanceof DiffCalculator)) { + throw new \InvalidArgumentException( + 'The difference calculator must implement the `AssertGD\DiffCalculator` interface' + ); + } + + $this->diffCalculator = $diffCalculator; } } diff --git a/src/GDSimilarityConstraint.php b/src/GDSimilarityConstraint.php index 3b7f577..1dcc37a 100644 --- a/src/GDSimilarityConstraint.php +++ b/src/GDSimilarityConstraint.php @@ -2,6 +2,7 @@ namespace AssertGD; +use AssertGD\DiffCalculator\RgbaChannels; use PHPUnit\Framework\Constraint\Constraint; /** @@ -16,6 +17,10 @@ class GDSimilarityConstraint extends Constraint { private $expected; private $threshold; + /** + * @var DiffCalculator The difference calculator to compare the images with. + */ + private $diffCalculator; /** * Constructs a new constraint. A threshold of 0 means only exactly equal @@ -23,11 +28,13 @@ class GDSimilarityConstraint extends Constraint * * @param string|resource $expected File name or resource to match against. * @param float $threshold Error threshold between 0 and 1. + * @param DiffCalculator|null $diffCalculator The difference calculator to use. */ - public function __construct($expected, $threshold = 0) + public function __construct($expected, $threshold = 0, $diffCalculator = null) { $this->expected = $expected; $this->threshold = $threshold; + $this->diffCalculator = $diffCalculator ?? new RgbaChannels(); } /** @@ -64,7 +71,7 @@ public function matches($other): bool $delta = 0; for ($x = 0; $x < $w; ++$x) { for ($y = 0; $y < $h; ++$y) { - $delta += $this->getPixelError($imgExpec, $imgOther, $x, $y); + $delta += $this->diffCalculator->calculate($imgExpec, $imgOther, $x, $y); } } @@ -76,27 +83,4 @@ public function matches($other): bool return $error <= $this->threshold; } - - /** - * Calculates the error between 0 and 1 (inclusive) of a specific pixel. - * - * @param GDImage $imgA The first image. - * @param GDImage $imgB The second image. - * @param int $x The pixel's x coordinate. - * @param int $y The pixel's y coordinate. - * - * @return float The pixel error. - */ - private function getPixelError(GDImage $imgA, GDImage $imgB, $x, $y) - { - $pixelA = $imgA->getPixel($x, $y); - $pixelB = $imgB->getPixel($x, $y); - - $diffR = abs($pixelA['red'] - $pixelB['red']) / 255; - $diffG = abs($pixelA['green'] - $pixelB['green']) / 255; - $diffB = abs($pixelA['blue'] - $pixelB['blue']) / 255; - $diffA = abs($pixelA['alpha'] - $pixelB['alpha']) / 127; - - return ($diffR + $diffG + $diffB + $diffA) / 4; - } } diff --git a/tests/GDAssertTraitTest.php b/tests/GDAssertTraitTest.php index e081a2b..c1d400a 100644 --- a/tests/GDAssertTraitTest.php +++ b/tests/GDAssertTraitTest.php @@ -1,5 +1,6 @@ assertSimilarGD('./tests/images/jpeg.jpg', './tests/images/jpeg-alt.jpg', '', 0.1); } + + public function testAlternativeDiffCalculator() + { + // the default method of calculating images will not consider these images exact due to the transparent pixels + // having different RGB values + $this->assertNotSimilarGD('./tests/images/transparent-black.gif', './tests/images/transparent-white.gif'); + + // using the ScaledRgbChannels diff calculator, the images will be considered exact + $this->assertSimilarGD('./tests/images/transparent-black.gif', './tests/images/transparent-white.gif', + '', 0, new ScaledRgbChannels()); + } + + public function testSetDiffCalculator() + { + // apply diff calculator on all further assertions + $this->setDiffCalculator(new ScaledRgbChannels()); + + $this->assertSimilarGD('./tests/images/transparent-black.gif', './tests/images/transparent-white.gif'); + } } diff --git a/tests/images/transparent-black.gif b/tests/images/transparent-black.gif new file mode 100644 index 0000000000000000000000000000000000000000..9c2126304754395b277fa9aae8b1dfeec1dec150 GIT binary patch literal 439 zcmV;o0Z9HwNk%w1VNd{20Kx000C2NV?qqFv>}*y*TU5yZ>M)j$>e+XsWL3;J$Dy&$GndI7{z5ZRfyT zZ#et~k1C?l7eP8v%P7qFL`tvJYVaz}YK7dT_j?PAo8+yTI8G3Mv8e|vI$Z-X*{c!)KHhER+#igS^Yj*otoFOzPXo0piLE}Uti1Egf8s9&m}kF9r@ zDXw1<6BUd4^&I(mcvvY|+H8cR+)nJ%SDmMA}}000C2NV?qqFv>}*y*TU5yZ>M)j$>e+XsWL3;J$Dy&$GndI7{z5ZRfyT zZ#et~k1C?l7eP8v%P7qFL`tvJYVaz}YK7dT_j?PAo8+yTI8G3Mv8e|vI$Z-X*{c!)KHhER+#igS^Yj*otoFOzPXo0piLE}Uti1Egf8s9&m}kF9r@ zDXw1<6BUd4^&I(mcvvY|+H8cR+)nJ%SDmMA}}