-
-
Save johnnoel/b6c80ef49de4e2fbf4c616956a289e23 to your computer and use it in GitHub Desktop.
import Colour from './Colour'; | |
import Resample from './Resample'; | |
/** | |
* Average hash for images | |
* | |
* @author John Noel <[email protected]> | |
* @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 <[email protected]> | |
*/ | |
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 <[email protected]> | |
*/ | |
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 <[email protected]> | |
* @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 <[email protected]> | |
* @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 <[email protected]> | |
*/ | |
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 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.
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!
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.
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.
@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.
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!!