phash-js
Version:
Perceptual image hashing in the browser without using HTML canvas
213 lines (174 loc) • 5.13 kB
JavaScript
import * as Magick from './magickApi'
const supportedTypes = ['image/jpeg', 'image/png', 'image/tiff', 'image/bmp']
class Hash {
constructor(bits) {
this.value = bits.join('')
}
toBinary() {
return this.value
}
toHex() {
return this.toInt().toString(16)
}
toInt() {
return parseInt(this.value, 2)
}
}
const pHash = {
async hash(input) {
let image = await this._readFileAsArrayBuffer(input)
image = await this._resizeImage(image)
const data = this._convertToObject(image)
return this._calculateHash(data)
},
_readFileAsArrayBuffer(input) {
if (input.constructor !== File) throw new Error('Input must be type of File')
if (!supportedTypes.includes(input.type))
throw new Error(
`Input file must be of one of the supported types: ${supportedTypes.join(', ')}`
)
return new Promise(resolve => {
const reader = new FileReader()
reader.onload = () => {
if (reader.result) {
resolve(reader.result)
}
}
reader.readAsArrayBuffer(input)
})
},
async _resizeImage(content) {
if (content.constructor !== ArrayBuffer) throw new Error('Content must be type of ArrayBuffer')
const files = [{ name: 'input.jpg', content }]
const command = ['convert', 'input.jpg', '-resize', '32x32!', 'output.txt']
const output = await Magick.Call(files, command) // eslint-disable-line new-cap
return output[0].buffer
},
_convertToObject(buffer) {
if (buffer.constructor !== Uint8Array) throw new Error('Buffer must be type of Uint8Array')
const string = String.fromCharCode.apply(null, buffer)
const lines = string.split('\n')
lines.shift()
const data = {}
for (const line of lines) {
const parts = line.split(' ').filter(v => v)
if (parts[0] && parts[2]) {
const key = parts[0].replace(':', '')
const value = this._convertToRGB(parts[2])
data[key] = value
}
}
return data
},
_calculateHash(data) {
if (typeof data !== 'object') throw new Error('Data must be type of object')
const matrix = []
const row = []
const rows = []
const col = []
const size = 32
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const color = data[`${x},${y}`]
if (!color) throw new Error(`There is no data for a pixel at [${x}, ${y}]`)
row[x] = parseInt(Math.floor(color.r * 0.299 + color.g * 0.587 + color.b * 0.114))
}
rows[y] = this._calculateDCT(row)
}
for (let x = 0; x < size; x++) {
for (let y = 0; y < size; y++) {
col[y] = rows[y][x]
}
matrix[x] = this._calculateDCT(col)
}
// Extract the top 8x8 pixels.
const pixels = []
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
pixels.push(matrix[y][x])
}
}
// Calculate hash.
const bits = []
const compare = this._average(pixels)
for (const pixel of pixels) {
bits.push(pixel > compare ? 1 : 0)
}
return new Hash(bits)
},
async compare(file1, file2) {
const hash1 = await this.hash(file1)
const hash2 = await this.hash(file2)
return this.distance(hash1, hash2)
},
distance(hash1, hash2) {
let bits1 = hash1.value
let bits2 = hash2.value
const length = Math.max(bits1.length, bits2.length)
// Add leading zeros so the bit strings are the same length.
bits1 = bits1.padStart(length, '0')
bits2 = bits2.padStart(length, '0')
return Object.keys(this._arrayDiffAssoc(bits1.split(''), bits2.split(''))).length
},
_arrayDiffAssoc(arr1) {
const retArr = {}
const argl = arguments.length
let k1 = ''
let i = 1
let k = ''
let arr = {}
for (k1 in arr1) {
for (i = 1; i < argl; i++) {
arr = arguments[i]
for (k in arr) {
if (arr[k] === arr1[k1] && k === k1) {
continue
}
}
retArr[k1] = arr1[k1]
}
}
return retArr
},
/**
* Perform a 1 dimension Discrete Cosine Transformation.
*/
_calculateDCT(matrix) {
const transformed = []
const size = matrix.length
for (let i = 0; i < size; i++) {
let sum = 0
for (let j = 0; j < size; j++) {
sum += matrix[j] * Math.cos((i * Math.PI * (j + 0.5)) / size)
}
sum *= Math.sqrt(2 / size)
if (i === 0) {
sum *= 1 / Math.sqrt(2)
}
transformed[i] = sum
}
return transformed
},
/**
* Get the average of the pixel values.
*/
_average(pixels) {
// Calculate the average value from top 8x8 pixels, except for the first one.
const n = pixels.length - 1
return pixels.slice(1, n).reduce((a, b) => a + b, 0) / n
},
_convertToRGB(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null
}
}
export default pHash
if (window !== 'undefined') {
window.pHash = pHash
}