-
-
Save VijitCoder/55ed9c336ba4db8d91f18e5437ce6096 to your computer and use it in GitHub Desktop.
PHP class for comparing two images and determine how much they similar, in percentages.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Compare two images and determine how much they similar, in percentages. | |
*/ | |
class ImagesComparator | |
{ | |
private const ACCEPTED_IMAGE_TYPES = [IMAGETYPE_JPEG, IMAGETYPE_PNG]; | |
/** | |
* Calculate the images similarity in percentages. | |
* | |
* Based on calculation of the hammering distance between color bit values of two images. | |
* | |
* What is about the snapshot size? Images comparison makes a lot of calculations based on pixels color. | |
* These calculations will give roughly the same result for the image in a different sizes, but the bigger image | |
* required more time for calculation. So it is make sense to use a resized images instead originals, which is | |
* called here the snapshots. Recommended value in the range of [8, 100] px. | |
* | |
* The smaller snapshots size, the rough the comparison you"ll get (in theory). | |
* | |
* @param string $file1 absolute path + file name to the first image | |
* @param string $file2 absolute path + file name to the second image | |
* @param int $snapshotSize snapshot with (or height) in pixels. The snapshot is a square image, always. | |
* @return int|null percentages of similarity | |
*/ | |
public function compare(string $file1, string $file2, int $snapshotSize): ?int | |
{ | |
$image1 = $this->prepareSnapshot($file1, $snapshotSize); | |
$image2 = $this->prepareSnapshot($file2, $snapshotSize); | |
if (!($image1 && $image2)) { | |
return null; | |
} | |
$colorsMap1 = $this->buildColorsMap($image1); | |
$colorsMap2 = $this->buildColorsMap($image2); | |
$deviationMap1 = $this->determineDeviationFromMeanValue($colorsMap1); | |
$deviationMap2 = $this->determineDeviationFromMeanValue($colorsMap2); | |
$similarityPercentage = $this->calcImagesSimilarity($deviationMap1, $deviationMap2); | |
imagedestroy($image1); | |
imagedestroy($image2); | |
return $similarityPercentage; | |
} | |
/** | |
* Create resized image resource from the source file. Apply grayscale filter. | |
* | |
* Accept jpeg or png source only. | |
* | |
* @param string $source absolute path + file name to the image | |
* @param int $snapshotSize | |
* @return resource|null - image resource identifier or NULL if failed | |
*/ | |
private function prepareSnapshot(string $source, int $snapshotSize) | |
{ | |
if (!file_exists($source)) { | |
return $this->fail("File not found: " . $source); | |
} | |
$heap = getimagesize($source); | |
$imageType = $heap[2]; | |
$mimeType = $heap['mime']; | |
if (!in_array($imageType, static::ACCEPTED_IMAGE_TYPES)) { | |
return $this->fail("Unsupported image type. Its MIME type is " . $mimeType); | |
} | |
$sourceImage = $this->createImage($source, $imageType); | |
if (!$source) { | |
return $this->fail("Failed to create image resource"); | |
} | |
$snapshot = $this->resizeImage($sourceImage, $snapshotSize); | |
if (!$snapshot) { | |
return $this->fail("Failed to resize the image"); | |
} | |
$isFiltered = imagefilter($snapshot, IMG_FILTER_GRAYSCALE); | |
if (!$isFiltered) { | |
return $this->fail("Failed to apply grayscale filter"); | |
} | |
return $snapshot; | |
} | |
/** | |
* Create image resource. Supports jpg and png only. | |
* | |
* @param string $source absolute path + file name to the image | |
* @param string $imageType image type, see php::IMAGETYPE_* constants | |
* @return resource|null - image resource identifier or NULL if failed | |
*/ | |
private function createImage(string $source, string $imageType) | |
{ | |
switch ($imageType) { | |
case IMAGETYPE_JPEG: | |
return imagecreatefromjpeg($source) ?: null; | |
case IMAGETYPE_PNG: | |
return imagecreatefrompng($source) ?: null; | |
default: | |
return null; | |
} | |
} | |
/** | |
* Resize the source image to specified size. The result will be a square image despite of original aspect ratio. | |
* | |
* @param $sourceImage | |
* @param int $resizeTo | |
* @return resource|null | |
*/ | |
private function resizeImage($sourceImage, int $resizeTo) | |
{ | |
$resizedImage = imagescale($sourceImage, $resizeTo, $resizeTo); | |
return $resizedImage ?: null; | |
} | |
/** | |
* Build the map of all pixels color in the image | |
* | |
* @param resource image resource identifier | |
* @return array | |
*/ | |
private function buildColorsMap($image): array | |
{ | |
$width = imagesx($image); | |
$height = imagesy($image); | |
$colorsMap = []; | |
for ($w = 0; $w < $width; $w++) { | |
for ($h = 0; $h < $height; $h++) { | |
$rgbIndex = imagecolorat($image, $w, $h); | |
$colorsMap[] = $rgbIndex & 0xFF; | |
} | |
} | |
return $colorsMap; | |
} | |
/** | |
* Determine a deviation of each pixel from the colors mean value. | |
* | |
* If a color is bigger than the mean value of colors - it is 1, other vise it"s 0. | |
* | |
* @param array $colorsMap | |
* @return array | |
*/ | |
private function determineDeviationFromMeanValue(array $colorsMap): array | |
{ | |
$colorMeanValue = array_sum($colorsMap) / count($colorsMap); | |
$deviations = []; | |
foreach ($colorsMap as $color) { | |
$deviations[] = (int)($color >= $colorMeanValue); | |
} | |
return $deviations; | |
} | |
/** | |
* Calculate the images similarity | |
* | |
* Calculate the Hamming distance between two maps of color deviations. The relation of that distance to the map | |
* count can be interpreted as the images difference in percentages. | |
* | |
* @param array $deviations1 | |
* @param array $deviations2 | |
* @return int percentages of similarity | |
*/ | |
private function calcImagesSimilarity(array $deviations1, array $deviations2): int | |
{ | |
$distance = 0; | |
// Note: it doesn"t matter which exactly map to take, they all have the same size. | |
$mapSize = count($deviations1); | |
// Don't use this. It gives a wrong result for 256 elements per array. | |
// $distance = count(array_diff($deviations1, $deviations2)); | |
for ($i = 0; $i < $mapSize; $i++) { | |
if ($deviations1[$i] !== $deviations2[$i]) { | |
$distance++; | |
} | |
} | |
return 100 - $distance * 100 / $mapSize; | |
} | |
/** | |
* Report about fail and return nothing. | |
* | |
* In the perspective, you can replace `php::echo()` here with logging or throwing exception. | |
* | |
* @param string $message | |
* @return null | |
*/ | |
private function fail(string $message) | |
{ | |
echo $message; | |
return null; | |
} | |
} |
I found a theoretical basis for this solution.
Now I believe my resizing improvements were redundant. It's not bad, but useless. I have no time to refactoring this class one more time. And I don't want to do that, because here is a vendor library that provides the same logic using Jenssegers\ImageHash\Implementation\AverageHash
and three more solutions. I suggest to use this library.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage example:
It is not necessary to use the cycle with many snapshot widths. It's just a demo. On the practice you should choose acceptable width just one time and use it everywhere.
The quote from the class: