Download lib/bench-core.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 24.6 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/bench-core.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/lib/bench-core.js
-
curl -L -o bench-core.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/bench-core.js
24.6 kB
| // SPDX-License-Identifier: Apache-2.0 | |
| // Copyright 2026 Chase Hendrick | |
| // Ink Bench core: decoding, alignment and agreement metrics. No DOM, so it runs in the | |
| // page, in a worker or in Node. tools/inkbench.py implements the same steps in the same | |
| // order so both write the same bundle (docs/BENCH_FORMAT.md). Change both together. | |
| export const VERSION = '1.0.0'; | |
| export const SCHEMA = 'unroll-inkbench/1'; | |
| export const DISCLAIMER = "Scores measure agreement with another model's output (the Vesuvius Challenge published ink detection), not agreement with ground truth. Letter boxes are candidates for expert review, not readings."; | |
| export const CREDITS = { | |
| data: 'PHerc. 139 scan, segments and published ink: Vesuvius Challenge open data (scrollprize.org), ESRF; held by the Biblioteca Nazionale di Napoli.', | |
| dataLicense: 'CC BY-NC 4.0 (https://creativecommons.org/licenses/by-nc/4.0/)', | |
| code: 'Unroll Ink Bench by Chase Hendrick', | |
| codeLicense: 'Apache-2.0', | |
| }; | |
| export const CANDIDATE = 'candidate for expert review'; | |
| const LONG_FIT = 2, COARSE_MAX = 80, SCALES = 25, SHIFT_FRAC = 0.25, MIN_VALID = 0.3, TOL = 1e-9, PR_STEPS = 50; | |
| export const SIGMAS = Array.from({ length: 13 }, (_, i) => i * 0.5); | |
| export const rnd = (x, d) => { const k = 10 ** d; return Math.round(x * k) / k; }; | |
| // ------------------------------------------------------------------ images | |
| // An image is { w, h, d: Float64Array } with values in [0, 1]. | |
| async function inflate(bytes) { | |
| const ds = new DecompressionStream('deflate'); | |
| const r = new Response(new Blob([bytes]).stream().pipeThrough(ds)); | |
| return new Uint8Array(await r.arrayBuffer()); | |
| } | |
| function normalizeInt(g, maxval) { | |
| let mx = 0; for (let i = 0; i < g.length; i++) if (g[i] > mx) mx = g[i]; | |
| if (mx <= 1) { for (let i = 0; i < g.length; i++) g[i] = g[i] / 1; return 'label'; } | |
| for (let i = 0; i < g.length; i++) g[i] = g[i] / maxval; | |
| return 'max' + maxval; | |
| } | |
| function normalizeFloat(g) { | |
| let lo = Infinity, hi = -Infinity, any = false; | |
| for (let i = 0; i < g.length; i++) { const v = g[i]; if (Number.isFinite(v)) { any = true; if (v < lo) lo = v; if (v > hi) hi = v; } } | |
| if (!any) { lo = 0; hi = 1; } | |
| for (let i = 0; i < g.length; i++) { const v = Number.isFinite(g[i]) ? g[i] : lo; g[i] = hi > lo ? (v - lo) / (hi - lo) : 0; } | |
| return 'minmax'; | |
| } | |
| const grayOf = (r, g, b) => (299.0 * r + 587.0 * g + 114.0 * b) / 1000.0; | |
| export async function decodePNG(buf) { | |
| const u8 = new Uint8Array(buf), dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength); | |
| const sig = [137, 80, 78, 71, 13, 10, 26, 10]; | |
| for (let i = 0; i < 8; i++) if (u8[i] !== sig[i]) throw new Error('Not a PNG file.'); | |
| let p = 8, W = 0, H = 0, bd = 0, ct = 0, il = 0, pal = null; const idat = []; | |
| while (p + 8 <= u8.length) { | |
| const len = dv.getUint32(p), type = String.fromCharCode(u8[p + 4], u8[p + 5], u8[p + 6], u8[p + 7]); | |
| const data = u8.subarray(p + 8, p + 8 + len); | |
| if (type === 'IHDR') { W = dv.getUint32(p + 8); H = dv.getUint32(p + 12); bd = u8[p + 16]; ct = u8[p + 17]; il = u8[p + 20]; } | |
| else if (type === 'PLTE') pal = data; | |
| else if (type === 'IDAT') idat.push(data); | |
| else if (type === 'IEND') break; | |
| p += 12 + len; | |
| } | |
| if (!W || !H) throw new Error('PNG has no size.'); | |
| if (il) throw new Error('Interlaced PNG is not supported; save without interlacing.'); | |
| if (bd !== 8 && bd !== 16) throw new Error(`PNG with ${bd} bits per sample is not supported; save as 8 or 16 bit.`); | |
| if (bd === 16 && ct !== 0) throw new Error('16 bit PNG must be grayscale; save color images as 8 bit.'); | |
| if (W * H > 64e6) throw new Error('PNG is larger than 64 megapixels.'); | |
| const ch = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ct]; | |
| if (!ch) throw new Error('Unknown PNG color type.'); | |
| let n = 0; for (const c of idat) n += c.length; | |
| const z = new Uint8Array(n); n = 0; for (const c of idat) { z.set(c, n); n += c.length; } | |
| const raw = await inflate(z); | |
| const B = bd / 8, bpp = ch * B, stride = W * bpp; | |
| const out = new Uint8Array(H * stride); | |
| for (let y = 0; y < H; y++) { | |
| const f = raw[y * (stride + 1)], src = y * (stride + 1) + 1, o = y * stride, up = o - stride; | |
| for (let i = 0; i < stride; i++) { | |
| const x = raw[src + i], a = i >= bpp ? out[o + i - bpp] : 0, b = y ? out[up + i] : 0, c = (y && i >= bpp) ? out[up + i - bpp] : 0; | |
| let v; | |
| if (f === 0) v = x; else if (f === 1) v = x + a; else if (f === 2) v = x + b; else if (f === 3) v = x + ((a + b) >> 1); | |
| else if (f === 4) { const pp = a + b - c, pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); } | |
| else throw new Error('Damaged PNG filter.'); | |
| out[o + i] = v & 255; | |
| } | |
| } | |
| const g = new Float64Array(W * H); | |
| if (bd === 16) for (let i = 0; i < W * H; i++) g[i] = (out[i * 2] << 8) | out[i * 2 + 1]; | |
| else if (ct === 3) { if (!pal) throw new Error('Palette PNG without a palette.'); for (let i = 0; i < W * H; i++) { const k = out[i] * 3; g[i] = grayOf(pal[k], pal[k + 1], pal[k + 2]); } } | |
| else if (ch >= 3) for (let i = 0; i < W * H; i++) { const k = i * ch; g[i] = grayOf(out[k], out[k + 1], out[k + 2]); } | |
| else for (let i = 0; i < W * H; i++) g[i] = out[i * ch]; | |
| const norm = normalizeInt(g, bd === 16 ? 65535 : 255); | |
| return { w: W, h: H, d: g, info: { format: 'png', width: W, height: H, normalization: norm } }; | |
| } | |
| function lzw(src, expect) { | |
| const out = new Uint8Array(Math.max(expect, 16)); | |
| let o = 0, bit = 0, size = 9, dict = []; | |
| const reset = () => { dict = []; for (let i = 0; i < 256; i++) dict.push([i]); dict.push(null, null); size = 9; }; | |
| const read = () => { let v = 0; for (let i = 0; i < size; i++) { const b = src[(bit + i) >> 3]; v = (v << 1) | ((b >> (7 - ((bit + i) & 7))) & 1); } bit += size; return v; }; | |
| reset(); let prev = null; | |
| while (bit + size <= src.length * 8) { | |
| const c = read(); | |
| if (c === 257) break; | |
| if (c === 256) { reset(); prev = null; continue; } | |
| let e; | |
| if (c < dict.length && dict[c]) e = dict[c]; else if (prev) e = prev.concat(prev[0]); else throw new Error('Damaged LZW data in TIFF.'); | |
| for (const x of e) if (o < out.length) out[o++] = x; | |
| if (prev) dict.push(prev.concat(e[0])); | |
| prev = e; | |
| if (dict.length + 1 >= (1 << size) && size < 12) size++; | |
| } | |
| return out; | |
| } | |
| function packbits(src, expect) { | |
| const out = new Uint8Array(expect); let o = 0, i = 0; | |
| while (i < src.length && o < expect) { | |
| const n = (src[i++] << 24) >> 24; | |
| if (n >= 0) { for (let k = 0; k <= n; k++) out[o++] = src[i++]; } else if (n !== -128) { const v = src[i++]; for (let k = 0; k < 1 - n; k++) out[o++] = v; } | |
| } | |
| return out; | |
| } | |
| export async function decodeTIFF(buf) { | |
| const u8 = new Uint8Array(buf), dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength); | |
| const bo = dv.getUint16(0); | |
| if (bo !== 0x4949 && bo !== 0x4d4d) throw new Error('Not a TIFF file.'); | |
| const le = bo === 0x4949; | |
| if (dv.getUint16(2, le) === 43) throw new Error('BigTIFF is not supported; save as a standard TIFF or PNG.'); | |
| const ifd = dv.getUint32(4, le), n = dv.getUint16(ifd, le), tags = {}; | |
| const SZ = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, 16: 8 }; | |
| for (let i = 0; i < n; i++) { | |
| const e = ifd + 2 + i * 12, tag = dv.getUint16(e, le), type = dv.getUint16(e + 2, le), cnt = dv.getUint32(e + 4, le); | |
| const off = (SZ[type] || 1) * cnt <= 4 ? e + 8 : dv.getUint32(e + 8, le), vals = []; | |
| for (let k = 0; k < Math.min(cnt, 1 << 20); k++) { | |
| if (type === 3) vals.push(dv.getUint16(off + k * 2, le)); else if (type === 4) vals.push(dv.getUint32(off + k * 4, le)); | |
| else if (type === 1 || type === 7) vals.push(dv.getUint8(off + k)); else break; | |
| } | |
| tags[tag] = vals; | |
| } | |
| const W = tags[256]?.[0], H = tags[257]?.[0]; | |
| if (!W || !H) throw new Error('TIFF has no width or height.'); | |
| if (W * H > 64e6) throw new Error('TIFF is larger than 64 megapixels.'); | |
| const bps = tags[258]?.[0] || 1, spp = tags[277]?.[0] || 1, comp = tags[259]?.[0] || 1, pred = tags[317]?.[0] || 1; | |
| const fmt = tags[339]?.[0] || 1, photo = tags[262]?.[0] ?? 1; | |
| if (![8, 16, 32].includes(bps)) throw new Error(`TIFF with ${bps} bits per sample is not supported (use 8, 16 or 32).`); | |
| if (fmt === 3 && bps !== 32) throw new Error('Only 32 bit float TIFF is supported.'); | |
| if (fmt === 2) throw new Error('Signed integer TIFF is not supported; use unsigned or float.'); | |
| if (bps > 8 && spp > 1) throw new Error('16 and 32 bit TIFF must have one channel.'); | |
| if ((tags[284]?.[0] || 1) !== 1 && spp > 1) throw new Error('Planar TIFF is not supported.'); | |
| if (photo === 3 || photo === 0) throw new Error('Palette and WhiteIsZero TIFF are not supported.'); | |
| if (pred === 3) throw new Error('Floating point predictor TIFF is not supported.'); | |
| const tiled = !!tags[322], tw = tiled ? tags[322][0] : W, th = tiled ? tags[323][0] : (tags[278]?.[0] || H); | |
| const offs = tiled ? tags[324] : tags[273], cnts = tiled ? tags[325] : tags[279]; | |
| if (!offs || !cnts) throw new Error('TIFF has no image data offsets.'); | |
| const B = bps / 8, rowB = tw * spp * B, raw = new Uint8Array(W * H * spp * B), across = Math.ceil(W / tw); | |
| for (let c = 0; c < offs.length; c++) { | |
| let chunk = u8.slice(offs[c], offs[c] + cnts[c]); | |
| if (comp === 5) chunk = lzw(chunk, rowB * th); | |
| else if (comp === 8 || comp === 32946) chunk = await inflate(chunk); | |
| else if (comp === 32773) chunk = packbits(chunk, rowB * th); | |
| else if (comp !== 1) throw new Error(`TIFF compression ${comp} is not supported (use none, LZW or Deflate).`); | |
| const x0 = tiled ? (c % across) * tw : 0, y0 = tiled ? Math.floor(c / across) * th : c * th; | |
| for (let y = 0; y < th && y0 + y < H; y++) { | |
| const row = chunk.subarray(y * rowB, (y + 1) * rowB); | |
| if (pred === 2) { | |
| if (B === 1) for (let i = spp; i < row.length; i++) row[i] = (row[i] + row[i - spp]) & 255; | |
| else if (B === 2) { const d = new DataView(row.buffer, row.byteOffset, row.length); for (let i = 1; i < tw; i++) d.setUint16(i * 2, (d.getUint16(i * 2, le) + d.getUint16((i - 1) * 2, le)) & 65535, le); } | |
| } | |
| raw.set(row.subarray(0, Math.min(tw, W - x0) * spp * B), ((y0 + y) * W + x0) * spp * B); | |
| } | |
| } | |
| const N = W * H, d = new DataView(raw.buffer), g = new Float64Array(N); | |
| const get = B === 1 ? (i) => raw[i] : B === 2 ? (i) => d.getUint16(i * 2, le) : fmt === 3 ? (i) => d.getFloat32(i * 4, le) : (i) => d.getUint32(i * 4, le); | |
| for (let p = 0; p < N; p++) { const s = p * spp; g[p] = spp >= 3 ? grayOf(get(s), get(s + 1), get(s + 2)) : get(s); } | |
| const norm = fmt === 3 ? normalizeFloat(g) : normalizeInt(g, B === 1 ? 255 : B === 2 ? 65535 : 4294967295); | |
| return { w: W, h: H, d: g, info: { format: 'tiff', width: W, height: H, normalization: norm } }; | |
| } | |
| export async function decodeBytes(buf) { | |
| const img = await decodeBytesRaw(buf); | |
| checkSize(img.w, img.h); | |
| return img; | |
| } | |
| // an ink map has to be big enough to align and not absurdly thin | |
| export function checkSize(w, h) { | |
| if (!(w >= 32 && h >= 32)) throw new Error(`The image is ${w} x ${h} px. An ink map needs at least 32 px on each side.`); | |
| if (Math.max(w, h) / Math.min(w, h) > 20) throw new Error(`The image is ${w} x ${h} px, an aspect ratio over 20:1. Use the prediction for the whole winding.`); | |
| } | |
| async function decodeBytesRaw(buf) { | |
| const u8 = new Uint8Array(buf); | |
| if (u8[0] === 137 && u8[1] === 80) return decodePNG(buf); | |
| if ((u8[0] === 0x49 && u8[1] === 0x49) || (u8[0] === 0x4d && u8[1] === 0x4d)) return decodeTIFF(buf); | |
| const jpg = u8[0] === 0xff && u8[1] === 0xd8, webp = u8[0] === 0x52 && u8[1] === 0x49 && u8[8] === 0x57 && u8[9] === 0x45; | |
| if (jpg || webp) throw new Error(`Ink Bench reads PNG or TIFF. This is a ${jpg ? 'JPEG' : 'WebP'}: convert it to PNG first (JPEG decoders differ, so scores would not be reproducible).`); | |
| throw new Error('This file is not an image Ink Bench can read. Use a PNG or TIFF ink probability map (gray, 8 or 16 bit, or float TIFF).'); | |
| } | |
| export async function sha256(buf) { | |
| const h = await crypto.subtle.digest('SHA-256', buf instanceof ArrayBuffer ? buf : new Uint8Array(buf)); | |
| return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, '0')).join(''); | |
| } | |
| // ------------------------------------------------------------------ image ops | |
| export function pool2(a) { | |
| const w = a.w >> 1, h = a.h >> 1, d = new Float64Array(w * h), s = a.d, W = a.w; | |
| for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { | |
| const i = 2 * y * W + 2 * x; | |
| d[y * w + x] = (((s[i] + s[i + 1]) + s[i + W]) + s[i + W + 1]) * 0.25; | |
| } | |
| return { w, h, d }; | |
| } | |
| export function prepool(P, W, H) { while (P.w > LONG_FIT * W || P.h > LONG_FIT * H) P = pool2(P); return P; } | |
| function axis(nOut, nIn, t, s, start, count) { | |
| const f = nIn / nOut, c = new Float64Array(count); | |
| for (let i = 0; i < count; i++) { const x = start + i; c[i] = ((((x + 0.5) - nOut / 2) - t) / s + nOut / 2) * f - 0.5; } | |
| return c; | |
| } | |
| // Bilinear sample of P onto an output grid. x0/y0/cw/ch select an extended window (coarse search). | |
| export function warp(P, W, H, tx, ty, sx, sy, x0 = 0, y0 = 0, cw = W, ch = H) { | |
| const wp = P.w, hp = P.h, u = axis(W, wp, tx, sx, x0, cw), v = axis(H, hp, ty, sy, y0, ch); | |
| const val = new Float64Array(cw * ch), m = new Uint8Array(cw * ch), s = P.d; | |
| const xi = new Int32Array(cw), fx = new Float64Array(cw), xok = new Uint8Array(cw), x1 = new Int32Array(cw); | |
| const cx = Math.max(wp - 2, 0), cy = Math.max(hp - 2, 0); | |
| for (let i = 0; i < cw; i++) { | |
| const a = Math.min(Math.max(Math.floor(u[i]), 0), cx); | |
| xi[i] = a; x1[i] = Math.min(a + 1, wp - 1); fx[i] = u[i] - a; xok[i] = u[i] >= 0 && u[i] <= wp - 1 ? 1 : 0; | |
| } | |
| for (let j = 0; j < ch; j++) { | |
| const vy = v[j]; if (!(vy >= 0 && vy <= hp - 1)) continue; | |
| const b = Math.min(Math.max(Math.floor(vy), 0), cy), b1 = Math.min(b + 1, hp - 1), fy = vy - b; | |
| const r0 = b * wp, r1 = b1 * wp, o = j * cw; | |
| for (let i = 0; i < cw; i++) { | |
| if (!xok[i]) continue; | |
| const p00 = s[r0 + xi[i]], p10 = s[r0 + x1[i]], p01 = s[r1 + xi[i]], p11 = s[r1 + x1[i]], FX = fx[i]; | |
| const top = p00 + (p10 - p00) * FX, bot = p01 + (p11 - p01) * FX; | |
| val[o + i] = top + (bot - top) * fy; m[o + i] = 1; | |
| } | |
| } | |
| return { val, m }; | |
| } | |
| // NCC of a (w x h) with window of b/m (stride bw, offset ox, oy) | |
| export function ncc(a, b, m, nmin, w = null, h = null, bw = null, ox = 0, oy = 0) { | |
| const A = a.d; w = w ?? a.w; h = h ?? a.h; bw = bw ?? w; | |
| let n = 0, Sa = 0, Sb = 0, Saa = 0, Sbb = 0, Sab = 0; | |
| for (let y = 0; y < h; y++) { | |
| const ra = y * w, rb = (y + oy) * bw + ox; | |
| for (let x = 0; x < w; x++) { | |
| if (!m[rb + x]) continue; | |
| const p = A[ra + x], q = b[rb + x]; | |
| n++; Sa += p; Sb += q; Saa += p * p; Sbb += q * q; Sab += p * q; | |
| } | |
| } | |
| if (n < nmin) return -1; | |
| const va = Saa - Sa * Sa / n, vb = Sbb - Sb * Sb / n; | |
| if (va <= 1e-12 || vb <= 1e-12) return 0; | |
| return (Sab - Sa * Sb / n) / Math.sqrt(va * vb); | |
| } | |
| export function blur(a, sigma) { | |
| if (sigma <= 0) return a; | |
| const r = Math.ceil(3 * sigma), w = []; | |
| for (let i = -r; i <= r; i++) w.push(Math.exp(-(i * i) / (2 * sigma * sigma))); | |
| let s = 0; for (const x of w) s += x; | |
| for (let k = 0; k < w.length; k++) w[k] = w[k] / s; | |
| const W = a.w, H = a.h, t = new Float64Array(W * H), o = new Float64Array(W * H), d = a.d; | |
| for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { | |
| let acc = 0; for (let k = 0; k < w.length; k++) acc = acc + w[k] * d[y * W + Math.min(Math.max(x + k - r, 0), W - 1)]; | |
| t[y * W + x] = acc; | |
| } | |
| for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { | |
| let acc = 0; for (let k = 0; k < w.length; k++) acc = acc + w[k] * t[Math.min(Math.max(y + k - r, 0), H - 1) * W + x]; | |
| o[y * W + x] = acc; | |
| } | |
| return { w: W, h: H, d: o }; | |
| } | |
| // ------------------------------------------------------------------ alignment | |
| const pyr = (a, L) => { const o = [a]; for (let i = 0; i < L; i++) o.push(pool2(o[o.length - 1])); return o; }; | |
| const tick = () => new Promise((r) => setTimeout(r, 0)); | |
| export async function align(ref, P, { start = null, onProgress = null } = {}) { | |
| const W = ref.w, H = ref.h; | |
| P = prepool(P, W, H); | |
| let K = 0; while (Math.max(W >> K, H >> K) > COARSE_MAX) K++; | |
| const R = pyr(ref, K), Q = pyr(P, K); | |
| let evals = 0, p, coarse = null; | |
| if (!start) { | |
| const r = R[K], q = Q[K], w = r.w, h = r.h, nmin = MIN_VALID * w * h; | |
| const Dx = Math.floor(SHIFT_FRAC * w + 0.5), Dy = Math.floor(SHIFT_FRAC * h + 0.5), cw = w + 2 * Dx, chh = h + 2 * Dy; | |
| let best = [-2, 1, 0, 0]; | |
| for (let i = 0; i < SCALES; i++) { | |
| const s = Math.exp(Math.log(0.6) + (Math.log(1.6) - Math.log(0.6)) * i / (SCALES - 1)); | |
| const { val, m } = warp(q, w, h, 0, 0, s, s, -Dx, -Dy, cw, chh); | |
| for (let dy = -Dy; dy <= Dy; dy++) for (let dx = -Dx; dx <= Dx; dx++) { | |
| const c = ncc(r, val, m, nmin, w, h, cw, Dx - dx, Dy - dy); evals++; | |
| if (c > best[0] + TOL) best = [c, s, dx, dy]; | |
| } | |
| if (onProgress) { onProgress(0.6 * (i + 1) / SCALES); await tick(); } | |
| } | |
| p = [best[2] * (1 << K), best[3] * (1 << K), best[1], best[1]]; coarse = best[0]; | |
| } else p = [start.tx, start.ty, start.sx, start.sy]; | |
| let ds = 0.02; | |
| for (let k = K; k >= 0; k--) { | |
| const r = R[k], q = Q[k], w = r.w, h = r.h, f = 1 << k, nmin = MIN_VALID * w * h; | |
| const F = (pp) => { const { val, m } = warp(q, w, h, pp[0] / f, pp[1] / f, pp[2], pp[3]); return ncc(r, val, m, nmin); }; | |
| let cur = F(p); evals++; | |
| let dt = f, n = 0; const stopT = k === 0 ? 0.02 : f * 0.25; | |
| while (dt >= stopT && n < 400) { | |
| let improved = false; | |
| for (let i = 0; i < 4; i++) { | |
| const st = i < 2 ? dt : ds; | |
| for (const sg of [1.0, -1.0]) { | |
| const t = p.slice(); t[i] = p[i] + sg * st; | |
| if (i >= 2 && !(t[i] >= 0.3 && t[i] <= 3.0)) continue; | |
| const c = F(t); evals++; n++; | |
| if (c > cur + TOL) { p = t; cur = c; improved = true; break; } | |
| } | |
| } | |
| if (!improved) { dt *= 0.5; ds *= 0.5; } | |
| } | |
| ds = Math.max(ds * 2, 0.0005); | |
| if (onProgress) { onProgress(0.6 + 0.4 * (K - k + 1) / (K + 1)); await tick(); } | |
| } | |
| return { tx: rnd(p[0], 3), ty: rnd(p[1], 3), sx: rnd(p[2], 5), sy: rnd(p[3], 5), evals, coarseNcc: coarse === null ? null : rnd(coarse, 6) }; | |
| } | |
| // ------------------------------------------------------------------ metrics | |
| export function applyAlignment(ref, P, al) { | |
| const PP = prepool(P, ref.w, ref.h); | |
| return warp(PP, ref.w, ref.h, al.tx, al.ty, al.sx, al.sy); | |
| } | |
| export function blurEstimate(ref, val, m) { | |
| let best = [-2, 0]; | |
| for (const s of SIGMAS) { const c = ncc(blur(ref, s), val, m, 1); if (c > best[0] + TOL) best = [c, s]; } | |
| return best; | |
| } | |
| export function metrics(ref, val, m, tRef = 0.5, tPred = 0.5, pxPerMM = null, blurBest = null) { | |
| const N = ref.w * ref.h, A = ref.d; | |
| let n = 0; for (let i = 0; i < N; i++) n += m[i]; | |
| const out = { nValid: n, validFraction: rnd(n / N, 6) }; | |
| out.pearson = rnd(ncc(ref, val, m, 1), 6); | |
| const bb = blurBest || blurEstimate(ref, val, m); | |
| out.blurSigmaPx = bb[1]; | |
| out.blurSigmaUM = pxPerMM ? rnd(bb[1] / pxPerMM * 1000, 1) : null; | |
| out.pearsonMatchedBlur = rnd(bb[0], 6); | |
| let inter = 0, uni = 0, a = 0, b = 0; | |
| for (let i = 0; i < N; i++) { | |
| if (!m[i]) continue; | |
| const x = A[i] >= tRef, y = val[i] >= tPred; | |
| if (x && y) inter++; if (x || y) uni++; if (x) a++; if (y) b++; | |
| } | |
| out.thresholds = { ref: tRef, pred: tPred }; | |
| out.iou = uni ? rnd(inter / uni, 6) : 0; | |
| out.dice = a + b ? rnd(2 * inter / (a + b), 6) : 0; | |
| out.precision = b ? rnd(inter / b, 6) : 1; | |
| out.recall = a ? rnd(inter / a, 6) : 0; | |
| out.refPositiveFraction = n ? rnd(a / n, 6) : 0; | |
| out.predPositiveFraction = n ? rnd(b / n, 6) : 0; | |
| // precision and recall for pred thresholds j / 50, from high to low | |
| const tp = new Float64Array(PR_STEPS + 1), fp = new Float64Array(PR_STEPS + 1); | |
| for (let i = 0; i < N; i++) { | |
| if (!m[i]) continue; | |
| const v = val[i], pos = A[i] >= tRef; | |
| for (let j = 0; j <= PR_STEPS; j++) { if (v >= j / PR_STEPS) { if (pos) tp[j]++; else fp[j]++; } else break; } | |
| } | |
| const pr = []; let ap = 0, prevR = 0, bf = [-1, 0]; | |
| for (let j = PR_STEPS; j >= 0; j--) { | |
| const t = j / PR_STEPS, T = tp[j], F = fp[j]; | |
| const p = T + F ? T / (T + F) : 1.0, r = a ? T / a : 0.0; | |
| ap += (r - prevR) * p; prevR = r; | |
| const f1 = p + r ? 2 * p * r / (p + r) : 0; | |
| if (f1 > bf[0] + TOL) bf = [f1, t]; | |
| pr.push({ t: rnd(t, 6), precision: rnd(p, 6), recall: rnd(r, 6) }); | |
| } | |
| out.ap = rnd(ap, 6); | |
| out.bestF1 = { f1: rnd(bf[0], 6), threshold: rnd(bf[1], 6) }; | |
| out.pr = pr; | |
| return out; | |
| } | |
| // ------------------------------------------------------------------ bundles | |
| export function makeBundle({ index, segment, refLayer, targetLayer, target, pred, predInfo, predHash, predName, invert, model, al, method, alignNcc, met, annotations, impl = 'browser' }) { | |
| const seg = index.segments[segment]; | |
| return { | |
| schema: SCHEMA, | |
| tool: { name: 'inkbench', version: VERSION, impl }, | |
| created: new Date().toISOString().replace(/\.\d+Z$/, 'Z'), | |
| scroll: index.scroll, | |
| segment, | |
| reference: { layer: 'ink', label: 'Published ink (2.4 um), Vesuvius Challenge', file: refLayer.file, sha256: refLayer.sha256, width: seg.width, height: seg.height, pxPerMM: seg.pxPerMM, alignTargetFile: targetLayer.file, alignTargetSha256: targetLayer.sha256 }, | |
| prediction: { name: predName || 'prediction', sha256: predHash, format: predInfo.format, width: predInfo.width, height: predInfo.height, normalization: predInfo.normalization, invert: !!invert, model: model || { name: 'unspecified' } }, | |
| alignment: { method, target, tx: al.tx, ty: al.ty, sx: al.sx, sy: al.sy, ncc: alignNcc, coarseNcc: al.coarseNcc ?? null, evals: al.evals ?? 0, model: 'bilinear, scale about grid center, then shift; units are reference px' }, | |
| metrics: met, | |
| annotations: (annotations || []).map(cleanAnnotation), | |
| credits: CREDITS, | |
| disclaimer: DISCLAIMER, | |
| }; | |
| } | |
| export const GREEK = ['Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ', 'Ι', 'Κ', 'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', 'Π', 'Ρ', 'Σ', 'Τ', 'Υ', 'Φ', 'Χ', 'Ψ', 'Ω', '?']; | |
| export function cleanAnnotation(a, i = 0) { | |
| const num = (v, d = 0) => (Number.isFinite(+v) ? +v : d); | |
| return { | |
| // ids end up in markup: letters, digits, _ and - only, up to 40 | |
| id: /^[A-Za-z0-9_-]{1,40}$/.test(String(a.id ?? '')) ? String(a.id) : 'a' + (i + 1), | |
| x: rnd(num(a.x), 1), y: rnd(num(a.y), 1), w: rnd(Math.max(1, num(a.w, 1)), 1), h: rnd(Math.max(1, num(a.h, 1)), 1), | |
| letter: GREEK.includes(a.letter) ? a.letter : '?', | |
| confidence: rnd(Math.min(1, Math.max(0, num(a.confidence, 0.5))), 2), | |
| note: String(a.note || '').slice(0, 500), | |
| status: CANDIDATE, | |
| }; | |
| } | |
| const REQUIRED = { | |
| '': ['schema', 'scroll', 'segment', 'reference', 'prediction', 'alignment', 'metrics', 'annotations', 'disclaimer'], | |
| reference: ['layer', 'sha256', 'width', 'height'], prediction: ['sha256', 'model'], | |
| alignment: ['method', 'tx', 'ty', 'sx', 'sy'], metrics: ['pearson', 'iou', 'dice', 'thresholds', 'pr'], | |
| }; | |
| export function validate(b) { | |
| const e = []; | |
| if (!b || typeof b !== 'object') return ['bundle is not a JSON object']; | |
| if (!String(b.schema || '').startsWith('unroll-inkbench/1')) e.push('schema must be unroll-inkbench/1'); | |
| for (const [s, keys] of Object.entries(REQUIRED)) { | |
| const o = s ? b[s] : b; | |
| if (!o || typeof o !== 'object') { e.push('missing object ' + s); continue; } | |
| for (const k of keys) if (!(k in o)) e.push('missing ' + (s ? s + '.' : '') + k); | |
| } | |
| (b.annotations || []).forEach((a, i) => { | |
| for (const k of ['x', 'y', 'w', 'h', 'letter', 'confidence', 'status']) if (!(k in a)) e.push(`annotations[${i}] missing ${k}`); | |
| if (a.status !== CANDIDATE) e.push(`annotations[${i}].status must be "${CANDIDATE}"`); | |
| }); | |
| return e; | |
| } | |
| export function rank(bundles, metric = 'pearson') { | |
| const out = {}; | |
| for (const b of bundles) { | |
| const m = b.metrics, v = metric === 'f1' ? m.bestF1?.f1 : m[metric]; | |
| (out[b.segment] ||= []).push({ segment: b.segment, model: b.prediction?.model?.name || '?', value: v, pearson: m.pearson, iou: m.iou, dice: m.dice, ap: m.ap, bundle: b }); | |
| } | |
| for (const s in out) { out[s].sort((a, b) => b.value - a.value); out[s].forEach((r, i) => { r.rank = i + 1; }); } | |
| return out; | |
| } | |
| export const comparable = (b) => { const c = JSON.parse(JSON.stringify(b)); delete c.tool; delete c.created; return c; }; | |