LiangLabUMB commited on
Commit
5683c28
·
verified ·
1 Parent(s): 9ba9b41

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +22 -59
  2. app.py +0 -0
  3. gt_canvas.js +752 -0
  4. requirements.txt +4 -17
README.md CHANGED
@@ -1,72 +1,35 @@
1
  ---
2
- title: CellposeCellCounter_v5
3
- emoji: 🔬
4
  colorFrom: blue
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.35.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- # CellposeCellCounter
13
 
14
- Cell counting and viability from a phone photograph of a hemocytometer, using a
15
- fine-tuned Cellpose-SAM model for segmentation and a single interpretable contrast
16
- threshold for viability.
17
 
18
- ## How to use it
 
 
 
 
 
 
 
 
 
 
19
 
20
- 1. Photograph one 4×4 counting block through the phone adaptor.
21
- 2. Upload it and **tap once in the centre of the block**. The app detects the four
22
- corners and crops to it. This step is not optional — an uncropped frame covers far
23
- more than one block and the concentration would be wrong.
24
- 3. Run segmentation. Live/dead and concentration are reported together.
25
 
26
- ## Segmentation
 
27
 
28
- Cellpose-SAM fine-tuned on 13 hand-corrected images (1,069 annotated cells) from this
29
- imaging setup. Held-out performance on 6 further images (556 annotated cells, IoU 0.5):
30
-
31
- | model | count error | precision | recall | F1 |
32
- |---|---|---|---|---|
33
- | **this model** | **−1.1%** | **0.940** | 0.926 | **0.933** |
34
- | previous fine-tune | +40.6% | 0.656 | 0.932 | 0.768 |
35
- | Cellpose-SAM, off the shelf | −63.7% | 0.741 | 0.238 | 0.319 |
36
-
37
- The dominant error of the previous model was over-segmentation of debris: correcting
38
- its output required 336 deletions against 79 additions.
39
-
40
- ## Viability
41
-
42
- Each cell's mean grey level is divided by the median grey of a background annulus
43
- around it (20 px wide, 2 px gap, neighbouring cells excluded). Cells scoring below
44
- **1.0** — no brighter than the medium surrounding them — are called dead.
45
-
46
- The threshold is not fitted: 1.0 means the cell has lost all refractile contrast
47
- against its own local background. It was chosen from that reasoning and then tested
48
- against 1,625 hand-labelled cells. On 556 held-out cells: **accuracy 0.993**, AUC
49
- 0.984, and per-field viability within **0.3 percentage points** of expert annotation.
50
-
51
- Accuracy is insensitive to the annulus geometry: across widths of 8–40 px and gaps of
52
- 0–8 px, held-out accuracy varied by 0.7 points (four cells of 556).
53
-
54
- No training data, calibration, scaler or colour information is used for viability.
55
-
56
- ## Note on the minimum size filter
57
-
58
- It is **off by default** and should stay off. When enabled without an explicit value
59
- it uses the 25th percentile of each image, so it always deletes a quarter of the
60
- objects however clean the field is — measured on this dataset it removed 18–25% of
61
- real cells, cut the count by the same fraction, and inflated viability by ~2 points
62
- because dead cells are the smaller ones.
63
-
64
- ## Limitations
65
-
66
- - Viability assumes brightfield imaging with an exclusion stain (live cells refractile,
67
- dead cells stained). It does not apply to fluorescence viability assays.
68
- - The threshold is calibration-free *given a segmentation convention*. Changing what
69
- the mask covers — a different model, a different working resolution — shifts the
70
- measured ratio even though the cell has not changed.
71
- - Any dark object accepted as a cell by segmentation will be called dead, so accuracy
72
- is bounded by segmentation quality.
 
1
  ---
2
+ title: Hemocytometer Ground Truth
3
+ emoji: 🎯
4
  colorFrom: blue
5
+ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 5.35.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Hemocytometer ground truth
13
 
14
+ Annotation tool for building ground-truth counts of the 4×4 hemocytometer block.
 
 
15
 
16
+ 1. **Upload** one photo.
17
+ 2. **Click the 4 corners** of the 4×4 block (any order). Drag a handle to adjust it.
18
+ 3. **Segment.** The block is warped to an upright square (≤1024 px) and segmented with the
19
+ fine-tuned hemocytometer Cellpose-SAM model.
20
+ 4. **Correct the count:**
21
+ - click a cell → remove it (false positive); click again → restore
22
+ - click empty space → add a dot (false negative)
23
+ - click a dot → delete it
24
+ - **Shift+click** → always add a dot, even on top of a cell (e.g. two cells merged into one mask)
25
+ - **Ctrl+Z / Ctrl+Shift+Z** undo / redo · **Space** (hold) hide outlines · **F** fit
26
+ - mouse wheel zooms, drag pans
27
 
28
+ The total is the count of detected objects, minus objects below the min size, minus
29
+ objects touching the left or bottom 1% exclusion zones, minus removed, plus added dots.
 
 
 
30
 
31
+ Everything after segmentation runs in the browser, so clicks are instant. The page
32
+ keeps nothing: write the count down before uploading the next image.
33
 
