Upload 4 files
Browse files- README.md +22 -59
- app.py +0 -0
- gt_canvas.js +752 -0
- requirements.txt +4 -17
README.md
CHANGED
|
@@ -1,72 +1,35 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.35.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
#
|
| 13 |
|
| 14 |
-
|
| 15 |
-
fine-tuned Cellpose-SAM model for segmentation and a single interpretable contrast
|
| 16 |
-
threshold for viability.
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 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 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 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 ? " − " + small + " below " + S.minSize + " px" : "") +
|
| 610 |
+
(excl ? " − " + excl + " on exclusion lines" : "") +
|
| 611 |
+
" − <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
|
| 2 |
-
#
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|