Upload 4 files
Browse files- README.md +65 -203
- app.py +0 -0
- requirements.txt +18 -6
README.md
CHANGED
|
@@ -1,210 +1,72 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 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 |
-
|
| 2 |
-
gradio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
spaces
|
| 4 |
opencv-python
|
|
|
|
|
|
|
| 5 |
matplotlib
|
| 6 |
Pillow
|
| 7 |
-
numpy
|
| 8 |
huggingface_hub
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
joblib
|
| 10 |
-
scikit-learn==1.6.1
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
| 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.
|