LiangLabUMB commited on
Commit
122d705
·
verified ·
1 Parent(s): 435301a

Upload 4 files

Browse files
Files changed (3) hide show
  1. README.md +113 -56
  2. app.py +310 -6
  3. grid_seeded.py +88 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: CellposeCellCounter_Mobile_v3b
3
  emoji: 🚀
4
  colorFrom: purple
5
  colorTo: pink
@@ -17,44 +17,73 @@ Open-source, low-cost cell counting from a phone photo of a hemocytometer.
17
  Segmentation uses a fine-tuned Cellpose model; viability uses a separate
18
  classifier. Everything runs in this Space — no local install, no instrument.
19
 
20
- ## What changed in this version
21
 
22
- **Setting the counting square now takes one tap instead of eight.**
 
 
 
 
23
 
24
- Previously each of the four squares needed four corners, and each corner needed
25
- two taps (tap roughly, then tap precisely in the zoom view) — 8 taps per image,
26
- 32 for a full run of four squares.
27
 
28
- Now the default is **Auto**: tap once near the centre of the 4×4 block and the
29
- app finds all four corners, then shows them for you to confirm. If it looks
30
- wrong, press **✎ Tap corners myself** and the original two-tap-per-corner flow
31
- comes back exactly as it was. A failed detection drops you there automatically
32
- and says why.
33
 
34
- Detection is CPU-only OpenCV (`grid_seeded.py`). It never touches the GPU, so
35
- it costs no ZeroGPU quota — and because it crops to the block, Cellpose then
36
- sees roughly 7× fewer pixels than a full frame.
 
37
 
38
- ### What it reports, and why you should read it
39
 
40
- After each auto-detection the status line shows how far the proposed edges sit
41
- from the printed rulings, measured against the image itself:
 
 
42
 
43
- > **Square placed** — confidence 100% • edges sit 4 px (1.1% of a small square)
44
- > from the printed rulings • tilt +1.5° • fit: similarity
 
45
 
46
- That number is not the fit's own residual — it is an independent check that
47
- re-finds the darkest line near each of the 10 fitted grid lines and reports the
48
- disagreement. On the validation set it runs 4–16 px, i.e. 1–5% of one small
49
- square. If it is large, confidence drops and the app hands you the manual flow
50
- rather than cropping badly.
51
 
52
- ## How the detection works
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  The hard part is that in a phone-through-eyepiece photo the rulings are only
55
- about 5–10 grey levels darker than the background, sitting inside a circular
56
  illuminated field, covered in bright cells. Thresholding, Canny and Hough all
57
- discard them — three such prototypes each resolved only 1 of 4 test images.
58
 
59
  What works is averaging *along* a ruling, so the line signal adds coherently
60
  while cells do not:
@@ -65,54 +94,80 @@ while cells do not:
65
  2. **Rotation** from a profile-contrast sweep. This matters: a 2° tilt smears a
66
  1-px ruling across ~15 px over a 450-px averaging span.
67
  3. **Period** from autocorrelation; **phase** from a 5-tooth comb fit scored by
68
- its *weakest* tooth, so all five rulings must really be present.
 
 
69
  4. **Refine.** Relocate all 25 ruling intersections, then fit similarity (4
70
  parameters), affine (6) and homography (8) and pick between them — a richer
71
  model is only accepted if it beats the simpler one by more than its extra
72
  parameters would gain by chance.
73
- 5. **Verify.** The independent line-offset check described above.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  ### On perspective distortion
76
 
77
  The block is a projective quadrilateral when the phone is not square-on, and
78
- the code fits that. But be careful reading the numbers: bootstrapping the
79
  homography gives a keystone standard deviation of 0.011–0.020, so on a single
80
  image **any keystone below about 4% is not distinguishable from noise.** On the
81
  four validation photos the model selection therefore settles on a rotated
82
- square, which is the honest answer — letting 8 free parameters chase that noise
83
- is precisely how an auto-crop ends up wider on one side than the grid really is.
84
 
85
- Tested against synthetic tilt, the model escalates as it should: mild tilt
86
- (4% keystone) selects affine and lands within ~19 px of ground truth; strong
87
- tilt (10%) selects the homography, detects the keystone as significant, but
88
- correctly reports low confidence and hands over to manual rather than returning
89
- a bad crop. Photograph reasonably square-on and it will place the square; tilt
90
- hard and it will tell you to do it yourself.
91
 
92
  ## Validation
93
 
94
- On `test1_hemocytometer_Q1..Q4.jpg` (4080×3060, Improved Neubauer):
 
95
 
96
  | | |
97
  |---|---|
98
  | ruling period | 329.7–338.7 px, CV 1.1% across images |
99
  | tilt recovered | −1.0° to +4.5° |
100
- | line-offset QC | 4–16 px mean = 1–5% of one small square |
101
- | block size | 1283–1324 px |
102
- | runtime | ~0.4 s per image, CPU |
 
 
 
 
103
 
104
- Tap robustness — 36 taps jittered ±67 px (±0.20 of a square) around centre:
105
- 36/36 detected, 36/36 locked onto the same block, corner agreement median 5 px.
106
- Taps displaced a full half-square (onto the boundary with the neighbouring
107
- block) drop to ~82% detected, which is ambiguous by construction and the reason
108
- the flow asks you to confirm.
109
 
110
  ## Files
111
 
112
  | file | purpose |
113
  |---|---|
114
- | `app.py` | Gradio app |
115
- | `grid_seeded.py` | one-tap grid detection (CPU, no GPU, no model weights) |
116
  | `requirements.txt` | dependencies |
