LiangLabUMB commited on
Commit
3bad943
·
verified ·
1 Parent(s): a4e168f

Upload 4 files

Browse files
Files changed (3) hide show
  1. README.md +65 -203
  2. app.py +0 -0
  3. requirements.txt +18 -6
README.md CHANGED
@@ -1,210 +1,72 @@
1
  ---
2
- title: CellposeCellCounter_Mobile_v3d
3
- emoji: 🚀
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: gradio
7
  sdk_version: 5.35.0
8
  app_file: app.py
9
  pinned: false
10
- license: apache-2.0
11
- short_description: Hemocytometer cell counter with one-tap grid detection
12
  ---
13
 
14
- > **v3d (2026-08-24) — grid detection made zoom-invariant.**
15
- > `grid_seeded.py` is the only file that differs from v3c; `app.py` is byte-identical
16
- > and needs no change. v3c sized its analysis window as a fixed fraction of the
17
- > image, so a more zoomed-in photo starved the period/phase search: the eight
18
- > photos in `misgana/20260818datacollection/Photos` came back with periods 10–20%
19
- > short and were rejected 8/8. v3d sizes the window in ruling periods and solves
20
- > for the period as a fixed point, so framing no longer matters. Acceptance is now
21
- > gated only on quantities measured against the image (`tooth_snr` is reported but
22
- > never gated), and the de-drift correction is weighted by the significance of its
23
- > own estimate instead of hard-switched.
24
- >
25
- > Measured: 12/12 photos and 108/108 jittered taps accepted across both sessions
26
- > (v3c: 4/12); interior ruling error median 0.027 of a square; 0/5 negative
27
- > controls accepted; on synthetic phantoms with exactly known corners, corner
28
- > error 0.003 of a square and block area 1.0003 of truth. See the module
29
- > docstring in `grid_seeded.py` for the full rationale and numbers.
30
-
31
-
32
- # CellposeCellCounter (mobile)
33
-
34
- Open-source, low-cost cell counting from a phone photo of a hemocytometer.
35
- Segmentation uses a fine-tuned Cellpose model; viability uses a separate
36
- classifier. Everything runs in this Space — no local install, no instrument.
37
-
38
- ## Two counting modes
39
-
40
- | tab | touches for four squares | when to use it |
41
- |---|---|---|
42
- | **fast_counting** | **5** — one upload, one tap per square | routine counts |
43
- | **comprehensive_counting** | 5, or 4 + 32 by hand | awkward images, or when you want the settings |
44
- | **confluency** | — | single-image confluency |
45
-
46
- Both counting tabs previously needed 4 uploads and 32 taps (four corners per
47
- square, two taps per corner via the zoom).
48
-
49
- ### fast_counting
50
-
51
- 1. Upload all four squares at once.
52
- 2. Tap the centre of the 4×4 block in each image. One tap places all four
53
- corners of that square.
54
- 3. Press **Run**.
55
-
56
- The layout is deliberately spare, because the point is speed:
57
-
58
- 1. the four squares, **two per row** so each is large enough to aim at —
59
- tap the block centre; tap again to re-place
60
- 2. per-square counts: total, live, dead, viability
61
- 3. the means, and the time taken — then the live-cell concentration
62
-
63
- There is no separate row of raw frames: until you tap it, each image *is* the
64
- raw frame, so a second copy only cost the screen space that made the tap target
65
- too small to judge.
66
-
67
- No settings are exposed here. It uses the same defaults the comprehensive tab
68
- ships with (automatic minimum cell size, 1%/1% stereological exclusion,
69
- viability on). Change them there if you need to.
70
-
71
- Nothing is saved automatically. CSV and PDF are checkboxes under **Export**,
72
- both **off** by default. A square you never tapped is **skipped**, not counted
73
- whole-frame — counting a whole frame would silently inflate the concentration.
74
-
75
- ### Why the tap is required, and not just a convenience
76
-
77
- An earlier version seeded each square from the centre of its frame, so no taps
78
- were needed. It looked like it worked: all four validation images produced a
79
- plausible block, high confidence, and good QC numbers.
80
-
81
- **It was wrong on one of the four, and none of the built-in checks could tell.**
82
- In `Q3` the intended block sits about 0.8 of a cell below the frame centre, so
83
- the phase fit snapped to the *neighbouring* set of rulings and placed the block
84
- one row too high — at confidence 0.99. The correct placement, seeded from the
85
- real block centre, scored *lower* (0.80).
86
-
87
- That is not a tuning problem, it is structural: a block placed on rulings 2–6
88
- instead of 1–5 sits on real printed lines just as well, so every
89
- line-agreement metric scores it perfectly. Nothing downstream can flag it.
90
-
91
- The tap fixes it because it puts the seed inside the intended block, which is
92
- the half-cell window the phase fit needs. **Still look at each proposed square before running** —
93
- the tap makes the common case right, it does not make the check unnecessary.
94
-
95
- ### comprehensive_counting
96
-
97
- The previous workflow, unchanged, plus the one-tap option. Per-square upload
98
- (or the same bulk upload), settings, label correction, and the full summary.
99
- Its crop picker offers **Auto** (one tap in the centre of the block) or
100
- **Manual** (the original tap-roughly-then-tap-precisely corner flow). A failed
101
- auto-detect drops you into Manual and says why.
102
-
103
- ## How grid detection works
104
-
105
- The hard part is that in a phone-through-eyepiece photo the rulings are only
106
- about 5–10 grey levels darker than the background, inside a circular
107
- illuminated field, covered in bright cells. Thresholding, Canny and Hough all
108
- throw them away — three such prototypes each resolved only 1 of 4 test images.
109
-
110
- What works is averaging *along* a ruling, so the line signal adds coherently
111
- while cells do not:
112
-
113
- 1. **Seed.** Your tap anchors the phase, because the centre of a 4×4 block sits
114
- on the middle ruling intersection. Working in a window around the tap also
115
- triples the effective resolution versus whole-frame detection.
116
- 2. **Rotation** from a profile-contrast sweep. This matters: a 2° tilt smears a
117
- 1-px ruling across ~15 px over a 450-px averaging span.
118
- 3. **Period** from autocorrelation; **phase** from a 5-tooth comb fit scored by
119
- its *weakest* tooth, so all five rulings must really be present. The search
120
- floor excludes periods below ~170 px, which keeps it off the 0.05 mm
121
- subdivisions of the central square — the target is the 0.25 mm lattice.
122
- 4. **Refine.** Relocate all 25 ruling intersections, then fit similarity (4
123
- parameters), affine (6) and homography (8) and pick between them — a richer
124
- model is only accepted if it beats the simpler one by more than its extra
125
- parameters would gain by chance.
126
- 5. **De-drift.** Rectify the block, find where the five rulings actually sit,
127
- and shift/scale so the edges land on the outer ones. See below.
128
- 6. **Verify.** An independent check that re-finds the darkest line near each of
129
- the 10 fitted grid lines and reports the disagreement in pixels.
130
-
131
- Detection is pure OpenCV on the CPU (`grid_seeded.py`) and is deliberately
132
- **not** behind `@spaces.GPU`, so it costs no ZeroGPU quota. Because it crops to
133
- the block, Cellpose then sees roughly 7× fewer pixels than a full frame.
134
-
135
- ### The size bias, and why step 5 exists
136
-
137
- Rectifying each detected block and re-measuring the rulings showed the block
138
- coming out systematically **too large** — 8 of 8 axes drifted the same way, 1–4%
139
- per axis, a **+1.2% to +6.5% area bias, mean +3.8%**. Area bias goes straight
140
- into the cell count and therefore into the reported concentration.
141
-
142
- Step 5 removes it where it is real. The drift is estimated from five offsets
143
- that each carry ~10 px of noise, and an ungated version of the correction made
144
- one of the four images *worse*, so an axis is only corrected when its slope
145
- reaches 2 standard errors. One pass; iterating oscillates.
146
-
147
- Result: **mean |area bias| 3.8% → 1.3%, worst 6.5% → 2.6%**, two images
148
- corrected, two left alone, none made worse.
149
-
150
- ### On perspective distortion
151
-
152
- The block is a projective quadrilateral when the phone is not square-on, and
153
- the code fits that. But read the numbers carefully: bootstrapping the
154
- homography gives a keystone standard deviation of 0.011–0.020, so on a single
155
- image **any keystone below about 4% is not distinguishable from noise.** On the
156
- four validation photos the model selection therefore settles on a rotated
157
- square, which is the honest answer.
158
-
159
- Against synthetic tilt the model escalates as it should: 4% keystone selects
160
- affine and lands within ~19 px of ground truth; 10% selects the homography,
161
- flags the keystone as significant, but reports low confidence and hands over to
162
- manual rather than returning a bad crop.
163
-
164
- ## Validation
165
-
166
- Real photos, `test1_hemocytometer_Q1..Q4.jpg` (4080×3060, Improved Neubauer),
167
- seeded from the block centre:
168
-
169
- | | |
170
- |---|---|
171
- | ruling period | 329.7–338.7 px, CV 1.1% across images |
172
- | tilt recovered | −1.0° to +4.5° |
173
- | line-offset QC | 3.6–13.5 px mean = 1–4% of one small square |
174
- | residual area bias | −0.8% to +2.6%, mean magnitude 1.3% |
175
- | runtime | ~0.45 s per image, CPU |
176
-
177
- Synthetic images with known ground truth (period 200 px, tilts −1.0/+1.5/+4.5/
178
- +0.7°, faint 6-grey-level rulings, 700 bright cells, circular field): period
179
- recovered as **200 px exactly**, tilts within **0.2°**.
180
-
181
- Tap robustness — 36 taps jittered ±67 px (±0.20 of a square) around the block
182
- centre: 35/36 detected, 35/35 on the same block, corner agreement median 2.8 px.
183
- **Displace the tap a full half-cell and it can snap to the neighbouring block**
184
- — that is the ambiguity described above, and why the tap should land near the
185
- middle of the intended square.
186
-
187
- ## Files
188
-
189
- | file | purpose |
190
- |---|---|
191
- | `app.py` | Gradio app — the three tabs |
192
- | `grid_seeded.py` | grid detection (CPU, no GPU, no model weights) |
193
- | `requirements.txt` | dependencies |
194
-
195
- Models are pulled from the Hub at startup: the fine-tuned Cellpose weights and
196
- `LiangLabUMB/viability_model`.
197
-
198
- ## Known limits
199
-
200
- - **Confidence and QC cannot detect a whole-cell phase error.** They measure
201
- whether the block sits on rulings, and a block one row off does. Look at the
202
- proposed square.
203
- - Tuned on one microscope / phone / eyepiece combination at ~333 px per 0.25 mm
204
- square. The period search brackets roughly a 3× magnification range; a very
205
- different setup means rechecking `PER_LO_FR` / `PER_HI_FR`, not new code.
206
- - A field of view can contain both a 0.25 mm corner square and the 0.05 mm
207
- subdivisions of the central square. The period floor keeps detection on the
208
- 0.25 mm lattice, but your tap still has to be in the block you mean.
209
- - The `confidence` score is a heuristic calibrated on four images; the accept
210
- threshold is exposed as `ACCEPT_CONF`.
 
1
  ---
2
+ title: CellposeCellCounter
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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,12 +1,24 @@
1
- cellpose
2
- gradio>=5.35.0
 
 
 
 
 
3
  spaces
4
  opencv-python
 
 
5
  matplotlib
6
  Pillow
7
- numpy
8
  huggingface_hub
 
 
 
 
 
9
  joblib
10
- scikit-learn==1.6.1 # pinned: viability_clf.pkl was fitted under 1.6.1
11
- xgboost
12
- scipy
 
 
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.