Skip to content

Instantly share code, notes, and snippets.

@johnnoel
Created February 18, 2017 11:58
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save johnnoel/b6c80ef49de4e2fbf4c616956a289e23 to your computer and use it in GitHub Desktop.
Save johnnoel/b6c80ef49de4e2fbf4c616956a289e23 to your computer and use it in GitHub Desktop.
JavaScript ES6 Classes for perceptual image hashing
import Colour from './Colour';
import Resample from './Resample';
/**
* Average hash for images
*
* @author John Noel <john.noel@chaostangent.com>
* @see http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html
* @see https://github.com/jenssegers/imagehash/blob/master/src/Implementations/AverageHash.php
*/
class Average
{
/**
* Hash the given image data
*
* @param ImageData imageData An ImageData object, usually from a <canvas>
* @return integer
*/
static hash(imageData) {
let size = Average.size,
pixelCount = (size * size);
if (imageData.width != size || imageData.size != size) {
imageData = Resample.nearestNeighbour(imageData, size, size);
}
imageData = Colour.grayscale(imageData);
let sum = 0;
for (let i = 0; i < pixelCount; i++) {
// already grayscale so just take the first channel
sum += imageData.data[4 * i];
}
let average = Math.floor(sum / pixelCount),
hash = 0,
one = 1;
for (let i = 0; i < pixelCount; i++) {
if (imageData.data[4 * i] > average) {
hash |= one;
}
one = one << 1;
}
return hash;
}
}
Average.size = 8;
export default Average;
/**
* Colour transformation functions
*
* @author John Noel <john.noel@chaostangent.com>
*/
export default class Colour
{
/**
* Convert the image data into grayscale
*
* This just takes the average of all three colour values (RGB) and sets
* each channel to that value. Alpha information is preserved
*
* @param ImageData imageData An ImageData object, usually from a <canvas>
* @return ImageData Returns a new ImageData object
* @see http://www.johndcook.com/blog/2009/08/24/algorithms-convert-color-grayscale/
*/
static grayscale(imageData) {
let pixelCount = imageData.width * imageData.height,
converted = new Uint8ClampedArray(pixelCount * 4);
for (let i = 0; i < pixelCount; i++) {
let offset = i * 4,
gray = Math.floor(imageData.data[offset] + imageData.data[offset + 1] + imageData.data[offset + 2]) / 3;
for (let j = 0; j < 3; j++) {
converted[offset + j] = gray;
}
// retain alpha channel
converted[offset + 3] = imageData[offset + 3];
}
return new ImageData(converted, imageData.width, imageData.height);
}
/**
* Convert the image data into YCbCr and extract the luminosity (Y) part
*
* This uses ITU-R BT.601 conversion method and will retain any alpha
* information on a pixel
*
* @param ImageData imageData An ImageData object, usually from a <canvas>
* @return ImageData Returns a converted ImageData object
* @see https://en.wikipedia.org/wiki/YCbCr#ITU-R_BT.601_conversion
*/
static luminosity(imageData) {
let pixels = imageData.width * imageData.height;
let converted = new Uint8ClampedArray(pixels * 4);
for (let i = 0; i < pixels; i++) {
let offset = i * 4;
converted[offset] = 0.299 * imageData.data[offset];
converted[offset + 1] = 0.587 * imageData.data[offset + 1];
converted[offset + 2] = 0.114 * imageData.data[offset + 2];
converted[offset + 3] = imageData.data[offset + 3];
}
return new ImageData(converted, imageData.width, imageData.height);
}
}
/**
* Discrete Cosine Transforms in JavaScript
*
* Not my code, mostly translated from other language implementations
*
* @author John Noel <john.noel@chaostangent.com>
*/
export default class DCT
{
/**
* 1 dimensional DCT
*
* @param integer N size
* @param array f A 1 dimensional array of values
* @return Float64Array
* @see https://github.com/jenssegers/imagehash/blob/master/src/Implementations/PerceptualHash.php
*/
static DCT1D(N, f) {
let F = new Float64Array(N);
for (let i = 0; i < N; i++) {
let sum = 0;
for (let j = 0; j < N; j++) {
sum += f[j] * Math.cos(i * Math.PI * (j + 0.5) / N);
}
sum *= Math.sqrt(2 / N);
if (i == 0) {
sum *= 1 / Math.sqrt(2);
}
F[i] = sum;
}
return F;
}
/**
* 2 dimensional DCT
*
* @param integer N size
* @param array f A 1 dimensional array of values
* @return Float64Array
* @see https://github.com/naptha/phash.js/blob/master/phash.js
*/
static DCT2D(N, f) {
let c = new Float64Array(N);
for (let i = 1; i < N; i++) {
c[i] = 1;
}
c[0] = 1 / Math.sqrt(2);
let F = new Float64Array(N * N);
// precompute cosine lookup table
let entries = (2 * N) * (N - 1);
let COS = new Float64Array(entries);
for (let i = 0; i < entries; i++) {
COS[i] = Math.cos(i / (2 * N) * Math.PI);
}
for (let u = 0; u < N; u++) {
for (let v = 0; v < N; v++) {
let sum = 0;
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
sum += COS[(2 * i + 1) * u]
* COS[(2 * j + 1) * v]
* f[N * i + j];
}
}
sum *= ((c[u] * c[v]) / 4);
F[N * u + v] = sum;
}
}
return F;
}
}
import Colour from './Colour';
import Resample from './Resample';
/**
* Difference hash for images
*
* @author John Noel <john.noel@chaostangent.com>
* @see http://www.hackerfactor.com/blog/?/archives/529-Kind-of-Like-That.html
* @see https://github.com/jenssegers/imagehash/blob/master/src/Implementations/DifferenceHash.php
*/
class Difference
{
/**
* Hash the given image data
*
* @param ImageData imageData An ImageData object, usually from a <canvas>
* @return integer
*/
static hash(imageData) {
let size = Difference.size,
w = size,
h = size + 1;
if (imageData.width != w || imageData.height != h) {
imageData = Resample.nearestNeighbour(imageData, w, h);
}
imageData = Colour.grayscale(imageData);
let hash = 0,
one = 1;
for (let y = 0; y < h; y++) {
// ignore other channels as already converted to grayscale
let left = imageData.data[4 * w * y];
for (let x = 1; x < w; x++) {
let right = imageData.data[4 * (w * x + y)];
if (left > right) {
hash |= one;
}
left = right;
one = one << 1;
}
}
return hash;
}
}
Difference.size = 8;
export default Difference;
import DCT from './DCT';
import Colour from './Colour';
import Resample from './Resample';
/**
* Perceptual hash for images
*
* @author John Noel <john.noel@chaostangent.com>
* @see http://www.phash.org/
* @see https://github.com/jenssegers/imagehash
*/
class Perceptual
{
/**
* Hash the given image data
*
* @param ImageData imageData An ImageData object, usually from a <canvas>
* @return integer
*/
static hash(imageData) {
let size = Perceptual.size;
if (imageData.width != size || imageData.height != size) {
imageData = Resample.nearestNeighbour(imageData, size, size);
}
imageData = Colour.luminosity(imageData);
// take a 1D DCT of each row
let rows = [];
for (let y = 0; y < size; y++) {
let row = new Float64Array(size);
for (let x = 0; x < size; x++) {
let base = 4 * (size * x + y);
row[x] = imageData.data[base]
+ imageData.data[base + 1]
+ imageData.data[base + 2];
}
rows[y] = DCT.DCT1D(size, row);
}
// then take a 1D DCT of each column
let matrix = [];
for (let x = 0; x < size; x++) {
let col = new Float64Array(size);
for (let y = 0; y < size; y++) {
col[y] = rows[y][x];
}
matrix[x] = DCT.DCT1D(size, col);
}
// grab the top 8x8 pixels
let top8 = [];
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
top8.push(matrix[y][x]);
}
}
// calculate the median
let median = top8.slice(0).sort((a, b) => {
return a - b;
})[31];
let hash = 0;
let one = 1;
// calculate the hash
for (let i = 0; i < 64; i++) {
if (top8[i] > median) {
hash |= one;
}
one = one << 1;
}
return hash;
}
}
Perceptual.size = 32;
export default Perceptual;
/**
* Image resampling algorithms
*
* @author John Noel <john.noel@chaostangent.com>
*/
export default class Resample
{
/**
* Resample an image using nearest neighbour
*
* @param ImageData imageData
* @param integer targetWidth
* @param integer targetHeight
* @return ImageData
* @see http://tech-algorithm.com/articles/nearest-neighbor-image-scaling/
*/
static nearestNeighbour(imageData, targetWidth, targetHeight) {
let w = imageData.width,
h = imageData.height,
xr = w / targetWidth,
yr = h / targetHeight,
ret = new Uint8ClampedArray((targetWidth * targetHeight) * 4);
for (let i = 0; i < targetHeight; i++) {
for (let j = 0; j < targetWidth; j++) {
let px = Math.floor(j * xr),
py = Math.floor(i * yr);
let rt = 4 * ((i * targetWidth) + j),
rs = 4 * ((py * w) + px);
for (let k = 0; k < 4; k++) {
ret[rt+k] = imageData.data[rs+k];
}
}
}
return new ImageData(ret, targetWidth, targetHeight);
}
/**
* Resample an image using bilinear
*
* @param ImageData imageData
* @param integer targetSize
* @return ImageData
* @see http://tech-algorithm.com/articles/bilinear-image-scaling/
*/
static bilinear(imageData, targetSize) {
let w = imageData.width,
h = imageData.height,
xr = w / targetSize,
yr = h / targetSize,
ret = new Uint8ClampedArray((targetSize * targetSize) * 4),
offset = 0;
for (let i = 0; i < targetSize; i++) {
for (let j = 0; j < targetSize; j++) {
let x = Math.round(xr * j),
y = Math.round(yr * i),
xd = (xr * j) - x,
yd = (yr * i) - y,
idx = (y * w + x) * 4;
for (let k = 0; k < 4; k++) {
let a = imageData.data[idx + k],
b = imageData.data[idx + k + 4],
c = imageData.data[idx + k + w],
d = imageData.data[idx + k + w + 4];
ret[offset + k] = a * (1 - xd) * (1 - yd) + b * xd * (1 - yd) +
c * yd * (1 - xd) + d * xd * yd;
}
offset += 4;
}
}
return new ImageData(ret, targetSize, targetSize);
}
/**
* Resample an image using bicubic
*
* @param ImageData imageData
* @param integer targetSize
* @return ImageData
* @see http://techslides.com/javascript-image-resizer-and-scaling-algorithms
* @see http://jsfiddle.net/HZewg/1/
*/
static bicubic(imageData, targetSize) {
// todo
}
}
@trogau
Copy link