117
 
118
  Models are pulled from the Hub at startup: the fine-tuned Cellpose weights and
@@ -120,12 +175,14 @@ Models are pulled from the Hub at startup: the fine-tuned Cellpose weights and
120
 
121
  ## Known limits
122
 
 
 
 
123
  - Tuned on one microscope / phone / eyepiece combination at ~333 px per 0.25 mm
124
  square. The period search brackets roughly a 3× magnification range; a very
125
- different setup means rechecking `PER_LO_FR` / `PER_HI_FR` in
126
- `grid_seeded.py`, not new code.
127
- - Assumes your tap is inside the intended block and the block is substantially
128
- within frame.
129
- - The `confidence` score is a heuristic calibrated on four images. The
130
- line-offset QC is the number to trust; the accept threshold is exposed as
131
- `ACCEPT_CONF`.
 
1
  ---
2
+ title: CellposeCellCounter_Mobile
3
  emoji: 🚀
4
  colorFrom: purple
5
  colorTo: pink
 
17
  Segmentation uses a fine-tuned Cellpose model; viability uses a separate
18
  classifier. Everything runs in this Space — no local install, no instrument.
19
 
20
+ ## Two counting modes
21
 
22
+ | tab | touches for four squares | when to use it |
23
+ |---|---|---|
24
+ | **fast_counting** | **5** — one upload, one tap per square | routine counts |
25
+ | **comprehensive_counting** | 5, or 4 + 32 by hand | awkward images, or when you want the settings |
26
+ | **confluency** | — | single-image confluency |
27
 
28
+ Both counting tabs previously needed 4 uploads and 32 taps (four corners per
29
+ square, two taps per corner via the zoom).
 
30
 
31
+ ### fast_counting
 
 
 
 
32
 
33
+ 1. Upload all four squares at once.
34
+ 2. Tap the centre of the 4×4 block in each of row 2. One tap places all four
35
+ corners of that square.
36
+ 3. Press **Run**.
37
 
38
+ The layout is deliberately spare, because the point is speed:
39
 
40
+ 1. the four raw frames
41
+ 2. the four squares — tap the block centre; tap again to re-place
42
+ 3. per-square counts: total, live, dead, viability
43
+ 4. the means, and the time taken — then the live-cell concentration
44
 
45
+ No settings are exposed here. It uses the same defaults the comprehensive tab
46
+ ships with (automatic minimum cell size, 1%/1% stereological exclusion,
47
+ viability on). Change them there if you need to.
48
 
49
+ Nothing is saved automatically. CSV and PDF are checkboxes under **Export**,
50
+ both **off** by default. A square you never tapped is **skipped**, not counted
51
+ whole-frame — counting a whole frame would silently inflate the concentration.
 
 
52
 
53
+ ### Why the tap is required, and not just a convenience
54
+
55
+ An earlier version seeded each square from the centre of its frame, so no taps
56
+ were needed. It looked like it worked: all four validation images produced a
57
+ plausible block, high confidence, and good QC numbers.
58
+
59
+ **It was wrong on one of the four, and none of the built-in checks could tell.**
60
+ In `Q3` the intended block sits about 0.8 of a cell below the frame centre, so
61
+ the phase fit snapped to the *neighbouring* set of rulings and placed the block
62
+ one row too high — at confidence 0.99. The correct placement, seeded from the
63
+ real block centre, scored *lower* (0.80).
64
+
65
+ That is not a tuning problem, it is structural: a block placed on rulings 2–6
66
+ instead of 1–5 sits on real printed lines just as well, so every
67
+ line-agreement metric scores it perfectly. Nothing downstream can flag it.
68
+
69
+ The tap fixes it because it puts the seed inside the intended block, which is
70
+ the half-cell window the phase fit needs. **Still look at row 2 before running** —
71
+ the tap makes the common case right, it does not make the check unnecessary.
72
+
73
+ ### comprehensive_counting
74
+
75
+ The previous workflow, unchanged, plus the one-tap option. Per-square upload
76
+ (or the same bulk upload), settings, label correction, and the full summary.
77
+ Its crop picker offers **Auto** (one tap in the centre of the block) or
78
+ **Manual** (the original tap-roughly-then-tap-precisely corner flow). A failed
79
+ auto-detect drops you into Manual and says why.
80
+
81
+ ## How grid detection works
82
 
83
  The hard part is that in a phone-through-eyepiece photo the rulings are only
84
+ about 5–10 grey levels darker than the background, inside a circular
85
  illuminated field, covered in bright cells. Thresholding, Canny and Hough all
86
+ throw them away — three such prototypes each resolved only 1 of 4 test images.
87
 
88
  What works is averaging *along* a ruling, so the line signal adds coherently
89
  while cells do not:
 
94
  2. **Rotation** from a profile-contrast sweep. This matters: a 2° tilt smears a
95
  1-px ruling across ~15 px over a 450-px averaging span.
96
  3. **Period** from autocorrelation; **phase** from a 5-tooth comb fit scored by
97
+ its *weakest* tooth, so all five rulings must really be present. The search
98
+ floor excludes periods below ~170 px, which keeps it off the 0.05 mm
99
+ subdivisions of the central square — the target is the 0.25 mm lattice.
100
  4. **Refine.** Relocate all 25 ruling intersections, then fit similarity (4
101
  parameters), affine (6) and homography (8) and pick between them — a richer
102
  model is only accepted if it beats the simpler one by more than its extra
103
  parameters would gain by chance.
