first commit

This commit is contained in:
Karl committed 2026-10-13 10:34:48 +02:00
commit ff482ebe23
251 files changed
+19126

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
'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 };