diff --git a/README.md b/README.md index b8e5bd5..a98d33a 100644 --- a/README.md +++ b/README.md @@ -113,3 +113,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..27df144 --- /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..61c5e21 --- /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 array( + 'red' => $pixel['red'] * $alpha, + 'green' => $pixel['green'] * $alpha, + 'blue' => $pixel['blue'] * $alpha, + ); + } +} diff --git a/src/GDAssertTrait.php b/src/GDAssertTrait.php index 24715f9..61789f7 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,31 @@ 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); + $calc = isset($diffCalculator) + ? $diffCalculator + : (isset($this->diffCalculator) ? $this->diffCalculator : new RgbaChannels()); + return new GDSimilarityConstraint($expected, $threshold, $calc); + } + + /** + * Sets the difference calculator to use for image comparisons in this test case. + * + * @var DiffCalculator $diffCalculator + */ + public function setDiffCalculator($diffCalculator) + { + 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 7ecdf95..84c2740 100644 --- a/src/GDSimilarityConstraint.php +++ b/src/GDSimilarityConstraint.php @@ -2,7 +2,7 @@ namespace AssertGD; -use PHPUnit\Framework\TestCase; +use AssertGD\DiffCalculator\RgbaChannels; use PHPUnit\Framework\Constraint\Constraint; /** @@ -17,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 @@ -24,13 +28,15 @@ 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) { parent::__construct(); $this->expected = $expected; $this->threshold = $threshold; + $this->diffCalculator = isset($diffCalculator) ? $diffCalculator : new RgbaChannels(); } /** @@ -67,7 +73,7 @@ public function matches($other) $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); } } @@ -79,27 +85,4 @@ public function matches($other) 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..276ee8f 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/GDAssertTraitTest53.php b/tests/GDAssertTraitTest53.php index 76f858a..a081319 100644 --- a/tests/GDAssertTraitTest53.php +++ b/tests/GDAssertTraitTest53.php @@ -1,5 +1,6 @@ assertThat('./tests/images/stripes-bw-10x10.png', - $this->logicalNot($this->equalTo(new GDSimilarityConstraint('./tests/images/stripes-bw-20x20.png')))); + $this->logicalNot(new GDSimilarityConstraint('./tests/images/stripes-bw-20x20.png'))); } public function testDifferentImages() { // should compare unsuccessfully $this->assertThat('./tests/images/stripes-bw-10x10.png', - $this->logicalNot($this->equalTo(new GDSimilarityConstraint('./tests/images/stripes-bw-10x10-alt.png')))); + $this->logicalNot(new GDSimilarityConstraint('./tests/images/stripes-bw-10x10-alt.png'))); } public function testDifferentImagesThreshold1() { // should compare successfully $this->assertThat('./tests/images/stripes-bw-10x10.png', - new GDSimilarityConstraint('./tests/images/stripes-bw-10x10-alt.png', 1), '', 1); + new GDSimilarityConstraint('./tests/images/stripes-bw-10x10-alt.png', 1), ''); } public function testJpeg() { // should compare unsuccessfully with threshold = 0.01 $this->assertThat('./tests/images/jpeg.jpg', - $this->logicalNot($this->equalTo(new GDSimilarityConstraint('./tests/images/jpeg-alt.jpg')), '', 0.01)); + $this->logicalNot(new GDSimilarityConstraint('./tests/images/jpeg-alt.jpg', 0.01)), ''); // should compare successfully with threshold = 0.1 $this->assertThat('./tests/images/jpeg.jpg', new GDSimilarityConstraint('./tests/images/jpeg-alt.jpg', 0.1), '', 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->assertThat('./tests/images/transparent-black.gif', + $this->logicalNot(new GDSimilarityConstraint('./tests/images/transparent-white.gif'))); + + // using the ScaledRgbChannels diff calculator, the images will be considered exact + $this->assertThat('./tests/images/transparent-black.gif', + new GDSimilarityConstraint('./tests/images/transparent-white.gif', 0.0, new ScaledRgbChannels())); + } } diff --git a/tests/images/transparent-black.gif b/tests/images/transparent-black.gif new file mode 100644 index 0000000..9c21263 Binary files /dev/null and b/tests/images/transparent-black.gif differ diff --git a/tests/images/transparent-white.gif b/tests/images/transparent-white.gif new file mode 100644 index 0000000..ed944ba Binary files /dev/null and b/tests/images/transparent-white.gif differ