73 lines
4.7 KiB
JavaScript
73 lines
4.7 KiB
JavaScript
'use strict';
|
|||
|
|
// A visual similarity feature, never an authentication or phishing verdict.
|
||
|
|
const SIZE = 32;
|
||
|
|
const MAX_PIXELS = 16 * 1024 * 1024;
|
||
|
|
const ALGORITHM = 'phash-dct32-v1';
|
||
|
|
const COSINES = Array.from({ length: 8 }, (_, frequency) =>
|
||
|
|
Float64Array.from({ length: SIZE }, (_, position) => Math.cos(Math.PI * (2 * position + 1) * frequency / (2 * SIZE))));
|
||
|
|
function invalid() { throw new Error('Invalid visual fingerprint input'); }
|
||
|
|
function visualFingerprint({ rgba, width, height, region } = {}) {
|
||
|
|
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 8192 || height > 8192 || width * height > MAX_PIXELS) invalid();
|
||
|
|
if (!ArrayBuffer.isView(rgba) || rgba.BYTES_PER_ELEMENT !== 1 || rgba.byteLength !== width * height * 4) invalid();
|
||
|
|
if (typeof SharedArrayBuffer !== 'undefined' && rgba.buffer instanceof SharedArrayBuffer) invalid();
|
||
|
|
const pixels = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
|
||
|
|
let left = 0, top = 0, right = width, bottom = height;
|
||
|
|
if (region !== undefined) {
|
||
|
|
if (!region || !['x', 'y', 'width', 'height'].every(key => Number.isInteger(region[key]) && Math.abs(region[key]) <= 32768) || region.width <= 0 || region.height <= 0) invalid();
|
||
|
|
left = Math.max(0, region.x); top = Math.max(0, region.y);
|
||
|
|
right = Math.min(width, region.x + region.width); bottom = Math.min(height, region.y + region.height);
|
||
|
|
if (left >= right || top >= bottom) invalid();
|
||
|
|
}
|
||
|
|
function luminance(x, y) {
|
||
|
|
const index = (y * width + x) * 4;
|
||
|
|
const alpha = pixels[index + 3] / 255;
|
||
|
|
return alpha * (0.299 * pixels[index] + 0.587 * pixels[index + 1] + 0.114 * pixels[index + 2]) + (1 - alpha) * 255;
|
||
|
|
}
|
||
|
|
const grid = new Float64Array(SIZE * SIZE);
|
||
|
|
// Centered bilinear resampling of the entire supplied crop preserves geometry.
|
||
|
|
for (let y = 0; y < SIZE; y++) {
|
||
|
|
const py = Math.max(top, Math.min(bottom - 1, top + (y + 0.5) * (bottom - top) / SIZE - 0.5));
|
||
|
|
const y0 = Math.floor(py), y1 = Math.min(bottom - 1, y0 + 1), dy = py - y0;
|
||
|
|
for (let x = 0; x < SIZE; x++) {
|
||
|
|
const px = Math.max(left, Math.min(right - 1, left + (x + 0.5) * (right - left) / SIZE - 0.5));
|
||
|
|
const x0 = Math.floor(px), x1 = Math.min(right - 1, x0 + 1), dx = px - x0;
|
||
|
|
grid[y * SIZE + x] = (1 - dy) * ((1 - dx) * luminance(x0, y0) + dx * luminance(x1, y0)) + dy * ((1 - dx) * luminance(x0, y1) + dx * luminance(x1, y1));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const mean = grid.reduce((sum, value) => sum + value, 0) / grid.length;
|
||
|
|
const stddev = Math.sqrt(grid.reduce((sum, value) => sum + (value - mean) ** 2, 0) / grid.length);
|
||
|
|
const histogram = new Uint16Array(16);
|
||
|
|
let minimum = 255, maximum = 0, detail = 0;
|
||
|
|
for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) {
|
||
|
|
const value = grid[y * SIZE + x];
|
||
|
|
minimum = Math.min(minimum, value); maximum = Math.max(maximum, value);
|
||
|
|
histogram[Math.min(15, Math.floor(value / 16))]++;
|
||
|
|
if (x) detail += Math.abs(value - grid[y * SIZE + x - 1]);
|
||
|
|
if (y) detail += Math.abs(value - grid[(y - 1) * SIZE + x]);
|
||
|
|
}
|
||
|
|
detail /= 2 * SIZE * (SIZE - 1);
|
||
|
|
const entropy = histogram.reduce((sum, count) => { const probability = count / grid.length; return count ? sum - probability * Math.log2(probability) : sum; }, 0);
|
||
|
|
const coefficients = [];
|
||
|
|
// Orthonormal DCT-II, low-frequency 8x8 block. DC is deliberately excluded.
|
||
|
|
for (let v = 0; v < 8; v++) for (let u = 0; u < 8; u++) {
|
||
|
|
if (!u && !v) { coefficients.push(0); continue; }
|
||
|
|
let coefficient = 0;
|
||
|
|
for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) coefficient += (grid[y * SIZE + x] - mean) * COSINES[u][x] * COSINES[v][y];
|
||
|
|
coefficient *= (u ? Math.sqrt(2 / SIZE) : Math.sqrt(1 / SIZE)) * (v ? Math.sqrt(2 / SIZE) : Math.sqrt(1 / SIZE));
|
||
|
|
coefficients.push(Math.round(coefficient * 1e8) / 1e8);
|
||
|
|
}
|
||
|
|
const sorted = coefficients.slice(1).sort((a, b) => a - b), median = sorted[31];
|
||
|
|
let bits = 0n;
|
||
|
|
for (let index = 1; index < coefficients.length; index++) if (coefficients[index] > median) bits |= 1n << BigInt(63 - index);
|
||
|
|
const round = value => Math.round(value * 10000) / 10000;
|
||
|
|
return { algorithm: ALGORITHM, hash: bits.toString(16).padStart(16, '0'),
|
||
|
|
quality: { contrast: round(stddev), stddev: round(stddev), dynamicRange: round(maximum - minimum), entropy: round(entropy), detail: round(detail) },
|
||
|
|
usable: stddev >= 10 && maximum - minimum >= 32 && entropy >= 0.5 && detail >= 1 };
|
||
|
|
}
|
||
|
|
function hammingDistance(left, right) {
|
||
|
|
if (typeof left !== 'string' || typeof right !== 'string' || !/^[a-f0-9]{16}$/.test(left) || !/^[a-f0-9]{16}$/.test(right)) invalid();
|
||
|
|
let different = BigInt('0x' + left) ^ BigInt('0x' + right), count = 0;
|
||
|
|
while (different) { different &= different - 1n; count++; }
|
||
|
|
return count;
|
||
|
|
}
|
||
|
|
module.exports = { visualFingerprint, hammingDistance };
|