104
+ 5. **De-drift.** Rectify the block, find where the five rulings actually sit,
105
+ and shift/scale so the edges land on the outer ones. See below.
106
+ 6. **Verify.** An independent check that re-finds the darkest line near each of
107
+ the 10 fitted grid lines and reports the disagreement in pixels.
108
+
109
+ Detection is pure OpenCV on the CPU (`grid_seeded.py`) and is deliberately
110
+ **not** behind `@spaces.GPU`, so it costs no ZeroGPU quota. Because it crops to
111
+ the block, Cellpose then sees roughly 7× fewer pixels than a full frame.
112
+
113
+ ### The size bias, and why step 5 exists
114
+
115
+ Rectifying each detected block and re-measuring the rulings showed the block
116
+ coming out systematically **too large** — 8 of 8 axes drifted the same way, 1–4%
117
+ per axis, a **+1.2% to +6.5% area bias, mean +3.8%**. Area bias goes straight
118
+ into the cell count and therefore into the reported concentration.
119
+
120
+ Step 5 removes it where it is real. The drift is estimated from five offsets
121
+ that each carry ~10 px of noise, and an ungated version of the correction made
122
+ one of the four images *worse*, so an axis is only corrected when its slope
123
+ reaches 2 standard errors. One pass; iterating oscillates.
124
+
125
+ Result: **mean |area bias| 3.8% → 1.3%, worst 6.5% → 2.6%**, two images
126
+ corrected, two left alone, none made worse.
127
 
128
  ### On perspective distortion
129
 
130
  The block is a projective quadrilateral when the phone is not square-on, and
131
+ the code fits that. But read the numbers carefully: bootstrapping the
132
  homography gives a keystone standard deviation of 0.011–0.020, so on a single
133
  image **any keystone below about 4% is not distinguishable from noise.** On the
134
  four validation photos the model selection therefore settles on a rotated
135
+ square, which is the honest answer.
 
136
 
137
+ Against synthetic tilt the model escalates as it should: 4% keystone selects
138
+ affine and lands within ~19 px of ground truth; 10% selects the homography,
139
+ flags the keystone as significant, but reports low confidence and hands over to
140
+ manual rather than returning a bad crop.
 
 
141
 
142
  ## Validation
143
 
144
+ Real photos, `test1_hemocytometer_Q1..Q4.jpg` (4080×3060, Improved Neubauer),
145
+ seeded from the block centre:
146
 
147
  | | |
148
  |---|---|
149
  | ruling period | 329.7–338.7 px, CV 1.1% across images |
150
  | tilt recovered | −1.0° to +4.5° |
151
+ | line-offset QC | 3.6–13.5 px mean = 1–4% of one small square |
152
+ | residual area bias | −0.8% to +2.6%, mean magnitude 1.3% |
153
+ | runtime | ~0.45 s per image, CPU |
154
+
155
+ Synthetic images with known ground truth (period 200 px, tilts −1.0/+1.5/+4.5/
156
+ +0.7°, faint 6-grey-level rulings, 700 bright cells, circular field): period
157
+ recovered as **200 px exactly**, tilts within **0.2°**.
158
 
159
+ Tap robustness — 36 taps jittered ±67 px (±0.20 of a square) around the block
160
+ centre: 35/36 detected, 35/35 on the same block, corner agreement median 2.8 px.
161
+ **Displace the tap a full half-cell and it can snap to the neighbouring block**
162
+ — that is the ambiguity described above, and why the tap should land near the
163
+ middle of the intended square.
164
 
165
  ## Files
166
 
167
  | file | purpose |
168
  |---|---|
169
+ | `app.py` | Gradio app — the three tabs |
170
+ | `grid_seeded.py` | grid detection (CPU, no GPU, no model weights) |
171
  | `requirements.txt` | dependencies |
172
 
173
  Models are pulled from the Hub at startup: the fine-tuned Cellpose weights and
 
175
 
176
  ## Known limits
177
 
178
+ - **Confidence and QC cannot detect a whole-cell phase error.** They measure
179
+ whether the block sits on rulings, and a block one row off does. Look at the
180
+ proposed square.
181
  - Tuned on one microscope / phone / eyepiece combination at ~333 px per 0.25 mm
182
  square. The period search brackets roughly a 3× magnification range; a very
183
+ different setup means rechecking `PER_LO_FR` / `PER_HI_FR`, not new code.
184
+ - A field of view can contain both a 0.25 mm corner square and the 0.05 mm
185
+ subdivisions of the central square. The period floor keeps detection on the
186
+ 0.25 mm lattice, but your tap still has to be in the block you mean.
187
+ - The `confidence` score is a heuristic calibrated on four images; the accept
188
+ threshold is exposed as `ACCEPT_CONF`.
 
app.py CHANGED
@@ -5,7 +5,7 @@ import numpy as np
5
  import cv2
6
  import matplotlib.pyplot as plt
7
  import tempfile
8
- from PIL import Image, ImageDraw
9
  import io
10
  from huggingface_hub import hf_hub_download
11
  import base64
@@ -1603,6 +1603,63 @@ HEMO_MODEL = "Hemocytometer Model"
1603
  GENERAL_MODEL = "General Model"
1604
 
1605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1606
  AUTO_LABEL = "Auto \u2014 one tap in the centre"
1607
  MANUAL_LABEL = "Manual \u2014 tap the 4 corners"
1608
 
@@ -1802,12 +1859,248 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
1802
  # =======================================================================
1803
  # Tab 1 — hemocytometer counting (4 squares, one run)
