LiangLabUMB's picture
Upload 6 files
e5921f5 verified
Raw History Blame Contribute Delete
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'>&minus;" + 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) => ({ "&": "&amp;", "<": "&lt;",
">": "&gt;", "'": "&#39;", '"': "&quot;" }[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 ? " &minus; " + c.small + " below " + S.minSize + " px" : "") +
(c.excl ? " &minus; " + c.excl + " on exclusion lines" : "") +
" &minus; <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);
})();