Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Options for detecting image rotation & image flipping when hashing. #17

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Expand Up @@ -70,6 +70,15 @@ If you prefer to have decimal image hashes, you can change the mode during the c
$hasher = new ImageHash($implementation, ImageHash::DECIMAL);
```

To auto rotate and/or auto flip images before hashing, so that images that have been rotated in 90 degree increments and/or flipped produce the same hash:

```php
$hasher = new ImageHash($implementation);
$hasher->autorotate = true;
$hasher->autoflip = true;
$hash = $hasher->hash('path/to/image.jpg');
```

Demo
----

Expand Down
188 changes: 188 additions & 0 deletions src/ImageHash.php
Expand Up @@ -15,13 +15,35 @@ class ImageHash
*/
const DECIMAL = 'dec';

const SIZE = 64;

/**
* The hashing implementation.
*
* @var Implementation
*/
protected $implementation;

/**
* Should we auto rotate the images before hashing them? This ensures (or
* tries to) that images that have been rotated 90, 180, or 270 degrees will
* return the same (or very similar) hashes. eg: It's very common for a
* camera to automatically rotate JPEG images, but not rotate the RAW
* versions, and without auto rotation they'll return two completely
* different hashes.
*/
public $autorotate = false;

/**
* Should we auto flip the images before hashing them? This ensures (or
* tries to) that images that have been flipped will
* return the same (or very similar) hashes. eg: It's very common for a
* camera to automatically rotate JPEG images, but not rotate the RAW
* versions, and without auto rotation they'll return two completely
* different hashes.
*/
public $autoflip = false;

/**
* Constructor.
*
Expand Down Expand Up @@ -50,6 +72,43 @@ public function hash($resource)
$destroy = true;
}

if ($this->autorotate || $this->autoflip) {

// Resize the image to 64x64 (the current max needed for any
// implementation) so we dramatically cut down the number of pixels
// we need to examine in PHP.
$resized = imagecreatetruecolor(static::SIZE, static::SIZE);
imagecopyresampled($resized, $resource, 0, 0, 0, 0, static::SIZE, static::SIZE, imagesx($resource), imagesy($resource));

// Ensure that images that have been rotated in 90 degree increments
// return the same hash. To use this, set AverageHash->autorotate = true.
// We auto rotate after resizing so that we only have to analyze a few
// pixels, instead of (potentially) many millions.
if ($this->autorotate) {
$rotated = $this->autorotateImageResource($resized);
imagedestroy($resized);
$resized = $rotated;
unset($rotated);
}

// Ensure that images that have been flipped return the same hash. To
// use this, set AverageHash->autoflip = true. Highly recommended that
// you enable autorotate if you're using this.
if ($this->autoflip) {
$this->autoflipImageResource($resized);
}

if ($destroy) {
imagedestroy($resource);
}
$resource = $resized;
unset($resized);

// since this new resource is ours now, we're responsible for memory
// cleanup - destroy it when done
$destroy = true;
}

$hash = $this->implementation->hash($resource);

if ($destroy) {
Expand Down Expand Up @@ -128,4 +187,133 @@ protected function loadImageResource($file)
throw new Exception("Unable to load file: $file");
}
}

/**
* Rotate the image so the brightest "side" is on top. This way images that
* have been rotated (in 90 degree increments) end up with the same (or
* very similar) hashes.
*
* @param resource $resource A resource pointing to the image to rotate.
* @return resource A new (possibly rotated) image resource.
*/
protected function autorotateImageResource($resource)
{

// Keep a running total of the brightness of each side, which ends up
// being a running total of what the "top" of the image would be if we
// rotated it by 0, 90, 180, or 270 degrees.
$rotBright = array(
0 => 0,
90 => 0,
180 => 0,
270 => 0,
);

// Split the image into four overlapping "sides". To do this we
// break the image into four quadrants (draw a + over your image).
// The top "side" ends up being the upper left and upper right
// quadrants. The right side ends up being the upper right and
// lower right quadrants, etc. All the pixels in each quadrant /
// side are summed up and the brightest side is rotated to be on
// top. Because we end up processing all pixels in the quadrants,
// it's a good idea to resize the image down to the largest size
// the hash will need first.
$width = imagesx($resource);
$height = imagesy($resource);
$halfw = $width / 2;
$halfh = $height / 2;
for ($y = 0; $y < $height; $y++) {
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorsforindex($resource, imagecolorat($resource, $x, $y));
$brightness = floor(($rgb['red'] + $rgb['green'] + $rgb['blue']) / 3);
if ($x >= $halfw) {
$rotBright[90] += $brightness;
} else {
$rotBright[270] += $brightness;
}
if ($y >= $halfh) {
$rotBright[180] += $brightness;
} else {
$rotBright[0] += $brightness;
}
}
}

// Sort the values so the brightest side is the first element in the
// array.
arsort($rotBright);

// And that first element will have a key of either 0, 90, 180, or 270.
$rotation = array_keys($rotBright);

// Only rotate if rotation required is not zero.
if ($rotation[0] > 0) {

// Rotate the image (returns a new image resource)
$rotated = imagerotate($resource, $rotation[0], 0);
} else {

// Just copy it so we don't return the same resource.
// This is slightly wasteful, but ensures that autorotate always
// returns a new resource, instead of sometimes returning a new one
// and sometimes returning the existing one.
$rotated = imagecreatetruecolor($width, $height);
imagecopyresampled($rotated, $resource, 0, 0, 0, 0, $width, $height, $width, $height);
}

return $rotated;
}

/**
* Flip the image so the brightest left/right "side" is on the left. This
* way images that have been flipped end up with the same (or very similar)
* hashes. This will only flip horizontally, as it assumes if you're trying
* to detect flips, you're also trying to detect rotations and have run
* autorotateImageResource first (and that the brightest side will already
* be on top).
*
* @param resource $resource A resource pointing to the image to flip. This
* resource will be directly modified (flipped) if needed.
* @return void
*/
protected function autoflipImageResource($resource)
{

// Keep a running total of the brightness of each side, which ends up
// being a running total of "to flip or not to flip".
$flipBright = array(
0 => 0,
1 => 0,
);

// Split the image into four overlapping "sides". To do this we
// break the image into four quadrants (draw a + over your image).
// The top "side" ends up being the upper left and upper right
// quadrants. The right side ends up being the upper right and
// lower right quadrants, etc. All the pixels in each quadrant /
// side are summed up and the brightest side is rotated to be on
// top. Because we end up processing all pixels in the quadrants,
// it's a good idea to resize the image down to the largest size
// the hash will need first.
$width = imagesx($resource);
$height = imagesy($resource);
$halfw = $width / 2;
for ($y = 0; $y < $height; $y++) {
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorsforindex($resource, imagecolorat($resource, $x, $y));
$brightness = floor(($rgb['red'] + $rgb['green'] + $rgb['blue']) / 3);
if ($x >= $halfw) {
$flipBright[1] += $brightness;
} else {
$flipBright[0] += $brightness;
}
}
}

if ($flipBright[1] > $flipBright[0]) {
imageflip($resource, IMG_FLIP_HORIZONTAL);
}

return;
}
}
8 changes: 4 additions & 4 deletions src/Implementations/DifferenceHash.php
Expand Up @@ -13,15 +13,15 @@ public function hash($resource)
{
// For this implementation we create a 8x9 image.
$width = static::SIZE + 1;
$heigth = static::SIZE;
$height = static::SIZE;

// Resize the image.
$resized = imagecreatetruecolor($width, $heigth);
imagecopyresampled($resized, $resource, 0, 0, 0, 0, $width, $heigth, imagesx($resource), imagesy($resource));
$resized = imagecreatetruecolor($width, $height);
imagecopyresampled($resized, $resource, 0, 0, 0, 0, $width, $height, imagesx($resource), imagesy($resource));

$hash = 0;
$one = 1;
for ($y = 0; $y < $heigth; $y++) {
for ($y = 0; $y < $height; $y++) {
// Get the pixel value for the leftmost pixel.
$rgb = imagecolorsforindex($resized, imagecolorat($resized, 0, $y));
$left = floor(($rgb['red'] + $rgb['green'] + $rgb['blue']) / 3);
Expand Down
111 changes: 111 additions & 0 deletions tests/OrientationTest.php
@@ -0,0 +1,111 @@
<?php

use Jenssegers\ImageHash\ImageHash;
use Jenssegers\ImageHash\Implementations\AverageHash;
use Jenssegers\ImageHash\Implementations\DifferenceHash;
use Jenssegers\ImageHash\Implementations\PerceptualHash;

class OrientationTest extends PHPUnit_Framework_TestCase
{
protected $precision = 5;

public static function setUpBeforeClass()
{
if (extension_loaded('gmp')) {
echo "INFO: gmp extension loaded \n";
} else {
echo "INFO: gmp extension not loaded \n";
}
}

public function setUp()
{
$this->hashers = [
new AverageHash,
new DifferenceHash,
new PerceptualHash,
];
}

public function testEqualHashes()
{
foreach ($this->hashers as $hasher) {
$score = 0;
$imageHash = new ImageHash($hasher);
$imageHash->autorotate = true;
$imageHash->autoflip = true;
$images = glob('tests/images/orientation/match*');

// Disable test flipping images on versions of PHP that include the
// imageflip GD function. Which means we need to remove the flip
// test images as well so they don't fail.
if (!function_exists('imageflip')) {
$imageHash->autoflip = true;
$newImages = array();
foreach ($images as $image) {
if (preg_match('/-f0\.jpg$/', $image)) {
$newImages[] = $image;
}
}
$images = $newImages;
}

$hashes = [];
foreach ($images as $image) {
$hashes[$image] = $hash = $imageHash->hash($image);

echo "[" . get_class($hasher) . "] $image = $hash \n";
}

foreach ($hashes as $image => $hash) {
foreach ($hashes as $target => $compare) {
if ($target == $image) {
continue;
}

$distance = $imageHash->distance($hash, $compare);
$this->assertLessThan($this->precision, $distance, "[" . get_class($hasher) . "] $image ($hash) ^ $target ($compare)");
$score += $distance;

echo "[" . get_class($hasher) . "] $image ^ $target = $distance \n";
}
}

echo "[" . get_class($hasher) . "] Total score: $score \n";
}
}

public function testDifferentHashes()
{
foreach ($this->hashers as $hasher) {
$score = 0;
$imageHash = new ImageHash($hasher);
$imageHash->autorotate = true;
$imageHash->autoflip = true;
$images = glob('tests/images/orientation/mismatch*');

$hashes = [];
foreach ($images as $image) {
$hashes[$image] = $hash = $imageHash->hash($image);

echo "[" . get_class($hasher) . "] $image = $hash \n";
}

foreach ($hashes as $image => $hash) {
foreach ($hashes as $target => $compare) {
if ($target == $image) {
continue;
}

$distance = $imageHash->distance($hash, $compare);
$this->assertGreaterThan($this->precision, $distance, "[" . get_class($hasher) . "] $image ($hash) ^ $target ($compare)");
$score += $distance;

echo "[" . get_class($hasher) . "] $image ^ $target = $distance \n";
}
}

echo "[" . get_class($hasher) . "] Total score: $score \n";
}
}
}
Binary file added tests/images/orientation/match-r0-f0.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r0-fb.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r0-fh.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r0-fv.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r180-f0.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r180-fb.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r180-fh.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r180-fv.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r270-f0.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r270-fb.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r270-fh.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r270-fv.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r90-f0.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r90-fb.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r90-fh.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/match-r90-fv.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/mismatch-1.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/mismatch-2.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/orientation/mismatch-3.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.