Skip to content

Commit

Permalink
[v1.x] feat: Add ability to define pixel difference calculators (#18)
Browse files Browse the repository at this point in the history
This is a backport of PR #17 to the v1.x release branch.

Co-authored-by: Ben Thomson <15900351+bennothommo@users.noreply.github.com>
  • Loading branch information
meyfa and bennothommo authored Apr 16, 2024
1 parent e550e5e commit 743303a
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 36 deletions.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions src/DiffCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace AssertGD;

use AssertGD\GDImage;

/**
* Difference calculator.
*
* Determines the difference between two given images.
*/
interface DiffCalculator
{
/**
* Calculates the difference between two pixels at the given coordinates.
*
* This method will be provided with two `GDImage` objects representing the images being compared, and co-ordinates
* of the pixel being compared.
*
* The method should return a float value between 0 and 1 inclusive, with 0 meaning that the pixels of both images
* at the given co-ordinates are an exact match, and 1 meaning that the pixels are the complete opposite.
*/
public function calculate(GDImage $imageA, GDImage $imageB, $pixelX, $pixelY);
}
31 changes: 31 additions & 0 deletions src/DiffCalculator/RgbaChannels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace AssertGD\DiffCalculator;

use AssertGD\DiffCalculator;
use AssertGD\GDImage;

/**
* Calculate the difference between two pixels using the RGBA channels.
*
* This is the default calculation method used by the `AssertGD` package. It simply takes each individual channel and
* compares the delta between the channel values of the two images.
*
* This works well for most images, but may not work for images with transparent pixels if the transparent pixels have
* different RGB values.
*/
class RgbaChannels implements DiffCalculator
{
public function calculate(GDImage $imageA, GDImage $imageB, $pixelX, $pixelY)
{
$pixelA = $imageA->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;
}
}
45 changes: 45 additions & 0 deletions src/DiffCalculator/ScaledRgbChannels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace AssertGD\DiffCalculator;

use AssertGD\DiffCalculator;
use AssertGD\GDImage;

/**
* Calculate the difference between two pixels using the RGB channels, and scales down the RGB difference by the alpha
* channel.
*
* This calculation will pre-multiply the RGB channels by the opacity percentage (alpha) of the pixel, meaning that a
* translucent pixel will have less of an impact on the overall difference than an opaque pixel. For transparent pixels,
* this will mean that the RGB difference will be scaled down to zero, effectively meaning that transparent pixels will
* match regardless of their RGB values.
*
* This calculation method is useful for images with transparent pixels or images that have been anti-aliased or
* blurred over a transparent background, effectively making translucent pixels less likely to cause a false positive as
* being different.
*/
class ScaledRgbChannels implements DiffCalculator
{
public function calculate(GDImage $imageA, GDImage $imageB, $pixelX, $pixelY)
{
$pixelA = $this->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,
);
}
}
41 changes: 35 additions & 6 deletions src/GDAssertTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

Expand All @@ -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);
}
Expand All @@ -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;
}
}
35 changes: 9 additions & 26 deletions src/GDSimilarityConstraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace AssertGD;

use PHPUnit\Framework\TestCase;
use AssertGD\DiffCalculator\RgbaChannels;
use PHPUnit\Framework\Constraint\Constraint;

/**
Expand All @@ -17,20 +17,26 @@ 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
* images are allowed, while a threshold of 1 matches every image.
*
* @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();
}

/**
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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;
}
}
20 changes: 20 additions & 0 deletions tests/GDAssertTraitTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use AssertGD\DiffCalculator\ScaledRgbChannels;
use PHPUnit\Framework\TestCase;

use AssertGD\GDAssertTrait;
Expand Down Expand Up @@ -49,4 +50,23 @@ public function testJpeg()
$this->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');
}
}
Loading

0 comments on commit 743303a

Please sign in to comment.