Download lib/bench.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 59.1 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/bench.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/lib/bench.js
-
curl -L -o bench.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/bench.js
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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; | |