// Ground-truth annotation canvas, one instance per
. // // Everything after segmentation happens in the browser: hit-testing, drawing, // undo, the min-size filter and the count. The server is only called to run // Cellpose, so no click ever round-trips an image or shows a progress bar. // // The server hands over, per square, the warped square (JPEG) and a label PNG // whose R,G channels hold the 16-bit object id and whose B channel holds each // pixel's distance, in px, to the object's edge. The distance lets the outline // thickness follow the zoom level without recomputing anything. // // Single mode (data-multi="0"): the instance also does the 4-corner step // before segmentation. Multi mode (data-multi="1"): up to 4 squares, switched // with buttons or keys 1-4, plus a results table and a copyable TSV row. (function () { "use strict"; const LEFT_PCT = 1; // exclusion zone on the LEFT edge, % of width const BOTTOM_PCT = 1; // exclusion zone on the BOTTOM edge, % of height const RING_SCREEN_PX = 3; // outline thickness on screen, whatever the zoom const DOT_R = 6; // added-dot radius, screen px const DOT_HIT_PX = 9; // a click this close to a dot (screen px) hits it const CORNER_HIT_PX = 12; // ditto for corner handles const DRAG_PX = 4; // movement beyond this turns a click into a pan const MAX_ZOOM = 40; const COUNTED = 0, REMOVED = 1, SMALL = 2, EXCLUDED = 3, ABSENT = 4; const INST = {}; // key -> instance const PENDING = {}; // key -> payload that arrived before mounting // ================================================================ instance function create(root) { const S = { key: root.dataset.key, multi: root.dataset.multi === "1", mode: "empty", // "empty" | "corners" | "annotate" seq: null, view: { s: 1, tx: 0, ty: 0 }, cimg: null, cW: 0, cH: 0, cscale: 1, corners: [], sets: [], cur: -1, d: null, minSize: 0, hover: null, hoverCanvas: null, hoverId: 0, hideOverlay: false, pointerIn: false, down: null, dragCorner: -1, panning: false, dirty: true, ringDirty: false, warn: "", t0: null, segTime: null, serverTime: null, stamp: "", }; const q = (c) => root.querySelector(".gt-" + c); const el = {}; for (const n of ["total", "breakdown", "min", "minnum", "undo", "redo", "fit", "resetc", "editc", "hint", "empty", "stage", "sq", "results", "canvas"]) el[n] = q(n); const cv = el.canvas, ctx = cv.getContext("2d"); S.root = root; cv.addEventListener("pointerdown", onDown); cv.addEventListener("pointermove", onMove); cv.addEventListener("pointerup", onUp); cv.addEventListener("pointercancel", onUp); cv.addEventListener("pointerenter", () => { S.pointerIn = true; }); cv.addEventListener("pointerleave", () => { S.pointerIn = false; S.hover = null; dirty(); }); cv.addEventListener("wheel", onWheel, { passive: false }); cv.addEventListener("contextmenu", (e) => e.preventDefault()); el.min.addEventListener("input", () => setMin(+el.min.value)); el.minnum.addEventListener("input", () => setMin(+el.minnum.value || 0)); el.undo.addEventListener("click", undo); el.redo.addEventListener("click", redo); el.fit.addEventListener("click", () => { fit(); dirty(); }); el.resetc.addEventListener("click", resetCorners); el.editc.addEventListener("click", backToCorners); if (el.results) { el.results.addEventListener("click", (e) => { const b = e.target.closest("button[data-copy]"); if (b) copyRow(b.dataset.copy === "header", b); }); } new ResizeObserver(() => { sizeCanvas(); dirty(); }).observe(el.stage); sizeCanvas(); // ---------------------------------------------------------- setup function sizeCanvas() { const dpr = window.devicePixelRatio || 1; const w = el.stage.clientWidth, h = el.stage.clientHeight; if (!w || !h) return; const first = !cv.width || cv.width === 300; cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); if (first || !S.fitted) fit(); } // ---------------------------------------------------------- loading async function load(p) { if (p.seq && p.seq === S.seq) return; S.seq = p.seq; if (p.mode === "corners") { S.cimg = await loadImage(p.img); S.cW = p.w; S.cH = p.h; S.cscale = p.scale; S.corners = []; S.sets = []; S.cur = -1; S.d = null; S.mode = "corners"; S.fitted = false; fit(); } else if (p.mode === "annotate") { S.sets = [await makeSet(p)]; S.serverTime = null; switchTo(0); S.mode = "annotate"; } else if (p.mode === "annotate4") { const sets = await Promise.all( p.squares.map((sq) => (sq ? makeSet(sq) : Promise.resolve(null)))); S.sets = sets; S.mode = "annotate"; S.serverTime = p.server_secs; const t = new Date(), z = (v) => String(v).padStart(2, "0"); S.stamp = t.getFullYear() + "-" + z(t.getMonth() + 1) + "-" + z(t.getDate()) + " " + z(t.getHours()) + ":" + z(t.getMinutes()) + ":" + z(t.getSeconds()); S.segTime = S.t0 != null ? (performance.now() - S.t0) / 1000 : null; S.t0 = null; const first = sets.findIndex((d) => d); if (first >= 0) switchTo(first); else { S.mode = "empty"; S.d = null; } S.note = p.note || ""; } updateUI(); dirty(); } async function makeSet(p) { const [img, px] = await Promise.all([loadImage(p.img), decodeLabels(p.lab, p.w, p.h)]); const W = p.w, H = p.h, N = W * H; const lab = new Uint16Array(N), dist = new Uint8Array(N); let maxId = 0; for (let i = 0, o = 0; i < N; i++, o += 4) { const id = (px[o] << 8) | px[o + 1]; lab[i] = id; dist[i] = px[o + 2]; if (id > maxId) maxId = id; } const n = maxId + 1; const area = new Int32Array(n); const minx = new Int32Array(n).fill(W), miny = new Int32Array(n).fill(H); const maxx = new Int32Array(n).fill(-1), maxy = new Int32Array(n).fill(-1); const sumx = new Float64Array(n), sumy = new Float64Array(n); for (let y = 0, i = 0; y < H; y++) { for (let x = 0; x < W; x++, i++) { const id = lab[i]; if (!id) continue; area[id]++; sumx[id] += x; sumy[id] += y; if (x < minx[id]) minx[id] = x; if (x > maxx[id]) maxx[id] = x; if (y < miny[id]) miny[id] = y; if (y > maxy[id]) maxy[id] = y; } } const overlay = document.createElement("canvas"); overlay.width = W; overlay.height = H; const octx = overlay.getContext("2d"); return { name: p.name || "", img, W, H, lab, dist, nobj: p.nobj, area, minx, miny, maxx, maxy, cx: sumx.map((v, i) => area[i] ? v / area[i] + 0.5 : 0), cy: sumy.map((v, i) => area[i] ? v / area[i] + 0.5 : 0), status: new Uint8Array(n), removed: new Uint8Array(n), colors: makeColors(n), dots: [], undo: [], redo: [], overlay, octx, oimg: octx.createImageData(W, H), tImg: -1, ovMin: -1, view: null, warn: maxId !== p.nobj ? "Label decode mismatch (" + maxId + " vs " + p.nobj + " objects) - counts may be off. Try another browser." : "", }; } function switchTo(k) { const D = S.sets[k]; if (!D) return; if (S.d) S.d.view = S.view; S.cur = k; S.d = D; S.hover = null; S.hoverId = 0; if (D.view) S.view = D.view; else { S.view = { s: 1, tx: 0, ty: 0 }; fitTo(D.W, D.H); } computeAllStatus(D); if (D.ovMin !== S.minSize || D.tImg !== ringT()) rebuildOverlay(); updateUI(); dirty(); } // ---------------------------------------------------------- status function statusOf(D, id) { if (!D.area[id]) return ABSENT; if (D.area[id] < S.minSize) return SMALL; const leftPx = Math.floor(D.W * LEFT_PCT / 100); const botPx = Math.floor(D.H * BOTTOM_PCT / 100); if (D.minx[id] < leftPx || D.maxy[id] >= D.H - botPx) return EXCLUDED; return D.removed[id] ? REMOVED : COUNTED; } function computeAllStatus(D) { for (let id = 1; id < D.status.length; id++) D.status[id] = statusOf(D, id); } function counts(D) { const c = [0, 0, 0, 0]; for (let id = 1; id < D.status.length; id++) { const st = D.status[id]; if (st < 4) c[st]++; } // model = what Cellpose counted (after min size + exclusion), before edits return { counted: c[0], removed: c[1], small: c[2], excl: c[3], added: D.dots.length, model: c[0] + c[1], total: c[0] + D.dots.length }; } // ---------------------------------------------------------- overlay function ringT() { return Math.max(1, Math.min(12, Math.round(RING_SCREEN_PX / S.view.s))); } function paint(D, i, d) { const id = D.lab[i], o = i * 4; if (!id) { d[o + 3] = 0; return; } const st = D.status[id], dd = D.dist[i], t = D.tImg; const edge = dd <= 1 && t >= 2; // 1 px black separator if (st === COUNTED) { if (edge) { d[o] = d[o + 1] = d[o + 2] = 0; d[o + 3] = 255; return; } d[o] = D.colors[id * 3]; d[o + 1] = D.colors[id * 3 + 1]; d[o + 2] = D.colors[id * 3 + 2]; d[o + 3] = dd <= t ? 255 : 40; } else if (st === REMOVED) { if (edge) { d[o] = d[o + 1] = d[o + 2] = 0; d[o + 3] = 255; return; } d[o] = 235; d[o + 1] = 30; d[o + 2] = 30; d[o + 3] = dd <= t ? 255 : 105; } else if (st === SMALL) { const on = dd <= Math.max(1, t - 1); d[o] = d[o + 1] = d[o + 2] = 150; d[o + 3] = on ? 230 : 0; } else if (st === EXCLUDED) { const on = dd <= Math.max(1, t - 1); d[o] = d[o + 1] = d[o + 2] = 255; d[o + 3] = on ? 150 : 0; } else { d[o + 3] = 0; } } function rebuildOverlay() { const D = S.d; if (!D) return; D.tImg = ringT(); D.ovMin = S.minSize; const d = D.oimg.data, N = D.W * D.H; for (let i = 0; i < N; i++) paint(D, i, d); D.octx.putImageData(D.oimg, 0, 0); S.hoverId = 0; dirty(); } function repaintObject(id) { const D = S.d, d = D.oimg.data, W = D.W; const x0 = D.minx[id], x1 = D.maxx[id], y0 = D.miny[id], y1 = D.maxy[id]; if (x1 < 0) return; for (let y = y0; y <= y1; y++) { for (let x = x0, i = y * W + x0; x <= x1; x++, i++) { if (D.lab[i] === id) paint(D, i, d); } } D.octx.putImageData(D.oimg, 0, 0, x0, y0, x1 - x0 + 1, y1 - y0 + 1); } function hoverOutline(id) { if (S.hoverId === id && S.hoverCanvas) return S.hoverCanvas; const D = S.d, x0 = D.minx[id], y0 = D.miny[id]; const w = D.maxx[id] - x0 + 1, h = D.maxy[id] - y0 + 1; const c = document.createElement("canvas"); c.width = w; c.height = h; const x = c.getContext("2d"), im = x.createImageData(w, h), d = im.data; const t = Math.max(1, D.tImg) + 1; for (let yy = 0; yy < h; yy++) { for (let xx = 0; xx < w; xx++) { const i = (y0 + yy) * D.W + x0 + xx, o = (yy * w + xx) * 4; if (D.lab[i] !== id) continue; d[o] = d[o + 1] = d[o + 2] = 255; d[o + 3] = D.dist[i] <= t ? 255 : 70; } } x.putImageData(im, 0, 0); S.hoverCanvas = c; S.hoverId = id; return c; } // ---------------------------------------------------------- view function curSize() { if (S.mode === "corners") return [S.cW, S.cH]; if (S.mode === "annotate" && S.d) return [S.d.W, S.d.H]; return [0, 0]; } function fitTo(w, h) { const cw = el.stage.clientWidth, ch = el.stage.clientHeight; if (!w || !cw || !ch) return; const s = Math.min(cw / w, ch / h) * 0.96; S.view = { s, tx: (cw - w * s) / 2, ty: (ch - h * s) / 2 }; S.fitted = true; } function fit() { const [w, h] = curSize(); fitTo(w, h); if (S.mode === "annotate" && S.d && ringT() !== S.d.tImg) rebuildOverlay(); } const toImg = (sx, sy) => [(sx - S.view.tx) / S.view.s, (sy - S.view.ty) / S.view.s]; const toScreen = (ix, iy) => [ix * S.view.s + S.view.tx, iy * S.view.s + S.view.ty]; function evPos(e) { const r = cv.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; } const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); function onWheel(e) { const [w, h] = curSize(); if (!w) return; e.preventDefault(); const [sx, sy] = evPos(e); const fitS = Math.min(el.stage.clientWidth / w, el.stage.clientHeight / h); const s = clamp(S.view.s * Math.pow(1.0015, -e.deltaY), fitS * 0.5, MAX_ZOOM); const [ix, iy] = toImg(sx, sy); S.view = { s, tx: sx - ix * s, ty: sy - iy * s }; if (S.mode === "annotate" && S.d && ringT() !== S.d.tImg) S.ringDirty = true; refreshHover(sx, sy, e.shiftKey); dirty(); } // ---------------------------------------------------------- pointer function onDown(e) { if (S.mode === "empty") return; cv.setPointerCapture(e.pointerId); root.focus({ preventScroll: true }); const [sx, sy] = evPos(e); S.down = { sx, sy, tx: S.view.tx, ty: S.view.ty, button: e.button }; S.panning = e.button !== 0; S.dragCorner = S.mode === "corners" && e.button === 0 ? cornerAt(sx, sy) : -1; } function onMove(e) { const [sx, sy] = evPos(e); if (!S.down) { refreshHover(sx, sy, e.shiftKey); return; } const dx = sx - S.down.sx, dy = sy - S.down.sy; if (S.dragCorner >= 0) { const [ix, iy] = toImg(sx, sy); S.corners[S.dragCorner] = [clamp(ix, 0, S.cW - 1), clamp(iy, 0, S.cH - 1)]; updateUI(); dirty(); return; } if (!S.panning && Math.hypot(dx, dy) > DRAG_PX) S.panning = true; if (S.panning) { S.view.tx = S.down.tx + dx; S.view.ty = S.down.ty + dy; S.hover = null; dirty(); } } function onUp(e) { if (!S.down) return; const [sx, sy] = evPos(e); const wasPan = S.panning, wasCorner = S.dragCorner >= 0, b = S.down.button; S.down = null; S.panning = false; S.dragCorner = -1; if (e.type === "pointercancel" || wasPan || wasCorner || b !== 0) { refreshHover(sx, sy, e.shiftKey); return; } if (S.mode === "corners") cornerClick(sx, sy); else if (S.mode === "annotate" && S.d) { const a = hitTest(sx, sy, e.shiftKey); if (a) { exec(a); S.d.undo.push(a); S.d.redo = []; afterEdit(); } refreshHover(sx, sy, e.shiftKey); } } // ---------------------------------------------------------- corners function cornerAt(sx, sy) { let best = -1, bd = CORNER_HIT_PX; S.corners.forEach(([x, y], k) => { const [px, py] = toScreen(x, y); const d = Math.hypot(px - sx, py - sy); if (d <= bd) { bd = d; best = k; } }); return best; } function cornerClick(sx, sy) { if (S.corners.length >= 4) { hint("All 4 corners placed: drag a handle to adjust, or press Reset " + "corners. Then press Segment."); return; } const [ix, iy] = toImg(sx, sy); if (ix < 0 || iy < 0 || ix >= S.cW || iy >= S.cH) return; S.corners.push([ix, iy]); updateUI(); dirty(); } function resetCorners() { if (S.mode !== "corners") return; S.corners = []; updateUI(); dirty(); } function backToCorners() { if (S.mode !== "annotate" || !S.cimg) return; if (S.d) S.d.view = S.view; S.mode = "corners"; S.fitted = false; fit(); updateUI(); dirty(); } function orderedCorners() { const c = S.corners; if (c.length !== 4) return c; const mx = c.reduce((a, p) => a + p[0], 0) / 4; const my = c.reduce((a, p) => a + p[1], 0) / 4; const o = c.slice().sort((a, b) => Math.atan2(a[1] - my, a[0] - mx) - Math.atan2(b[1] - my, b[0] - mx)); let k = 0; o.forEach((p, i) => { if (p[0] + p[1] < o[k][0] + o[k][1]) k = i; }); return o.slice(k).concat(o.slice(0, k)); } function editCount() { return S.sets.reduce((a, D) => a + (D ? D.undo.length : 0), 0); } function confirmDiscard() { const n = editCount(); return !n || window.confirm("Re-segmenting discards your " + n + " edit(s). Continue?"); } function getCorners() { if (S.mode === "annotate") { if (!confirmDiscard()) return "CANCEL"; if (S.cimg) { S.mode = "corners"; S.fitted = false; fit(); } } if (S.mode !== "corners" || S.corners.length !== 4) return ""; hint("Segmenting..."); return JSON.stringify(S.corners.map(([x, y]) => [x / S.cscale, y / S.cscale])); } function markStart() { // Multi mode: Segment pressed. Starts the end-to-end timer. if (S.mode === "annotate" && !confirmDiscard()) return false; S.t0 = performance.now(); hint("Segmenting 4 squares..."); if (el.total) el.total.textContent = "…"; return true; } // ---------------------------------------------------------- edits function hitTest(sx, sy, shift) { const D = S.d, [ix, iy] = toImg(sx, sy); if (ix < 0 || iy < 0 || ix >= D.W || iy >= D.H) return null; if (!shift) { // Dots first, tolerance in SCREEN px: zooming in makes it tighter. let best = null, bd = DOT_HIT_PX; for (const d of D.dots) { const [px, py] = toScreen(d.x, d.y); const dd = Math.hypot(px - sx, py - sy); if (dd <= bd) { bd = dd; best = d; } } if (best) return { type: "deldot", dot: best }; // Masks are hit EXACTLY: a click on background between touching // cells always adds a dot rather than removing a neighbour. const id = D.lab[Math.floor(iy) * D.W + Math.floor(ix)]; if (id) { const st = D.status[id]; if (st === COUNTED) return { type: "remove", id }; if (st === REMOVED) return { type: "restore", id }; } } return { type: "add", dot: { x: ix, y: iy } }; } function exec(a) { const D = S.d; if (a.type === "add") D.dots.push(a.dot); else if (a.type === "deldot") { const k = D.dots.indexOf(a.dot); if (k >= 0) D.dots.splice(k, 1); } else { D.removed[a.id] = a.type === "remove" ? 1 : 0; D.status[a.id] = statusOf(D, a.id); repaintObject(a.id); if (S.hoverId === a.id) S.hoverId = 0; } } function inverse(a) { const t = { add: "deldot", deldot: "add", remove: "restore", restore: "remove" }[a.type]; return Object.assign({}, a, { type: t }); } function undo() { if (!S.d) return; const a = S.d.undo.pop(); if (!a) return; exec(inverse(a)); S.d.redo.push(a); afterEdit(); } function redo() { if (!S.d) return; const a = S.d.redo.pop(); if (!a) return; exec(a); S.d.undo.push(a); afterEdit(); } function afterEdit() { updateUI(); dirty(); } function setMin(v) { v = Math.max(0, Math.round(v || 0)); S.minSize = v; if (+el.min.value !== v) el.min.value = Math.min(v, +el.min.max); if (+el.minnum.value !== v) el.minnum.value = v; for (const D of S.sets) if (D) computeAllStatus(D); if (S.mode === "annotate" && S.d) rebuildOverlay(); updateUI(); } // ---------------------------------------------------------- hover function refreshHover(sx, sy, shift) { if (S.mode !== "annotate" || !S.d || !S.pointerIn) { S.hover = null; dirty(); return; } const a = hitTest(sx, sy, shift); S.hover = a ? Object.assign({ sx, sy }, a) : null; if (a) { const msg = { add: shift ? "Shift+click: add a dot here (even on a cell)" : "Click: add a dot (missed cell)", deldot: "Click: delete this dot", remove: "Click: remove this cell (" + (a.id ? S.d.area[a.id] : 0) + " px)", restore: "Click: restore this removed cell", }[a.type]; hint(msg + " · Shift+click always adds · hold Space " + "to hide outlines · Ctrl+Z undo" + (S.multi ? " · 1-4 switch square" : "")); } dirty(); } // ---------------------------------------------------------- keys function onKey(e) { if (S.mode === "empty") return false; const mod = e.ctrlKey || e.metaKey; if (e.code === "Space") { e.preventDefault(); if (!S.hideOverlay) { S.hideOverlay = true; dirty(); } } else if (mod && e.key.toLowerCase() === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); } else if (mod && e.key.toLowerCase() === "y") { e.preventDefault(); redo(); } else if (e.key === "f" || e.key === "F") { fit(); dirty(); } else if (S.multi && S.mode === "annotate" && /^[1-4]$/.test(e.key) && !mod) { switchTo(+e.key - 1); } else if (e.key === "Shift" && S.hover) { refreshHover(S.hover.sx, S.hover.sy, true); } return true; } function onKeyUp(e) { if (e.code === "Space" && S.hideOverlay) { S.hideOverlay = false; dirty(); } else if (e.key === "Shift" && S.hover) { refreshHover(S.hover.sx, S.hover.sy, false); } } // ---------------------------------------------------------- UI text function hint(t) { if (el.hint) el.hint.textContent = t; } function tsv(header) { const head = ["timestamp", "files"]; const row = [S.stamp, S.sets.map((D) => (D ? D.name : "")).join(";")]; const corr = []; for (let k = 0; k < 4; k++) { const D = S.sets[k]; const c = D ? counts(D) : null; head.push("model_sq" + (k + 1), "corrected_sq" + (k + 1)); row.push(c ? c.model : "", c ? c.total : ""); if (c) corr.push(c.total); } head.push("mean_corrected", "seg_time_s", "server_time_s", "min_size_px"); row.push(corr.length ? (corr.reduce((a, b) => a + b, 0) / corr.length).toFixed(2) : "", S.segTime != null ? S.segTime.toFixed(2) : "", S.serverTime != null ? S.serverTime.toFixed(2) : "", S.minSize); return (header ? head.join("\t") + "\n" : "") + row.join("\t"); } function copyRow(header, btn) { const text = tsv(header); const done = () => { const old = btn.textContent; btn.textContent = "✓ Copied"; setTimeout(() => (btn.textContent = old), 1200); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done)); } else fallbackCopy(text, done); } function fallbackCopy(text, done) { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); done(); } catch (e) { /* ignore */ } ta.remove(); } function renderMulti() { if (!S.multi) return; // square switcher el.sq.innerHTML = ""; S.sets.forEach((D, k) => { const b = document.createElement("button"); b.textContent = "Square " + (k + 1) + (D ? " · " + counts(D).total : " · —"); b.disabled = !D; if (k === S.cur) b.classList.add("gt-cur"); b.addEventListener("click", () => switchTo(k)); el.sq.appendChild(b); }); // results table + TSV row if (!S.sets.length) { el.results.innerHTML = ""; return; } let rows = ""; let sumM = 0, sumC = 0, n = 0; S.sets.forEach((D, k) => { if (!D) { rows += "" + (k + 1) + "not segmented"; return; } const c = counts(D); sumM += c.model; sumC += c.total; n++; rows += "" + (k + 1) + "" + esc(D.name) + "" + c.model + "−" + c.removed + "+" + c.added + "" + c.total + "" + // count error vs ground truth, as the main app reports it: // positive = the model over-counted (c.total ? ((c.model - c.total) / c.total * 100 > 0 ? "+" : "") + ((c.model - c.total) / c.total * 100).toFixed(1) + "%" : "") + ""; }); const mean = n ? "Mean" + (sumM / n).toFixed(1) + "" + (sumC / n).toFixed(1) + "" : ""; const time = "Segmentation: " + (S.segTime != null ? S.segTime.toFixed(2) + " s" : "—") + " end-to-end (click → all squares ready)" + (S.serverTime != null ? " · server " + S.serverTime.toFixed(2) + " s" : ""); el.results.innerHTML = "
" + time + "
" + (S.note ? "
" + esc(S.note) + "
" : "") + "" + "" + rows + mean + "
SqFileModelRemovedAddedCorrectedCount error
" + "
" + "" + "
"; } function esc(s) { return String(s).replace(/[&<>'"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[c])); } function updateUI() { el.empty.style.display = S.mode === "empty" ? "" : "none"; el.resetc.style.display = S.mode === "corners" ? "" : "none"; el.editc.style.display = S.mode === "annotate" && S.cimg ? "" : "none"; el.undo.disabled = !(S.d && S.d.undo.length); el.redo.disabled = !(S.d && S.d.redo.length); if (S.mode === "annotate" && S.d) { const D = S.d, c = counts(D); el.total.textContent = c.total; el.breakdown.innerHTML = (S.multi ? "Square " + (S.cur + 1) + ": " : "") + D.nobj + " detected" + (c.small ? " − " + c.small + " below " + S.minSize + " px" : "") + (c.excl ? " − " + c.excl + " on exclusion lines" : "") + " − " + c.removed + " removed" + " + " + c.added + " added" + (D.warn ? "
" + D.warn + "" : ""); if (!S.hover) { hint("Click a cell to remove it · click empty space to add a dot " + "· Shift+click always adds · wheel zooms, drag pans " + "· hold Space to hide outlines · Ctrl+Z undo · F fit" + (S.multi ? " · 1-4 switch square" : "")); } } else if (S.mode === "corners") { el.total.textContent = "—"; el.breakdown.textContent = S.corners.length + " / 4 corners"; hint(S.corners.length < 4 ? "Click corner " + (S.corners.length + 1) + " of the 4×4 block " + "(any order) · wheel zooms, drag pans" : "Drag any handle to fine-tune, then press Segment."); } else if (S.t0 == null) { el.total.textContent = "—"; el.breakdown.textContent = ""; hint(S.multi ? "Upload 4 images and set their corners above, then " + "press Segment all 4." : ""); } renderMulti(); } // ---------------------------------------------------------- draw function dirty() { S.dirty = true; } function frame() { if (S.ringDirty && !S.down) { S.ringDirty = false; rebuildOverlay(); } if (!S.dirty) return; S.dirty = false; draw(); } function draw() { const dpr = window.devicePixelRatio || 1; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = "#1b1d22"; ctx.fillRect(0, 0, cv.width, cv.height); if (S.mode === "empty") return; const { s, tx, ty } = S.view; ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * tx, dpr * ty); ctx.imageSmoothingEnabled = s < 2; if (S.mode === "corners") { ctx.drawImage(S.cimg, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); drawCorners(); return; } const D = S.d; if (!D) return; ctx.drawImage(D.img, 0, 0); if (!S.hideOverlay) { ctx.imageSmoothingEnabled = false; ctx.drawImage(D.overlay, 0, 0); ctx.fillStyle = "rgba(255,40,40,0.22)"; const lp = Math.floor(D.W * LEFT_PCT / 100); const bp = Math.floor(D.H * BOTTOM_PCT / 100); ctx.fillRect(0, 0, lp, D.H); ctx.fillRect(0, D.H - bp, D.W, bp); const h = S.hover; if (h && (h.type === "remove" || h.type === "restore")) { ctx.drawImage(hoverOutline(h.id), D.minx[h.id], D.miny[h.id]); } } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (S.hideOverlay) return; // removed cells: a red X sized to the cell, so "removed" never looks // like a counted cell that happens to be drawn in a warm colour ctx.lineCap = "round"; for (let id = 1; id < D.status.length; id++) { if (D.status[id] !== REMOVED) continue; const [px, py] = toScreen(D.cx[id], D.cy[id]); const r = Math.max(5, Math.min(14, 0.35 * S.view.s * Math.max(D.maxx[id] - D.minx[id], D.maxy[id] - D.miny[id]))); ctx.beginPath(); ctx.moveTo(px - r, py - r); ctx.lineTo(px + r, py + r); ctx.moveTo(px + r, py - r); ctx.lineTo(px - r, py + r); ctx.strokeStyle = "#000"; ctx.lineWidth = 5; ctx.stroke(); ctx.strokeStyle = "#ff2b2b"; ctx.lineWidth = 3; ctx.stroke(); } ctx.lineCap = "butt"; ctx.lineWidth = 2; for (const d of D.dots) { const [px, py] = toScreen(d.x, d.y); const hot = S.hover && S.hover.type === "deldot" && S.hover.dot === d; ctx.beginPath(); ctx.arc(px, py, DOT_R, 0, 2 * Math.PI); ctx.fillStyle = hot ? "#ff3b3b" : "#ffe600"; ctx.fill(); ctx.strokeStyle = "#000"; ctx.stroke(); } const h = S.hover; if (h && h.type === "add" && !S.down) { const [px, py] = toScreen(h.dot.x, h.dot.y); ctx.globalAlpha = 0.55; ctx.beginPath(); ctx.arc(px, py, DOT_R, 0, 2 * Math.PI); ctx.fillStyle = "#ffe600"; ctx.fill(); ctx.strokeStyle = "#000"; ctx.stroke(); ctx.globalAlpha = 1; } } function drawCorners() { const pts = S.corners.length === 4 ? orderedCorners() : S.corners; const sp = pts.map(([x, y]) => toScreen(x, y)); ctx.lineWidth = 2; ctx.strokeStyle = "#4aaaff"; if (sp.length >= 2) { ctx.beginPath(); ctx.moveTo(sp[0][0], sp[0][1]); for (const p of sp.slice(1)) ctx.lineTo(p[0], p[1]); if (sp.length === 4) { ctx.closePath(); ctx.fillStyle = "rgba(74,170,255,0.12)"; ctx.fill(); } ctx.stroke(); } S.corners.forEach(([x, y]) => { const [px, py] = toScreen(x, y); ctx.beginPath(); ctx.arc(px, py, 9, 0, 2 * Math.PI); ctx.fillStyle = "#4aaaff"; ctx.fill(); ctx.strokeStyle = "#fff"; ctx.stroke(); ctx.beginPath(); ctx.moveTo(px - 4, py); ctx.lineTo(px + 4, py); ctx.moveTo(px, py - 4); ctx.lineTo(px, py + 4); ctx.stroke(); }); } updateUI(); return { S, load, getCorners, markStart, onKey, onKeyUp, frame, root, active: () => S.pointerIn || root.contains(document.activeElement) }; } // ================================================================ shared function loadImage(src) { return new Promise((res, rej) => { const im = new Image(); im.onload = () => res(im); im.onerror = rej; im.src = src; }); } async function decodeLabels(src, W, H) { // No colour conversion: the bytes ARE the ids, so any colour management // would corrupt them. let bmp; try { const blob = await (await fetch(src)).blob(); bmp = await createImageBitmap(blob, { colorSpaceConversion: "none", premultiplyAlpha: "none" }); } catch (e) { bmp = await loadImage(src); } const c = document.createElement("canvas"); c.width = W; c.height = H; const x = c.getContext("2d", { willReadFrequently: true }); x.drawImage(bmp, 0, 0); return x.getImageData(0, 0, W, H).data; } function makeColors(n) { // Golden-angle hues: ids numbered close together, which Cellpose tends to // give to neighbours, land far apart on the colour wheel. const c = new Uint8Array(n * 3); for (let id = 1; id < n; id++) { // hues 25..335 deg only: red is reserved for removed cells const h = (25 + (id * 137.508) % 310) / 60, x = 1 - Math.abs(h % 2 - 1); const k = Math.floor(h); const rgb = [[1, x, 0], [x, 1, 0], [0, 1, x], [0, x, 1], [x, 0, 1], [1, 0, x]][k % 6]; c[id * 3] = rgb[0] * 255; c[id * 3 + 1] = rgb[1] * 255; c[id * 3 + 2] = rgb[2] * 255; } return c; } function scan() { document.querySelectorAll(".gt-root").forEach((root) => { const k = root.dataset.key; if (INST[k] && INST[k].root === root) return; INST[k] = create(root); if (PENDING[k]) { const p = PENDING[k]; delete PENDING[k]; INST[k].load(p); } }); } function frame() { requestAnimationFrame(frame); for (const k in INST) INST[k].frame(); } function onKey(e, up) { for (const k in INST) { const I = INST[k]; if (!I.active()) continue; const tag = (e.target && e.target.tagName) || ""; // With the mouse over the canvas, shortcuts win even if focus was left // in an input; otherwise inputs keep their own keys. if (!I.S.pointerIn && (tag === "INPUT" || tag === "TEXTAREA")) return; if (I.S.pointerIn && tag === "INPUT" && !up) e.target.blur(); up ? I.onKeyUp(e) : I.onKey(e); return; } } window.addEventListener("keydown", (e) => onKey(e, false), true); window.addEventListener("keyup", (e) => onKey(e, true), true); window.GT = { load(payload) { if (!payload) return; let p; try { p = typeof payload === "string" ? JSON.parse(payload) : payload; } catch (e) { return; } if (!p || !p.key) return; scan(); if (!INST[p.key]) { PENDING[p.key] = p; return; } return INST[p.key].load(p); }, getCorners(key) { scan(); return INST[key] ? INST[key].getCorners() : ""; }, markStart(key) { scan(); return INST[key] ? INST[key].markStart() : true; }, _inst: INST, }; setInterval(scan, 300); requestAnimationFrame(frame); })();