Download engine.js from snarmatha/verisight: direct link, hf CLI and curl.
- Browser
- Download file 25.1 kB
-
https://huggingface.co/spaces/snarmatha/verisight/resolve/main/engine.js
- Command line
-
hf download hf://spaces/snarmatha/verisight/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/snarmatha/verisight/resolve/main/engine.js
25.1 kB
| // VeriSight browser engine — pure, dependency-free analysis functions. | |
| // Mirrors the Python analyzers in app/ so both editions behave the same. | |
| // Works in browsers and in Node (see web/tests). No DOM access in this file. | |
| export const ENGINE_VERSION = "1.1.0-web"; | |
| // ------------------------------------------------------------------ helpers | |
| const clamp = (x, lo, hi) => Math.min(Math.max(x, lo), hi); | |
| const logit = (p) => { p = clamp(p, 1e-3, 1 - 1e-3); return Math.log(p / (1 - p)); }; | |
| const round = (x, d = 3) => Math.round(x * 10 ** d) / 10 ** d; | |
| /** Decode bytes as latin1 so binary-safe substring search works. */ | |
| function latin1(bytes) { | |
| let s = ""; | |
| const CH = 0x8000; | |
| for (let i = 0; i < bytes.length; i += CH) s += String.fromCharCode.apply(null, bytes.subarray(i, i + CH)); | |
| return s; | |
| } | |
| /** Only scan the head and tail of large files (metadata lives there). */ | |
| export function scanWindow(bytes, head = 4 << 20, tail = 1 << 20) { | |
| if (bytes.length <= head + tail) return bytes; | |
| const out = new Uint8Array(head + tail); | |
| out.set(bytes.subarray(0, head), 0); | |
| out.set(bytes.subarray(bytes.length - tail), head); | |
| return out; | |
| } | |
| // ------------------------------------------------------------------ metadata | |
| export const GENERATOR_MARKERS = [ | |
| ["trainedalgorithmicmedia", "IPTC digital source type: AI-generated (trainedAlgorithmicMedia)"], | |
| ["compositewithtrainedalgorithmicmedia", "IPTC digital source type: composite with AI content"], | |
| ["stable diffusion", "Stable Diffusion"], ["stablediffusion", "Stable Diffusion"], | |
| ["comfyui", "ComfyUI"], ["automatic1111", "AUTOMATIC1111 WebUI"], ["midjourney", "Midjourney"], | |
| ["dall-e", "DALL-E"], ["dall·e", "DALL-E"], ["openai", "OpenAI"], ["adobe firefly", "Adobe Firefly"], | |
| ["made with google ai", "Google AI (Imagen / Gemini)"], ["novelai", "NovelAI"], ["leonardo.ai", "Leonardo.Ai"], | |
| ["ideogram", "Ideogram"], ["black forest labs", "FLUX (Black Forest Labs)"], ["invokeai", "InvokeAI"], | |
| ["fooocus", "Fooocus"], ["runwayml", "Runway"], ["synthesia", "Synthesia"], ["heygen", "HeyGen"], | |
| ]; | |
| const GENERATOR_TEXT_KEYS = new Set(["parameters", "prompt", "workflow", "sd-metadata", "invokeai_metadata", "dream"]); | |
| const EDITING_SOFTWARE = ["photoshop", "gimp", "lightroom", "affinity", "pixelmator", "snapseed", "facetune", "canva"]; | |
| const IPTC_LABELS = new Set([GENERATOR_MARKERS[0][1], GENERATOR_MARKERS[1][1]]); | |
| export function scanGeneratorMarkers(bytes) { | |
| const text = latin1(scanWindow(bytes)).toLowerCase(); | |
| const found = []; | |
| for (const [marker, label] of GENERATOR_MARKERS) { | |
| if (text.includes(marker) && !found.includes(label)) found.push(label); | |
| } | |
| if (found.includes(GENERATOR_MARKERS[1][1])) return found.filter((f) => f !== GENERATOR_MARKERS[0][1]); | |
| return found; | |
| } | |
| /** Keywords of PNG tEXt / iTXt / zTXt chunks. */ | |
| export function pngTextKeys(bytes) { | |
| const sig = [137, 80, 78, 71, 13, 10, 26, 10]; | |
| if (bytes.length < 8 || sig.some((b, i) => bytes[i] !== b)) return []; | |
| const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| const keys = []; | |
| let p = 8; | |
| while (p + 8 <= bytes.length) { | |
| const len = dv.getUint32(p); | |
| const type = String.fromCharCode(...bytes.subarray(p + 4, p + 8)); | |
| if (type === "tEXt" || type === "iTXt" || type === "zTXt") { | |
| const data = bytes.subarray(p + 8, Math.min(p + 8 + len, bytes.length)); | |
| const nul = data.indexOf(0); | |
| if (nul > 0) keys.push(String.fromCharCode(...data.subarray(0, Math.min(nul, 79)))); | |
| } | |
| if (type === "IEND") break; | |
| p += 12 + len; | |
| } | |
| return keys; | |
| } | |
| /** Minimal EXIF reader for JPEG: Make, Model, Software, DateTimeOriginal, ExposureTime, FNumber, GPS. */ | |
| export function readExif(bytes) { | |
| const out = {}; | |
| if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return out; | |
| const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| let p = 2; | |
| while (p + 4 < bytes.length && bytes[p] === 0xff) { | |
| const marker = bytes[p + 1]; | |
| const size = dv.getUint16(p + 2); | |
| if (marker === 0xe1 && latin1(bytes.subarray(p + 4, p + 10)) === "Exif\0\0") { | |
| try { parseTiff(dv, p + 10, out); } catch { /* corrupt EXIF never blocks analysis */ } | |
| break; | |
| } | |
| if (marker === 0xda) break; // start of scan | |
| p += 2 + size; | |
| } | |
| return out; | |
| } | |
| const TAGS = { 0x010f: "Make", 0x0110: "Model", 0x0131: "Software", 0x9003: "DateTimeOriginal", | |
| 0x829a: "ExposureTime", 0x829d: "FNumber", 0x013b: "Artist" }; | |
| function parseTiff(dv, base, out) { | |
| const le = dv.getUint16(base) === 0x4949; | |
| const u16 = (o) => dv.getUint16(base + o, le); | |
| const u32 = (o) => dv.getUint32(base + o, le); | |
| const readIfd = (off) => { | |
| const n = u16(off); | |
| let exifPtr = 0; | |
| for (let i = 0; i < n; i++) { | |
| const e = off + 2 + i * 12; | |
| const tag = u16(e), type = u16(e + 2), count = u32(e + 4); | |
| const valOff = count * ([0, 1, 1, 2, 4, 8][type] || 1) > 4 ? u32(e + 8) : e + 8; | |
| if (tag === 0x8769) exifPtr = u32(e + 8); | |
| else if (tag === 0x8825) out.GPS = "present"; | |
| else if (TAGS[tag]) { | |
| if (type === 2) { | |
| let s = ""; | |
| for (let k = 0; k < Math.min(count, 120); k++) { const c = dv.getUint8(base + valOff + k); if (!c) break; s += String.fromCharCode(c); } | |
| out[TAGS[tag]] = s.trim(); | |
| } else if (type === 5) out[TAGS[tag]] = String(round(u32(valOff) / (u32(valOff + 4) || 1), 4)); | |
| } | |
| } | |
| return exifPtr; | |
| }; | |
| const exifPtr = readIfd(u32(4)); | |
| if (exifPtr) readIfd(exifPtr); | |
| } | |
| export function analyzeMetadata(bytes, { dropIptc = false } = {}) { | |
| let markers = scanGeneratorMarkers(bytes); | |
| if (dropIptc) markers = markers.filter((m) => !IPTC_LABELS.has(m)); | |
| const textKeys = pngTextKeys(bytes).filter((k) => GENERATOR_TEXT_KEYS.has(k.toLowerCase())); | |
| if (textKeys.length) markers.push(`Local generator metadata chunk (${textKeys.join(", ")})`); | |
| const exif = readExif(bytes); | |
| const details = { exif, generator_markers: markers }; | |
| const base = { id: "metadata", name: "Metadata & generator fingerprints", category: "metadata" }; | |
| if (markers.length) return { ...base, status: "ok", score: 0.97, weight: 3.0, details, | |
| summary: `The file's metadata declares an AI generator: ${markers.slice(0, 3).join("; ")}.` }; | |
| const software = (exif.Software || "").toLowerCase(); | |
| if (EDITING_SOFTWARE.some((t) => software.includes(t))) return { ...base, status: "ok", score: 0.55, weight: 0.3, details, | |
| summary: `Edited with ${exif.Software}. Editing is not proof of fakery, but the image is not straight from a camera.` }; | |
| if ((exif.Make || exif.Model) && (exif.DateTimeOriginal || exif.ExposureTime || exif.FNumber)) return { ...base, status: "ok", | |
| score: 0.3, weight: 0.6, details, | |
| summary: `Camera metadata present (${[exif.Make, exif.Model].filter(Boolean).join(" ")}). Consistent with a real photo, though metadata can be forged.` }; | |
| return { ...base, status: "neutral", score: null, weight: 0, details, | |
| summary: "No camera or generator metadata. Common for screenshots and social-media uploads, which strip it." }; | |
| } | |
| // ------------------------------------------------------------------ provenance (C2PA) | |
| /** Detects embedded C2PA Content Credentials and the digital source types they declare. | |
| * The browser edition reads the manifest but does not verify its signature. */ | |
| export function analyzeProvenance(bytes) { | |
| const base = { id: "provenance", name: "Content Credentials (C2PA)", category: "provenance" }; | |
| const text = latin1(scanWindow(bytes)); | |
| const lower = text.toLowerCase(); | |
| const present = lower.includes("c2pa") && (text.includes("jumb") || text.includes("caBX") || lower.includes("c2pa.claim")); | |
| if (!present) return { ...base, status: "neutral", score: null, weight: 0, details: {}, | |
| summary: "No Content Credentials attached. Most media doesn't carry them yet, so this is neither good nor bad." }; | |
| const sources = [...new Set([...text.matchAll(/digitalsourcetype\/([A-Za-z]+)/gi)].map((m) => m[1]))]; | |
| const gen = text.match(/claim_generator[^A-Za-z0-9]{1,12}([A-Za-z0-9 ._\/()-]{3,60})/); | |
| const details = { digital_source_types: sources, generator: gen ? gen[1].trim() : null, signature_verified: false }; | |
| const s = sources.map((x) => x.toLowerCase()); | |
| const note = " (Signature not verified in the browser edition.)"; | |
| if (s.includes("compositewithtrainedalgorithmicmedia")) return { ...base, status: "ok", score: 0.9, weight: 2.5, details, | |
| summary: "Content Credentials declare this media contains AI-generated parts." + note }; | |
| if (s.includes("trainedalgorithmicmedia")) return { ...base, status: "ok", score: 0.96, weight: 3.0, details, | |
| summary: "Content Credentials declare this media is AI-generated." + note }; | |
| if (s.includes("digitalcapture")) return { ...base, status: "ok", score: 0.2, weight: 1.0, details, | |
| summary: "Content Credentials say this was captured by a camera." + note }; | |
| return { ...base, status: "neutral", score: null, weight: 0, details, | |
| summary: "Content Credentials found, but they don't state how the content was created." + note }; | |
| } | |
| // ------------------------------------------------------------------ colour maps | |
| function lut(stops) { | |
| const out = new Uint8Array(256 * 3); | |
| for (let i = 0; i < 256; i++) { | |
| const t = i / 255 * (stops.length - 1), k = Math.min(Math.floor(t), stops.length - 2), f = t - k; | |
| for (let c = 0; c < 3; c++) out[i * 3 + c] = Math.round(stops[k][c] + (stops[k + 1][c] - stops[k][c]) * f); | |
| } | |
| return out; | |
| } | |
| const INFERNO = lut([[0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164]]); | |
| const VIRIDIS = lut([[68, 1, 84], [72, 40, 120], [62, 74, 137], [49, 104, 142], [38, 130, 142], [31, 158, 137], [53, 183, 121], [109, 205, 89], [180, 222, 44], [253, 231, 37]]); | |
| // ------------------------------------------------------------------ error level analysis | |
| const BLOCK = 16; | |
| /** orig, resaved: RGBA ImageData-like {width, height, data}. resaved = orig re-encoded as JPEG q≈0.9. */ | |
| export function computeEla(orig, resaved) { | |
| const { width: w, height: h } = orig; | |
| const diff = new Float32Array(w * h); | |
| for (let i = 0, j = 0; i < diff.length; i++, j += 4) { | |
| const a = orig.data, b = resaved.data; | |
| diff[i] = Math.max(Math.abs(a[j] - b[j]), Math.abs(a[j + 1] - b[j + 1]), Math.abs(a[j + 2] - b[j + 2])); | |
| } | |
| const hb = Math.floor(h / BLOCK), wb = Math.floor(w / BLOCK); | |
| if (hb < 2 || wb < 2) return { diff, stats: { blocks: 0 } }; | |
| const blocks = new Float64Array(hb * wb); | |
| for (let y = 0; y < hb * BLOCK; y++) for (let x = 0; x < wb * BLOCK; x++) | |
| blocks[Math.floor(y / BLOCK) * wb + Math.floor(x / BLOCK)] += diff[y * w + x]; | |
| const n = blocks.length, area = BLOCK * BLOCK; | |
| let mean = 0; for (let i = 0; i < n; i++) { blocks[i] /= area; mean += blocks[i]; } mean /= n; | |
| let v = 0; for (let i = 0; i < n; i++) v += (blocks[i] - mean) ** 2; | |
| const std = Math.sqrt(v / n); | |
| let hot = 0; for (let i = 0; i < n; i++) if (blocks[i] > mean + 3 * std) hot++; | |
| return { diff, stats: { mean_error: round(mean), block_cv: round(std / (mean + 1e-6)), hotspot_ratio: round(hot / n, 4), blocks: n } }; | |
| } | |
| export function elaSignal(stats, format) { | |
| const base = { id: "ela", name: "Error Level Analysis", category: "forensic" }; | |
| if (!stats.blocks) return { ...base, status: "unavailable", score: null, weight: 0, details: stats, summary: "Image too small for ELA." }; | |
| const raw = 0.5 + 0.3 * Math.tanh(2 * (stats.block_cv - 0.9)) + Math.min(stats.hotspot_ratio * 6, 0.1); | |
| const score = clamp(raw, 0.15, 0.85); | |
| let summary; | |
| if (score >= 0.62) summary = "Some regions compress very differently from the rest — inspect the bright areas in the heatmap for edits."; | |
| else if (score <= 0.38) summary = "No statistical anomaly in compression levels. Still check the heatmap: a pasted area can show up as a patch with its own texture even when the numbers look normal."; | |
| else summary = "Compression levels show no strong pattern either way. The heatmap shows where to look."; | |
| if (format && format !== "JPEG") summary += ` (ELA is most meaningful for JPEGs; this is ${format}.)`; | |
| return { ...base, status: "ok", score: round(score), weight: 0.35, summary, details: { ...stats, source_format: format || null } }; | |
| } | |
| function percentile(arr, q) { | |
| const s = Float32Array.from(arr).sort(); | |
| return s[Math.min(s.length - 1, Math.floor((q / 100) * (s.length - 1)))]; | |
| } | |
| function gaussianBlur(src, w, h, sigma) { | |
| const r = Math.ceil(sigma * 3), k = new Float32Array(2 * r + 1); | |
| let sum = 0; for (let i = -r; i <= r; i++) { k[i + r] = Math.exp(-(i * i) / (2 * sigma * sigma)); sum += k[i + r]; } | |
| for (let i = 0; i < k.length; i++) k[i] /= sum; | |
| const tmp = new Float32Array(w * h), out = new Float32Array(w * h); | |
| for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { | |
| let a = 0; for (let i = -r; i <= r; i++) a += src[y * w + clamp(x + i, 0, w - 1)] * k[i + r]; tmp[y * w + x] = a; | |
| } | |
| for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { | |
| let a = 0; for (let i = -r; i <= r; i++) a += tmp[clamp(y + i, 0, h - 1) * w + x] * k[i + r]; out[y * w + x] = a; | |
| } | |
| return out; | |
| } | |
| /** Returns RGBA pixels: original blended with an inferno-coloured error map. */ | |
| export function elaHeatmap(orig, diff) { | |
| const { width: w, height: h } = orig; | |
| const scale = percentile(diff, 99.5) || 1; | |
| const norm = new Float32Array(diff.length); | |
| for (let i = 0; i < diff.length; i++) norm[i] = clamp((diff[i] / scale) * 255, 0, 255); | |
| const blurred = gaussianBlur(norm, w, h, 1.2); | |
| const out = new Uint8ClampedArray(w * h * 4); | |
| for (let i = 0, j = 0; i < blurred.length; i++, j += 4) { | |
| const v = Math.round(clamp(blurred[i], 0, 255)) * 3; | |
| out[j] = 0.35 * orig.data[j] + 0.65 * INFERNO[v]; | |
| out[j + 1] = 0.35 * orig.data[j + 1] + 0.65 * INFERNO[v + 1]; | |
| out[j + 2] = 0.35 * orig.data[j + 2] + 0.65 * INFERNO[v + 2]; | |
| out[j + 3] = 255; | |
| } | |
| return { width: w, height: h, data: out }; | |
| } | |
| // ------------------------------------------------------------------ frequency spectrum | |
| export const FFT_SIZE = 512; | |
| /** Centre-crop to a square and area-resample to size×size grayscale in [0,1]. */ | |
| export function squareGray(img, size = FFT_SIZE) { | |
| const { width: w, height: h, data } = img; | |
| const side = Math.min(w, h), x0 = Math.floor((w - side) / 2), y0 = Math.floor((h - side) / 2); | |
| const out = new Float32Array(size * size), step = side / size; | |
| // OpenCV's fixed-point RGB→GRAY, rounded to uint8 (matters: quantisation shapes the high-frequency spectrum) | |
| const lum = (x, y) => { const j = (y * w + x) * 4; return (data[j] * 4899 + data[j + 1] * 9617 + data[j + 2] * 1868 + 8192) >> 14; }; | |
| if (side < size) { // enlarging: OpenCV INTER_AREA up-scaling coefficients | |
| const inv = size / side; | |
| const coef = (d) => { const s0 = Math.floor(d * step); let f = d + 1 - (s0 + 1) * inv; f = f <= 0 ? 0 : f - Math.floor(f); return [s0, Math.min(s0 + 1, side - 1), f]; }; | |
| const cols = Array.from({ length: size }, (_, d) => coef(d)); | |
| for (let oy = 0; oy < size; oy++) { | |
| const [iy, iy1, ty] = coef(oy); | |
| for (let ox = 0; ox < size; ox++) { | |
| const [ix, ix1, tx] = cols[ox]; | |
| const a = lum(x0 + ix, y0 + iy), b = lum(x0 + ix1, y0 + iy), c = lum(x0 + ix, y0 + iy1), d = lum(x0 + ix1, y0 + iy1); | |
| out[oy * size + ox] = Math.round((a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty) / 255; | |
| } | |
| } | |
| return out; | |
| } | |
| for (let oy = 0; oy < size; oy++) { | |
| const sy0 = y0 + Math.floor(oy * step), sy1 = Math.max(sy0 + 1, y0 + Math.floor((oy + 1) * step)); | |
| for (let ox = 0; ox < size; ox++) { | |
| const sx0 = x0 + Math.floor(ox * step), sx1 = Math.max(sx0 + 1, x0 + Math.floor((ox + 1) * step)); | |
| let acc = 0, n = 0; | |
| for (let y = sy0; y < sy1; y++) for (let x = sx0; x < sx1; x++) { acc += lum(x, y); n++; } | |
| out[oy * size + ox] = Math.round(acc / n) / 255; | |
| } | |
| } | |
| return out; | |
| } | |
| function fft1d(re, im) { | |
| const n = re.length; | |
| for (let i = 1, j = 0; i < n; i++) { | |
| let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; | |
| if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } | |
| } | |
| for (let len = 2; len <= n; len <<= 1) { | |
| const ang = (-2 * Math.PI) / len, wr = Math.cos(ang), wi = Math.sin(ang); | |
| for (let i = 0; i < n; i += len) { | |
| let cr = 1, ci = 0; | |
| for (let k = 0; k < len / 2; k++) { | |
| const a = i + k, b = a + len / 2; | |
| const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr; | |
| re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti; | |
| const nr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = nr; | |
| } | |
| } | |
| } | |
| } | |
| /** log1p |FFT2(windowed, mean-removed gray)|, fft-shifted. */ | |
| export function logSpectrum(gray, n = FFT_SIZE) { | |
| let mean = 0; for (const v of gray) mean += v; mean /= gray.length; | |
| const han = new Float64Array(n); for (let i = 0; i < n; i++) han[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1)); | |
| const re = new Float64Array(n * n), im = new Float64Array(n * n); | |
| for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) re[y * n + x] = (gray[y * n + x] - mean) * han[y] * han[x]; | |
| const rr = new Float64Array(n), ri = new Float64Array(n); | |
| for (let y = 0; y < n; y++) { // rows | |
| for (let x = 0; x < n; x++) { rr[x] = re[y * n + x]; ri[x] = im[y * n + x]; } | |
| fft1d(rr, ri); | |
| for (let x = 0; x < n; x++) { re[y * n + x] = rr[x]; im[y * n + x] = ri[x]; } | |
| } | |
| for (let x = 0; x < n; x++) { // columns | |
| for (let y = 0; y < n; y++) { rr[y] = re[y * n + x]; ri[y] = im[y * n + x]; } | |
| fft1d(rr, ri); | |
| for (let y = 0; y < n; y++) { re[y * n + x] = rr[y]; im[y * n + x] = ri[y]; } | |
| } | |
| const mag = new Float32Array(n * n), half = n / 2; | |
| for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { | |
| const sy = (y + half) % n, sx = (x + half) % n, i = y * n + x; | |
| mag[sy * n + sx] = Math.log1p(Math.hypot(re[i], im[i])); | |
| } | |
| return mag; | |
| } | |
| /** 9×9 median filter on uint8 data with replicated borders (Huang's sliding histogram). */ | |
| function median9(src, n) { | |
| const R = 4, out = new Uint8Array(n * n), hist = new Int32Array(256), half = 41; | |
| const at = (y, x) => src[clamp(y, 0, n - 1) * n + clamp(x, 0, n - 1)]; | |
| for (let y = 0; y < n; y++) { | |
| hist.fill(0); | |
| for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++) hist[at(y + dy, dx)]++; | |
| for (let x = 0; x < n; x++) { | |
| if (x > 0) for (let dy = -R; dy <= R; dy++) { hist[at(y + dy, x - R - 1)]--; hist[at(y + dy, x + R)]++; } | |
| let c = 0, v = 0; for (; v < 256; v++) { c += hist[v]; if (c >= half) break; } | |
| out[y * n + x] = v; | |
| } | |
| } | |
| return out; | |
| } | |
| export function frequencyFeatures(gray, n = FFT_SIZE) { | |
| const mag = logSpectrum(gray, n); | |
| const c = n / 2, r = new Float32Array(n * n); | |
| for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) r[y * n + x] = Math.hypot(y - c, x - c) / c; | |
| // radial profile slope (log-log) | |
| const B = 40, edges = Array.from({ length: B }, (_, i) => 0.05 + (0.95 * i) / (B - 1)); | |
| const sums = new Float64Array(B), counts = new Int32Array(B); | |
| for (let i = 0; i < mag.length; i++) { | |
| let k = 0; while (k < B && r[i] >= edges[k]) k++; // numpy.digitize | |
| if (k >= 1 && k <= B - 1) { sums[k] += mag[i]; counts[k]++; } | |
| } | |
| const xs = [], ys = []; | |
| for (let k = 1; k < B; k++) if (counts[k]) { | |
| const p = sums[k] / counts[k], cen = (edges[k - 1] + edges[k]) / 2; | |
| if (p > 0) { xs.push(Math.log(cen)); ys.push(Math.log(p)); } | |
| } | |
| let slope = 0; | |
| if (xs.length > 5) { | |
| const mx = xs.reduce((a, b) => a + b) / xs.length, my = ys.reduce((a, b) => a + b) / ys.length; | |
| let num = 0, den = 0; for (let i = 0; i < xs.length; i++) { num += (xs[i] - mx) * (ys[i] - my); den += (xs[i] - mx) ** 2; } | |
| slope = num / den; | |
| } | |
| // high-frequency energy share | |
| let ring = 0, hf = 0; | |
| for (let i = 0; i < mag.length; i++) { if (r[i] > 0.05 && r[i] <= 1) { ring += mag[i]; if (r[i] > 0.6) hf += mag[i]; } } | |
| // periodic peaks: spikes far above a local median background | |
| let mn = Infinity, mxv = -Infinity; for (const v of mag) { if (v < mn) mn = v; if (v > mxv) mxv = v; } | |
| const u8 = new Uint8Array(mag.length); | |
| for (let i = 0; i < mag.length; i++) u8[i] = Math.round(((mag[i] - mn) / (mxv - mn || 1)) * 255); | |
| const bg = median9(u8, n); | |
| const res = []; | |
| for (let i = 0; i < mag.length; i++) if (r[i] > 0.15 && r[i] < 0.98) res.push(u8[i] - bg[i]); | |
| const rm = res.reduce((a, b) => a + b, 0) / res.length; | |
| const rs = Math.sqrt(res.reduce((a, b) => a + (b - rm) ** 2, 0) / res.length); | |
| const thresh = rm + 6 * rs; | |
| let peaks = 0; for (const v of res) if (v > thresh) peaks++; | |
| return { spectral_slope: round(slope), hf_energy_ratio: round(hf / (ring + 1e-9), 4), periodic_peaks: peaks, mag: u8 }; | |
| } | |
| export function frequencyScore(f) { | |
| let s = 0.5 + (Math.min(f.periodic_peaks, 30) / 30) * 0.3; | |
| s += clamp((f.spectral_slope + 0.35) * 0.6, -0.1, 0.15); | |
| return clamp(s, 0.15, 0.85); | |
| } | |
| export function frequencySignal(f) { | |
| const base = { id: "frequency", name: "Frequency-spectrum fingerprint", category: "forensic" }; | |
| const score = frequencyScore(f); | |
| let summary; | |
| if (f.periodic_peaks >= 12) summary = `Found ${f.periodic_peaks} periodic spectral peaks — a grid pattern often left by AI up-sampling layers.`; | |
| else if (score <= 0.4) summary = "The frequency spectrum decays smoothly, like a typical camera photo."; | |
| else summary = "The frequency spectrum shows no strong generator fingerprint."; | |
| const { mag, ...details } = f; | |
| return { ...base, status: "ok", score: round(score), weight: 0.3, summary, details }; | |
| } | |
| export function spectrumImage(u8, n = FFT_SIZE) { | |
| const out = new Uint8ClampedArray(n * n * 4); | |
| for (let i = 0; i < u8.length; i++) { | |
| const v = u8[i] * 3; out[i * 4] = VIRIDIS[v]; out[i * 4 + 1] = VIRIDIS[v + 1]; out[i * 4 + 2] = VIRIDIS[v + 2]; out[i * 4 + 3] = 255; | |
| } | |
| return { width: n, height: n, data: out }; | |
| } | |
| // ------------------------------------------------------------------ neural detector | |
| // CommunityForensics ViT-Small (Park & Owens, CVPR 2025): shortest edge → 440, | |
| // centre-crop 384, CLIP normalisation, single logit → sigmoid = P(AI-generated). | |
| export const MODEL = { | |
| id: "buildborderless/CommunityForensics-DeepfakeDet-ViT", | |
| url: "https://huggingface.co/buildborderless/CommunityForensics-DeepfakeDet-ViT/resolve/main/onnx/model_int8.onnx", | |
| shortEdge: 440, crop: 384, | |
| mean: [0.48145466, 0.4578275, 0.40821073], std: [0.26862954, 0.26130258, 0.27577711], | |
| }; | |
| /** RGBA crop (crop×crop) → Float32Array NCHW, CLIP-normalised. */ | |
| export function toModelInput(rgba, size = MODEL.crop) { | |
| const t = new Float32Array(3 * size * size), plane = size * size; | |
| for (let i = 0; i < plane; i++) for (let c = 0; c < 3; c++) | |
| t[c * plane + i] = (rgba[i * 4 + c] / 255 - MODEL.mean[c]) / MODEL.std[c]; | |
| return t; | |
| } | |
| export const sigmoid = (x) => 1 / (1 + Math.exp(-x)); | |
| export function learnedSignal(prob, modelName) { | |
| const base = { id: "learned", name: "Neural AI-image detector", category: "learned" }; | |
| if (prob == null) return { ...base, status: "unavailable", score: null, weight: 0, details: { model: modelName }, | |
| summary: "The neural detector couldn't load in this browser. The other checks still apply." }; | |
| return { ...base, status: "ok", score: round(prob, 4), weight: 2.0, | |
| details: { model: modelName, in_domain: true, probability_ai: round(prob, 4) }, | |
| summary: `A Vision Transformer trained on 2.7M images from 4,800+ generators rates this ${Math.round(prob * 100)}% likely AI-generated.` }; | |
| } | |
| // ------------------------------------------------------------------ fusion | |
| export function fuse(signals) { | |
| const ev = signals.filter((s) => s.score != null && s.weight > 0); | |
| if (!ev.length) return { score: 0.5, verdict: "inconclusive", confidence: "low", headline: "Not enough evidence to judge this file." }; | |
| const total = ev.reduce((a, s) => a + s.weight * logit(s.score), 0); | |
| const score = clamp(sigmoid(total), 0.001, 0.999); | |
| const decisive = ev.some((s) => s.weight >= 2.5); | |
| const learned = ev.find((s) => s.category === "learned"); | |
| let confidence = "low"; | |
| if (decisive) confidence = "high"; | |
| else if (learned) confidence = Math.abs(learned.score - 0.5) > 0.35 && learned.details?.in_domain ? "high" : "medium"; | |
| let verdict = "inconclusive"; | |
| if (confidence !== "low") verdict = score >= 0.65 ? "likely_synthetic" : score <= 0.35 ? "likely_authentic" : "inconclusive"; | |
| let headline = { likely_synthetic: "Likely AI-generated or manipulated", likely_authentic: "No signs of AI generation found", | |
| inconclusive: "Inconclusive: review the evidence below" }[verdict]; | |
| if (confidence === "low") headline = "Limited evidence: only heuristic checks could run"; | |
| return { score: round(score, 4), verdict, confidence, headline }; | |
| } | |
| export const DISCLAIMER = "Automated detection is probabilistic and can be wrong, especially on heavily compressed, " + | |
| "resized or novel-generator content. Use this report as evidence to support human review, not as proof."; | |