Skip to content

Commit 46d8b7e

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

9 files changed

+210
-31
lines changed

README.md

+49
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,52 @@ $this->assertThat(
8989
$this->isSimilarGD('./tests/expected.png')
9090
);
9191
```
92+
93+
## Difference calculation
94+
95+
By default, this library calculates the difference between two images by
96+
comparing the RGBA color channel information at each pixel coordinate of the
97+
source image and the test image, and averaging the difference between each
98+
pixel to calculate the difference score.
99+
100+
This will work for the majority of cases, but may give incorrect scoring
101+
in certain circumstances, such as images that contain a lot of transparency.
102+
103+
An alternative calculation method, which scales the RGB color channels
104+
based on their alpha transparency - meaning more transparent pixels will
105+
affect the difficulty score less to offset their less observable difference
106+
on the image itself - can be enabled by adding a new `ScaledRgbChannels`
107+
instance to the 5th parameter of the `assertSimilarGD` or `assertNotSimilarGD`
108+
methods.
109+
110+
```php
111+
use AssertGD\DiffCalculator\ScaledRgbChannels;
112+
113+
public function testImage()
114+
{
115+
$this->assertSimilarGD(
116+
'expected.png',
117+
'actual.png',
118+
'',
119+
0,
120+
new ScaledRgbChannels()
121+
);
122+
}
123+
```
124+
125+
### Custom difference calculators
126+
127+
If you wish to completely customise how calculations are done in this
128+
library, you may also create your own calculation algorithm by creating
129+
a class that implements the `AssertGd\DiffCalculator` interface.
130+
131+
A class implementing this interface must provide a `calculate` method
132+
that is provided two `GdImage` instances, and the X and Y co-ordinate
133+
(as `ints`) of the pixel being compared in both images.
134+
135+
The method should return a `float` between `0` and `1`, where 0 is
136+
an exact match and 1 is the complete opposite.
137+
138+
You may then provide an instance of the class as the 5th parameter of
139+
the `assertSimilarGD` or `assertNotSimilarGD` method to use this
140+
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, int $pixelX, int $pixelY): float;
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, int $pixelX, int $pixelY): float
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, int $pixelX, int $pixelY): float
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 [
40+
'red' => $pixel['red'] * $alpha,
41+
'green' => $pixel['green'] * $alpha,
42+
'blue' => $pixel['blue'] * $alpha,
43+
];
44+
}
45+
}

src/GDAssertTrait.php

+32-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,28 @@ 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+
return new GDSimilarityConstraint($expected, $threshold, $diffCalculator ?? $this->diffCalculator ?? new RgbaChannels());
85+
}
86+
87+
/**
88+
* Sets the difference calculator to use for image comparisons in this test case.
89+
*
90+
* @var DiffCalculator $diffCalculator
91+
*/
92+
public function setDiffCalculator($diffCalculator): void
93+
{
94+
if (!($diffCalculator instanceof DiffCalculator)) {
95+
throw new \InvalidArgumentException(
96+
'The difference calculator must implement the `AssertGD\DiffCalculator` interface'
97+
);
98+
}
99+
100+
$this->diffCalculator = $diffCalculator;
75101
}
76102
}

src/GDSimilarityConstraint.php

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

33
namespace AssertGD;
44

5+
use AssertGD\DiffCalculator\RgbaChannels;
56
use PHPUnit\Framework\Constraint\Constraint;
67

78
/**
@@ -16,18 +17,24 @@ class GDSimilarityConstraint extends Constraint
1617
{
1718
private $expected;
1819
private $threshold;
20+
/**
21+
* @var DiffCalculator The difference calculator to compare the images with.
22+
*/
23+
private $diffCalculator;
1924

2025
/**
2126
* Constructs a new constraint. A threshold of 0 means only exactly equal
2227
* images are allowed, while a threshold of 1 matches every image.
2328
*
2429
* @param string|resource $expected File name or resource to match against.
2530
* @param float $threshold Error threshold between 0 and 1.
31+
* @param DiffCalculator|null $diffCalculator The difference calculator to use.
2632
*/
27-
public function __construct($expected, $threshold = 0)
33+
public function __construct($expected, $threshold = 0, $diffCalculator = null)
2834
{
2935
$this->expected = $expected;
3036
$this->threshold = $threshold;
37+
$this->diffCalculator = $diffCalculator ?? new RgbaChannels();
3138
}
3239

3340
/**
@@ -64,7 +71,7 @@ public function matches($other): bool
6471
$delta = 0;
6572
for ($x = 0; $x < $w; ++$x) {
6673
for ($y = 0; $y < $h; ++$y) {
67-
$delta += $this->getPixelError($imgExpec, $imgOther, $x, $y);
74+
$delta += $this->diffCalculator->calculate($imgExpec, $imgOther, $x, $y);
6875
}
6976
}
7077

@@ -76,27 +83,4 @@ public function matches($other): bool
7683

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

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
}

tests/images/transparent-black.gif

439 Bytes
Loading

tests/images/transparent-white.gif

439 Bytes
Loading

0 commit comments

Comments
 (0)