Skip to content

Commit 743303a

Browse files
meyfabennothommo
andauthored
[v1.x] feat: Add ability to define pixel difference calculators (#18)
This is a backport of PR #17 to the v1.x release branch. Co-authored-by: Ben Thomson <[email protected]>
1 parent e550e5e commit 743303a

10 files changed

+230
-36
lines changed

README.md

+49
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,52 @@ $this->assertThat(
113113
$this->isSimilarGD('./tests/expected.png')
114114
);
115115
```
116+
117+
## Difference calculation
118+
119+
By default, this library calculates the difference between two images by
120+
comparing the RGBA color channel information at each pixel coordinate of the
121+
source image and the test image, and averaging the difference between each
122+
pixel to calculate the difference score.
123+
124+
This will work for the majority of cases, but may give incorrect scoring
125+
in certain circumstances, such as images that contain a lot of transparency.
126+
127+
An alternative calculation method, which scales the RGB color channels
128+
based on their alpha transparency - meaning more transparent pixels will
129+
affect the difficulty score less to offset their less observable difference
130+
on the image itself - can be enabled by adding a new `ScaledRgbChannels`
131+
instance to the 5th parameter of the `assertSimilarGD` or `assertNotSimilarGD`
132+
methods.
133+
134+
```php
135+
use AssertGD\DiffCalculator\ScaledRgbChannels;
136+
137+
public function testImage()
138+
{
139+
$this->assertSimilarGD(
140+
'expected.png',
141+
'actual.png',
142+
'',
143+
0,
144+
new ScaledRgbChannels()
145+
);
146+
}
147+
```
148+
149+
### Custom difference calculators
150+
151+
If you wish to completely customise how calculations are done in this
152+
library, you may also create your own calculation algorithm by creating
153+
a class that implements the `AssertGd\DiffCalculator` interface.
154+
155+
A class implementing this interface must provide a `calculate` method
156+
that is provided two `GdImage` instances, and the X and Y co-ordinate
157+
(as `ints`) of the pixel being compared in both images.
158+
159+
The method should return a `float` between `0` and `1`, where 0 is
160+
an exact match and 1 is the complete opposite.
161+
162+
You may then provide an instance of the class as the 5th parameter of
163+
the `assertSimilarGD` or `assertNotSimilarGD` method to use this
164+
calculation method for determining the image difference.

src/DiffCalculator.php

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace AssertGD;
4+
5+
use AssertGD\GDImage;
6+
7+
/**
8+
* Difference calculator.
9+
*
10+
* Determines the difference between two given images.
11+
*/
12+
interface DiffCalculator
13+
{
14+
/**
15+
* Calculates the difference between two pixels at the given coordinates.
16+
*
17+
* This method will be provided with two `GDImage` objects representing the images being compared, and co-ordinates
18+
* of the pixel being compared.
19+
*
20+
* The method should return a float value between 0 and 1 inclusive, with 0 meaning that the pixels of both images
21+
* at the given co-ordinates are an exact match, and 1 meaning that the pixels are the complete opposite.
22+
*/
23+
public function calculate(GDImage $imageA, GDImage $imageB, $pixelX, $pixelY);
24+
}

src/DiffCalculator/RgbaChannels.php

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace AssertGD\DiffCalculator;
4+
5+
use AssertGD\DiffCalculator;
6+
use AssertGD\GDImage;
7+
8+
/**
9+
* Calculate the difference between two pixels using the RGBA channels.
10+
*
11+
* This is the default calculation method used by the `AssertGD` package. It simply takes each individual channel and
12+
* compares the delta between the channel values of the two images.
13+
*
14+
* This works well for most images, but may not work for images with transparent pixels if the transparent pixels have
15+
* different RGB values.
16+
*/
17+
class RgbaChannels implements DiffCalculator
18+
{
19+
public function calculate(GDImage $imageA, GDImage $imageB, $pixelX, $pixelY)
20+
{
21+
$pixelA = $imageA->getPixel($pixelX, $pixelY);
22+
$pixelB = $imageB->getPixel($pixelX, $pixelY);
23+
24+
$diffR = abs($pixelA['red'] - $pixelB['red']) / 255;
25+
$diffG = abs($pixelA['green'] - $pixelB['green']) / 255;
26+
$diffB = abs($pixelA['blue'] - $pixelB['blue']) / 255;
27+
$diffA = abs($pixelA['alpha'] - $pixelB['alpha']) / 127;
28+
29+
return ($diffR + $diffG + $diffB + $diffA) / 4;
30+
}
31+
}
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace AssertGD\DiffCalculator;
4+
5+
use AssertGD\DiffCalculator;
6+
use AssertGD\GDImage;
7+
8+
/**
9+
* Calculate the difference between two pixels using the RGB channels, and scales down the RGB difference by the alpha
10+
* channel.
11+
*
12+
* This calculation will pre-multiply the RGB channels by the opacity percentage (alpha) of the pixel, meaning that a
13+
* translucent pixel will have less of an impact on the overall difference than an opaque pixel. For transparent pixels,
14+
* this will mean that the RGB difference will be scaled down to zero, effectively meaning that transparent pixels will
15+
* match regardless of their RGB values.
16+
*
17+
* This calculation method is useful for images with transparent pixels or images that have been anti-aliased or
18+
* blurred over a transparent background, effectively making translucent pixels less likely to cause a false positive as
19+
* being different.
20+
*/
21+
class ScaledRgbChannels implements DiffCalculator
22+
{
23+
public function calculate(GDImage $imageA, GDImage $imageB, $pixelX, $pixelY)
24+
{
25+
$pixelA = $this->premultiply($imageA->getPixel($pixelX, $pixelY));
26+
$pixelB = $this->premultiply($imageB->getPixel($pixelX, $pixelY));
27+
28+
$diffR = abs($pixelA['red'] - $pixelB['red']) / 255;
29+
$diffG = abs($pixelA['green'] - $pixelB['green']) / 255;
30+
$diffB = abs($pixelA['blue'] - $pixelB['blue']) / 255;
31+
32+
return ($diffR + $diffG + $diffB) / 4;
33+
}
34+
35+
protected function premultiply(array $pixel)
36+
{
37+
$alpha = 1 - ($pixel['alpha'] / 127);
38+
39+
return array(
40+
'red' => $pixel['red'] * $alpha,
41+
'green' => $pixel['green'] * $alpha,
42+
'blue' => $pixel['blue'] * $alpha,
43+
);
44+
}
45+
}

src/GDAssertTrait.php

+35-6
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
namespace AssertGD;
44

5+
use AssertGD\DiffCalculator\RgbaChannels;
6+
57
/**
68
* Use this trait in a test case class to gain access to the image similarity
79
* assertions.
810
*/
911
trait GDAssertTrait
1012
{
13+
/**
14+
* @var DiffCalculator The difference calculator to compare the images with.
15+
*/
16+
protected $diffCalculator;
17+
1118
/**
1219
* Asserts that the difference between $expected and $actual is AT MOST
1320
* $threshold. $expected and $actual can be GD image resources or paths to
@@ -22,14 +29,15 @@ trait GDAssertTrait
2229
* @param string|resource $actual The actual image.
2330
* @param string $message The failure message.
2431
* @param float $threshold Error threshold between 0 and 1.
32+
* @param DiffCalculator|null $diffCalculator The difference calculator to use.
2533
*
2634
* @return void
2735
*
2836
* @throws PHPUnit_Framework_AssertionFailedError
2937
*/
30-
public function assertSimilarGD($expected, $actual, $message = '', $threshold = 0)
38+
public function assertSimilarGD($expected, $actual, $message = '', $threshold = 0, $diffCalculator = null)
3139
{
32-
$constraint = $this->isSimilarGD($expected, $threshold);
40+
$constraint = $this->isSimilarGD($expected, $threshold, $diffCalculator);
3341
$this->assertThat($actual, $constraint, $message);
3442
}
3543

@@ -44,15 +52,16 @@ public function assertSimilarGD($expected, $actual, $message = '', $threshold =
4452
* @param string|resource $actual The actual image.
4553
* @param string $message The failure message.
4654
* @param float $threshold Error threshold between 0 and 1.
55+
* @param DiffCalculator|null $diffCalculator The difference calculator to use.
4756
*
4857
* @return void
4958
*
5059
* @throws PHPUnit_Framework_AssertionFailedError
5160
*/
52-
public function assertNotSimilarGD($expected, $actual, $message = '', $threshold = 0)
61+
public function assertNotSimilarGD($expected, $actual, $message = '', $threshold = 0, $diffCalculator = null)
5362
{
5463
$constraint = $this->logicalNot(
55-
$this->isSimilarGD($expected, $threshold)
64+
$this->isSimilarGD($expected, $threshold, $diffCalculator)
5665
);
5766
$this->assertThat($actual, $constraint, $message);
5867
}
@@ -66,11 +75,31 @@ public function assertNotSimilarGD($expected, $actual, $message = '', $threshold
6675
*
6776
* @param string|resource $expected The expected image.
6877
* @param float $threshold Error threshold between 0 and 1.
78+
* @param DiffCalculator|null $diffCalculator The difference calculator to use.
6979
*
7080
* @return GDSimilarityConstraint The constraint.
7181
*/
72-
public function isSimilarGD($expected, $threshold = 0)
82+
public function isSimilarGD($expected, $threshold = 0, $diffCalculator = null)
7383
{
74-
return new GDSimilarityConstraint($expected, $threshold);
84+
$calc = isset($diffCalculator)
85+
? $diffCalculator
86+
: (isset($this->diffCalculator) ? $this->diffCalculator : new RgbaChannels());
87+
return new GDSimilarityConstraint($expected, $threshold, $calc);
88+
}
89+
90+
/**
91+
* Sets the difference calculator to use for image comparisons in this test case.
92+
*
93+
* @var DiffCalculator $diffCalculator
94+
*/
95+
public function setDiffCalculator($diffCalculator)
96+
{
97+
if (!($diffCalculator instanceof DiffCalculator)) {
98+
throw new \InvalidArgumentException(
99+
'The difference calculator must implement the `AssertGD\DiffCalculator` interface'
100+
);
101+
}
102+
103+
$this->diffCalculator = $diffCalculator;
75104
}
76105
}

src/GDSimilarityConstraint.php

+9-26
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace AssertGD;
44

5-
use PHPUnit\Framework\TestCase;
5+
use AssertGD\DiffCalculator\RgbaChannels;
66
use PHPUnit\Framework\Constraint\Constraint;
77

88
/**
@@ -17,20 +17,26 @@ class GDSimilarityConstraint extends Constraint
1717
{
1818
private $expected;
1919
private $threshold;
20+
/**
21+
* @var DiffCalculator The difference calculator to compare the images with.
22+
*/
23+
private $diffCalculator;
2024

2125
/**
2226
* Constructs a new constraint. A threshold of 0 means only exactly equal
2327
* images are allowed, while a threshold of 1 matches every image.
2428
*
2529
* @param string|resource $expected File name or resource to match against.
2630
* @param float $threshold Error threshold between 0 and 1.
31+
* @param DiffCalculator|null $diffCalculator The difference calculator to use.
2732
*/
28-
public function __construct($expected, $threshold = 0)
33+
public function __construct($expected, $threshold = 0, $diffCalculator = null)
2934
{
3035
parent::__construct();
3136

3237
$this->expected = $expected;
3338
$this->threshold = $threshold;
39+
$this->diffCalculator = isset($diffCalculator) ? $diffCalculator : new RgbaChannels();
3440
}
3541

3642
/**
@@ -67,7 +73,7 @@ public function matches($other)
6773
$delta = 0;
6874
for ($x = 0; $x < $w; ++$x) {
6975
for ($y = 0; $y < $h; ++$y) {
70-
$delta += $this->getPixelError($imgExpec, $imgOther, $x, $y);
76+
$delta += $this->diffCalculator->calculate($imgExpec, $imgOther, $x, $y);
7177
}
7278
}
7379

@@ -79,27 +85,4 @@ public function matches($other)
7985

8086
return $error <= $this->threshold;
8187
}
82-
83-
/**
84-
* Calculates the error between 0 and 1 (inclusive) of a specific pixel.
85-
*
86-
* @param GDImage $imgA The first image.
87-
* @param GDImage $imgB The second image.
88-
* @param int $x The pixel's x coordinate.
89-
* @param int $y The pixel's y coordinate.
90-
*
91-
* @return float The pixel error.
92-
*/
93-
private function getPixelError(GDImage $imgA, GDImage $imgB, $x, $y)
94-
{
95-
$pixelA = $imgA->getPixel($x, $y);
96-
$pixelB = $imgB->getPixel($x, $y);
97-
98-
$diffR = abs($pixelA['red'] - $pixelB['red']) / 255;
99-
$diffG = abs($pixelA['green'] - $pixelB['green']) / 255;
100-
$diffB = abs($pixelA['blue'] - $pixelB['blue']) / 255;
101-
$diffA = abs($pixelA['alpha'] - $pixelB['alpha']) / 127;
102-
103-
return ($diffR + $diffG + $diffB + $diffA) / 4;
104-
}
10588
}

tests/GDAssertTraitTest.php

+20
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use AssertGD\DiffCalculator\ScaledRgbChannels;
34
use PHPUnit\Framework\TestCase;
45

56
use AssertGD\GDAssertTrait;
@@ -49,4 +50,23 @@ public function testJpeg()
4950
$this->assertSimilarGD('./tests/images/jpeg.jpg',
5051
'./tests/images/jpeg-alt.jpg', '', 0.1);
5152
}
53+
54+
public function testAlternativeDiffCalculator()
55+
{
56+
// the default method of calculating images will not consider these images exact due to the transparent pixels
57+
// having different RGB values
58+
$this->assertNotSimilarGD('./tests/images/transparent-black.gif', './tests/images/transparent-white.gif');
59+
60+
// using the ScaledRgbChannels diff calculator, the images will be considered exact
61+
$this->assertSimilarGD('./tests/images/transparent-black.gif', './tests/images/transparent-white.gif',
62+
'', 0, new ScaledRgbChannels());
63+
}
64+
65+
public function testSetDiffCalculator()
66+
{
67+
// apply diff calculator on all further assertions
68+
$this->setDiffCalculator(new ScaledRgbChannels());
69+
70+
$this->assertSimilarGD('./tests/images/transparent-black.gif', './tests/images/transparent-white.gif');
71+
}
5272
}

0 commit comments

Comments
 (0)