unroll / lib /bench-core.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
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; };