1804
  # =======================================================================
1805
- with gr.Tab("tab1_hemacyto_counting"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1806
  gr.Markdown(
1807
  "### Hemocytometer counting\n"
1808
- "Upload the **four corner squares**, set four corners on each, then "
1809
- "press **Run segmentation** once. Uses the hemocytometer model and "
1810
- "runs viability automatically."
 
1811
  )
1812
 
1813
  with gr.Accordion("Settings", open=False):
@@ -1827,6 +2120,12 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
1827
  info="Adds a tap-to-flip thumbnail grid per square. Off by "
1828
  "default because building it costs one crop per cell.")
1829
 
 
 
 
 
 
 
1830
  pickers, seg_imgs, viab_imgs, stat_boxes = [], [], [], []
1831
  corr_accordions, corr_grids, corr_btns, corr_infos, corr_files = [], [], [], [], []
1832
  masks_sts, img_sts, raw_sts, lab_sts, lmap_sts = [], [], [], [], []
@@ -1972,6 +2271,11 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
1972
  inputs=[masks_sts[_i], img_sts[_i], lab_sts[_i], lmap_sts[_i]],
1973
  outputs=[corr_files[_i], corr_infos[_i]])
1974
 
 
 
 
 
 
1975
  run_all_btn.click(
1976
  fn=run_all,
1977
  inputs=[pickers[0]["img"], pickers[0]["points"],
@@ -2000,7 +2304,7 @@ with gr.Blocks(title="CellposeCellCounter", theme=gr.themes.Soft()) as demo:
2000
  # =======================================================================
2001
  # Tab 2 — confluency only (general model, no viability)
2002
  # =======================================================================
2003
- with gr.Tab("tab2_confluency"):
2004
  gr.Markdown(
2005
  "### Confluency estimation\n"
2006
  "Uses the **general** model. Cropping is optional — leave the corners "
 
5
  import cv2
6
  import matplotlib.pyplot as plt
7
  import tempfile
8
+ from PIL import Image, ImageDraw, ImageOps
9
  import io
10
  from huggingface_hub import hf_hub_download
11
  import base64
 
1603
  GENERAL_MODEL = "General Model"
1604
 
1605
 
1606
+ MAX_SQUARES = 4
1607
+
1608
+
1609
+ def _load_uploads(files):
1610
+ """Fan a multi-file upload out into the four per-square image slots.
1611
+
1612
+ Returns FILE PATHS, not PIL images, and that is deliberate. gradio's
1613
+ save_image() passes a str path straight through untouched, but re-encodes a
1614
+ PIL image to lossy WebP (gr.Image.format defaults to "webp"). Measured on a
1615
+ synthetic 6-grey-level ruling, that round trip costs ~9% of the contrast --
1616
+ and contrast is the entire signal grid detection runs on. Returning the path
1617
+ also leaves EXIF orientation to gradio's own preprocessing, exactly as for a
1618
+ per-square upload, so the two upload routes cannot drift apart.
1619
+
1620
+ Files are sorted by filename, so ..._Q1..Q4 land in Square 1..4. Anything
1621
+ unreadable, or beyond the fourth image, is named in the status line rather
1622
+ than dropped silently: alphabetical order is not always what the user
1623
+ expected, so they need to see which file went where.
1624
+
1625
+ Each slot assignment fires that square's own on_upload via gradio's
1626
+ .change(), which is documented to trigger on a function update and not only
1627
+ on user input. Nothing else in the picker needs to know about this.
1628
+ """
1629
+ slots = [gr.update() for _ in range(MAX_SQUARES)]
1630
+ if not files:
1631
+ return (*slots, "*Optional — or upload each square separately below*")
1632
+
1633
+ paths = sorted((str(getattr(f, "name", f)) for f in files),
1634
+ key=lambda p: os.path.basename(p).lower())
1635
+ used, bad, leftover = [], [], []
1636
+ for p in paths:
1637
+ base = os.path.basename(p)
1638
+ if len(used) >= MAX_SQUARES:
1639
+ leftover.append(base)
1640
+ continue
1641
+ try:
1642
+ with Image.open(p) as probe: # validate without re-encoding
1643
+ probe.verify()
1644
+ except Exception:
1645
+ bad.append(base)
1646
+ continue
1647
+ slots[len(used)] = p
1648
+ used.append(base)
1649
+
1650
+ if not used:
1651
+ return (*slots, "⚠️ *None of those files could be read as images.*")
1652
+ msg = "*%s*" % " · ".join(
1653
+ "Square %d ← %s" % (i + 1, n) for i, n in enumerate(used))
1654
+ if bad:
1655
+ msg += " \n⚠️ *Unreadable, skipped: %s*" % ", ".join(bad)
1656
+ if leftover:
1657
+ msg += (" \n⚠️ *Not used — there are only 4 squares: %s*"
1658
+ % ", ".join(leftover))
1659
+ msg += " \n*Now tap once in the centre of each block.*"
1660
+ return (*slots, msg)
1661
+
1662
+
1663
  AUTO_LABEL = "Auto \u2014 one tap in the centre"
1664
  MANUAL_LABEL = "Manual \u2014 tap the 4 corners"
1665
 
 
1859
  # =======================================================================
1860
  # Tab 1 — hemocytometer counting (4 squares, one run)
1861
  # =======================================================================
1862
+ # ==================================================================
1863
+ # Fast counting -- two touches: upload, then run.
1864
+ #
1865
+ # Deliberately spartan. Every square is seeded from the CENTRE of its own
1866
+ # frame, which is where people put the block when they take the photo (all
1867
+ # four validation images detect correctly from the frame centre). Row 2 is
1868
+ # tappable so a mis-placed square can be redone individually without
1869
+ # falling back to the full corner-tapping flow in comprehensive_counting.
1870
+ #
1871
+ # No settings are exposed here on purpose: it uses the same defaults the
1872
+ # comprehensive tab ships with (auto minimum size, stereological exclusion
1873
+ # 1%/1%, viability on). Change them there.
1874
+ # ==================================================================
1875
+ with gr.Tab("fast_counting"):
1876
+ gr.Markdown(
1877
+ "### Fast counting\n"
1878
+ "Upload all four squares, tap the centre of the 4×4 block in each "
1879
+ "of row 2, then press **Run**.\n\n"
1880
+ "The tap is what tells the detector which block you mean. It is not "
1881
+ "optional: the centre of the frame is often not inside the intended "
1882
+ "square, and a block placed one row off still sits on real rulings, "
1883
+ "so nothing downstream can flag it."
1884
+ )
1885
+
1886
+ f_upload = gr.Files(label="1 · Upload the four square images",
1887
+ file_count="multiple", file_types=["image"],
1888
+ height=95)
1889
+ f_status = gr.Markdown("*Waiting for images.*")
1890
+
1891
+ with gr.Row(): # row 1 -- raw frames
1892
+ f_raw = [gr.Image(type="pil", label="Square %d" % (i + 1),
1893
+ height=140, interactive=False,
1894
+ show_download_button=False)
1895
+ for i in range(4)]
1896
+
1897
+ with gr.Row(): # row 2 -- detected square, tap centre to redo
1898
+ f_sel = [gr.Image(type="pil",
1899
+ label="Square %d — tap the block centre" % (i + 1),
1900
+ height=165, interactive=True, format="jpeg",
1901
+ show_download_button=False)
1902
+ for i in range(4)]
1903
+
1904
+ with gr.Row():
1905
+ f_clear = gr.Button("✕ Clear squares", size="sm")
1906
+ f_run = gr.Button("2 · Run segmentation", variant="primary",
1907
+ size="lg")
1908
+
1909
+ with gr.Row(): # row 3 -- per-square counts
1910
+ f_table = gr.Dataframe(
1911
+ headers=["Square", "Total", "Live", "Dead", "Viability %"],
1912
+ datatype=["str", "str", "str", "str", "str"],
1913
+ row_count=(4, "fixed"), col_count=(5, "fixed"),
1914
+ interactive=False, label="Per square")
1915
+
1916
+ with gr.Row(): # row 4 -- means and time
1917
+ f_m_total = gr.Number(label="Mean total", precision=1)
1918
+ f_m_live = gr.Number(label="Mean live", precision=1)
1919
+ f_m_dead = gr.Number(label="Mean dead", precision=1)
1920
+ f_m_viab = gr.Number(label="Mean viability %", precision=1)
1921
+ f_secs = gr.Number(label="Time (s)", precision=2)
1922
+
1923
+ f_conc = gr.Textbox(label="Live-cell concentration", lines=2,
1924
+ interactive=False)
1925
+
1926
+ with gr.Accordion("Export (optional)", open=False):
1927
+ with gr.Row():
1928
+ f_csv = gr.Checkbox(label="CSV", value=False)
1929
+ f_pdf = gr.Checkbox(label="PDF", value=False)
1930
+ f_exp = gr.Button("Export", size="sm")
1931
+ f_out = gr.File(label="Download", file_count="multiple",
1932
+ visible=False)
1933
+ f_exp_msg = gr.Markdown()
1934
+
1935
+ f_full = [gr.State(None) for _ in range(4)] # full-res PIL, per square
1936
+ f_prev = [gr.State(None) for _ in range(4)] # preview PIL
1937
+ f_scale = [gr.State(1.0) for _ in range(4)] # preview / full
1938
+ f_pts = [gr.State(None) for _ in range(4)] # corners, full-res px
1939
+ f_res = gr.State([None, None, None, None])
1940
+
1941
+ def _f_overlay(prev, pts, scale):
1942
+ if prev is None:
1943
+ return None
1944
+ if not pts:
1945
+ return prev
1946
+ return draw_polygon_overlay(
1947
+ prev, [(int(x * scale), int(y * scale)) for x, y in pts])
1948
+
1949
+ def _f_detect(full, seed=None):
1950
+ """Detect around `seed`, defaulting to the centre of the frame."""
1951
+ if seed is None:
1952
+ seed = (full.size[0] / 2.0, full.size[1] / 2.0)
1953
+ res = detect_from_seed(np.array(full.convert("RGB")), seed)
1954
+ if not res.get("ok"):
1955
+ return None, res.get("reason", "no fit")
1956
+ return [(int(x), int(y)) for x, y in res["corners"]], res
1957
+
1958
+ def f_on_upload(files):
1959
+ raws, sels = [None] * 4, [None] * 4
1960
+ fulls, prevs = [None] * 4, [None] * 4
1961
+ scs, ptss = [1.0] * 4, [None] * 4
1962
+ if not files:
1963
+ return (*raws, *sels, *fulls, *prevs, *scs, *ptss,
1964
+ "*Waiting for images.*")
1965
+ paths = sorted((str(getattr(f, "name", f)) for f in files),
1966
+ key=lambda p: os.path.basename(p).lower())
1967
+ notes, found = [], 0
1968
+ for i, p in enumerate(paths[:4]):
1969
+ try:
1970
+ im = Image.open(p)
1971
+ im = ImageOps.exif_transpose(im) or im
1972
+ im = im.convert("RGB")
1973
+ except Exception:
1974
+ notes.append("%s unreadable" % os.path.basename(p))
1975
+ continue
1976
+ fulls[i] = raws[i] = im
1977
+ prevs[i], scs[i] = make_preview(im)
1978
+ sels[i] = prevs[i] # awaiting the user's tap
1979
+ found += 1
1980
+ msg = "**%d image%s loaded.** Now tap the centre of the 4×4 block " \
1981
+ "in each of row 2." % (found, "" if found == 1 else "s")
1982
+ if notes:
1983
+ msg += " \n⚠️ " + "; ".join(notes)
1984
+ if len(paths) > 4:
1985
+ msg += " \n*Ignored %d extra file(s).*" % (len(paths) - 4)
1986
+ return (*raws, *sels, *fulls, *prevs, *scs, *ptss, msg)
1987
+
1988
+ f_upload.change(
1989
+ fn=f_on_upload, inputs=[f_upload],
1990
+ outputs=[*f_raw, *f_sel, *f_full, *f_prev, *f_scale, *f_pts,
1991
+ f_status])
1992
+
1993
+ def _f_mk_reseed(idx):
1994
+ def h(full, prev, scale, evt: gr.SelectData):
1995
+ if full is None or prev is None or evt is None or evt.index is None:
1996
+ return gr.update(), gr.update(), gr.update()
1997
+ seed = (evt.index[0] / scale, evt.index[1] / scale)
1998
+ pts, info = _f_detect(full, seed)
1999
+ if not pts:
2000
+ return prev, None, "⚠️ *Square %d: %s.*" % (idx + 1, info)
2001
+ return (_f_overlay(prev, pts, scale), pts,
2002
+ "*Square %d placed — confidence %.0f%%, edges %.0f px "
2003
+ "off the rulings. Check it looks like the block you meant.*" % (
2004
+ idx + 1, info["confidence"] * 100,
2005
+ (info.get("qc") or {}).get("line_offset_mean_px", 0)))
2006
+ return h
2007
+
2008
+ for _fi in range(4):
2009
+ f_sel[_fi].select(fn=_f_mk_reseed(_fi),
2010
+ inputs=[f_full[_fi], f_prev[_fi], f_scale[_fi]],
2011
+ outputs=[f_sel[_fi], f_pts[_fi], f_status])
2012
+
2013
+ def f_on_clear(p1, p2, p3, p4):
2014
+ return (p1, p2, p3, p4, None, None, None, None,
2015
+ "*Squares cleared — tap the centre of each block.*")
2016
+
2017
+ f_clear.click(fn=f_on_clear, inputs=[*f_prev],
2018
+ outputs=[*f_sel, *f_pts, f_status])
2019
+
2020
+ def f_on_run(i1, i2, i3, i4, p1, p2, p3, p4):
2021
+ imgs, ptss = [i1, i2, i3, i4], [p1, p2, p3, p4]
2022
+ t0 = time.time()
2023
+ rows, results, notes = [], [], []
2024
+ for k, (im, pts) in enumerate(zip(imgs, ptss), start=1):
2025
+ blank = ["Square %d" % k, "—", "—", "—", "—"]
2026
+ if im is None:
2027
+ rows.append(blank); results.append(None)
2028
+ continue
2029
+ if not (pts and len(pts) >= 3):
2030
+ # Deliberately skipped, not segmented whole-frame: the whole
2031
+ # frame is a much larger area than one block, so counting it
2032
+ # would silently corrupt the concentration.
2033
+ rows.append(blank); results.append(None)
2034
+ notes.append("Square %d: no block tapped, skipped" % k)
2035
+ continue
2036
+ r = _segment_one(im, pts, HEMO_MODEL, True, 0, True, 1, 1, True)
2037
+ if r is None or "error" in r:
2038
+ rows.append(blank); results.append(None)
2039
+ notes.append("Square %d failed" % k)
2040
+ continue
2041
+ rows.append(["Square %d" % k,
2042
+ "%d" % (r["count"] or 0),
2043
+ "%d" % (r["alive"] or 0),
2044
+ "%d" % (r["dead"] or 0),
2045
+ "%.1f" % (r["viab"] or 0)])
2046
+ results.append(save_tab_result(
2047
+ r["count"], r["confluency"], r["viab"], r["alive"], r["dead"],
2048
+ r["seconds"], r["raw"], r["seg_overlay"], r["viab_overlay"]))
2049
+ wall = time.time() - t0
2050
+ avg_c, _f, avg_v, _b, _c2, _c10 = compute_summary(*results)
2051
+
2052
+ def _mean(key):
2053
+ vals = [r[key] for r in results if r and r.get(key) is not None]
2054
+ return (sum(vals) / len(vals)) if vals else 0.0
2055
+
2056
+ m_live, m_dead = _mean("live"), _mean("dead")
2057
+ # Hemocytometer convention: average the squares FIRST, then multiply.
2058
+ conc = "\n".join(
2059
+ "%s: %.2f x 10^6 live cells/mL (%s cells/mL)"
2060
+ % (name, m_live * mult / 1e6, format(int(m_live * mult), ","))
2061
+ for name, mult in CONC_PRESETS)
2062
+ n = len([r for r in results if r])
2063
+ msg = "**Done — %d of 4 squares in %.1f s.**" % (n, wall)
2064
+ if n != 4:
2065
+ msg += " ⚠️ *Convention uses all 4 squares.*"
2066
+ if notes:
2067
+ msg += " \n⚠️ " + "; ".join(notes)
2068
+ return (rows, avg_c, m_live, m_dead, avg_v, wall, conc, results, msg)
2069
+
2070
+ f_run.click(
2071
+ fn=f_on_run, inputs=[*f_full, *f_pts],
2072
+ outputs=[f_table, f_m_total, f_m_live, f_m_dead, f_m_viab, f_secs,
2073
+ f_conc, f_res, f_status])
2074
+
2075
+ def f_on_export(results, want_csv, want_pdf):
2076
+ if not results or not any(results):
2077
+ return gr.update(visible=False), "*Run segmentation first.*"
2078
+ if not (want_csv or want_pdf):
2079
+ return gr.update(visible=False), "*Tick CSV or PDF first.*"
2080
+ paths, msgs = [], []
2081
+ if want_csv:
2082
+ p, m = export_summary_csv(*results)
2083
+ if p:
2084
+ paths.append(p)
2085
+ msgs.append("CSV — " + m)
2086
+ if want_pdf:
2087
+ p, m = export_summary_pdf(*results)
2088
+ if p:
2089
+ paths.append(p)
2090
+ msgs.append("PDF — " + m)
2091
+ return (gr.update(value=paths, visible=bool(paths)),
2092
+ " \n".join(msgs))
2093
+
2094
+ f_exp.click(fn=f_on_export, inputs=[f_res, f_csv, f_pdf],
2095
+ outputs=[f_out, f_exp_msg])
2096
+
2097
+ with gr.Tab("comprehensive_counting"):
2098
  gr.Markdown(
2099
  "### Hemocytometer counting\n"
2100
+ "Upload the **four corner squares** \u2014 all at once, or one at a "
2101
+ "time. Tap once in the centre of each block to place the counting "
2102
+ "square, then press **Run segmentation** once. Uses the "
2103
+ "hemocytometer model and runs viability automatically."
2104
  )
2105
 
2106
  with gr.Accordion("Settings", open=False):
 
2120
  info="Adds a tap-to-flip thumbnail grid per square. Off by "
2121
  "default because building it costs one crop per cell.")
2122
 
2123
+ multi_upload = gr.Files(
2124
+ label="Upload all 4 square images at once (sorted by filename)",
2125
+ file_count="multiple", file_types=["image"], height=110)
2126
+ multi_status = gr.Markdown(
2127
+ "*Optional \u2014 or upload each square separately below*")
2128
+
2129
  pickers, seg_imgs, viab_imgs, stat_boxes = [], [], [], []
2130
  corr_accordions, corr_grids, corr_btns, corr_infos, corr_files = [], [], [], [], []
2131
  masks_sts, img_sts, raw_sts, lab_sts, lmap_sts = [], [], [], [], []
 
2271
  inputs=[masks_sts[_i], img_sts[_i], lab_sts[_i], lmap_sts[_i]],
2272
  outputs=[corr_files[_i], corr_infos[_i]])
2273
 
2274
+ multi_upload.change(
2275
+ fn=_load_uploads, inputs=[multi_upload],
2276
+ outputs=[pickers[0]["img"], pickers[1]["img"],
2277
+ pickers[2]["img"], pickers[3]["img"], multi_status])
2278
+
2279
  run_all_btn.click(
2280
  fn=run_all,
2281
  inputs=[pickers[0]["img"], pickers[0]["points"],
 
2304
  # =======================================================================
2305
  # Tab 2 — confluency only (general model, no viability)
2306
  # =======================================================================
2307
+ with gr.Tab("confluency"):
2308
  gr.Markdown(
2309
  "### Confluency estimation\n"
2310
  "Uses the **general** model. Cropping is optional — leave the corners "
grid_seeded.py CHANGED
@@ -276,6 +276,85 @@ def _select_model(src, dst, per_px):
276
  "rms_all": {s[0]: round(s[3], 2) for s in scored}}
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  # ------------------------------------------------- stage 3: independent QC
280
  def _scan_line(g, Hm, axis, t, per, n=140, span=0.22, step=0.01):
281
  """Offset (lattice units) of the truly darkest line near fitted line t."""
@@ -386,6 +465,14 @@ def detect_from_seed(image, seed_xy, verify=True, want_debug=False):
386
  sel, Hm = s, s["H"]
387
  quad = _apply(Hm, ideal) if sel else q1
388
 
 
 
 
 
 
 
 
 
389
  # ---- stage 3: independent QC ----------------------------------------
390
  qc = None
391
  if verify and sel:
@@ -427,6 +514,7 @@ def detect_from_seed(image, seed_xy, verify=True, want_debug=False):
427
  "angle_deg": round(float(ang), 2),
428
  "period_px": round(float(per_orig), 1),
429
  "block_px": [int(round(0.5 * (top + bot))), int(round(0.5 * (lft + rgt)))],
 
430
  "keystone_ratio": round(float(keystone), 4),
431
  # Honest about this one: bootstrapping gives a keystone SD of
432
  # 0.011-0.020, so a ratio under ~1.04 on a single image is not
 
276
  "rms_all": {s[0]: round(s[3], 2) for s in scored}}
277
 
278
 
279
+ # --------------------------------------------- stage 2b: de-drift the block
280
+ DEDRIFT_S = 900 # rectification size for the drift measurement
281
+ DEDRIFT_Z = 2.0 # require |slope| >= this many standard errors
282
+ DEDRIFT_MAX = 0.06 # and an implied change no larger than this fraction
283
+
284
+
285
+ def _ruling_offsets(g, quad, S=DEDRIFT_S):
286
+ """Rectify by `quad`, then find where the 5 true rulings actually sit.
287
+
288
+ Returns {'x': array(5), 'y': array(5)} in rectified px, where (S-1)/4 px is
289
+ one cell. Median along each line, so the bright cells cannot dominate.
290
+ """
291
+ M = cv2.getPerspectiveTransform(
292
+ np.asarray(quad, np.float32),
293
+ np.array([[0, 0], [S - 1, 0], [S - 1, S - 1], [0, S - 1]], np.float32))
294
+ w = cv2.warpPerspective(g, M, (S, S)).astype(np.float32)
295
+ win = int(0.20 * (S - 1) / 4)
296
+ out = {}
297
+ for nm, p in (('x', np.median(w, axis=0)), ('y', np.median(w, axis=1))):
298
+ v = []
299
+ for t in range(N):
300
+ c = int(round(t * (S - 1) / 4.0))
301
+ a, b = max(0, c - win), min(S, c + win + 1)
302
+ v.append(a + int(np.argmin(p[a:b])) - c)
303
+ out[nm] = np.array(v, float)
304
+ return out
305
+
306
+
307
+ def _drift_fit(offs):
308
+ """Least squares on offset-vs-index: (intercept, slope, slope std error)."""
309
+ t = np.arange(float(len(offs)))
310
+ slope, intercept = np.polyfit(t, offs, 1)
311
+ resid = offs - (intercept + slope * t)
312
+ dof = max(1, len(offs) - 2)
313
+ se = np.sqrt((resid ** 2).sum() / dof / ((t - t.mean()) ** 2).sum())
314
+ return float(intercept), float(slope), float(se)
315
+
316
+
317
+ def _dedrift(g, quad, S=DEDRIFT_S):
318
+ """Shift and scale the block so its edges sit on the outer rulings.
319
+
320
+ On the four validation photos the fitted block came out systematically
321
+ LARGE: 8 of 8 axes drifted the same way, +1 to +4% per axis, i.e. a +1.2 to
322
+ +6.5% area bias. That lands straight on the cell count and therefore on the
323
+ reported concentration, so it is worth removing -- it is a bias, not noise.
324
+
325
+ But only where it is real. The drift is estimated from 5 offsets that each
326
+ carry ~10 px of noise, and an ungated version of this correction made one of
327
+ the four images worse. So an axis is corrected only when its slope reaches
328
+ DEDRIFT_Z standard errors and the implied change is within DEDRIFT_MAX.
329
+ One pass, never iterated -- iterating oscillates.
330
+
331
+ Measured effect: mean |area bias| 3.8% -> 1.2%, worst 6.5% -> 2.6%, with no
332
+ image made worse (two corrected, two left alone).
333
+ """
334
+ m = _ruling_offsets(g, quad, S)
335
+ H = cv2.getPerspectiveTransform(
336
+ np.array([[0, 0], [4, 0], [4, 4], [0, 4]], np.float32),
337
+ np.asarray(quad, np.float32))
338
+ k = 4.0 / (S - 1)
339
+ bounds, info = {}, {}
340
+ for nm in ('x', 'y'):
341
+ a, b, se = _drift_fit(m[nm])
342
+ z = abs(b) / max(se, 1e-9)
343
+ corr = abs(4 * b / (S - 1))
344
+ if z >= DEDRIFT_Z and corr <= DEDRIFT_MAX:
345
+ bounds[nm] = (4 * a / (S - 1), 4 + k * (a + 4 * b))
346
+ info[nm] = {"applied": True, "z": round(z, 1),
347
+ "size_change_pct": round(-100 * 4 * b / (S - 1), 2)}
348
+ else:
349
+ bounds[nm] = (0.0, 4.0)
350
+ info[nm] = {"applied": False, "z": round(z, 1)}
351
+ if not (info['x']['applied'] or info['y']['applied']):
352
+ return None, info
353
+ (u0, u1), (v0, v1) = bounds['x'], bounds['y']
354
+ lat = np.array([[u0, v0], [u1, v0], [u1, v1], [u0, v1]], np.float32)
355
+ return _apply(H, lat), info
356
+
357
+
358
  # ------------------------------------------------- stage 3: independent QC
359
  def _scan_line(g, Hm, axis, t, per, n=140, span=0.22, step=0.01):
360
  """Offset (lattice units) of the truly darkest line near fitted line t."""
 
465
  sel, Hm = s, s["H"]
466
  quad = _apply(Hm, ideal) if sel else q1
467
 
468
+ # ---- stage 2b: remove the systematic size bias -----------------------
469
+ drift_info = None
470
+ if sel:
471
+ q_corrected, drift_info = _dedrift(g, quad)
472
+ if q_corrected is not None:
473
+ quad = q_corrected
474
+ Hm = cv2.getPerspectiveTransform(ideal, quad.astype(np.float32))
475
+
476
  # ---- stage 3: independent QC ----------------------------------------
477
  qc = None
478
  if verify and sel:
 
514
  "angle_deg": round(float(ang), 2),
515
  "period_px": round(float(per_orig), 1),
516
  "block_px": [int(round(0.5 * (top + bot))), int(round(0.5 * (lft + rgt)))],
517
+ "drift_correction": drift_info,
518
  "keystone_ratio": round(float(keystone), 4),
519
  # Honest about this one: bootstrapping gives a keystone SD of
520
  # 0.011-0.020, so a ratio under ~1.04 on a single image is not