34
+ Set hardware to **ZeroGPU** in the Space settings. To use local weights instead of the
35
+ Hub file, set `CPCC_LOCAL_MODEL=/path/to/weights.npy`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
gt_canvas.js ADDED
@@ -0,0 +1,752 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ground-truth annotation canvas.
2
+ //
3
+ // Everything after segmentation happens in the browser: hit-testing, drawing,
4
+ // undo, the min-size filter and the count. The server is only called to run
5
+ // Cellpose, so no click ever round-trips an image or shows a progress bar.
6
+ //
7
+ // The server hands over two images: the warped square (JPEG) and a label PNG
8
+ // whose R,G channels hold the 16-bit object id and whose B channel holds each
9
+ // pixel's distance, in px, to the object's edge. The distance lets the outline
10
+ // thickness follow the zoom level without recomputing anything.
11
+ (function () {
12
+ "use strict";
13
+
14
+ const LEFT_PCT = 1; // exclusion zone on the LEFT edge, % of width
15
+ const BOTTOM_PCT = 1; // exclusion zone on the BOTTOM edge, % of height
16
+ const RING_SCREEN_PX = 3; // outline thickness on screen, whatever the zoom
17
+ const DOT_R = 6; // added-dot radius, screen px
18
+ const DOT_HIT_PX = 9; // a click this close to a dot (screen px) hits it
19
+ const CORNER_HIT_PX = 12; // ditto for corner handles
20
+ const DRAG_PX = 4; // movement beyond this turns a click into a pan
21
+ const MAX_ZOOM = 40;
22
+
23
+ const S = {
24
+ mode: "empty", // "empty" | "corners" | "annotate"
25
+ seq: null,
26
+ view: { s: 1, tx: 0, ty: 0 },
27
+ // corner step (preview pixel space)
28
+ cimg: null, cW: 0, cH: 0, cscale: 1, corners: [],
29
+ // annotate step (warped-image pixel space)
30
+ img: null, W: 0, H: 0, lab: null, dist: null, nobj: 0,
31
+ area: null, minx: null, miny: null, maxx: null, maxy: null,
32
+ status: null, removed: null, colors: null,
33
+ dots: [], undo: [], redo: [], minSize: 0,
34
+ overlay: null, octx: null, oimg: null, tImg: 0,
35
+ hover: null, hoverCanvas: null, hoverId: 0,
36
+ hideOverlay: false, pointerIn: false, shift: false,
37
+ down: null, dragCorner: -1, panning: false,
38
+ dirty: true, ringDirty: false,
39
+ };
40
+ // status codes per object id
41
+ const COUNTED = 0, REMOVED = 1, SMALL = 2, EXCLUDED = 3, ABSENT = 4;
42
+
43
+ let root, cv, ctx, el = {};
44
+
45
+ // ---------------------------------------------------------------- setup
46
+ function init() {
47
+ const r = document.getElementById("gt-root");
48
+ if (!r) return false;
49
+ if (r === root) return true;
50
+ root = r;
51
+ cv = document.getElementById("gt-canvas");
52
+ ctx = cv.getContext("2d");
53
+ for (const id of ["total", "breakdown", "min", "minnum", "undo", "redo",
54
+ "fit", "resetc", "editc", "hint", "empty", "stage"]) {
55
+ el[id] = document.getElementById("gt-" + id);
56
+ }
57
+ cv.addEventListener("pointerdown", onDown);
58
+ cv.addEventListener("pointermove", onMove);
59
+ cv.addEventListener("pointerup", onUp);
60
+ cv.addEventListener("pointercancel", onUp);
61
+ cv.addEventListener("pointerenter", () => { S.pointerIn = true; });
62
+ cv.addEventListener("pointerleave", () => {
63
+ S.pointerIn = false; setHover(null); dirty();
64
+ });
65
+ cv.addEventListener("wheel", onWheel, { passive: false });
66
+ cv.addEventListener("contextmenu", (e) => e.preventDefault());
67
+ window.addEventListener("keydown", onKey, true);
68
+ window.addEventListener("keyup", onKeyUp, true);
69
+ el.min.addEventListener("input", () => setMin(+el.min.value));
70
+ el.minnum.addEventListener("input", () => setMin(+el.minnum.value || 0));
71
+ el.undo.addEventListener("click", undo);
72
+ el.redo.addEventListener("click", redo);
73
+ el.fit.addEventListener("click", () => { fit(); dirty(); });
74
+ el.resetc.addEventListener("click", resetCorners);
75
+ el.editc.addEventListener("click", backToCorners);
76
+ new ResizeObserver(() => { sizeCanvas(); dirty(); }).observe(el.stage);
77
+ sizeCanvas();
78
+ requestAnimationFrame(frame);
79
+ updateUI();
80
+ return true;
81
+ }
82
+
83
+ function sizeCanvas() {
84
+ const dpr = window.devicePixelRatio || 1;
85
+ const w = el.stage.clientWidth, h = el.stage.clientHeight;
86
+ if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
87
+ const keep = cv.width > 0 && S.mode !== "empty";
88
+ cv.width = Math.round(w * dpr);
89
+ cv.height = Math.round(h * dpr);
90
+ if (!keep) fit();
91
+ }
92
+ }
93
+
94
+ function loadImage(src) {
95
+ return new Promise((res, rej) => {
96
+ const im = new Image();
97
+ im.onload = () => res(im);
98
+ im.onerror = rej;
99
+ im.src = src;
100
+ });
101
+ }
102
+
103
+ async function decodeLabels(src, W, H) {
104
+ // createImageBitmap with no colour conversion: the bytes ARE the ids, so
105
+ // any colour management would corrupt them.
106
+ let bmp;
107
+ try {
108
+ const blob = await (await fetch(src)).blob();
109
+ bmp = await createImageBitmap(blob, {
110
+ colorSpaceConversion: "none", premultiplyAlpha: "none" });
111
+ } catch (e) {
112
+ bmp = await loadImage(src);
113
+ }
114
+ const c = document.createElement("canvas");
115
+ c.width = W; c.height = H;
116
+ const x = c.getContext("2d", { willReadFrequently: true });
117
+ x.drawImage(bmp, 0, 0);
118
+ return x.getImageData(0, 0, W, H).data;
119
+ }
120
+
121
+ // ---------------------------------------------------------------- loading
122
+ async function load(payload) {
123
+ if (!init() || !payload) return;
124
+ let p;
125
+ try { p = JSON.parse(payload); } catch (e) { return; }
126
+ if (!p || p.seq === S.seq) return; // same payload re-delivered
127
+ S.seq = p.seq;
128
+ if (p.mode === "corners") {
129
+ S.cimg = await loadImage(p.img);
130
+ S.cW = p.w; S.cH = p.h; S.cscale = p.scale;
131
+ S.corners = [];
132
+ S.mode = "corners";
133
+ clearAnnotation();
134
+ fit();
135
+ } else if (p.mode === "annotate") {
136
+ await loadAnnotate(p);
137
+ }
138
+ updateUI();
139
+ dirty();
140
+ }
141
+
142
+ function clearAnnotation() {
143
+ S.img = null; S.lab = null; S.dots = []; S.undo = []; S.redo = [];
144
+ S.hover = null; S.hoverId = 0;
145
+ }
146
+
147
+ async function loadAnnotate(p) {
148
+ const [img, px] = await Promise.all([loadImage(p.img),
149
+ decodeLabels(p.lab, p.w, p.h)]);
150
+ const W = p.w, H = p.h, N = W * H;
151
+ const lab = new Uint16Array(N), dist = new Uint8Array(N);
152
+ let maxId = 0;
153
+ for (let i = 0, o = 0; i < N; i++, o += 4) {
154
+ const id = (px[o] << 8) | px[o + 1];
155
+ lab[i] = id; dist[i] = px[o + 2];
156
+ if (id > maxId) maxId = id;
157
+ }
158
+ const n = maxId + 1;
159
+ const area = new Int32Array(n);
160
+ const minx = new Int32Array(n).fill(W), miny = new Int32Array(n).fill(H);
161
+ const maxx = new Int32Array(n).fill(-1), maxy = new Int32Array(n).fill(-1);
162
+ const sumx = new Float64Array(n), sumy = new Float64Array(n);
163
+ for (let y = 0, i = 0; y < H; y++) {
164
+ for (let x = 0; x < W; x++, i++) {
165
+ const id = lab[i];
166
+ if (!id) continue;
167
+ area[id]++; sumx[id] += x; sumy[id] += y;
168
+ if (x < minx[id]) minx[id] = x;
169
+ if (x > maxx[id]) maxx[id] = x;
170
+ if (y < miny[id]) miny[id] = y;
171
+ if (y > maxy[id]) maxy[id] = y;
172
+ }
173
+ }
174
+ Object.assign(S, {
175
+ img, W, H, lab, dist, nobj: p.nobj, area, minx, miny, maxx, maxy,
176
+ cx: sumx.map((v, i) => area[i] ? v / area[i] + 0.5 : 0),
177
+ cy: sumy.map((v, i) => area[i] ? v / area[i] + 0.5 : 0),
178
+ status: new Uint8Array(n), removed: new Uint8Array(n),
179
+ colors: makeColors(n), dots: [], undo: [], redo: [],
180
+ hover: null, hoverId: 0, mode: "annotate",
181
+ });
182
+ if (maxId !== p.nobj) {
183
+ S.warn = "Label decode mismatch (" + maxId + " vs " + p.nobj +
184
+ " objects) - counts may be off. Try another browser.";
185
+ } else {
186
+ S.warn = "";
187
+ }
188
+ S.overlay = document.createElement("canvas");
189
+ S.overlay.width = W; S.overlay.height = H;
190
+ S.octx = S.overlay.getContext("2d");
191
+ S.oimg = S.octx.createImageData(W, H);
192
+ fit();
193
+ computeAllStatus();
194
+ rebuildOverlay();
195
+ }
196
+
197
+ function makeColors(n) {
198
+ // Golden-angle hues: ids numbered close together, which Cellpose tends to
199
+ // give to neighbours, land far apart on the colour wheel
200
+ const c = new Uint8Array(n * 3);
201
+ for (let id = 1; id < n; id++) {
202
+ // hues 25..335 deg only: red is reserved for removed cells
203
+ const h = (25 + (id * 137.508) % 310) / 60, x = 1 - Math.abs(h % 2 - 1);
204
+ const k = Math.floor(h);
205
+ const rgb = [[1, x, 0], [x, 1, 0], [0, 1, x], [0, x, 1], [x, 0, 1],
206
+ [1, 0, x]][k % 6];
207
+ c[id * 3] = rgb[0] * 255; c[id * 3 + 1] = rgb[1] * 255;
208
+ c[id * 3 + 2] = rgb[2] * 255;
209
+ }
210
+ return c;
211
+ }
212
+
213
+ // ---------------------------------------------------------------- status
214
+ function statusOf(id) {
215
+ if (!S.area[id]) return ABSENT;
216
+ if (S.area[id] < S.minSize) return SMALL;
217
+ const leftPx = Math.floor(S.W * LEFT_PCT / 100);
218
+ const botPx = Math.floor(S.H * BOTTOM_PCT / 100);
219
+ if (S.minx[id] < leftPx || S.maxy[id] >= S.H - botPx) return EXCLUDED;
220
+ return S.removed[id] ? REMOVED : COUNTED;
221
+ }
222
+
223
+ function computeAllStatus() {
224
+ for (let id = 1; id < S.status.length; id++) S.status[id] = statusOf(id);
225
+ }
226
+
227
+ // ---------------------------------------------------------------- overlay
228
+ function ringT() {
229
+ return Math.max(1, Math.min(12, Math.round(RING_SCREEN_PX / S.view.s)));
230
+ }
231
+
232
+ function paint(i, d) {
233
+ // d: RGBA buffer. Writes overlay pixel i from the object's status.
234
+ const id = S.lab[i], o = i * 4;
235
+ if (!id) { d[o + 3] = 0; return; }
236
+ const st = S.status[id], dd = S.dist[i], t = S.tImg;
237
+ const edge = dd <= 1 && t >= 2; // 1 px black separator
238
+ if (st === COUNTED) {
239
+ if (edge) { d[o] = d[o + 1] = d[o + 2] = 0; d[o + 3] = 255; return; }
240
+ d[o] = S.colors[id * 3]; d[o + 1] = S.colors[id * 3 + 1];
241
+ d[o + 2] = S.colors[id * 3 + 2];
242
+ d[o + 3] = dd <= t ? 255 : 40;
243
+ } else if (st === REMOVED) {
244
+ if (edge) { d[o] = d[o + 1] = d[o + 2] = 0; d[o + 3] = 255; return; }
245
+ d[o] = 235; d[o + 1] = 30; d[o + 2] = 30;
246
+ d[o + 3] = dd <= t ? 255 : 105;
247
+ } else if (st === SMALL) {
248
+ const on = dd <= Math.max(1, t - 1);
249
+ d[o] = d[o + 1] = d[o + 2] = 150; d[o + 3] = on ? 230 : 0;
250
+ } else if (st === EXCLUDED) {
251
+ const on = dd <= Math.max(1, t - 1);
252
+ d[o] = d[o + 1] = d[o + 2] = 255; d[o + 3] = on ? 150 : 0;
253
+ } else {
254
+ d[o + 3] = 0;
255
+ }
256
+ }
257
+
258
+ function rebuildOverlay() {
259
+ if (!S.oimg) return;
260
+ S.tImg = ringT();
261
+ const d = S.oimg.data, N = S.W * S.H;
262
+ for (let i = 0; i < N; i++) paint(i, d);
263
+ S.octx.putImageData(S.oimg, 0, 0);
264
+ S.hoverId = 0; // hover outline depends on tImg too
265
+ dirty();
266
+ }
267
+
268
+ function repaintObject(id) {
269
+ const d = S.oimg.data, W = S.W;
270
+ const x0 = S.minx[id], x1 = S.maxx[id], y0 = S.miny[id], y1 = S.maxy[id];
271
+ if (x1 < 0) return;
272
+ for (let y = y0; y <= y1; y++) {
273
+ for (let x = x0, i = y * W + x0; x <= x1; x++, i++) {
274
+ if (S.lab[i] === id) paint(i, d);
275
+ }
276
+ }
277
+ S.octx.putImageData(S.oimg, 0, 0, x0, y0, x1 - x0 + 1, y1 - y0 + 1);
278
+ }
279
+
280
+ function hoverOutline(id) {
281
+ if (S.hoverId === id && S.hoverCanvas) return S.hoverCanvas;
282
+ const x0 = S.minx[id], y0 = S.miny[id];
283
+ const w = S.maxx[id] - x0 + 1, h = S.maxy[id] - y0 + 1;
284
+ const c = document.createElement("canvas");
285
+ c.width = w; c.height = h;
286
+ const x = c.getContext("2d"), im = x.createImageData(w, h), d = im.data;
287
+ const t = Math.max(1, S.tImg) + 1;
288
+ for (let yy = 0; yy < h; yy++) {
289
+ for (let xx = 0; xx < w; xx++) {
290
+ const i = (y0 + yy) * S.W + x0 + xx, o = (yy * w + xx) * 4;
291
+ if (S.lab[i] !== id) continue;
292
+ d[o] = d[o + 1] = d[o + 2] = 255;
293
+ d[o + 3] = S.dist[i] <= t ? 255 : 70;
294
+ }
295
+ }
296
+ x.putImageData(im, 0, 0);
297
+ S.hoverCanvas = c; S.hoverId = id;
298
+ return c;
299
+ }
300
+
301
+ // ---------------------------------------------------------------- view
302
+ function curSize() {
303
+ if (S.mode === "corners") return [S.cW, S.cH];
304
+ if (S.mode === "annotate") return [S.W, S.H];
305
+ return [0, 0];
306
+ }
307
+
308
+ function fit() {
309
+ const [w, h] = curSize();
310
+ const cw = el.stage.clientWidth, ch = el.stage.clientHeight;
311
+ if (!w || !cw) return;
312
+ const s = Math.min(cw / w, ch / h) * 0.96;
313
+ S.view = { s, tx: (cw - w * s) / 2, ty: (ch - h * s) / 2 };
314
+ if (S.mode === "annotate" && ringT() !== S.tImg) rebuildOverlay();
315
+ }
316
+
317
+ function toImg(sx, sy) {
318
+ return [(sx - S.view.tx) / S.view.s, (sy - S.view.ty) / S.view.s];
319
+ }
320
+
321
+ function toScreen(ix, iy) {
322
+ return [ix * S.view.s + S.view.tx, iy * S.view.s + S.view.ty];
323
+ }
324
+
325
+ function evPos(e) {
326
+ const r = cv.getBoundingClientRect();
327
+ return [e.clientX - r.left, e.clientY - r.top];
328
+ }
329
+
330
+ function onWheel(e) {
331
+ if (S.mode === "empty") return;
332
+ e.preventDefault();
333
+ const [sx, sy] = evPos(e);
334
+ const [w, h] = curSize();
335
+ const fitS = Math.min(el.stage.clientWidth / w, el.stage.clientHeight / h);
336
+ const f = Math.pow(1.0015, -e.deltaY);
337
+ const s = Math.max(fitS * 0.5, Math.min(MAX_ZOOM, S.view.s * f));
338
+ const [ix, iy] = toImg(sx, sy);
339
+ S.view = { s, tx: sx - ix * s, ty: sy - iy * s };
340
+ if (S.mode === "annotate" && ringT() !== S.tImg) S.ringDirty = true;
341
+ refreshHover(sx, sy, e.shiftKey);
342
+ dirty();
343
+ }
344
+
345
+ // ---------------------------------------------------------------- pointer
346
+ function onDown(e) {
347
+ if (S.mode === "empty") return;
348
+ cv.setPointerCapture(e.pointerId);
349
+ root.focus({ preventScroll: true });
350
+ const [sx, sy] = evPos(e);
351
+ S.down = { sx, sy, tx: S.view.tx, ty: S.view.ty, button: e.button };
352
+ S.panning = e.button !== 0; // middle / right drag always pans
353
+ S.dragCorner = -1;
354
+ if (S.mode === "corners" && e.button === 0) {
355
+ S.dragCorner = cornerAt(sx, sy);
356
+ }
357
+ }
358
+
359
+ function onMove(e) {
360
+ const [sx, sy] = evPos(e);
361
+ S.shift = e.shiftKey;
362
+ if (!S.down) { refreshHover(sx, sy, e.shiftKey); return; }
363
+ const dx = sx - S.down.sx, dy = sy - S.down.sy;
364
+ if (S.dragCorner >= 0) {
365
+ const [ix, iy] = toImg(sx, sy);
366
+ S.corners[S.dragCorner] = [clamp(ix, 0, S.cW - 1), clamp(iy, 0, S.cH - 1)];
367
+ updateUI(); dirty();
368
+ return;
369
+ }
370
+ if (!S.panning && Math.hypot(dx, dy) > DRAG_PX) S.panning = true;
371
+ if (S.panning) {
372
+ S.view.tx = S.down.tx + dx; S.view.ty = S.down.ty + dy;
373
+ setHover(null);
374
+ dirty();
375
+ }
376
+ }
377
+
378
+ function onUp(e) {
379
+ if (!S.down) return;
380
+ const [sx, sy] = evPos(e);
381
+ const wasPan = S.panning, wasCorner = S.dragCorner >= 0, b = S.down.button;
382
+ S.down = null; S.panning = false; S.dragCorner = -1;
383
+ if (e.type === "pointercancel" || wasPan || wasCorner || b !== 0) {
384
+ refreshHover(sx, sy, e.shiftKey);
385
+ return;
386
+ }
387
+ if (S.mode === "corners") cornerClick(sx, sy);
388
+ else if (S.mode === "annotate") {
389
+ const a = hitTest(sx, sy, e.shiftKey);
390
+ if (a) { exec(a); S.undo.push(a); S.redo = []; afterEdit(); }
391
+ refreshHover(sx, sy, e.shiftKey);
392
+ }
393
+ }
394
+
395
+ function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
396
+
397
+ // ---------------------------------------------------------------- corners
398
+ function cornerAt(sx, sy) {
399
+ let best = -1, bd = CORNER_HIT_PX;
400
+ S.corners.forEach(([x, y], k) => {
401
+ const [px, py] = toScreen(x, y);
402
+ const d = Math.hypot(px - sx, py - sy);
403
+ if (d <= bd) { bd = d; best = k; }
404
+ });
405
+ return best;
406
+ }
407
+
408
+ function cornerClick(sx, sy) {
409
+ if (S.corners.length >= 4) {
410
+ hint("All 4 corners placed: drag a handle to adjust, or press Reset " +
411
+ "corners. Then press Segment.");
412
+ return;
413
+ }
414
+ const [ix, iy] = toImg(sx, sy);
415
+ if (ix < 0 || iy < 0 || ix >= S.cW || iy >= S.cH) return;
416
+ S.corners.push([ix, iy]);
417
+ updateUI(); dirty();
418
+ }
419
+
420
+ function resetCorners() {
421
+ if (S.mode !== "corners") return;
422
+ S.corners = []; updateUI(); dirty();
423
+ }
424
+
425
+ function backToCorners() {
426
+ if (S.mode !== "annotate" || !S.cimg) return;
427
+ S.mode = "corners"; fit(); updateUI(); dirty();
428
+ }
429
+
430
+ function orderedCorners() {
431
+ // clockwise from the top-left, as the server's order_quad does
432
+ const c = S.corners;
433
+ if (c.length !== 4) return c;
434
+ const mx = c.reduce((a, p) => a + p[0], 0) / 4;
435
+ const my = c.reduce((a, p) => a + p[1], 0) / 4;
436
+ const o = c.slice().sort((a, b) => Math.atan2(a[1] - my, a[0] - mx) -
437
+ Math.atan2(b[1] - my, b[0] - mx));
438
+ let k = 0;
439
+ o.forEach((p, i) => { if (p[0] + p[1] < o[k][0] + o[k][1]) k = i; });
440
+ return o.slice(k).concat(o.slice(0, k));
441
+ }
442
+
443
+ function getCorners() {
444
+ // Called by the Segment button. Returns full-resolution coordinates.
445
+ if (!init()) return "";
446
+ if (S.mode === "annotate") {
447
+ const n = S.undo.length;
448
+ if (n && !window.confirm("Re-segmenting discards your " + n +
449
+ " edit(s) on this image. Continue?")) {
450
+ return "CANCEL";
451
+ }
452
+ if (S.cimg) { S.mode = "corners"; fit(); }
453
+ }
454
+ if (S.mode !== "corners" || S.corners.length !== 4) return "";
455
+ hint("Segmenting...");
456
+ return JSON.stringify(S.corners.map(([x, y]) =>
457
+ [x / S.cscale, y / S.cscale]));
458
+ }
459
+
460
+ // ---------------------------------------------------------------- edits
461
+ function hitTest(sx, sy, shift) {
462
+ const [ix, iy] = toImg(sx, sy);
463
+ if (ix < 0 || iy < 0 || ix >= S.W || iy >= S.H) return null;
464
+ if (!shift) {
465
+ // Dots first, with a tolerance in SCREEN pixels: zooming in shrinks it
466
+ // in image pixels, so crowded areas get more precise as you zoom.
467
+ let best = null, bd = DOT_HIT_PX;
468
+ for (const d of S.dots) {
469
+ const [px, py] = toScreen(d.x, d.y);
470
+ const dd = Math.hypot(px - sx, py - sy);
471
+ if (dd <= bd) { bd = dd; best = d; }
472
+ }
473
+ if (best) return { type: "deldot", dot: best };
474
+ // Masks are hit EXACTLY: only the pixel under the cursor counts, so a
475
+ // click on background between two touching cells always adds a dot.
476
+ const id = S.lab[Math.floor(iy) * S.W + Math.floor(ix)];
477
+ if (id) {
478
+ const st = S.status[id];
479
+ if (st === COUNTED) return { type: "remove", id };
480
+ if (st === REMOVED) return { type: "restore", id };
481
+ }
482
+ }
483
+ return { type: "add", dot: { x: ix, y: iy } };
484
+ }
485
+
486
+ function exec(a) {
487
+ if (a.type === "add") S.dots.push(a.dot);
488
+ else if (a.type === "deldot") {
489
+ const k = S.dots.indexOf(a.dot);
490
+ if (k >= 0) S.dots.splice(k, 1);
491
+ } else {
492
+ S.removed[a.id] = a.type === "remove" ? 1 : 0;
493
+ S.status[a.id] = statusOf(a.id);
494
+ repaintObject(a.id);
495
+ if (S.hoverId === a.id) S.hoverId = 0;
496
+ }
497
+ }
498
+
499
+ function inverse(a) {
500
+ const t = { add: "deldot", deldot: "add", remove: "restore",
501
+ restore: "remove" }[a.type];
502
+ return Object.assign({}, a, { type: t });
503
+ }
504
+
505
+ function undo() {
506
+ const a = S.undo.pop();
507
+ if (!a) return;
508
+ exec(inverse(a)); S.redo.push(a); afterEdit();
509
+ }
510
+
511
+ function redo() {
512
+ const a = S.redo.pop();
513
+ if (!a) return;
514
+ exec(a); S.undo.push(a); afterEdit();
515
+ }
516
+
517
+ function afterEdit() { updateUI(); dirty(); }
518
+
519
+ function setMin(v) {
520
+ v = Math.max(0, Math.round(v || 0));
521
+ S.minSize = v;
522
+ if (+el.min.value !== v) el.min.value = Math.min(v, +el.min.max);
523
+ if (+el.minnum.value !== v) el.minnum.value = v;
524
+ if (S.mode === "annotate") { computeAllStatus(); rebuildOverlay(); }
525
+ updateUI();
526
+ }
527
+
528
+ // ---------------------------------------------------------------- hover
529
+ function setHover(h) { S.hover = h; }
530
+
531
+ function refreshHover(sx, sy, shift) {
532
+ if (S.mode !== "annotate" || !S.pointerIn) { setHover(null); dirty(); return; }
533
+ const a = hitTest(sx, sy, shift);
534
+ setHover(a ? Object.assign({ sx, sy }, a) : null);
535
+ if (a) {
536
+ const msg = {
537
+ add: shift ? "Shift+click: add a dot here (even on a cell)"
538
+ : "Click: add a dot (missed cell)",
539
+ deldot: "Click: delete this dot",
540
+ remove: "Click: remove this cell (" + (a.id ? S.area[a.id] : 0) + " px)",
541
+ restore: "Click: restore this removed cell",
542
+ }[a.type];
543
+ hint(msg + " · Shift+click always adds · hold Space " +
544
+ "to hide outlines · Ctrl+Z undo");
545
+ }
546
+ dirty();
547
+ }
548
+
549
+ // ---------------------------------------------------------------- keys
550
+ function active() {
551
+ return S.pointerIn || (root && root.contains(document.activeElement));
552
+ }
553
+
554
+ function onKey(e) {
555
+ if (!root || S.mode === "empty" || !active()) return;
556
+ // With the mouse over the canvas, shortcuts win even if focus was left in
557
+ // the min-size box; otherwise let inputs keep their own keys.
558
+ const tag = (e.target && e.target.tagName) || "";
559
+ if (!S.pointerIn && (tag === "INPUT" || tag === "TEXTAREA")) return;
560
+ if (S.pointerIn && tag === "INPUT") e.target.blur();
561
+ const mod = e.ctrlKey || e.metaKey;
562
+ if (e.code === "Space") {
563
+ e.preventDefault();
564
+ if (!S.hideOverlay) { S.hideOverlay = true; dirty(); }
565
+ } else if (mod && e.key.toLowerCase() === "z") {
566
+ e.preventDefault(); e.shiftKey ? redo() : undo();
567
+ } else if (mod && e.key.toLowerCase() === "y") {
568
+ e.preventDefault(); redo();
569
+ } else if (e.key === "f" || e.key === "F") {
570
+ fit(); dirty();
571
+ } else if (e.key === "Shift" && S.hover) {
572
+ refreshHover(S.hover.sx, S.hover.sy, true);
573
+ }
574
+ }
575
+
576
+ function onKeyUp(e) {
577
+ if (e.code === "Space" && S.hideOverlay) {
578
+ S.hideOverlay = false; dirty();
579
+ } else if (e.key === "Shift" && S.hover) {
580
+ refreshHover(S.hover.sx, S.hover.sy, false);
581
+ }
582
+ }
583
+
584
+ // ---------------------------------------------------------------- UI text
585
+ function hint(t) { if (el.hint) el.hint.textContent = t; }
586
+
587
+ function counts() {
588
+ const c = [0, 0, 0, 0];
589
+ for (let id = 1; id < S.status.length; id++) {
590
+ const st = S.status[id];
591
+ if (st < 4) c[st]++;
592
+ }
593
+ return c;
594
+ }
595
+
596
+ function updateUI() {
597
+ if (!root) return;
598
+ el.empty.style.display = S.mode === "empty" ? "" : "none";
599
+ el.resetc.style.display = S.mode === "corners" ? "" : "none";
600
+ el.editc.style.display = S.mode === "annotate" && S.cimg ? "" : "none";
601
+ el.undo.disabled = !S.undo.length;
602
+ el.redo.disabled = !S.redo.length;
603
+ if (S.mode === "annotate") {
604
+ const [cnt, rem, small, excl] = counts();
605
+ const add = S.dots.length;
606
+ el.total.textContent = cnt + add;
607
+ el.breakdown.innerHTML =
608
+ S.nobj + " detected" +
609
+ (small ? " &minus; " + small + " below " + S.minSize + " px" : "") +
610
+ (excl ? " &minus; " + excl + " on exclusion lines" : "") +
611
+ " &minus; <b class='gt-r'>" + rem + " removed</b>" +
612
+ " + <b class='gt-a'>" + add + " added</b>" +
613
+ (S.warn ? "<br><span class='gt-r'>" + S.warn + "</span>" : "");
614
+ if (!S.hover) {
615
+ hint("Click a cell to remove it · click empty space to add a dot " +
616
+ "· Shift+click always adds · wheel zooms, drag pans " +
617
+ "· hold Space to hide outlines · Ctrl+Z undo · F fit");
618
+ }
619
+ } else if (S.mode === "corners") {
620
+ el.total.textContent = "—";
621
+ el.breakdown.textContent = S.corners.length + " / 4 corners";
622
+ hint(S.corners.length < 4
623
+ ? "Click corner " + (S.corners.length + 1) + " of the 4×4 block " +
624
+ "(any order) · wheel zooms, drag pans"
625
+ : "Drag any handle to fine-tune, then press Segment.");
626
+ } else {
627
+ el.total.textContent = "—";
628
+ el.breakdown.textContent = "";
629
+ hint("");
630
+ }
631
+ }
632
+
633
+ // ---------------------------------------------------------------- draw
634
+ function dirty() { S.dirty = true; }
635
+
636
+ function frame() {
637
+ requestAnimationFrame(frame);
638
+ if (S.ringDirty && !S.down) { S.ringDirty = false; rebuildOverlay(); }
639
+ if (!S.dirty || !ctx) return;
640
+ S.dirty = false;
641
+ draw();
642
+ }
643
+
644
+ function draw() {
645
+ const dpr = window.devicePixelRatio || 1;
646
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
647
+ ctx.fillStyle = "#1b1d22";
648
+ ctx.fillRect(0, 0, cv.width, cv.height);
649
+ if (S.mode === "empty") return;
650
+ const { s, tx, ty } = S.view;
651
+ ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * tx, dpr * ty);
652
+ ctx.imageSmoothingEnabled = s < 2; // crisp pixels when zoomed in
653
+
654
+ if (S.mode === "corners") {
655
+ ctx.drawImage(S.cimg, 0, 0);
656
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
657
+ drawCorners();
658
+ return;
659
+ }
660
+
661
+ ctx.drawImage(S.img, 0, 0);
662
+ if (!S.hideOverlay) {
663
+ ctx.imageSmoothingEnabled = false;
664
+ ctx.drawImage(S.overlay, 0, 0);
665
+ // exclusion zones: left and bottom
666
+ ctx.fillStyle = "rgba(255,40,40,0.22)";
667
+ const lp = Math.floor(S.W * LEFT_PCT / 100);
668
+ const bp = Math.floor(S.H * BOTTOM_PCT / 100);
669
+ ctx.fillRect(0, 0, lp, S.H);
670
+ ctx.fillRect(0, S.H - bp, S.W, bp);
671
+ const h = S.hover;
672
+ if (h && (h.type === "remove" || h.type === "restore")) {
673
+ ctx.drawImage(hoverOutline(h.id), S.minx[h.id], S.miny[h.id]);
674
+ }
675
+ }
676
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
677
+ if (!S.hideOverlay) {
678
+ // removed cells: a red X sized to the cell, so "removed" never looks
679
+ // like a counted cell that happens to be drawn in a warm colour
680
+ ctx.lineCap = "round";
681
+ for (let id = 1; id < S.status.length; id++) {
682
+ if (S.status[id] !== REMOVED) continue;
683
+ const [px, py] = toScreen(S.cx[id], S.cy[id]);
684
+ const r = Math.max(5, Math.min(14, 0.35 * S.view.s *
685
+ Math.max(S.maxx[id] - S.minx[id], S.maxy[id] - S.miny[id])));
686
+ ctx.beginPath();
687
+ ctx.moveTo(px - r, py - r); ctx.lineTo(px + r, py + r);
688
+ ctx.moveTo(px + r, py - r); ctx.lineTo(px - r, py + r);
689
+ ctx.strokeStyle = "#000"; ctx.lineWidth = 5; ctx.stroke();
690
+ ctx.strokeStyle = "#ff2b2b"; ctx.lineWidth = 3; ctx.stroke();
691
+ }
692
+ ctx.lineCap = "butt";
693
+ ctx.lineWidth = 2;
694
+ for (const d of S.dots) {
695
+ const [px, py] = toScreen(d.x, d.y);
696
+ const hot = S.hover && S.hover.type === "deldot" && S.hover.dot === d;
697
+ ctx.beginPath();
698
+ ctx.arc(px, py, DOT_R, 0, 2 * Math.PI);
699
+ ctx.fillStyle = hot ? "#ff3b3b" : "#ffe600";
700
+ ctx.fill();
701
+ ctx.strokeStyle = "#000";
702
+ ctx.stroke();
703
+ }
704
+ const h = S.hover;
705
+ if (h && h.type === "add" && !S.down) {
706
+ const [px, py] = toScreen(h.dot.x, h.dot.y);
707
+ ctx.globalAlpha = 0.55;
708
+ ctx.beginPath();
709
+ ctx.arc(px, py, DOT_R, 0, 2 * Math.PI);
710
+ ctx.fillStyle = "#ffe600"; ctx.fill();
711
+ ctx.strokeStyle = "#000"; ctx.stroke();
712
+ ctx.globalAlpha = 1;
713
+ }
714
+ }
715
+ }
716
+
717
+ function drawCorners() {
718
+ const pts = S.corners.length === 4 ? orderedCorners() : S.corners;
719
+ const sp = pts.map(([x, y]) => toScreen(x, y));
720
+ ctx.lineWidth = 2;
721
+ ctx.strokeStyle = "#4aaaff";
722
+ if (sp.length >= 2) {
723
+ ctx.beginPath();
724
+ ctx.moveTo(sp[0][0], sp[0][1]);
725
+ for (const p of sp.slice(1)) ctx.lineTo(p[0], p[1]);
726
+ if (sp.length === 4) {
727
+ ctx.closePath();
728
+ ctx.fillStyle = "rgba(74,170,255,0.12)";
729
+ ctx.fill();
730
+ }
731
+ ctx.stroke();
732
+ }
733
+ ctx.font = "bold 12px sans-serif";
734
+ ctx.textAlign = "center"; ctx.textBaseline = "middle";
735
+ S.corners.forEach(([x, y], k) => {
736
+ const [px, py] = toScreen(x, y);
737
+ ctx.beginPath();
738
+ ctx.arc(px, py, 9, 0, 2 * Math.PI);
739
+ ctx.fillStyle = "#4aaaff"; ctx.fill();
740
+ ctx.strokeStyle = "#fff"; ctx.stroke();
741
+ // crosshair so the exact point is visible inside the handle
742
+ ctx.beginPath();
743
+ ctx.moveTo(px - 4, py); ctx.lineTo(px + 4, py);
744
+ ctx.moveTo(px, py - 4); ctx.lineTo(px, py + 4);
745
+ ctx.stroke();
746
+ });
747
+ }
748
+
749
+ window.GT = { load, getCorners, init, _state: S };
750
+ // gr.HTML mounts after this script runs; try until it exists.
751
+ const t = setInterval(() => { if (init()) clearInterval(t); }, 200);
752
+ })();
requirements.txt CHANGED
@@ -1,24 +1,11 @@
1
- # Pinned deliberately. Two of these have already broken this app once:
2
- # gradio 6 removed show_download_button and the UI failed to build;
3
- # cellpose 4.0.1 deprecated the channels= argument, and a future major version
4
- # could change model loading again. The Space rebuilds from this file on every
5
- # restart, so an unpinned range means the app can break with no code change.
6
  cellpose==4.1.1
7
  gradio==5.35.0
8
  spaces
9
  opencv-python
10
  numpy
11
- scipy
12
- matplotlib
13
  Pillow
14
  huggingface_hub
15
-
16
- # joblib and scikit-learn are needed ONLY if VIABILITY_METHOD is set back to
17
- # "model". The default local-contrast rule uses neither. Kept so that switch
18
- # still works; the sklearn pin matches the version viability_clf.pkl was fitted
19
- # under, since unpickling across versions is not guaranteed.
20
- joblib
21
- scikit-learn==1.6.1
22
-
23
- # xgboost removed: never imported by app.py. It was only used by an offline
24
- # comparison script, where it scored measurably worse than the linear model.
 
1
+ # Pinned to match the main CellposeCellCounter Space (see its requirements.txt
2
+ # for why: unpinned gradio/cellpose have each broken that app once).
 
 
 
3
  cellpose==4.1.1
4
  gradio==5.35.0
5
  spaces
6
  opencv-python
7
  numpy
 
 
8
  Pillow
9
  huggingface_hub
10
+ # gradio 5.35 imports requests but recent huggingface_hub no longer pulls it in
11
+ requests