unroll / lib /bench.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
59.1 kB
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Chase Hendrick
// Ink Bench: score your own ink prediction for a winding against the published ink, in the
// browser. Nothing is uploaded. Maths lives in bench-core.js (mirrored by tools/inkbench.py).
import * as C from './bench-core.js';
import { writeZip } from './formats.js';
const BASE = new URL('../', import.meta.url).href;
const SPACE = 'https://huggingface.co/spaces/SharpMeow/unroll';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmt = (v, d = 3) => (v === null || v === undefined || Number.isNaN(v) ? 'n/a' : (+v).toFixed(d));
const S = {
index: null, seg: null, layers: {}, ref: null, target: 'ink', base: 'ink',
pred: null, predRaw: null, predInfo: null, predHash: null, predName: null, source: null,
invert: false, model: { name: '' }, al: null, method: null, aligned: null, blurBest: null, met: null, alignNcc: null,
tRef: 0.5, tPred: 0.5, ann: [], view: 'side', swipe: 0.5, zoom: 1, tool: 'pan', pendingHash: null, sel: null,
board: [], busy: false,
};
// ------------------------------------------------------------------ data
async function fetchBytes(url) {
const r = await fetch(url, { mode: 'cors' });
if (!r.ok) throw new Error(`Could not fetch ${url} (HTTP ${r.status}).`);
return new Uint8Array(await r.arrayBuffer());
}
async function loadIndex() {
if (!S.index) S.index = await (await fetch(BASE + 'data/bench/index.json')).json();
return S.index;
}
async function layer(seg, name) {
const key = seg + ':' + name;
if (!S.layers[key]) {
const L = S.index.segments[seg].layers[name];
if (!L) throw new Error(`No ${name} layer for ${seg}.`);
S.layers[key] = (async () => {
const bytes = await fetchBytes(BASE + L.file);
const h = await C.sha256(bytes);
if (h !== L.sha256) throw new Error(`Reference ${L.file} failed its checksum.`);
return C.decodeBytes(bytes);
})();
}
return S.layers[key];
}
const PUBLIC = {
gpu: { label: 'ink_9um (9 µm), ink_9um seed 42 step 75000', model: { name: 'scrollprize/ink_9um (seed 42, step 75000) on the 9.362 µm scan', url: 'https://huggingface.co/scrollprize/ink_9um' } },
ct: { label: 'X-ray layer as a baseline (not an ink model)', model: { name: 'baseline: X-ray intensity (9.362 um surface volume)' } },
};
const thr = (v, what) => { const n = typeof v === 'string' && v.trim() !== '' ? +v : v; if (typeof n !== 'number' || !Number.isFinite(n) || n < 0 || n > 1) throw new Error(`${what} threshold must be a number from 0 to 1; got ${typeof v === "number" ? String(v) : JSON.stringify(v)}.`); return n; };
async function selectSegment(seg) {
await loadIndex();
if (typeof seg !== 'string' || !Object.prototype.hasOwnProperty.call(S.index.segments, seg)) throw new Error(`Unknown segment ${JSON.stringify(seg)}. Segments: ${Object.keys(S.index.segments).join(', ')}.`);
if (S.seg !== seg) { S.ann = []; S.al = null; S.aligned = null; S.met = null; }
S.seg = seg;
S.ref = await layer(seg, 'ink');
const sel = $('bSeg'); if (sel) sel.value = seg;
const g = $('bUseGpu'); if (g) g.hidden = !S.index.segments[seg].layers.gpu;
render();
}
async function setPrediction({ bytes, name, source, model }) {
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const img = await C.decodeBytes(u8);
S.predRaw = img; S.predInfo = img.info; S.predHash = await C.sha256(u8); S.predName = name || 'prediction'; S.source = source || 'file';
if (model) S.model = model;
S.pred = applyInvert();
S.al = null; S.aligned = null; S.met = null;
syncInputs();
status(`Loaded ${S.predName}: ${img.w} x ${img.h} px, ${img.info.format.toUpperCase()}, ${img.info.normalization}. SHA-256 ${S.predHash.slice(0, 12)}...`);
if (S.pendingHash) {
if (S.pendingHash.hash === S.predHash) { const p = S.pendingHash; S.pendingHash = null; await restoreAlignment(p); return; }
status('This file does not match the shared comparison (different SHA-256). Aligning it fresh.', true);
S.pendingHash = null;
}
render();
}
function applyInvert() {
const P = S.predRaw; if (!P) return null;
if (!S.invert) return P;
const d = new Float64Array(P.d.length); for (let i = 0; i < d.length; i++) d[i] = 1.0 - P.d[i];
return { w: P.w, h: P.h, d, info: P.info };
}
// worker (falls back to the main thread if workers are unavailable)
let worker = null, wid = 0; const pending = new Map();
function getWorker() {
if (worker === false) return null;
if (!worker) {
try {
worker = new Worker(new URL('./bench-worker.js', import.meta.url), { type: 'module' });
worker.onmessage = ({ data }) => { const p = pending.get(data.id); if (!p) return; if (data.progress !== undefined) { progress(data.progress); return; } pending.delete(data.id); data.error ? p.reject(new Error(data.error)) : p.resolve(data.result); };
worker.onerror = () => { worker = false; for (const p of pending.values()) p.reject(new Error('worker failed')); pending.clear(); };
} catch { worker = false; return null; }
}
return worker;
}
const plain = (img) => ({ w: img.w, h: img.h, d: img.d });
function call(msg) {
const w = getWorker(); if (!w) return null;
const id = ++wid; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); w.postMessage({ id, ...msg }); });
}
async function coreAlign(tgt, P) {
const r = call({ op: 'align', tgt: plain(tgt), P: plain(P) });
if (r) { try { return await r; } catch (e) { if (worker !== false) throw e; } }
return C.align(tgt, P, { onProgress: (f) => progress(f) });
}
async function coreScore(ref, tgt, P, al) {
const r = call({ op: 'score', ref: plain(ref), tgt: plain(tgt), P: plain(P), al });
if (r) { try { return await r; } catch (e) { if (worker !== false) throw e; } }
const { val, m } = C.applyAlignment(ref, P, al);
return { val, m, alignNcc: C.rnd(C.ncc(tgt, val, m, 1), 6), blurBest: C.blurEstimate(ref, val, m) };
}
async function runAlign(target = S.target) {
if (!S.pred || !S.ref) throw new Error('Load a segment and a prediction first.');
S.target = target;
const tgt = target === 'ink' ? S.ref : await layer(S.seg, target);
busy(true, 'Aligning...');
try {
const al = await coreAlign(tgt, S.pred);
S.al = al; S.method = 'auto';
await recompute(true);
status(S.alignNcc < 0.2 ? `Aligned, but the match is weak (NCC ${fmt(S.alignNcc)}). Check the winding, the invert box, or nudge by hand.` : `Aligned in UV space: shift ${fmt(al.tx, 2)}, ${fmt(al.ty, 2)} px, scale ${fmt(al.sx, 4)} x ${fmt(al.sy, 4)}, NCC ${fmt(S.alignNcc)}.`, S.alignNcc < 0.2);
autoHide();
} finally { busy(false); }
return S.al;
}
async function restoreAlignment({ tx, ty, sx, sy, method = 'shared' }) {
S.al = { tx: C.rnd(+tx, 3), ty: C.rnd(+ty, 3), sx: C.rnd(+sx, 5), sy: C.rnd(+sy, 5), evals: 0, coarseNcc: null };
S.method = method;
await recompute(true);
}
async function recompute(geometryChanged) {
if (!S.al || !S.pred || !S.ref) { render(); return null; }
if (geometryChanged || !S.aligned) {
const tgt = S.target === 'ink' ? S.ref : await layer(S.seg, S.target);
const r = await coreScore(S.ref, tgt, S.pred, S.al);
S.aligned = { val: r.val, m: r.m }; S.alignNcc = r.alignNcc; S.blurBest = r.blurBest;
layersCache.pred = null; layersCache.diff = null;
}
S.met = C.metrics(S.ref, S.aligned.val, S.aligned.m, S.tRef, S.tPred, S.index.segments[S.seg].pxPerMM, S.blurBest);
render();
return S.met;
}
function bundle() {
if (!S.met) throw new Error('Nothing to export yet: load a prediction and align it.');
const seg = S.index.segments[S.seg];
const model = { ...S.model, name: S.model.name || 'unspecified' };
return C.makeBundle({ index: S.index, segment: S.seg, refLayer: seg.layers.ink, targetLayer: seg.layers[S.target], target: S.target,
predInfo: S.predInfo, predHash: S.predHash, predName: S.predName, invert: S.invert, model, al: S.al, method: S.method,
alignNcc: S.alignNcc, met: S.met, annotations: S.ann });
}
// ------------------------------------------------------------------ share links
const b64u = (s) => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const unb64u = (s) => decodeURIComponent(escape(atob(s.replace(/-/g, '+').replace(/_/g, '/'))));
function shareState() {
if (!S.al) throw new Error('Align a prediction first.');
return { v: 1, s: S.seg, p: S.source, h: S.predHash, n: S.predName, m: S.model, a: [S.al.tx, S.al.ty, S.al.sx, S.al.sy], g: S.target,
t: [S.tRef, S.tPred], i: S.invert ? 1 : 0, an: S.ann.map((a) => [a.x, a.y, a.w, a.h, a.letter, a.confidence, a.note || '']) };
}
function shareURL() {
const u = new URL(location.href); u.hash = ''; u.search = '';
u.searchParams.set('bench', b64u(JSON.stringify(shareState())));
return u.toString();
}
async function restoreShare(code) {
let st;
try { st = JSON.parse(unb64u(code)); } catch { throw new Error('This Ink Bench share link is damaged (it is not valid base64 JSON). Ask for the link again.'); }
if (!st || typeof st !== 'object') throw new Error('This Ink Bench share link is damaged.');
if (st.v !== 1) throw new Error(`This Ink Bench share link has version ${JSON.stringify(st.v)}; this page reads version 1.`);
const okArr = (x, n) => Array.isArray(x) && x.length >= n && x.slice(0, n).every((v) => Number.isFinite(+v));
if (typeof st.s !== 'string' || !okArr(st.a, 4) || !okArr(st.t, 2)) throw new Error('This Ink Bench share link is incomplete (segment, alignment or thresholds missing).');
await loadIndex();
if (!Object.prototype.hasOwnProperty.call(S.index.segments, st.s)) throw new Error(`This Ink Bench share link names an unknown segment ${JSON.stringify(st.s)}.`);
thr(st.t[0], 'ref'); thr(st.t[1], 'pred');
S.seg = null; await open('compare', st.s);
await selectSegment(st.s);
S.tRef = +st.t[0]; S.tPred = +st.t[1]; S.invert = !!st.i; S.target = st.g || 'ink'; S.model = st.m || { name: '' };
S.ann = (st.an || []).map((a, i) => C.cleanAnnotation({ x: a[0], y: a[1], w: a[2], h: a[3], letter: a[4], confidence: a[5], note: a[6] }, i));
const al = { tx: st.a[0], ty: st.a[1], sx: st.a[2], sy: st.a[3] };
if (st.p === 'layer:gpu' || st.p === 'layer:ct') {
await usePublic(st.p.slice(6), false);
await restoreAlignment(al);
} else if (/^url:https:\/\//.test(st.p)) {
const url = st.p.slice(4);
await setPrediction({ bytes: await fetchBytes(url), name: st.n, source: st.p, model: st.m });
await restoreAlignment(al);
} else {
S.pendingHash = { hash: st.h, ...al };
syncInputs(); render();
status(`This link restores a comparison made with a local file (${String(st.n || 'prediction').slice(0, 200)}, SHA-256 ${String(st.h).slice(0, 12)}...). Drop that same file to restore it; nothing is uploaded.`);
}
return S.met;
}
async function usePublic(which, align = true) {
const L = S.index.segments[S.seg].layers[which];
if (!L) throw new Error(`No public ${which} layer for ${S.seg}.`);
const bytes = await fetchBytes(BASE + L.file);
await setPrediction({ bytes, name: L.file.split('/').pop(), source: 'layer:' + which, model: { ...PUBLIC[which].model } });
if (align) await runAlign();
}
// ------------------------------------------------------------------ figure, citation, export
function bibtex() {
return `@software{hendrick_unroll_inkbench_2026,\n author = {Hendrick, Chase},\n title = {Unroll Ink Bench: model-agnostic agreement metrics for Herculaneum ink detection},\n year = {2026},\n version = {${C.VERSION}},\n url = {${SPACE}},\n license = {Apache-2.0},\n note = {Data: Vesuvius Challenge open data, CC BY-NC 4.0}\n}\n`;
}
function citationCFF() {
return `cff-version: 1.2.0\nmessage: "If you use the Unroll Ink Bench, please cite it as below. Cite the Vesuvius Challenge for the data."\ntitle: "Unroll Ink Bench"\nversion: "${C.VERSION}"\nauthors:\n - family-names: Hendrick\n given-names: Chase\nlicense: Apache-2.0\nurl: "${SPACE}"\nrepository-code: "${SPACE}/tree/main"\nkeywords: [vesuvius-challenge, herculaneum, ink-detection, benchmark]\nreferences:\n - type: data\n title: "Vesuvius Challenge open data: PHerc. 139"\n url: "https://scrollprize.org/data"\n license: CC-BY-NC-4.0\n`;
}
const layersCache = {};
function grayCanvas(img, key) {
if (key && layersCache[key]) return layersCache[key];
const c = document.createElement('canvas'); c.width = img.w; c.height = img.h;
const x = c.getContext('2d'), id = x.createImageData(img.w, img.h);
for (let i = 0; i < img.w * img.h; i++) { const v = Math.round(Math.min(1, Math.max(0, img.d[i])) * 255); id.data[i * 4] = id.data[i * 4 + 1] = id.data[i * 4 + 2] = v; id.data[i * 4 + 3] = 255; }
x.putImageData(id, 0, 0);
if (key) layersCache[key] = c;
return c;
}
function predCanvas() {
if (layersCache.pred) return layersCache.pred;
const W = S.ref.w, H = S.ref.h, c = document.createElement('canvas'); c.width = W; c.height = H;
const x = c.getContext('2d'), id = x.createImageData(W, H), { val, m } = S.aligned;
for (let i = 0; i < W * H; i++) {
const o = i * 4;
if (!m[i]) { const k = (((i % W) + ((i / W) | 0)) >> 2) & 1; id.data[o] = id.data[o + 1] = 20 + 10 * k; id.data[o + 2] = 34 + 10 * k; id.data[o + 3] = 255; continue; }
const v = Math.round(val[i] * 255); id.data[o] = v * 0.37 | 0; id.data[o + 1] = v * 0.94 | 0; id.data[o + 2] = v; id.data[o + 3] = 255;
}
x.putImageData(id, 0, 0);
return (layersCache.pred = c);
}
function refCanvas() {
if (S.base === 'ct') return null;
const key = 'ref:' + S.seg;
if (layersCache[key]) return layersCache[key];
const W = S.ref.w, H = S.ref.h, c = document.createElement('canvas'); c.width = W; c.height = H;
const x = c.getContext('2d'), id = x.createImageData(W, H);
for (let i = 0; i < W * H; i++) { const v = S.ref.d[i]; id.data[i * 4] = v * 255; id.data[i * 4 + 1] = v * 179; id.data[i * 4 + 2] = v * 71; id.data[i * 4 + 3] = 255; }
x.putImageData(id, 0, 0);
return (layersCache[key] = c);
}
function diffCanvas() {
if (layersCache.diff) return layersCache.diff;
const W = S.ref.w, H = S.ref.h, c = document.createElement('canvas'); c.width = W; c.height = H;
const x = c.getContext('2d'), id = x.createImageData(W, H), { val, m } = S.aligned, R = S.ref.d;
for (let i = 0; i < W * H; i++) {
const o = i * 4; if (!m[i]) { id.data[o] = 18; id.data[o + 1] = 18; id.data[o + 2] = 26; id.data[o + 3] = 255; continue; }
const r = R[i], p = val[i], both = Math.min(r, p), a = Math.max(0, r - p), b = Math.max(0, p - r);
id.data[o] = 210 * both + 255 * a + 94 * b; id.data[o + 1] = 210 * both + 179 * a + 240 * b; id.data[o + 2] = 210 * both + 71 * a + 255 * b; id.data[o + 3] = 255;
}
x.putImageData(id, 0, 0);
return (layersCache.diff = c);
}
let ctImg = null;
async function ensureCT() { ctImg = await layer(S.seg, 'ct'); return ctImg; }
function drawBoxes(x, k, ox = 0, oy = 0, fontPx = 11) {
x.save(); x.lineWidth = Math.max(1, k * 0.8); x.font = `${fontPx}px ui-monospace, Menlo, monospace`; x.textBaseline = 'bottom';
for (const a of S.ann) {
const X = ox + a.x * k, Y = oy + a.y * k, Wd = a.w * k, Hd = a.h * k, on = S.sel === a.id;
x.strokeStyle = on ? '#ffffff' : '#ffe08a'; x.setLineDash([4, 3]); x.strokeRect(X, Y, Wd, Hd); x.setLineDash([]);
const t = `${a.letter} ${Math.round(a.confidence * 100)}% candidate for expert review`;
const tw = x.measureText(t).width + 6;
x.fillStyle = 'rgba(0,0,0,.72)'; x.fillRect(X, Y - fontPx - 4, tw, fontPx + 4);
x.fillStyle = on ? '#ffffff' : '#ffe08a'; x.fillText(t, X + 3, Y - 2);
}
x.restore();
}
function scaleBar(x, k, ox, oy, h, fontPx = 11) {
const ppmm = S.index.segments[S.seg].pxPerMM, mm = 5, L = mm * ppmm * k, X = ox + 12, Y = oy + h - 14;
x.save(); x.fillStyle = 'rgba(0,0,0,.6)'; x.fillRect(X - 6, Y - fontPx - 10, Math.max(L, 40) + 12, fontPx + 18);
x.fillStyle = '#fff'; x.fillRect(X, Y, L, 3); x.font = `${fontPx}px ui-monospace, Menlo, monospace`; x.textBaseline = 'bottom'; x.fillText(mm + ' mm', X, Y - 3); x.restore();
}
function figure() {
if (!S.met) throw new Error('Align a prediction first.');
const W = S.ref.w, H = S.ref.h, pad = 24, top = 92, foot = 150, k = 1;
const c = document.createElement('canvas'); c.width = 3 * W + 4 * pad; c.height = top + H + foot;
const x = c.getContext('2d');
x.fillStyle = '#0b0a09'; x.fillRect(0, 0, c.width, c.height);
x.fillStyle = '#ece6db'; x.font = '26px Georgia, serif'; x.fillText(`PHerc. 139, winding ${S.seg}: Ink Bench comparison`, pad, 38);
x.font = '14px ui-monospace, Menlo, monospace'; x.fillStyle = '#b9b1a4';
x.fillText(`Prediction: ${(S.model.name || 'unspecified').slice(0, 110)} SHA-256 ${S.predHash.slice(0, 16)}`, pad, 62);
x.fillText(`Alignment (${S.method}, to ${S.target === 'ink' ? 'published ink' : 'X-ray'}): shift ${fmt(S.al.tx, 2)}, ${fmt(S.al.ty, 2)} px, scale ${fmt(S.al.sx, 4)} x ${fmt(S.al.sy, 4)}, NCC ${fmt(S.alignNcc)}`, pad, 82);
const panels = [['Published ink (2.4 µm), Vesuvius Challenge', refCanvas()], ['Prediction, aligned', predCanvas()], ['Difference: amber published only, cyan prediction only', diffCanvas()]];
panels.forEach(([label, cv], i) => {
const ox = pad + i * (W + pad);
x.drawImage(cv, ox, top, W, H);
drawBoxes(x, k, ox, top, 12); scaleBar(x, k, ox, top, H, 12);
x.fillStyle = '#ece6db'; x.font = '13px ui-monospace, Menlo, monospace'; x.fillText(label, ox, top + H + 20);
});
const m = S.met; let y = top + H + 48;
x.fillStyle = '#ffb347'; x.font = '15px ui-monospace, Menlo, monospace';
x.fillText(`Agreement with another model's output (published ink), not ground truth. Pearson r ${fmt(m.pearson)} r at matched blur ${fmt(m.pearsonMatchedBlur)} (sigma ${m.blurSigmaPx} px, ${m.blurSigmaUM} µm)`, pad, y); y += 22;
x.fillText(`Thresholds ref ${m.thresholds.ref} pred ${m.thresholds.pred}: IoU ${fmt(m.iou)} Dice ${fmt(m.dice)} precision ${fmt(m.precision)} recall ${fmt(m.recall)} AP ${fmt(m.ap)} best F1 ${fmt(m.bestF1.f1)} at ${m.bestF1.threshold}`, pad, y); y += 22;
x.fillStyle = '#b9b1a4'; x.font = '12px ui-monospace, Menlo, monospace';
x.fillText(`${S.ann.length} letter box${S.ann.length === 1 ? '' : 'es'}, each a candidate for expert review, not a reading. Made with the Unroll Ink Bench ${C.VERSION} by Chase Hendrick (code Apache-2.0), ${SPACE}`, pad, y); y += 18;
x.fillText(`Data: ${C.CREDITS.data} License CC BY-NC 4.0, non-commercial use with attribution.`, pad, y);
return c;
}
const canvasPNG = (c) => new Promise((r) => c.toBlob((b) => r(b), 'image/png'));
function download(blob, name) {
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 2000);
}
function slug(s) { return String(s || 'prediction').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'prediction'; }
async function exportFiles() {
const b = bundle(), enc = new TextEncoder();
const fig = new Uint8Array(await (await canvasPNG(figure())).arrayBuffer());
let share = ''; try { share = shareURL(); } catch {}
const readme = `Unroll Ink Bench bundle\n\nsegment ${b.segment} of ${b.scroll}, prediction ${b.prediction.name} (sha256 ${b.prediction.sha256})\n\nbundle.json metrics, alignment, hashes and annotations (schema ${C.SCHEMA}, see ${SPACE}/blob/main/docs/BENCH_FORMAT.md)\nfigure.png reference, aligned prediction and difference, with a 5 mm scale bar and credits\nCITATION.cff and inkbench.bib how to cite the Bench\nshare-url.txt opens this comparison again${b.prediction.model && S.source.startsWith('layer:') ? '' : ' (for a local file, drop the same file to restore it)'}\n\nReproduce headlessly:\n python3 inkbench.py compare <your prediction> --segment ${b.segment} --alignment bundle.json\n\n${C.DISCLAIMER}\n\nData credits: ${C.CREDITS.data}\nData license: ${C.CREDITS.dataLicense}\nBench code: Apache-2.0, Chase Hendrick.\n`;
const name = `inkbench-${b.segment}-${slug(b.prediction.model.name)}`;
const files = [
{ name: `${name}/bundle.json`, bytes: enc.encode(JSON.stringify(b, null, 1) + '\n') },
{ name: `${name}/figure.png`, bytes: fig },
{ name: `${name}/CITATION.cff`, bytes: enc.encode(citationCFF()) },
{ name: `${name}/inkbench.bib`, bytes: enc.encode(bibtex()) },
{ name: `${name}/share-url.txt`, bytes: enc.encode(share + '\n') },
{ name: `${name}/README.txt`, bytes: enc.encode(readme) },
];
return { name: name + '.zip', zip: writeZip(files), bundle: b };
}
async function exportBundle() {
const { name, zip, bundle: b } = await exportFiles();
download(zip, name);
return { name, size: zip.size, bundle: b };
}
// ------------------------------------------------------------------ leaderboard
async function loadBoard(sources) {
const out = [];
for (const s of sources) {
try {
const b = typeof s === 'string' ? await (await fetch(new URL(s, location.href), { mode: 'cors' })).json() : s;
out.push({ src: typeof s === 'string' ? s : 'local', bundle: b, errors: C.validate(b) });
} catch (e) { out.push({ src: String(s), bundle: null, errors: [String(e.message || e)] }); }
}
S.board.push(...out); renderBoard();
return out.map((o) => ({ src: o.src, ok: !o.errors.length, errors: o.errors }));
}
const examples = () => Object.keys(S.index.segments).filter((w) => S.index.segments[w].layers.gpu).flatMap((w) => ['ink9um', 'ct-baseline', 'noise-baseline'].map((k) => `${BASE}docs/examples/bench/${w}-${k}.json`));
// ------------------------------------------------------------------ UI
function status(msg, warn = false) { const el = $('bStatus'); if (!el) return; el.hidden = !msg; el.textContent = msg || ''; el.classList.toggle('warn', !!warn); }
let hideT = 0; function autoHide() { clearTimeout(hideT); hideT = setTimeout(() => { const el = $('bStatus'); if (el && !el.classList.contains('warn')) el.hidden = true; }, 6000); }
function progress(f) { const el = $('bProg'); if (el) el.style.width = Math.round(f * 100) + '%'; }
function busy(on, msg) { S.busy = on; const el = $('bench'); if (el) el.classList.toggle('busy', on); if (msg) { clearTimeout(hideT); status(msg); } if (on) progress(0); }
function build() {
if ($('bench')) return;
const segs = Object.keys(S.index.segments);
const sec = document.createElement('section');
sec.id = 'bench'; sec.className = 'sheet bench'; sec.hidden = true; sec.setAttribute('aria-label', 'Ink Bench');
sec.innerHTML = `
<div class="sheet-head b-head">
<div><div class="mono dim">INK BENCH</div><h2>Score your ink model on a real segment</h2></div>
<div class="b-tabs" role="tablist">
<button class="chip on" data-tab="compare" role="tab">Compare</button><button class="chip" data-tab="board" role="tab">Leaderboard</button><button class="chip" data-tab="cite" role="tab">Cite or use this</button>
</div>
<button class="icon" id="bClose" aria-label="Close">✕</button>
</div>
<p class="b-private mono"><b>Nothing is uploaded.</b> Your prediction is decoded, aligned and scored inside this browser. Scores are <b>agreement with another model's output</b> (the published ink), not ground truth.</p>
<div class="b-pane" data-pane="compare">
<div class="b-stage-wrap">
<div class="b-toolbar mono">
<span class="seg small b-views"><button class="chip on" data-view="side">Side by side</button><button class="chip" data-view="swipe">Swipe</button><button class="chip" data-view="diff">Difference</button></span>
<span class="seg small"><button class="chip" data-zoom="1">Fit</button><button class="chip" data-zoom="2">2x</button><button class="chip" data-zoom="4">4x</button></span>
<span class="seg small"><button class="chip" id="bBase" title="Show the published ink or the X-ray layer as the reference image">Base: ink</button><button class="chip" id="bBox" aria-pressed="false" title="Draw a box around a possible letter">Draw box</button></span>
</div>
<div class="b-stage" id="bStage"><div class="b-empty mono dim" id="bEmpty">Pick a winding, then drop your ink prediction (PNG or TIFF) here, or try the public ink_9um layer.</div><div class="b-canvases" id="bCanvases"></div></div>
<div class="b-legend mono dim small" id="bLegend"></div>
</div>
<div class="b-side">
<div class="b-block">
<label class="mono dim small">WINDING <select id="bSeg">${segs.map((s) => `<option>${s}</option>`).join('')}</select></label>
<div class="o-drop b-drop" id="bDrop" tabindex="0" role="button" aria-label="Drop or pick your prediction">
<div>Drop your prediction <b>PNG or TIFF</b> for this winding</div>
<label class="chip">Pick file<input type="file" id="bPick" accept=".png,.tif,.tiff,image/png,image/tiff" hidden></label>
</div>
<div class="b-row"><button class="chip" id="bUseGpu">Use public ink_9um layer</button><button class="chip" id="bUseCt" title="The X-ray layer scored as if it were a prediction: a floor any ink model should beat">X-ray baseline</button></div>
<form class="o-url b-url" id="bURLForm"><input type="url" id="bURL" placeholder="https://... prediction.png (CORS)" aria-label="Prediction URL"><button class="chip" type="submit">Open URL</button></form>
<label class="mono dim small b-model">MODEL NAME <input id="bModel" placeholder="e.g. my-timesformer v3, fold 2" maxlength="200"></label>
<label class="mono small"><input type="checkbox" id="bInvert"> Ink is dark in my image (invert)</label>
</div>
<div class="b-block">
<div class="mono dim small">ALIGNMENT, IN UV SPACE <span class="seg small"><button class="chip on" data-target="ink">to ink</button><button class="chip" data-target="ct">to X-ray</button></span></div>
<div class="b-row"><button class="chip b-go" id="bAlign">Auto align</button><span class="mono small" id="bAlignInfo"></span></div>
<div class="b-bar"><i id="bProg"></i></div>
<div class="b-nudge mono small">
<label>shift x <input type="number" step="0.1" id="bTx"></label><label>shift y <input type="number" step="0.1" id="bTy"></label>
<label>scale x <input type="number" step="0.001" id="bSx"></label><label>scale y <input type="number" step="0.001" id="bSy"></label>
</div>
<div class="b-row b-pad mono small">
<button class="chip" data-n="x-">◀</button><button class="chip" data-n="y-">▲</button><button class="chip" data-n="y+">▼</button><button class="chip" data-n="x+">▶</button>
<button class="chip" data-n="s-">−</button><button class="chip" data-n="s+">+</button>
<select id="bStep" aria-label="Nudge step"><option value="0.1">0.1 px</option><option value="1" selected>1 px</option><option value="5">5 px</option></select>
</div>
</div>
<div class="b-block">
<div class="mono dim small">AGREEMENT WITH PUBLISHED INK (ANOTHER MODEL'S OUTPUT, NOT GROUND TRUTH)</div>
<div class="b-metrics mono" id="bMetrics"><span class="dim">No scores yet.</span></div>
<label class="mono small b-thr">ref threshold <input type="range" min="0.02" max="0.98" step="0.02" id="bTRef"><b id="bTRefV"></b></label>
<label class="mono small b-thr">pred threshold <input type="range" min="0.02" max="0.98" step="0.02" id="bTPred"><b id="bTPredV"></b></label>
<canvas id="bPR" class="b-pr" width="600" height="300" aria-label="Precision and recall curve"></canvas>
</div>
<div class="b-block">
<div class="mono dim small">LETTER CANDIDATES <span class="dim">(each one a candidate for expert review)</span></div>
<p class="mono small dim">Turn on Draw box and drag over the image. Tag a candidate Greek letter and your confidence. These are prompts for papyrologists, never readings.</p>
<ul class="b-ann" id="bAnn"></ul>
</div>
<div class="b-block">
<div class="mono dim small">EXPORT A REPRODUCIBLE BUNDLE</div>
<div class="b-row"><button class="chip b-go" id="bExport">Export bundle (.zip)</button><button class="chip" id="bJSON">JSON</button><button class="chip" id="bFig">Figure</button><button class="chip" id="bShare">Copy share link</button></div>
<p class="mono small dim">The zip holds bundle.json (schema ${C.SCHEMA}), figure.png with a scale bar and credits, CITATION.cff, inkbench.bib and a share link. Share links fully restore public layers; for a local file they restore everything once you drop the same file (checked by SHA-256).</p>
</div>
<p class="mono small dim">Data: Vesuvius Challenge open data, CC BY-NC 4.0 (non-commercial, with attribution). Bench by Chase Hendrick, code Apache-2.0.</p>
</div>
</div>
<div class="b-pane" data-pane="board" hidden>
<div class="about-body b-board">
<p>Load Ink Bench bundles (JSON) from URLs or files and rank their agreement with the published ink, per winding. Everything runs in this browser. A higher score means closer to the published model's output, which is a reference, not the truth: a model can disagree because it is better.</p>
<textarea id="bBoardURLs" rows="4" placeholder="One bundle URL per line (the server must allow CORS)"></textarea>
<div class="b-row"><button class="chip b-go" id="bBoardLoad">Load URLs</button><label class="chip">Add files<input type="file" id="bBoardPick" accept=".json,application/json" multiple hidden></label><button class="chip" id="bBoardCur">Add current comparison</button><button class="chip" id="bBoardEx">Load examples</button><button class="chip" id="bBoardClear">Clear</button>
<label class="mono small">rank by <select id="bBoardMetric"><option value="ap" selected>AP</option><option value="pearson">Pearson r</option><option value="f1">best F1</option><option value="iou">IoU</option><option value="dice">Dice</option></select></label></div>
<div id="bBoardOut" class="mono small"></div>
</div>
</div>
<div class="b-pane" data-pane="cite" hidden>
<div class="about-body b-cite">
<p><b>Use it in your pipeline.</b> The same metrics run headlessly in Python, with identical JSON output (tested on every format the page reads):</p>
<pre>pip install "git+https://huggingface.co/spaces/SharpMeow/unroll"
# or the single file:
curl -LO ${SPACE}/resolve/main/tools/inkbench.py
inkbench compare pred.png --segment w046 --model "my-model v3" -o bundle.json
inkbench compare pred.png --segment w046 --min-pearson 0.5 # fails CI below 0.5
inkbench rank bundles/*.json
inkbench adapt tif-stack layers.tif -o pred.png # also villa-folder, nnunet, timesformer</pre>
<p>In a training loop: <code>import inkbench; b = inkbench.compare(prob_map, segment="w046")</code>. In the page: <code>window.unrollBench</code> (also <code>window.unroll.bench</code>). Bundle format: <a href="${SPACE}/blob/main/docs/BENCH_FORMAT.md" target="_blank" rel="noopener">docs/BENCH_FORMAT.md</a>, guide: <a href="${SPACE}/blob/main/docs/BENCH.md" target="_blank" rel="noopener">docs/BENCH.md</a>.</p>
<p><b>Cite it.</b></p>
<pre id="bBib">${esc(bibtex())}</pre>
<div class="b-row"><button class="chip" id="bCopyBib">Copy BibTeX</button><button class="chip" id="bCFF">Download CITATION.cff</button></div>
<p><b>Licenses.</b> The Ink Bench code (lib/bench.js, lib/bench-core.js, bench.css, tools/inkbench.py, its adapters and tests) and the bundle format spec are licensed under the <a href="${SPACE}/blob/main/LICENSE" target="_blank" rel="noopener">Apache License 2.0</a>, with a <a href="${SPACE}/blob/main/NOTICE" target="_blank" rel="noopener">NOTICE</a> crediting Chase Hendrick: use, change and redistribute it, including commercially, keeping the notice. The scan data, segments, ink layers and reference images in data/ come from the Vesuvius Challenge and stay under <a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener">CC BY-NC 4.0</a>: non-commercial use only, with attribution. The code license does not change the data terms.</p>
<p class="mono dim small">Data credits: ${esc(C.CREDITS.data)} Ink Bench made by Chase Hendrick.</p>
</div>
</div>
<div class="b-status mono" id="bStatus" role="status" aria-live="polite" hidden></div>`;
document.body.appendChild(sec);
sec.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Escape') close(); });
wire();
}
function syncInputs() {
if (!$('bench')) return;
$('bModel').value = S.model.name || ''; $('bInvert').checked = S.invert;
$('bTRef').value = S.tRef; $('bTPred').value = S.tPred; $('bTRefV').textContent = S.tRef.toFixed(2); $('bTPredV').textContent = S.tPred.toFixed(2);
for (const b of document.querySelectorAll('#bench [data-target]')) b.classList.toggle('on', b.dataset.target === S.target);
if (S.al) { $('bTx').value = S.al.tx; $('bTy').value = S.al.ty; $('bSx').value = S.al.sx; $('bSy').value = S.al.sy; }
}
function wire() {
const tabs = document.querySelectorAll('#bench [data-tab]');
tabs.forEach((t) => (t.onclick = () => showTab(t.dataset.tab)));
$('bClose').onclick = close;
$('bSeg').onchange = () => guard(async () => { await selectSegment($('bSeg').value); if (S.source?.startsWith('layer:')) await usePublic(S.source.slice(6)); else if (S.pred) await runAlign(); });
$('bPick').onchange = (e) => { const f = e.target.files[0]; if (f) guard(() => fromFile(f)); e.target.value = ''; };
const drop = $('bDrop'), stage = $('bStage');
for (const el of [drop, stage]) {
el.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
el.addEventListener('dragleave', () => drop.classList.remove('over'));
el.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) guard(() => fromFile(f)); });
}
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('bPick').click(); } });
$('bUseGpu').onclick = () => guard(() => usePublic('gpu'));
$('bUseCt').onclick = () => guard(() => usePublic('ct'));
$('bURLForm').onsubmit = (e) => { e.preventDefault(); guard(async () => {
const url = $('bURL').value.trim(); if (!/^https:\/\//.test(url)) throw new Error('Use an https URL.');
await setPrediction({ bytes: await fetchBytes(url), name: url.split('/').pop().split('?')[0], source: 'url:' + url, model: { name: S.model.name || '', url } });
await runAlign();
}); };
$('bModel').oninput = () => { S.model = { ...S.model, name: $('bModel').value.trim() }; };
$('bInvert').onchange = () => guard(async () => { S.invert = $('bInvert').checked; S.pred = applyInvert(); if (S.al) await recompute(true); });
document.querySelectorAll('#bench [data-target]').forEach((b) => (b.onclick = () => guard(async () => { S.target = b.dataset.target; syncInputs(); if (S.pred) await runAlign(); })));
$('bAlign').onclick = () => guard(() => runAlign());
for (const id of ['bTx', 'bTy', 'bSx', 'bSy']) $(id).onchange = () => guard(() => manual({ tx: +$('bTx').value, ty: +$('bTy').value, sx: +$('bSx').value, sy: +$('bSy').value }));
document.querySelectorAll('#bench [data-n]').forEach((b) => (b.onclick = () => guard(() => {
if (!S.al) throw new Error('Align first, then nudge.');
const st = +$('bStep').value, n = b.dataset.n, a = { ...S.al }, ds = st / Math.max(S.ref.w, S.ref.h);
if (n === 'x-') a.tx -= st; if (n === 'x+') a.tx += st; if (n === 'y-') a.ty -= st; if (n === 'y+') a.ty += st;
if (n === 's-') { a.sx -= ds; a.sy -= ds; } if (n === 's+') { a.sx += ds; a.sy += ds; }
return manual(a);
})));
$('bTRef').oninput = () => { S.tRef = +$('bTRef').value; syncInputs(); recompute(false); };
$('bTPred').oninput = () => { S.tPred = +$('bTPred').value; syncInputs(); recompute(false); };
document.querySelectorAll('#bench [data-view]').forEach((b) => (b.onclick = () => { S.view = b.dataset.view; render(); }));
document.querySelectorAll('#bench [data-zoom]').forEach((b) => (b.onclick = () => { S.zoom = +b.dataset.zoom; render(); }));
$('bBase').onclick = () => guard(async () => { S.base = S.base === 'ink' ? 'ct' : 'ink'; if (S.base === 'ct') await ensureCT(); render(); });
$('bBox').onclick = () => { S.tool = S.tool === 'box' ? 'pan' : 'box'; render(); };
$('bExport').onclick = () => guard(async () => { const r = await exportBundle(); status(`Saved ${r.name} (${Math.round(r.size / 1024)} KB). Nothing left this browser.`); });
$('bJSON').onclick = () => guard(() => { const b = bundle(); download(new Blob([JSON.stringify(b, null, 1) + '\n'], { type: 'application/json' }), `inkbench-${b.segment}.json`); });
$('bFig').onclick = () => guard(async () => download(await canvasPNG(figure()), `inkbench-${S.seg}.png`));
$('bShare').onclick = () => guard(async () => { const u = shareURL(); try { await navigator.clipboard.writeText(u); status('Share link copied. It holds the settings, not your image.'); } catch { status(u); } });
$('bCopyBib').onclick = () => navigator.clipboard?.writeText(bibtex()).then(() => status('BibTeX copied.'), () => status(bibtex()));
$('bCFF').onclick = () => download(new Blob([citationCFF()], { type: 'text/yaml' }), 'CITATION.cff');
$('bBoardLoad').onclick = () => guard(() => loadBoard($('bBoardURLs').value.split(/\s+/).filter(Boolean)));
$('bBoardPick').onchange = (e) => guard(async () => { const bs = []; for (const f of e.target.files) { try { bs.push(JSON.parse(await f.text())); } catch { status(`${f.name} is not JSON.`, true); } } await loadBoard(bs); e.target.value = ''; });
$('bBoardCur').onclick = () => guard(() => loadBoard([bundle()]));
$('bBoardEx').onclick = () => guard(() => loadBoard(examples()));
$('bBoardClear').onclick = () => { S.board = []; renderBoard(); };
$('bBoardMetric').onchange = renderBoard;
stagePointer();
window.addEventListener('resize', () => { if (!$('bench').hidden) render(); });
}
async function manual(a) {
if (!S.pred || !S.ref) throw new Error('Load a prediction (and pick a winding) before setting the alignment.');
for (const k of ['tx', 'ty', 'sx', 'sy']) if (typeof a[k] !== 'number' || !Number.isFinite(a[k])) throw new Error(`Alignment ${k} must be a finite number; got ${JSON.stringify(a[k])}.`);
if (a.sx < 0.3 || a.sx > 3 || a.sy < 0.3 || a.sy > 3) throw new Error(`Scale must be between 0.3 and 3; got ${a.sx} x ${a.sy}.`);
const W = S.ref.w, H = S.ref.h;
if (Math.abs(a.tx) > W || Math.abs(a.ty) > H) throw new Error(`Shift must stay within the image: |tx| up to ${W} px and |ty| up to ${H} px; got ${a.tx}, ${a.ty}.`);
await restoreAlignment({ ...a, method: S.method === 'auto' || S.method === 'auto+manual' ? 'auto+manual' : 'manual' });
}
async function fromFile(f) {
if (f.size > 400 * 1024 * 1024) throw new Error('That file is over 400 MB.');
await setPrediction({ bytes: new Uint8Array(await f.arrayBuffer()), name: f.name, source: 'file', model: { name: S.model.name || f.name.replace(/\.[^.]+$/, '') } });
if (S.pred && !S.al) await runAlign();
}
async function guard(fn) {
try { return await fn(); } catch (e) { console.error(e); status(String(e.message || e), true); busy(false); }
}
function showTab(t) {
document.querySelectorAll('#bench [data-tab]').forEach((b) => b.classList.toggle('on', b.dataset.tab === t));
document.querySelectorAll('#bench [data-pane]').forEach((p) => (p.hidden = p.dataset.pane !== t));
if (t === 'compare') render();
}
// stage drawing ---------------------------------------------------------------
function stageCanvases() {
const box = $('bCanvases'); const n = S.view === 'side' ? 2 : 1;
while (box.children.length > n) box.lastChild.remove();
while (box.children.length < n) { const c = document.createElement('canvas'); c.className = 'b-cv'; box.appendChild(c); }
return [...box.children];
}
function render() {
if (!$('bench') || !S.ref) return;
const W = S.ref.w, H = S.ref.h, stage = $('bStage');
$('bEmpty').hidden = !!S.aligned;
document.querySelectorAll('#bench [data-view]').forEach((b) => b.classList.toggle('on', b.dataset.view === S.view));
document.querySelectorAll('#bench [data-zoom]').forEach((b) => b.classList.toggle('on', +b.dataset.zoom === S.zoom));
$('bBox').classList.toggle('on', S.tool === 'box'); $('bBox').setAttribute('aria-pressed', S.tool === 'box');
$('bBase').textContent = 'Base: ' + (S.base === 'ct' ? 'X-ray' : 'ink');
stage.classList.toggle('drawing', S.tool === 'box');
const cvs = stageCanvases();
const narrow = stage.clientWidth < 640;
const side = S.view === 'side' && !narrow;
$('bCanvases').classList.toggle('stack', S.view === 'side' && narrow);
const availW = (stage.clientWidth - 16) / (side ? 2 : 1) - (side ? 6 : 0);
const availH = narrow ? Infinity : stage.clientHeight - 16;
const fit = Math.min(availW / W, availH / H);
const k = fit * S.zoom, dpr = Math.min(window.devicePixelRatio || 1, 2);
const base = S.base === 'ct' ? (ctImg && grayCanvas(ctImg, 'ct:' + S.seg)) : refCanvas();
cvs.forEach((c, i) => {
c.style.width = W * k + 'px'; c.style.height = H * k + 'px';
const s = Math.min(dpr * k, 4096 / W); c.width = Math.round(W * s); c.height = Math.round(H * s);
const x = c.getContext('2d'); x.imageSmoothingEnabled = k < 1.5; x.clearRect(0, 0, c.width, c.height);
if (!S.aligned) { if (base) x.drawImage(base, 0, 0, c.width, c.height); }
else if (S.view === 'side') x.drawImage(i === 0 ? base : predCanvas(), 0, 0, c.width, c.height);
else if (S.view === 'diff') x.drawImage(diffCanvas(), 0, 0, c.width, c.height);
else {
const sw = Math.round(c.width * S.swipe);
x.drawImage(base, 0, 0, c.width, c.height);
x.save(); x.beginPath(); x.rect(sw, 0, c.width - sw, c.height); x.clip(); x.drawImage(predCanvas(), 0, 0, c.width, c.height); x.restore();
x.fillStyle = '#fff'; x.fillRect(sw - 1, 0, 2, c.height);
x.fillStyle = 'rgba(0,0,0,.6)'; x.fillRect(sw - 16, c.height / 2 - 16, 32, 32); x.fillStyle = '#fff'; x.font = `${14 * s / k}px sans-serif`; x.textAlign = 'center'; x.fillText('⇆', sw, c.height / 2 + 5);
}
drawBoxes(x, s, 0, 0, Math.max(10, 11 * dpr));
scaleBar(x, s, 0, 0, c.height, Math.max(10, 11 * dpr));
});
const legend = { side: 'Left: ' + (S.base === 'ct' ? 'X-ray layer' : 'published ink (amber)') + '. Right: your prediction after alignment (cyan); hatched where it does not cover.', swipe: 'Drag across the image: published ink on the left, your prediction on the right.', diff: 'White: both see ink. Amber: published ink only. Cyan: your prediction only. Dark: neither.' }[S.view];
$('bLegend').textContent = S.aligned ? legend : '';
renderSide();
}
function renderSide() {
const info = $('bAlignInfo'), mEl = $('bMetrics');
syncInputs();
if (S.al) {
const low = S.alignNcc !== null && S.alignNcc < 0.2;
info.innerHTML = `${esc(S.method)} · NCC ${fmt(S.alignNcc)}${low ? ' <span class="warn">low: check the winding or try the nudge</span>' : ''}`;
} else info.textContent = S.pendingHash ? 'waiting for the shared file' : '';
if (S.met) {
const m = S.met;
mEl.innerHTML = `<div><span>Pearson r</span><b>${fmt(m.pearson)}</b></div><div><span>r at matched blur</span><b>${fmt(m.pearsonMatchedBlur)}</b></div>
<div><span>relative blur</span><b>${m.blurSigmaPx} px · ${m.blurSigmaUM} µm</b></div><div><span>IoU</span><b>${fmt(m.iou)}</b></div><div><span>Dice</span><b>${fmt(m.dice)}</b></div>
<div><span>precision</span><b>${fmt(m.precision)}</b></div><div><span>recall</span><b>${fmt(m.recall)}</b></div><div><span>AP</span><b>${fmt(m.ap)}</b></div>
<div><span>best F1</span><b>${fmt(m.bestF1.f1)} @ ${m.bestF1.threshold}</b></div><div><span>coverage</span><b>${(m.validFraction * 100).toFixed(1)}%</b></div>`;
} else mEl.innerHTML = '<span class="dim">No scores yet.</span>';
drawPR();
const ul = $('bAnn');
ul.innerHTML = S.ann.length ? '' : '<li class="dim mono small">No boxes yet.</li>';
for (const a of S.ann) {
const li = document.createElement('li'); li.className = 'mono small' + (S.sel === a.id ? ' on' : '');
li.innerHTML = `<select aria-label="Candidate letter">${C.GREEK.map((g) => `<option${g === a.letter ? ' selected' : ''}>${g}</option>`).join('')}</select>
<label>conf <input type="range" min="0" max="1" step="0.05" value="${a.confidence}" aria-label="Confidence"><b>${Math.round(a.confidence * 100)}%</b></label>
<input class="b-note" placeholder="note" value="${esc(a.note || '')}" maxlength="500" aria-label="Note"><button class="chip" aria-label="Delete box">✕</button>
<div class="dim">${esc(a.id)} at ${a.x}, ${a.y} (${a.w} x ${a.h} px): candidate for expert review</div>`;
const [sel, rng, note, del] = [li.querySelector('select'), li.querySelector('input[type=range]'), li.querySelector('.b-note'), li.querySelector('button')];
sel.onchange = () => { a.letter = sel.value; render(); };
rng.oninput = () => { a.confidence = +rng.value; li.querySelector('b').textContent = Math.round(a.confidence * 100) + '%'; renderStageOnly(); };
note.onchange = () => { a.note = note.value.slice(0, 500); };
del.onclick = () => { S.ann = S.ann.filter((b) => b !== a); render(); };
li.onclick = (e) => { if (e.target === li) { S.sel = a.id; render(); } };
ul.appendChild(li);
}
}
let rsPending = false;
function renderStageOnly() { if (rsPending) return; rsPending = true; requestAnimationFrame(() => { rsPending = false; render(); }); }
function drawPR() {
const c = $('bPR'), x = c.getContext('2d'), W = c.width, H = c.height, L = 46, B = 34, T = 14, R = 14;
x.clearRect(0, 0, W, H); x.fillStyle = '#0d0c0b'; x.fillRect(0, 0, W, H);
x.strokeStyle = 'rgba(236,230,219,.25)'; x.lineWidth = 1; x.font = '18px ui-monospace, Menlo, monospace'; x.fillStyle = '#8d857a';
x.strokeRect(L, T, W - L - R, H - T - B);
x.fillText('recall', W / 2 - 30, H - 8); x.save(); x.translate(16, H / 2 + 40); x.rotate(-Math.PI / 2); x.fillText('precision', 0, 0); x.restore();
if (!S.met) return;
const px = (r) => L + r * (W - L - R), py = (p) => T + (1 - p) * (H - T - B);
x.strokeStyle = '#5ef0ff'; x.lineWidth = 3; x.beginPath();
S.met.pr.forEach((q, i) => (i ? x.lineTo(px(q.recall), py(q.precision)) : x.moveTo(px(q.recall), py(q.precision)))); x.stroke();
const m = S.met; x.fillStyle = '#ffb347'; x.beginPath(); x.arc(px(m.recall), py(m.precision), 7, 0, 7); x.fill();
x.fillStyle = '#ece6db'; x.fillText(`AP ${fmt(m.ap)} · ● at pred ${m.thresholds.pred}`, L + 10, T + 26);
}
function stagePointer() {
const st = $('bStage'); let drag = null;
const toRef = (e, c) => { const r = c.getBoundingClientRect(); return [(e.clientX - r.left) / r.width * S.ref.w, (e.clientY - r.top) / r.height * S.ref.h, (e.clientX - r.left) / r.width]; };
st.addEventListener('pointerdown', (e) => {
const c = e.target.closest('canvas'); if (!c || !S.ref) return;
const [x, y, fx] = toRef(e, c);
if (S.tool === 'box') { drag = { c, x0: x, y0: y, a: null }; try { c.setPointerCapture(e.pointerId); } catch {} e.preventDefault(); }
else if (S.view === 'swipe' && S.aligned) { drag = { c, swipe: true }; S.swipe = Math.min(1, Math.max(0, fx)); try { c.setPointerCapture(e.pointerId); } catch {} renderStageOnly(); e.preventDefault(); }
else {
const hit = S.ann.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h);
if (hit) { S.sel = hit.id; render(); }
}
});
st.addEventListener('pointermove', (e) => {
if (!drag) return;
const [x, y, fx] = toRef(e, drag.c);
if (drag.swipe) { S.swipe = Math.min(1, Math.max(0, fx)); renderStageOnly(); return; }
const X = Math.max(0, Math.min(drag.x0, x)), Y = Math.max(0, Math.min(drag.y0, y)), w = Math.abs(x - drag.x0), h = Math.abs(y - drag.y0);
if (!drag.a && w + h > 4) { drag.a = C.cleanAnnotation({ id: nextId(), x: X, y: Y, w, h, letter: '?', confidence: 0.5 }); S.ann.push(drag.a); S.sel = drag.a.id; }
if (drag.a) Object.assign(drag.a, C.cleanAnnotation({ ...drag.a, x: X, y: Y, w: Math.min(w, S.ref.w - X), h: Math.min(h, S.ref.h - Y) }));
renderStageOnly();
});
const end = () => { if (drag && drag.a) render(); drag = null; };
st.addEventListener('pointerup', end); st.addEventListener('pointercancel', end);
}
function nextId() { let i = S.ann.length + 1; while (S.ann.some((a) => a.id === 'a' + i)) i++; return 'a' + i; }
function renderBoard() {
const out = $('bBoardOut'); if (!out) return;
const metric = $('bBoardMetric').value;
const good = S.board.filter((o) => o.bundle && !o.errors.length).map((o) => o.bundle);
const bad = S.board.filter((o) => o.errors.length);
const groups = C.rank(good, metric);
let h = '';
for (const seg of Object.keys(groups).sort()) {
h += `<h3>${esc(seg)}</h3><table class="b-table"><thead><tr><th>#</th><th>model</th><th>${metric === 'f1' ? 'best F1' : esc(metric)}</th><th>r</th><th>IoU</th><th>Dice</th><th>AP</th><th>align NCC</th><th>input sha256</th></tr></thead><tbody>`;
for (const r of groups[seg]) h += `<tr><td>${r.rank}</td><td>${esc(r.model)}</td><td><b>${fmt(r.value)}</b></td><td>${fmt(r.pearson)}</td><td>${fmt(r.iou)}</td><td>${fmt(r.dice)}</td><td>${fmt(r.ap)}</td><td>${fmt(r.bundle.alignment.ncc)}</td><td>${esc(String(r.bundle.prediction.sha256).slice(0, 10))}</td></tr>`;
h += '</tbody></table>';
}
if (bad.length) h += `<h3>Not loaded</h3><ul>${bad.map((o) => `<li>${esc(o.src)}: ${esc(o.errors.join('; '))}</li>`).join('')}</ul>`;
out.innerHTML = h || '<p class="dim">No bundles loaded. Try Load examples: the public ink_9um run on every winding and two baselines (X-ray intensity, noise).</p>';
if (good.length) out.insertAdjacentHTML('afterbegin', `<p class="dim">${good.length} bundle${good.length === 1 ? '' : 's'}, ranked by agreement with the published ink (another model's output, not ground truth). Thresholds differ between bundles if their authors chose so; compare r and AP for threshold-free numbers. Takeaway from the examples: rank by AP, where ink_9um (trained by the Vesuvius Challenge, run by Chase Hendrick) beats the X-ray floor on every winding; on Pearson r the X-ray floor wins on w039, w053, w054 and w057.</p>`);
}
// ------------------------------------------------------------------ open, close, API
let opener = null;
async function open(tab = 'compare', seg = null) {
await loadIndex(); build();
if (seg && !S.seg) S.seg = seg;
opener = document.activeElement;
$('bench').hidden = false; document.body.classList.add('bench-open');
if (S.seg && !S.ref) await selectSegment(S.seg);
else if (!S.seg) await selectSegment(Object.keys(S.index.segments).includes('w046') ? 'w046' : Object.keys(S.index.segments)[0]);
showTab(tab);
$('bClose').focus();
}
function close() { const b = $('bench'); if (b) b.hidden = true; document.body.classList.remove('bench-open'); if (opener?.focus) opener.focus(); }
function toBytes(x) {
if (x instanceof Uint8Array) return x;
if (x instanceof ArrayBuffer) return new Uint8Array(x);
if (typeof x === 'string') { let s; try { s = atob(x.replace(/^data:[^,]*,/, '').replace(/\s/g, '')); } catch { throw new Error('bytes is a string but not valid base64.'); } const u = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); return u; }
throw new Error('bytes must be a Uint8Array, an ArrayBuffer or a base64 string.');
}
const api = {
version: C.VERSION, schema: C.SCHEMA, core: C, disclaimer: C.DISCLAIMER,
async segments() { await loadIndex(); return Object.entries(S.index.segments).map(([id, s]) => ({ id, width: s.width, height: s.height, pxPerMM: s.pxPerMM, layers: Object.keys(s.layers) })); },
open, close,
async load({ segment, layer: lay, url, bytes, name, model, invert } = {}) {
await open('compare');
if (segment) await selectSegment(segment);
if (invert !== undefined) S.invert = !!invert;
if (model) S.model = typeof model === 'string' ? { name: model } : model;
if (lay) { await usePublic(lay, false); }
else if (url) await setPrediction({ bytes: await fetchBytes(url), name: name || url.split('/').pop(), source: 'url:' + url, model: S.model });
else if (bytes) await setPrediction({ bytes: toBytes(bytes), name: name || 'prediction', source: 'file', model: S.model });
if (model) S.model = typeof model === 'string' ? { name: model } : model;
return api.state();
},
async align({ target } = {}) { return runAlign(target || S.target); },
async setAlignment(a) { if (!a || typeof a !== 'object') throw new Error('setAlignment takes { tx, ty, sx, sy }.'); await manual({ tx: 0, ty: 0, sx: 1, sy: 1, ...(S.al || {}), ...a }); return S.al; },
async nudge({ dx = 0, dy = 0, ds = 0 } = {}) { if (!S.al) throw new Error('Nothing to nudge yet: load a prediction and align it first.'); for (const [k, v] of Object.entries({ dx, dy, ds })) if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`nudge ${k} must be a finite number; got ${JSON.stringify(v)}.`); const a = { ...S.al }; a.tx += dx; a.ty += dy; a.sx += ds; a.sy += ds; await manual(a); return S.al; },
async setThresholds({ ref, pred } = {}) { const r = ref !== undefined ? thr(ref, 'ref') : S.tRef, p = pred !== undefined ? thr(pred, 'pred') : S.tPred; S.tRef = r; S.tPred = p; return recompute(false); },
metrics() { return S.met; },
annotate(box) { if (!box || typeof box !== 'object') throw new Error('annotate takes { x, y, w, h, letter, confidence, note, id }.'); if (box.id !== undefined && !/^[A-Za-z0-9_-]{1,40}$/.test(String(box.id))) throw new Error('Annotation id may use only letters, digits, _ and -, up to 40 characters.'); const a = C.cleanAnnotation({ ...box, id: box.id ?? nextId() }); S.ann.push(a); render(); return a; },
annotations() { return S.ann.map((a) => ({ ...a })); },
removeAnnotation(id) { S.ann = S.ann.filter((a) => a.id !== id); render(); },
clearAnnotations() { S.ann = []; render(); },
bundle,
async exportBundle({ download: dl = true } = {}) { if (dl) return exportBundle(); const r = await exportFiles(); return { name: r.name, size: r.zip.size, bundle: r.bundle, zip: r.zip }; },
figure() { return figure().toDataURL('image/png'); },
shareURL, restore: restoreShare, bibtex, citation: citationCFF,
async compare({ segment, layer: lay, url, bytes, name, model, invert, target, alignment, thresholds, annotations } = {}) {
await api.load({ segment, layer: lay, url, bytes, name, model, invert });
if (thresholds) { const r = thr(thresholds.ref ?? S.tRef, 'ref'), p = thr(thresholds.pred ?? S.tPred, 'pred'); S.tRef = r; S.tPred = p; }
if (annotations) S.ann = annotations.map((a, i) => C.cleanAnnotation(a, i));
if (target) S.target = target;
if (alignment) await restoreAlignment({ ...alignment, method: 'reused' }); else await runAlign(S.target);
return bundle();
},
async rank(items, metric = 'pearson') {
const bs = [];
for (const it of items) bs.push(typeof it === 'string' ? await (await fetch(new URL(it, location.href))).json() : it);
const r = C.rank(bs.filter((b) => !C.validate(b).length), metric);
for (const s in r) r[s] = r[s].map(({ bundle: _b, ...rest }) => rest);
return r;
},
validate: C.validate,
async board(items) { await open('board'); return loadBoard(items); },
state() { return { segment: S.seg, prediction: S.predName, sha256: S.predHash, source: S.source, alignment: S.al, method: S.method, alignNcc: S.alignNcc, thresholds: { ref: S.tRef, pred: S.tPred }, annotations: S.ann.length, scored: !!S.met, busy: S.busy }; },
};
window.unrollBench = api;
// register with the page API (window.unroll.bench), which wraps every call as { ok, ... }.
// The API may not exist yet: window.unrollExtensions queues the registration until it does.
const benchMethods = Object.fromEntries(Object.entries(api).filter(([, f]) => typeof f === 'function').map(([k, f]) => [k, (...a) => f.apply(api, a)]));
const benchSpecs = { open: { summary: 'Open the Ink Bench panel on a tab (compare, board or cite).' }, close: { summary: 'Close the Ink Bench panel.' } };
if (window.unroll && typeof window.unroll.register === 'function') { if (!window.unroll.bench) window.unroll.register('bench', benchMethods, benchSpecs); }
else (window.unrollExtensions = window.unrollExtensions || []).push(['bench', benchMethods, benchSpecs]);
// entry points
const btn = $('btnBench'); if (btn) btn.onclick = () => guard(() => ($('bench') && !$('bench').hidden ? close() : open()));
document.addEventListener('click', (e) => {
const a = e.target.closest && e.target.closest('[data-open-bench]'); if (!a) return;
e.preventDefault(); const ab = $('about'); if (ab) ab.hidden = true; guard(() => open(a.dataset.openBench || 'compare'));
});
// "Why this matters for researchers" in the About panel (the page may rebuild that panel from the scroll manifest)
const WHY = `<div class="bench-why">
<h3>Why this matters for researchers</h3>
<p>The <a href="?bench" data-open-bench="compare">Ink Bench</a> is a fast, model-agnostic and reproducible way to compare ink detectors on real segments. Drop your model's prediction for a winding (PNG or TIFF) and it is aligned to the published ink and X-ray layers in UV space, then scored: Pearson r, IoU, Dice, a precision and recall curve, and the relative blur between the two. Nothing is uploaded.</p>
<p>Scores are agreement with another model's output, the published ink, not ground truth. Use them to see where detectors agree and differ, then box letter-shaped strokes as candidates for expert review. One click exports a bundle (metrics, alignment, input hashes, annotations, a figure with a scale bar and credits, a citation and a share link) that anyone can check, and <code>tools/inkbench.py</code> gives the same numbers in Python for training loops and CI. <a href="?bench" data-open-bench="cite">Cite or use this</a>.</p>
</div>`;
function injectWhy() {
const body = document.querySelector('#about .about-body'); if (!body || body.querySelector('.bench-why')) return;
const made = body.querySelector('.made-by');
made ? made.insertAdjacentHTML('beforebegin', WHY) : body.insertAdjacentHTML('beforeend', WHY);
}
injectWhy();
const aboutEl = $('about'); if (aboutEl) new MutationObserver(injectWhy).observe(aboutEl, { childList: true, subtree: true });
const q = new URLSearchParams(location.search);
if (q.get('bench')) guard(async () => { try { return await restoreShare(q.get('bench')); } catch (e) { if (!$('bench') || $('bench').hidden) { try { await open('compare'); } catch {} } throw e; } });
else if (q.has('bench')) guard(() => open());
if (q.get('board')) guard(async () => { await open('board'); await loadBoard(q.get('board').split(',')); });
export default api;