File size: 59,106 Bytes
f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 cc6b6d8 05acee5 f918b86 cc6b6d8 f918b86 757768f f918b86 757768f f918b86 a9ce211 f918b86 4de4420 f918b86 757768f f918b86 a9ce211 f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 a9ce211 f918b86 05acee5 f918b86 05acee5 f918b86 05acee5 f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 cc6b6d8 f918b86 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 | // 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;
|