trogau commented Jun 18, 2020

Hey! Thanks for taking the time to convert this code; I currently use jenssegers/imagehash in a PHP project and was looking for a JavaScript version to use on the browser side and stumbled across this, which I'm hoping will save me many hours of painful conversion.

I was just wondering if you had any example implementations of this in action? Browser-based or node-based would be fine. I have hacked together a little command line script to test it out but I keep getting negative values returned in the hash() call; I can't tell if it's an error on my part in my hacking to get it working or if there is some overflow happening somewhere in the code.

Just thought I'd ask before I start debugging line-by-line in case you have some example code lying around I can check out first :)

Thanks!!

@johnnoel
Copy link
Author

@trogau All of my implementions for this are internal only so I'm not able to share. As indicated by some of the comments, most of the methods expect an ImageData object, which I get from loading an image into a <canvas> element then calling createImageData() on it. It shouldn't be too hard to set this up in a browser context; as for using it from node on the server-side that was never my use case so not sure how you'd approach it.

@trogau
Copy link

trogau commented Jun 19, 2020

Thanks mate! I'll keep tinkering. I managed to get it sort-of-working by simply changing the hash calculation step to use a BigInt - I'm not sure but I think it was overflowing with the default integer types, which is why I was getting weird negative numbers in hashes. Not sure if this is a node-specific issue or I just don't get how JavaScript does numbers.

I was hoping to get results that were identical to jenssegers/imagehash, but it looks like the resize operation results in a slightly different source image, so it's not completely compatible. I am going to try some other JavaScript image resize libraries to see if I can get it close enough, but I need to do some more testing to make sure I'm not breaking it in some other ways with the BigInt change.

Thanks for the reply & thanks again for publishing what you had done publicly!

@freearhey
Copy link

Unfortunately, this implementation does not even closely resemble the jenssegers/imagehash implementation, so be careful if you decide to use it on the same array of images as the php version.

@johnnoel
Copy link
Author

Would you mind elaborating @freearhey? This is 3.5 years old at this point so it's more than likely there's been a lot of drift in the code.

@freearhey
Copy link

@johnnoel of course. This code produces a hash completely different from the current version of jenssegers/imagehash and since you have not specified anywhere that this code is outdated for me personally it was a surprise. Also just to make it work, I had to spend a lot of time, so I decided to warn the others.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment