Download gt_canvas.js from LiangLabUMB/CellposeCellCounter_GroundTruth: direct link, hf CLI and curl.
- Browser
- Download file 35.7 kB
-
https://huggingface.co/spaces/LiangLabUMB/CellposeCellCounter_GroundTruth/resolve/main/gt_canvas.js
- Command line
-
hf download hf://spaces/LiangLabUMB/CellposeCellCounter_GroundTruth/gt_canvas.js
-
curl -L -o gt_canvas.js https://huggingface.co/spaces/LiangLabUMB/CellposeCellCounter_GroundTruth/resolve/main/gt_canvas.js
35.7 kB
| // Ground-truth annotation canvas, one instance per <div class="gt-root" | |
| // data-key="..." data-multi="0|1">. | |
| // | |
| // 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 += "<tr><td>" + (k + 1) + "</td><td colspan=6>not segmented</td></tr>"; | |
| return; | |
| } | |
| const c = counts(D); | |
| sumM += c.model; sumC += c.total; n++; | |
| rows += "<tr" + (k === S.cur ? " class='gt-cur'" : "") + "><td>" + (k + 1) + | |
| "</td><td class='gt-name'>" + esc(D.name) + "</td><td>" + c.model + | |
| "</td><td class='gt-r'>−" + c.removed + "</td><td class='gt-a'>+" + | |
| c.added + "</td><td><b>" + c.total + "</b></td><td>" + | |
| // 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) + "%" : "") + | |
| "</td></tr>"; | |
| }); | |
| const mean = n ? "<tr class='gt-mean'><td colspan=2>Mean</td><td>" + | |
| (sumM / n).toFixed(1) + "</td><td></td><td></td><td><b>" + | |
| (sumC / n).toFixed(1) + "</b></td><td></td></tr>" : ""; | |
| const time = "Segmentation: <b>" + | |
| (S.segTime != null ? S.segTime.toFixed(2) + " s" : "—") + | |
| "</b> end-to-end (click → all squares ready)" + | |
| (S.serverTime != null ? " · server " + S.serverTime.toFixed(2) + " s" : ""); | |
| el.results.innerHTML = | |
| "<div class='gt-time'>" + time + "</div>" + | |
| (S.note ? "<div class='gt-r'>" + esc(S.note) + "</div>" : "") + | |
| "<table><thead><tr><th>Sq</th><th>File</th><th>Model</th><th>Removed</th>" + | |
| "<th>Added</th><th>Corrected</th><th title=\"(model - corrected) / corrected\">Count error</th></tr></thead><tbody>" + | |
| rows + mean + "</tbody></table>" + | |
| "<div class='gt-copy'><input readonly value='" + esc(tsv(false)) + "'>" + | |
| "<button data-copy='row'>Copy row</button>" + | |
| "<button data-copy='header'>Copy with header</button></div>"; | |
| } | |
| 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 ? "<b>Square " + (S.cur + 1) + "</b>: " : "") + | |
| D.nobj + " detected" + | |
| (c.small ? " − " + c.small + " below " + S.minSize + " px" : "") + | |
| (c.excl ? " − " + c.excl + " on exclusion lines" : "") + | |
| " − <b class='gt-r'>" + c.removed + " removed</b>" + | |
| " + <b class='gt-a'>" + c.added + " added</b>" + | |
| (D.warn ? "<br><span class='gt-r'>" + D.warn + "</span>" : ""); | |
| 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); | |
| })(); | |