LiangLabUMB commited on
Commit
41b787e
·
verified ·
1 Parent(s): ff00a4b

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1444
app.py DELETED
@@ -1,1444 +0,0 @@
1
- import gradio as gr
2
- import spaces
3
- from cellpose import models
4
- 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
12
- import csv
13
- import joblib
14
- import os
15
-
16
- HF_REPO_ID = "myang4218/cellposemodel"
17
- HF_REPO_ID2 = "LiangLabUMB/viability_model"
18
- HF_REPO_CPSAM = "mouseland/cellpose-sam"
19
- MODEL_OPTIONS = {
20
- "Hemocytometer Model": "hemocytometermodel.npy",
21
- "General Model": "generalmodel.npy",
22
- "Cellpose SAMv2": "cpsam_v2",
23
- }
24
- MODEL_REPOS = {
25
- "hemocytometermodel.npy": HF_REPO_ID,
26
- "generalmodel.npy": HF_REPO_ID,
27
- "cpsam_v2": HF_REPO_CPSAM,
28
- }
29
-
30
-
31
- # Viability classifier is loaded LAZILY (only when viability is first requested).
32
- # Loading XGBoost / the pickled model at module level initialises a CUDA context
33
- # in the main process, which conflicts with ZeroGPU's per-call GPU allocation and
34
- # leaves segmentation requests stuck in the GPU queue. Keeping the main process
35
- # CUDA-clean until viability is explicitly called avoids this.
36
- VIABILITY_CLF = None
37
- VIABILITY_SCALER = None
38
- _VIABILITY_LOADED = False
39
- _VIABILITY_ERROR = None
40
-
41
-
42
- def ensure_viability_loaded():
43
- """
44
- Load the XGBoost classifier + scaler on first use, pinned to CPU.
45
-
46
- XGBoost initialises a CUDA context if it thinks a GPU is available. Once
47
- that context exists in the main process, every subsequent ZeroGPU fork for
48
- run_segmentation inherits a dirty CUDA state and hangs in the GPU queue.
49
- We force the loaded booster onto CPU so it never touches CUDA — viability
50
- inference on ~20 features per cell is trivially fast on CPU anyway.
51
- """
52
- global VIABILITY_CLF, VIABILITY_SCALER, _VIABILITY_LOADED, _VIABILITY_ERROR
53
- if _VIABILITY_LOADED:
54
- return
55
- try:
56
- import xgboost
57
- _clf_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_xgb_clf.pkl")
58
- _scaler_path = hf_hub_download(repo_id=HF_REPO_ID2, filename="viability_xgb_scaler.pkl")
59
- VIABILITY_CLF = joblib.load(_clf_path)
60
- VIABILITY_SCALER = joblib.load(_scaler_path)
61
-
62
- # Force CPU inference so XGBoost never initialises a CUDA context.
63
- try:
64
- VIABILITY_CLF.set_params(device="cpu", predictor="cpu_predictor", tree_method="hist")
65
- except Exception:
66
- pass
67
- try:
68
- booster = VIABILITY_CLF.get_booster()
69
- booster.set_param({"device": "cpu", "predictor": "cpu_predictor"})
70
- except Exception:
71
- pass
72
-
73
- _VIABILITY_LOADED = True
74
- print("✓ Viability classifier loaded (lazy, CPU-pinned).")
75
- except Exception as e:
76
- _VIABILITY_ERROR = str(e)
77
- print(f"Viability classifier failed to load: {e}")
78
-
79
- # mobile safe resize limits
80
- MAX_SIDE = 1024
81
- MAX_PIXELS = 1024 * 1024
82
-
83
-
84
- def safe_resize(image_np):
85
-
86
- h, w = image_np.shape[:2]
87
- total = h * w
88
-
89
- if max(h, w) <= MAX_SIDE and total <= MAX_PIXELS:
90
- return image_np
91
-
92
- # compute scale
93
- scale_side = MAX_SIDE / max(h, w)
94
- scale_pixels = (MAX_PIXELS / total) ** 0.5
95
- scale = min(scale_side, scale_pixels)
96
-
97
- new_w = max(1, int(w * scale))
98
- new_h = max(1, int(h * scale))
99
-
100
- return cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
101
-
102
-
103
- def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
104
-
105
- h, w = image_np.shape[:2]
106
-
107
- # Convert to PIL for drawing
108
- img_pil = Image.fromarray(image_np)
109
- draw = ImageDraw.Draw(img_pil, 'RGBA')
110
-
111
- # Calculate pixel widths from percentages
112
- left_px = int(w * left_width_pct / 100)
113
- top_px = int(h * top_width_pct / 100)
114
-
115
- # Draw overlays for exclusion zones
116
- if left_px > 0:
117
- # Left exclusion zone
118
- draw.rectangle(
119
- [(0, 0), (left_px, h)],
120
- fill=(255, 0, 0, 80) # Semi-transparent red
121
- )
122
- # border line
123
- draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
124
-
125
- if top_px > 0:
126
- # Top exclusion zone
127
- draw.rectangle(
128
- [(0, 0), (w, top_px)],
129
- fill=(255, 0, 0, 80) # Semi-transparent red
130
- )
131
- # border line
132
- draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
133
-
134
- return np.array(img_pil)
135
-
136
-
137
- def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
138
- h, w = masks.shape
139
-
140
- # Calculate pixel widths from percentages
141
- left_px = int(w * left_width_pct / 100)
142
- top_px = int(h * top_width_pct / 100)
143
-
144
- filtered_masks = masks.copy()
145
- cell_ids = np.unique(masks)
146
- cell_ids = cell_ids[cell_ids > 0]
147
-
148
- excluded_cells = []
149
- included_cells = []
150
-
151
- for cell_id in cell_ids:
152
- cell_mask = (masks == cell_id)
153
-
154
- # Get cell boundary coordinates
155
- rows, cols = np.where(cell_mask)
156
-
157
- # Check if cell touches left exclusion zone
158
- touches_left = np.any(cols < left_px) if left_px > 0 else False
159
-
160
- # Check if cell touches top exclusion zone
161
- touches_top = np.any(rows < top_px) if top_px > 0 else False
162
-
163
- # Exclude if touching left or top
164
- if touches_left or touches_top:
165
- filtered_masks[cell_mask] = 0
166
- excluded_cells.append(cell_id)
167
- else:
168
- included_cells.append(cell_id)
169
-
170
- # Renumber remaining cells
171
- unique_ids = np.unique(filtered_masks)
172
- unique_ids = unique_ids[unique_ids > 0]
173
-
174
- renumbered_masks = np.zeros_like(filtered_masks)
175
- for new_id, old_id in enumerate(unique_ids, start=1):
176
- renumbered_masks[filtered_masks == old_id] = new_id
177
-
178
- return renumbered_masks, len(excluded_cells), len(included_cells)
179
-
180
-
181
-
182
- FEATURE_COLS_INFERENCE = [
183
- "mean_r", "mean_g", "mean_b", "std_r", "std_g", "std_b",
184
- "mean_h", "mean_s", "mean_v", "std_s", "std_v",
185
- "blue_red_ratio", "blue_green_ratio", "rg_ratio",
186
- "inner_brightness", "peak_brightness",
187
- "bright_spot_fraction", "ring_darkness",
188
- "centre_periphery_ratio", "brightness_std_normalised",
189
- ]
190
-
191
-
192
- def classify_cells_by_model(image_np, masks):
193
-
194
- import numpy as np
195
- cell_ids = np.unique(masks)
196
- cell_ids = cell_ids[cell_ids > 0]
197
- if len(cell_ids) == 0:
198
- return 0, 0, image_np.copy(), {}
199
-
200
- features = extract_cell_features(image_np, masks)
201
- if not features:
202
- return 0, 0, image_np.copy(), {}
203
-
204
- import numpy as np
205
- X = np.array([[f[c] for c in FEATURE_COLS_INFERENCE] for f in features], dtype=np.float32)
206
-
207
- # replace any NaN/Inf with column median
208
- for j in range(X.shape[1]):
209
- bad = ~np.isfinite(X[:, j])
210
- if bad.any():
211
- X[bad, j] = float(np.nanmedian(X[:, j]))
212
-
213
- X_scaled = VIABILITY_SCALER.transform(X)
214
- predictions = VIABILITY_CLF.predict(X_scaled) # 0=live, 1=dead
215
-
216
- label_map = {int(f["cell_id"]): int(p) for f, p in zip(features, predictions)}
217
- overlay = draw_viability_overlay(image_np, masks, label_map)
218
-
219
- dead = int(sum(predictions))
220
- alive = int(len(predictions) - dead)
221
- return dead, alive, overlay, label_map
222
-
223
-
224
- def draw_viability_overlay(image_np, masks, label_map):
225
-
226
- overlay = image_np.copy()
227
- cell_ids = np.unique(masks)
228
- cell_ids = cell_ids[cell_ids > 0]
229
- cell_enum = {int(cid): idx + 1 for idx, cid in enumerate(sorted(cell_ids))}
230
-
231
- for cid in cell_ids:
232
- cid_int = int(cid)
233
- label = label_map.get(cid_int, 0)
234
- color = (220, 50, 50) if label == 1 else (50, 220, 80)
235
- cell_mask = (masks == cid).astype(np.uint8)
236
- contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
237
- cv2.drawContours(overlay, contours, -1, color, thickness=2)
238
-
239
- ys, xs = np.where(cell_mask)
240
- if len(ys) > 0:
241
- cx, cy = int(xs.mean()), int(ys.mean())
242
- label_str = str(cell_enum[cid_int])
243
- font = cv2.FONT_HERSHEY_SIMPLEX
244
- font_scale = 0.35
245
- thickness = 1
246
- (tw, th), _ = cv2.getTextSize(label_str, font, font_scale, thickness)
247
- cv2.rectangle(overlay,
248
- (cx - tw//2 - 1, cy - th//2 - 1),
249
- (cx + tw//2 + 1, cy + th//2 + 1),
250
- (0, 0, 0), -1)
251
- cv2.putText(overlay, label_str,
252
- (cx - tw//2, cy + th//2),
253
- font, font_scale, color, thickness, cv2.LINE_AA)
254
- return overlay
255
-
256
-
257
-
258
-
259
- def measure_confluency(masks, image_np):
260
- tot_pixels = image_np.shape[0] * image_np.shape[1]
261
- cell_pixels = np.count_nonzero(masks)
262
- confluency = cell_pixels / tot_pixels * 100
263
- return confluency
264
-
265
- def filter_mask_by_size(masks, minimum_pixels):
266
- filtered_masks = masks.copy()
267
- cell_ids = np.unique(masks)
268
- cell_ids = cell_ids[cell_ids > 0]
269
-
270
- removed_count = 0
271
-
272
- for cell_id in cell_ids:
273
- cell_mask = (masks == cell_id)
274
- cell_pixels = np.count_nonzero(cell_mask)
275
- if cell_pixels < minimum_pixels:
276
- filtered_masks[cell_mask] = 0
277
- removed_count += 1
278
-
279
- unique_ids = np.unique(filtered_masks)
280
- unique_ids = unique_ids[unique_ids > 0]
281
-
282
- renumbered_masks = np.zeros_like(filtered_masks)
283
- for new_id, old_id in enumerate(unique_ids, start=1):
284
- renumbered_masks[filtered_masks == old_id] = new_id
285
-
286
- return renumbered_masks, removed_count
287
-
288
-
289
- def filter_mask_by_maxsize(masks, maximum_pixels):
290
- filtered_masks = masks.copy()
291
- cell_ids = np.unique(masks)
292
- cell_ids = cell_ids[cell_ids > 0]
293
-
294
- removed_count = 0
295
- for cell_id in cell_ids:
296
- cell_mask = (masks == cell_id)
297
- cell_pixels = np.count_nonzero(cell_mask)
298
- if cell_pixels > maximum_pixels:
299
- filtered_masks[cell_mask] = 0
300
- removed_count += 1
301
-
302
- unique_ids = np.unique(filtered_masks)
303
- unique_ids = unique_ids[unique_ids > 0]
304
-
305
- renumbered_masks = np.zeros_like(filtered_masks)
306
- for new_id, old_id in enumerate(unique_ids, start=1):
307
- renumbered_masks[filtered_masks == old_id] = new_id
308
-
309
- return renumbered_masks, removed_count
310
-
311
-
312
- def rec_min_size(masks, q=25):
313
- ids = np.unique(masks)
314
- ids = ids[ids > 0]
315
- if len(ids) == 0:
316
- return 0
317
- sizes = np.array([np.count_nonzero(masks == cid) for cid in ids])
318
- return int(round(np.percentile(sizes, q)))
319
-
320
-
321
- def apply_polygon_mask(image_pil, points_json):
322
- """
323
- Given a PIL image and a JSON string of [[x,y],...] points,
324
- zero out everything outside the polygon and return a PIL image.
325
- """
326
- import json
327
- if not points_json or points_json.strip() in ("", "[]"):
328
- return image_pil
329
- try:
330
- pts = json.loads(points_json)
331
- except Exception:
332
- return image_pil
333
- if len(pts) < 3:
334
- return image_pil
335
-
336
- image_np = np.array(image_pil)
337
- h, w = image_np.shape[:2]
338
- poly = np.array(pts, dtype=np.int32)
339
- poly[:, 0] = np.clip(poly[:, 0], 0, w - 1)
340
- poly[:, 1] = np.clip(poly[:, 1], 0, h - 1)
341
- mask = np.zeros((h, w), dtype=np.uint8)
342
- cv2.fillPoly(mask, [poly], 255)
343
- if len(image_np.shape) == 3:
344
- result = np.where(mask[:, :, np.newaxis] == 255, image_np, 0).astype(np.uint8)
345
- else:
346
- result = np.where(mask == 255, image_np, 0).astype(np.uint8)
347
- return Image.fromarray(result)
348
-
349
- def warp_polygon_to_square(image_np, points):
350
- pts = np.array(points, dtype=np.float32)
351
-
352
- s = pts.sum(axis=1)
353
- diff = np.diff(pts, axis=1).ravel()
354
- tl = pts[np.argmin(s)]
355
- br = pts[np.argmax(s)]
356
- tr = pts[np.argmin(diff)]
357
- bl = pts[np.argmax(diff)]
358
- src = np.array([tl, tr, br, bl], dtype=np.float32)
359
-
360
- w1 = np.linalg.norm(br-bl)
361
- w2 = np.linalg.norm(tr-tl)
362
- h1 = np.linalg.norm(tr-br)
363
- h2 = np.linalg.norm(tl-bl)
364
- out_w = int(max(w1, w2))
365
- out_h = int(max(h1, h2))
366
-
367
- dst = np.array(
368
- [[0, 0],
369
- [out_w - 1, 0],
370
- [out_w - 1, out_h - 1],
371
- [0, out_h - 1]],
372
- dtype=np.float32)
373
-
374
- M = cv2.getPerspectiveTransform(src, dst)
375
- warped = cv2.warpPerspective(image_np, M, (out_w, out_h))
376
- return warped
377
-
378
-
379
- def toggle_stereological_mode(use_stereology):
380
- return gr.update(visible=use_stereology)
381
-
382
-
383
- def update_exclusion_preview(image, left_width, top_width):
384
- if image is None:
385
- return None
386
-
387
- image_np = np.array(image)
388
- overlay = draw_exclusion_overlay(image_np, left_width, top_width)
389
- return Image.fromarray(overlay)
390
-
391
-
392
- # Patch segmentation
393
-
394
- PATCH_SIZE = 512 # target patch side length
395
- PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
396
- MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
397
-
398
-
399
- def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
400
- """
401
- Split image into overlapping patches.
402
- Returns list of (patch_np, row_start, col_start) tuples.
403
- """
404
- h, w = image_np.shape[:2]
405
- patches = []
406
- row = 0
407
- while row < h:
408
- row_end = min(row + patch_size, h)
409
- col = 0
410
- while col < w:
411
- col_end = min(col + patch_size, w)
412
- patch = image_np[row:row_end, col:col_end]
413
- patches.append((patch, row, col))
414
- if col_end == w:
415
- break
416
- col += patch_size - overlap
417
- if row_end == h:
418
- break
419
- row += patch_size - overlap
420
- return patches
421
-
422
-
423
- def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
424
- """
425
- Stitch per-patch masks into a single full-image mask.
426
-
427
- Strategy:
428
- - Each patch gets a unique ID offset so cell IDs never collide.
429
- - Patches are pasted into the canvas using a priority canvas that
430
- gives interior pixels precedence over overlap-border pixels.
431
- - After pasting, cells whose centroids fall in the overlap zone
432
- of two adjacent patches are deduplicated: if two cells from
433
- different patches share >50% IoU they are the same cell — keep
434
- the one whose centroid is furthest from a patch edge.
435
- """
436
- full_mask = np.zeros((full_h, full_w), dtype=np.int32)
437
- # track which patch_idx owns each pixel (used for overlap resolution)
438
- owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
439
- # distance-to-nearest-edge for the owning patch (higher = more central)
440
- priority = np.zeros((full_h, full_w), dtype=np.float32)
441
-
442
- id_offset = 0
443
- patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
444
-
445
- for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
446
- ph, pw = mask_patch.shape
447
- # offset all non-zero IDs so they're globally unique
448
- shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
449
-
450
- # compute per-pixel priority = min distance to any patch edge
451
- rows_idx = np.arange(ph)
452
- cols_idx = np.arange(pw)
453
- dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
454
- dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
455
- pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
456
-
457
- roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
458
- roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
459
- roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
460
-
461
- # where this patch has higher priority, overwrite
462
- better = pri_patch > roi_pri
463
- roi_full [better] = shifted [better]
464
- roi_owner[better] = patch_idx
465
- roi_pri [better] = pri_patch [better]
466
-
467
- max_id = int(mask_patch.max())
468
- patch_meta.append((id_offset, row_start, col_start, ph, pw))
469
- id_offset += max_id + 1
470
-
471
- # --- Renumber to compact sequential IDs ---
472
- unique_ids = np.unique(full_mask)
473
- unique_ids = unique_ids[unique_ids > 0]
474
- renumbered = np.zeros_like(full_mask)
475
- for new_id, old_id in enumerate(unique_ids, start=1):
476
- renumbered[full_mask == old_id] = new_id
477
-
478
- return renumbered
479
-
480
-
481
- def run_segmentation_patched(image_np, model_filename):
482
- """
483
- Split image into overlapping patches, run Cellpose sequentially on each,
484
- then stitch back into a single full-resolution mask.
485
-
486
- NOTE: ThreadPoolExecutor was removed because ZeroGPU allocates the GPU
487
- only to the main thread. Worker threads spawned by ThreadPoolExecutor run
488
- outside the GPU context, causing a 3-minute timeout waiting for a GPU
489
- grant that never arrives for the worker threads.
490
- Sequential patching within the @spaces.GPU context is correct and fast.
491
- """
492
- h, w = image_np.shape[:2]
493
- repo = MODEL_REPOS.get(model_filename, HF_REPO_ID)
494
- # hf_hub_download caches the weights file on disk, so this only hits the
495
- # network once — subsequent calls read from the local cache.
496
- model_path = hf_hub_download(repo_id=repo, filename=model_filename)
497
-
498
- # IMPORTANT: build the CellposeModel fresh on every call. ZeroGPU allocates
499
- # a DIFFERENT physical GPU for each @spaces.GPU invocation, so a model cached
500
- # from a previous call holds a CUDA context bound to a now-deallocated GPU.
501
- # Reusing it makes the next request hang in the GPU queue. Constructing the
502
- # model is cheap because the weights are already on local disk.
503
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
504
-
505
- # Small images: no benefit from patching
506
- if max(h, w) <= MIN_PATCH_DIM * 2:
507
- mask, _, _ = model.eval(image_np, diameter=None)
508
- return mask, 1
509
-
510
- patches = _split_patches(image_np)
511
- n_patches = len(patches)
512
-
513
- patch_results = []
514
- for patch, row_start, col_start in patches:
515
- mask_patch, _, _ = model.eval(patch, diameter=None)
516
- patch_results.append((mask_patch, row_start, col_start))
517
-
518
- full_mask = _merge_patch_masks(patch_results, h, w)
519
- return full_mask, n_patches
520
-
521
-
522
- @spaces.GPU
523
- def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
524
- use_min_filter, use_max_filter,
525
- use_stereology, left_exclusion, top_exclusion,
526
- crop_points=None):
527
- image_np = np.array(image)
528
- image_np = safe_resize(image_np)
529
-
530
- raw_image_np = image_np.copy()
531
-
532
- # Apply polygon crop mask if the user drew one (need ≥3 points for a polygon)
533
- if crop_points and len(crop_points) >= 3:
534
- import json
535
- pts_json = json.dumps(crop_points)
536
- image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), pts_json)
537
- image_np = np.array(image_pil_masked)
538
-
539
- if len(crop_points) == 4:
540
- image_np = warp_polygon_to_square(image_np, crop_points)
541
-
542
-
543
- try:
544
- model_filename = MODEL_OPTIONS[model_choice]
545
-
546
- # Process image format to RGB
547
- if len(image_np.shape) == 2:
548
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
549
- elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
550
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
551
- else:
552
- processed_image_np = image_np
553
-
554
- # Run patch-parallel Cellpose segmentation
555
- masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
556
-
557
- ids = np.unique(masks_raw)
558
- ids = ids[ids > 0]
559
-
560
- sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
561
-
562
- print("num_cells:", len(ids))
563
- print("mean:", sizes.mean() if len(sizes) > 0 else 0)
564
- print("median:", np.median(sizes) if len(sizes) > 0 else 0)
565
- print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
566
- print("max:", sizes.max() if len(sizes) > 0 else 0)
567
-
568
- # Compute recommendation from RAW masks (always shown, never auto-applied)
569
- recommend_min = rec_min_size(masks_raw)
570
-
571
- # Apply filters only if their checkboxes are enabled
572
- masks = masks_raw.copy()
573
- removed_small = 0
574
- removed_large = 0
575
-
576
- if use_min_filter and int(min_cell_size) > 0:
577
- masks, removed_small = filter_mask_by_size(masks, int(min_cell_size))
578
-
579
- if use_max_filter and max_cell_size > 0:
580
- masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
581
-
582
- # Apply stereological exclusion if enabled
583
- excluded_count = 0
584
- if use_stereology:
585
- masks, excluded_count, included_count = apply_stereological_exclusion(
586
- masks, left_exclusion, top_exclusion
587
- )
588
-
589
- filter_msg = ""
590
- if removed_small:
591
- filter_msg += f"Removed {removed_small} small objects (< {int(min_cell_size)} pixels).\n"
592
- if removed_large:
593
- filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
594
- if use_stereology and excluded_count > 0:
595
- filter_msg += f"Stereological exclusion: {excluded_count} cells excluded (touching left/top zones).\n"
596
-
597
- cell_count = len(np.unique(masks)) - 1
598
- confluency = measure_confluency(masks, processed_image_np)
599
-
600
- # Create a basic segmentation overlay (without viability)
601
- segmentation_overlay = processed_image_np.copy().astype(np.float32)
602
- if masks.max() > 0:
603
- np.random.seed(42) # For consistent random colors
604
- colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
605
- colors[0] = [0, 0, 0]
606
- colored_mask = colors[masks]
607
- alpha = 0.4
608
- segmentation_overlay = (1 - alpha) * segmentation_overlay + alpha * colored_mask
609
- segmentation_overlay = np.clip(segmentation_overlay, 0, 255).astype(np.uint8)
610
-
611
- # Add exclusion zone overlay if stereology is enabled
612
- if use_stereology:
613
- segmentation_overlay = draw_exclusion_overlay(segmentation_overlay, left_exclusion, top_exclusion)
614
-
615
- info_msg = ""
616
- if filter_msg:
617
- info_msg += filter_msg
618
- info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
619
- info_msg += f"Confluency: {confluency:.1f}%\n"
620
- info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
621
- if use_stereology:
622
- info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
623
- info_msg += "Now run the viability classification model for viability assessment."
624
-
625
- return (
626
- cell_count,
627
- Image.fromarray(segmentation_overlay),
628
- info_msg,
629
- gr.update(visible=True),
630
- pack_array(masks),
631
- pack_array(processed_image_np),
632
- confluency,
633
- f"Recommended minimum: **{recommend_min} px** (25th percentile of detected cell sizes)",
634
- pack_array(raw_image_np),
635
- )
636
-
637
- except Exception as e:
638
- import traceback
639
- traceback.print_exc()
640
- return (
641
- 0,
642
- None,
643
- f"Error during segmentation: {str(e)}",
644
- gr.update(visible=False),
645
- None,
646
- None,
647
- 0.0,
648
- "",
649
- None,
650
- )
651
-
652
-
653
- def run_viability(stored_masks, stored_image_np):
654
- if stored_masks is None or stored_image_np is None:
655
- return None, 0, 0, 0.0, "Please run segmentation first.", {}
656
-
657
- # Lazy-load the classifier the first time viability is requested
658
- ensure_viability_loaded()
659
- if VIABILITY_CLF is None:
660
- err = _VIABILITY_ERROR or "unknown error"
661
- return None, 0, 0, 0.0, f"Viability model failed to load: {err}", {}
662
-
663
- masks = unpack_array(stored_masks)
664
- image_np = unpack_array(stored_image_np)
665
-
666
- try:
667
- dead, alive, overlay_np, label_map = classify_cells_by_model(image_np, masks)
668
- total = alive + dead
669
- viab_pct = (alive / total * 100) if total > 0 else 0.0
670
- confluency = measure_confluency(masks, image_np)
671
- info_msg = f"Total cells: {total}\nLive (green): {alive}\nDead (red): {dead}\n"
672
- info_msg += f"Viability: {viab_pct:.1f}%\nConfluency: {confluency:.1f}%"
673
- return Image.fromarray(overlay_np), alive, dead, viab_pct, info_msg, label_map
674
- except Exception as e:
675
- import traceback; traceback.print_exc()
676
- return None, 0, 0, 0.0, f"Error: {str(e)}", {}
677
-
678
-
679
- def pack_array(arr):
680
- """
681
- Serialise a numpy array to a base64 string for gr.State storage.
682
- Uses numpy .npy format (preserves int32 exactly, no 255 truncation)
683
- encoded as base64 so it is a plain Python string — safe for ZeroGPU
684
- state serialisation which cannot handle raw bytes objects.
685
- """
686
- buf = io.BytesIO()
687
- np.save(buf, arr)
688
- return base64.b64encode(buf.getvalue()).decode("ascii")
689
-
690
-
691
- def unpack_array(data):
692
- buf = io.BytesIO(base64.b64decode(data))
693
- return np.load(buf, allow_pickle=False)
694
-
695
-
696
- def save_tab_result(cell_count, confluency, viab_percent):
697
- """Package per-tab results into a dict for Tab 5 summary."""
698
- return {
699
- "cell_count": float(cell_count) if cell_count is not None else None,
700
- "confluency": float(confluency) if confluency is not None else None,
701
- "viab_percent": float(viab_percent) if viab_percent is not None else None,
702
- }
703
-
704
-
705
- def compute_summary(r1, r2, r3, r4):
706
- """Average cell count, confluency, and viability across tabs that have data."""
707
- all_results = [r1, r2, r3, r4]
708
- valid = [(i + 1, r) for i, r in enumerate(all_results)
709
- if r is not None and r.get("cell_count") is not None]
710
-
711
- if not valid:
712
- return (
713
- 0.0, 0.0, 0.0,
714
- "No data yet — run segmentation in at least one tab, then click Refresh Summary."
715
- )
716
-
717
- avg_count = sum(r["cell_count"] for _, r in valid) / len(valid)
718
- avg_conf = sum(r["confluency"] for _, r in valid) / len(valid)
719
- avg_viab = sum(r["viab_percent"] for _, r in valid) / len(valid)
720
-
721
- lines = [f"Tab {tab_num}: {r['cell_count']:.0f} cells | "
722
- f"{r['confluency']:.1f}% confluency | "
723
- f"{r['viab_percent']:.1f}% viability"
724
- for tab_num, r in valid]
725
- lines.append(f"\nAverages ({len(valid)} tab{'s' if len(valid) > 1 else ''}):")
726
- lines.append(f" Cell count: {avg_count:.1f}")
727
- lines.append(f" Confluency: {avg_conf:.1f}%")
728
- lines.append(f" Viability: {avg_viab:.1f}%")
729
-
730
- return avg_count, avg_conf, avg_viab, "\n".join(lines)
731
-
732
-
733
-
734
- # Training data export — feature extraction per cell
735
-
736
-
737
- def extract_cell_features(image_np, masks):
738
-
739
- if len(image_np.shape) == 2:
740
- image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
741
- elif image_np.shape[2] == 4:
742
- image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
743
-
744
- hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV).astype(np.float32)
745
-
746
- h_img, w_img = image_np.shape[:2]
747
- grid_y, grid_x = np.mgrid[:h_img, :w_img]
748
-
749
- cell_ids = np.unique(masks)
750
- cell_ids = cell_ids[cell_ids > 0]
751
- rows = []
752
-
753
- for cid in cell_ids:
754
- cell_mask = (masks == cid)
755
- pixels_rgb = image_np[cell_mask].astype(np.float32)
756
- pixels_hsv = hsv[cell_mask]
757
-
758
- r, g, b = pixels_rgb[:, 0], pixels_rgb[:, 1], pixels_rgb[:, 2]
759
- h, s, v = pixels_hsv[:, 0], pixels_hsv[:, 1], pixels_hsv[:, 2]
760
-
761
- eps = 1e-6
762
- blue_red_ratio = b.mean() / (r.mean() + eps)
763
- blue_green_ratio = b.mean() / (g.mean() + eps)
764
- rg_ratio = r.mean() / (g.mean() + eps)
765
-
766
- area_px = int(cell_mask.sum())
767
- contours, _ = cv2.findContours(
768
- cell_mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
769
- )
770
- perimeter = cv2.arcLength(contours[0], True) if contours else 1.0
771
- circularity = (4 * np.pi * area_px / (perimeter ** 2 + eps)) if perimeter > 0 else 0.0
772
-
773
- ys_cell = grid_y[cell_mask].astype(np.float32)
774
- xs_cell = grid_x[cell_mask].astype(np.float32)
775
- centroid_y = ys_cell.mean()
776
- centroid_x = xs_cell.mean()
777
-
778
- cell_radius = np.sqrt(area_px / np.pi) + eps
779
- dist_norm = np.sqrt((xs_cell - centroid_x)**2 + (ys_cell - centroid_y)**2) / cell_radius
780
-
781
- v_all = hsv[:, :, 2][cell_mask]
782
-
783
- # Tight inner core (15% radius) — captures specular highlight spot only
784
- inner_mask = dist_norm < 0.15
785
- # Membrane ring zone (20-60%) — dark navy ring on live cells
786
- ring_mask = (dist_norm >= 0.20) & (dist_norm <= 0.60)
787
- # Outer zone (>60%) — denominator for centre ratio
788
- outer_mask = dist_norm > 0.60
789
-
790
- inner_brightness = float(v_all[inner_mask].mean()) if inner_mask.any() else float(v.mean())
791
- ring_brightness = float(v_all[ring_mask].mean()) if ring_mask.any() else float(v.mean())
792
- outer_brightness = float(v_all[outer_mask].mean()) if outer_mask.any() else float(v.mean())
793
-
794
- # Peak V — specular spot is just a few pixels so mean dilutes it
795
- peak_brightness = float(v_all.max())
796
-
797
- # Fraction of cell pixels with V > 200 (specular highlight region)
798
- bright_spot_fraction = float((v_all > 200).sum()) / (len(v_all) + eps)
799
-
800
- # Ring darkness: ratio of ring zone to outer zone brightness
801
- # Live: ring << outer (dark membrane ring) -> ratio < 1
802
- # Dead: uniform blob -> ratio ~ 1
803
- ring_darkness = ring_brightness / (outer_brightness + eps)
804
-
805
- centre_periphery_ratio = inner_brightness / (outer_brightness + eps)
806
-
807
- brightness_std_normalised = float(v.std()) / (float(v.mean()) + eps)
808
-
809
- rows.append({
810
- "cell_id": int(cid),
811
- "mean_r": float(r.mean()),
812
- "mean_g": float(g.mean()),
813
- "mean_b": float(b.mean()),
814
- "std_r": float(r.std()),
815
- "std_g": float(g.std()),
816
- "std_b": float(b.std()),
817
- "mean_h": float(h.mean()),
818
- "mean_s": float(s.mean()),
819
- "mean_v": float(v.mean()),
820
- "std_s": float(s.std()),
821
- "std_v": float(v.std()),
822
- "blue_red_ratio": round(blue_red_ratio, 5),
823
- "blue_green_ratio": round(blue_green_ratio, 5),
824
- "rg_ratio": round(rg_ratio, 5),
825
- "area_px": area_px,
826
- "circularity": round(float(circularity), 5),
827
- "inner_brightness": round(inner_brightness, 3),
828
- "peak_brightness": round(peak_brightness, 3),
829
- "bright_spot_fraction": round(bright_spot_fraction, 6),
830
- "ring_darkness": round(ring_darkness, 5),
831
- "centre_periphery_ratio": round(centre_periphery_ratio, 5),
832
- "brightness_std_normalised": round(brightness_std_normalised, 5),
833
- })
834
-
835
- return rows
836
-
837
- def attach_viability_labels(cell_features, masks, image_np, label_map=None):
838
- """
839
- Attach model predictions (from label_map) to each feature dict.
840
- label_map: {cell_id: 0=live, 1=dead} from classify_cells_by_model.
841
- If label_map is None, defaults all labels to 0 (live).
842
- """
843
- if not cell_features:
844
- return []
845
- labelled = []
846
- for feat in cell_features:
847
- row = dict(feat)
848
- cid = int(feat["cell_id"])
849
- row["label"] = int(label_map.get(cid, 0)) if label_map else 0
850
- row["corrected"] = False
851
- labelled.append(row)
852
- return labelled
853
-
854
-
855
- def export_cell_data_csv(cell_data):
856
- """Write cell_data list-of-dicts to a temp CSV and return the file path."""
857
- if not cell_data:
858
- return None
859
- tmp = tempfile.NamedTemporaryFile(
860
- mode="w", suffix=".csv", delete=False, newline=""
861
- )
862
- # Union of all keys across rows so any late-added keys (e.g. "corrected") are included
863
- fieldnames = list(dict.fromkeys(k for row in cell_data for k in row.keys()))
864
- writer = csv.DictWriter(tmp, fieldnames=fieldnames, extrasaction="ignore")
865
- writer.writeheader()
866
- writer.writerows(cell_data)
867
- tmp.close()
868
- return tmp.name
869
-
870
-
871
- def prepare_export(stored_masks, stored_image, threshold_bias):
872
- """
873
- Called by the Export button. Unpacks state, extracts features,
874
- attaches labels, writes CSV, returns (path, status_message).
875
- """
876
- if stored_masks is None or stored_image is None:
877
- return None, "Run segmentation first before exporting."
878
-
879
- masks = unpack_array(stored_masks)
880
- image_np = unpack_array(stored_image)
881
-
882
- features = extract_cell_features(image_np, masks)
883
- if not features:
884
- return None, "No cells found to export."
885
-
886
- labelled = attach_viability_labels(features, masks, image_np, threshold_bias)
887
- path = export_cell_data_csv(labelled)
888
-
889
- n = len(labelled)
890
- dead = sum(1 for r in labelled if r["label"] == 1)
891
- alive = n - dead
892
- msg = (f"Exported {n} cells ({alive} live, {dead} dead) — "
893
- f"threshold bias={threshold_bias:+d}.\n"
894
- f"Columns: {', '.join(list(labelled[0].keys())[:6])}… "
895
- f"({len(labelled[0])} total).")
896
- return path, msg
897
-
898
-
899
-
900
- # Tab builder
901
-
902
- def draw_polygon_overlay(image_pil, points):
903
- """
904
- Draw numbered vertex dots and polygon edges onto a copy of image_pil.
905
- points: list of (x, y) tuples in original image pixel space.
906
- Returns a new PIL image.
907
- """
908
- img = image_pil.copy().convert("RGBA")
909
- overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
910
- draw = ImageDraw.Draw(overlay)
911
-
912
- if len(points) >= 2:
913
- # Draw edges
914
- for i in range(len(points) - 1):
915
- draw.line([points[i], points[i + 1]], fill=(74, 170, 255, 220), width=3)
916
- if len(points) == 4:
917
- draw.line([points[-1], points[0]], fill=(74, 170, 255, 220), width=3)
918
- # Semi-transparent fill
919
- draw.polygon(points, fill=(74, 170, 255, 50))
920
-
921
- # Draw vertex dots + numbers
922
- r = max(8, min(img.width, img.height) // 60)
923
- for i, (x, y) in enumerate(points):
924
- draw.ellipse([x - r, y - r, x + r, y + r],
925
- fill=(74, 170, 255, 255), outline=(255, 255, 255, 255))
926
- draw.text((x, y), str(i + 1), fill=(255, 255, 255, 255), anchor="mm")
927
-
928
- combined = Image.alpha_composite(img, overlay)
929
- return combined.convert("RGB")
930
-
931
-
932
- def add_crop_point(image_pil, points, evt: gr.SelectData):
933
- """
934
- Called by gr.Image .select(). Appends the clicked coordinate,
935
- redraws the overlay, returns (updated_image, updated_points).
936
- Ignores clicks once 4 points are set.
937
- """
938
- if image_pil is None:
939
- return image_pil, points
940
- if points is None:
941
- points = []
942
- if len(points) >= 4:
943
- return draw_polygon_overlay(image_pil, points), points
944
-
945
- x, y = int(evt.index[0]), int(evt.index[1])
946
- new_points = points + [(x, y)]
947
- return draw_polygon_overlay(image_pil, new_points), new_points
948
-
949
-
950
- def clear_crop_points(image_pil):
951
- """Reset polygon — return original image with no overlay and empty points."""
952
- return image_pil, []
953
-
954
-
955
-
956
-
957
-
958
- # Label correction grid
959
-
960
- THUMB_SIZE = 80
961
- GRID_COLS = 10
962
- BORDER = 4
963
- LABEL_H = 16
964
-
965
- def _crop_cell_thumb(image_np, masks, cid):
966
- """
967
- Return a tight square crop of the cell, padded to THUMB_SIZE × THUMB_SIZE.
968
- """
969
- ys, xs = np.where(masks == cid)
970
- if len(ys) == 0:
971
- return Image.fromarray(np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8))
972
-
973
- y0, y1 = ys.min(), ys.max() + 1
974
- x0, x1 = xs.min(), xs.max() + 1
975
-
976
- # add a small context border around the tight bounding box
977
- pad = max(4, int(max(y1 - y0, x1 - x0) * 0.15))
978
- h, w = image_np.shape[:2]
979
- y0c = max(0, y0 - pad)
980
- y1c = min(h, y1 + pad)
981
- x0c = max(0, x0 - pad)
982
- x1c = min(w, x1 + pad)
983
-
984
- crop = image_np[y0c:y1c, x0c:x1c].copy()
985
-
986
- # dim pixels that don't belong to this cell
987
- dim_mask = (masks[y0c:y1c, x0c:x1c] != cid)
988
- crop[dim_mask] = (crop[dim_mask] * 0.3).astype(np.uint8)
989
-
990
- pil = Image.fromarray(crop).resize((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
991
- return pil
992
-
993
-
994
- def build_correction_grid(image_np, masks, labelled_features, raw_image_np=None):
995
-
996
- if not labelled_features:
997
- placeholder = Image.fromarray(
998
- np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8)
999
- )
1000
- return placeholder
1001
-
1002
- thumb_src = raw_image_np if raw_image_np is not None else image_np
1003
-
1004
- n = len(labelled_features)
1005
- n_cols = GRID_COLS
1006
- n_rows = (n + n_cols - 1) // n_cols
1007
-
1008
- cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1009
- cell_w = THUMB_SIZE + 2 * BORDER
1010
-
1011
- grid_w = n_cols * cell_w
1012
- grid_h = n_rows * cell_h
1013
-
1014
- grid = Image.new("RGB", (grid_w, grid_h), (30, 30, 30))
1015
- draw = ImageDraw.Draw(grid)
1016
-
1017
- for idx, feat in enumerate(labelled_features):
1018
- cid = feat["cell_id"]
1019
- label = feat["label"] # 0=live, 1=dead (may have been corrected)
1020
- color = (220, 50, 50) if label == 1 else (50, 200, 80)
1021
-
1022
- thumb = _crop_cell_thumb(thumb_src, masks, cid)
1023
-
1024
- col = idx % n_cols
1025
- row = idx // n_cols
1026
- x0 = col * cell_w
1027
- y0 = row * cell_h
1028
-
1029
- # coloured border rectangle
1030
- draw.rectangle([x0, y0, x0 + cell_w - 1, y0 + cell_h - 1], outline=color, width=BORDER)
1031
-
1032
- # paste thumbnail inside border
1033
- grid.paste(thumb, (x0 + BORDER, y0 + BORDER))
1034
-
1035
- # small cell-id label strip
1036
- strip_y = y0 + BORDER + THUMB_SIZE
1037
- draw.rectangle([x0, strip_y, x0 + cell_w - 1, y0 + cell_h - 1],
1038
- fill=(20, 20, 20))
1039
- draw.text((x0 + BORDER + 2, strip_y + 1),
1040
- f"#{cid} {'D' if label == 1 else 'L'}",
1041
- fill=color)
1042
-
1043
- return grid
1044
-
1045
-
1046
- def toggle_cell_label(labelled_features, image_np, masks, raw_image_np, evt: gr.SelectData):
1047
- """
1048
- Called when user taps the correction grid image.
1049
- Maps the tap pixel coordinate back to which thumbnail was tapped,
1050
- flips that cell's label, rebuilds and returns the updated grid.
1051
- """
1052
- if not labelled_features or image_np is None:
1053
- return build_correction_grid(image_np, masks, labelled_features), labelled_features
1054
-
1055
- cell_w = THUMB_SIZE + 2 * BORDER
1056
- cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1057
-
1058
- px, py = int(evt.index[0]), int(evt.index[1])
1059
- col = px // cell_w
1060
- row = py // cell_h
1061
- idx = row * GRID_COLS + col
1062
-
1063
- if idx < 0 or idx >= len(labelled_features):
1064
- return build_correction_grid(image_np, masks, labelled_features, raw_image_np), labelled_features
1065
-
1066
- # Flip the label
1067
- updated = list(labelled_features) # shallow copy of list
1068
- cell = dict(updated[idx]) # copy the dict so we don't mutate in place
1069
- cell["label"] = 1 - cell["label"] # 0→1 or 1→0
1070
- cell["corrected"] = True
1071
- updated[idx] = cell
1072
-
1073
- grid = build_correction_grid(image_np, masks, updated, raw_image_np)
1074
- n_corrected = sum(1 for f in updated if f.get("corrected"))
1075
- return grid, updated, f"Tapped cell #{cell['cell_id']} → {'Dead' if cell['label']==1 else 'Live'}. {n_corrected} correction(s) total."
1076
-
1077
-
1078
- def prepare_export_corrected(stored_masks, stored_image, labelled_features, label_map):
1079
- """Export CSV using labelled_features with any manual corrections applied."""
1080
- if stored_masks is None or stored_image is None:
1081
- return None, "Run segmentation first before exporting."
1082
- masks = unpack_array(stored_masks)
1083
- image_np = unpack_array(stored_image)
1084
- if not labelled_features:
1085
- features = extract_cell_features(image_np, masks)
1086
- labelled_features = attach_viability_labels(features, masks, image_np, label_map)
1087
- if not labelled_features:
1088
- return None, "No cells found to export."
1089
- path = export_cell_data_csv(labelled_features)
1090
- n = len(labelled_features)
1091
- dead = sum(1 for r in labelled_features if r["label"] == 1)
1092
- alive = n - dead
1093
- corrected = sum(1 for r in labelled_features if r.get("corrected"))
1094
- msg = (f"Exported {n} cells ({alive} live, {dead} dead). "
1095
- f"{corrected} label(s) manually corrected.")
1096
- return path, msg
1097
-
1098
- def build_tab(tab_index, masks_state, image_state, result_state):
1099
- with gr.Tab(f"Tab {tab_index}"):
1100
- gr.Markdown("Run segmentation")
1101
-
1102
- # Per-tab state: list of (x,y) crop polygon points
1103
- crop_points_state = gr.State(value=[])
1104
- # Clean copy of the uploaded image (no polygon drawn on it)
1105
- base_image_state = gr.State(value=None)
1106
- #raw image state
1107
- raw_image_state = gr.State(value=None)
1108
-
1109
- with gr.Row():
1110
- with gr.Column():
1111
- img_input = gr.Image(
1112
- type="pil",
1113
- label="Upload image",
1114
- image_mode="RGB",
1115
- height=512
1116
- )
1117
-
1118
- gr.Markdown(
1119
- "### Crop region (optional)\n"
1120
- "Click/tap up to **4 points** on the image below to define the region "
1121
- "to segment. The polygon will be drawn as you click. "
1122
- "Leave empty to segment the full image."
1123
- )
1124
-
1125
- crop_display = gr.Image(
1126
- type="pil",
1127
- label="Click to set crop vertices (up to 4)",
1128
- interactive=True,
1129
- height=400,
1130
- )
1131
-
1132
- crop_status = gr.Markdown("*Upload an image to enable cropping*")
1133
-
1134
- clear_crop_btn = gr.Button("✕ Clear crop points", size="sm")
1135
-
1136
- model_dropdown = gr.Dropdown(
1137
- choices=list(MODEL_OPTIONS.keys()),
1138
- label="Select Model",
1139
- value="Hemocytometer Model"
1140
- )
1141
-
1142
- gr.Markdown("### Size Filters")
1143
-
1144
- use_min_filter = gr.Checkbox(
1145
- label="Enable minimum size filter",
1146
- value=False,
1147
- info="Remove objects smaller than the threshold below"
1148
- )
1149
- min_size_slider = gr.Slider(
1150
- minimum=0,
1151
- maximum=500,
1152
- value=0,
1153
- step=10,
1154
- label="Minimum Cell Size (pixels)",
1155
- )
1156
- min_size_recommendation = gr.Markdown(
1157
- value="*Run segmentation to see recommended minimum*",
1158
- )
1159
-
1160
- use_max_filter = gr.Checkbox(
1161
- label="Enable maximum size filter",
1162
- value=False,
1163
- info="Remove objects larger than the threshold below"
1164
- )
1165
- max_size_slider = gr.Slider(
1166
- minimum=0,
1167
- maximum=10000,
1168
- value=10000,
1169
- step=10,
1170
- label="Maximum Cell Size (pixels)",
1171
- )
1172
-
1173
- gr.Markdown("### Stereological Counting")
1174
- use_stereo = gr.Checkbox(
1175
- label="Enable Stereological Counting",
1176
- value=False,
1177
- info="Use unbiased stereological rules for cell counting"
1178
- )
1179
-
1180
- with gr.Group(visible=False) as stereo_controls:
1181
- gr.Markdown("""
1182
- **Stereological Counting Rules:**
1183
- - Cells touching LEFT or TOP exclusion zones are EXCLUDED
1184
- - Cells touching RIGHT or BOTTOM edges are INCLUDED
1185
- - This provides unbiased counting for quantification
1186
- """)
1187
-
1188
- excl_preview = gr.Image(
1189
- type="pil",
1190
- label="Exclusion Zone Preview (Red = Excluded)",
1191
- height=500
1192
- )
1193
-
1194
- left_excl = gr.Slider(
1195
- minimum=0,
1196
- maximum=50,
1197
- value=10,
1198
- step=1,
1199
- label="Left Exclusion Width (%)",
1200
- info="Width of left exclusion zone"
1201
- )
1202
-
1203
- top_excl = gr.Slider(
1204
- minimum=0,
1205
- maximum=50,
1206
- value=10,
1207
- step=1,
1208
- label="Top Exclusion Width (%)",
1209
- info="Width of top exclusion zone"
1210
- )
1211
-
1212
- segment_btn = gr.Button("🔬 Run Segmentation", variant="primary", size="lg")
1213
-
1214
- with gr.Column():
1215
- cell_count_out = gr.Number(label="Total Cells Detected", precision=0)
1216
- confluency_out = gr.Number(label="Confluency (%)", precision=1)
1217
- overlay_out = gr.Image(type="pil", label="Segmentation Result")
1218
- info_out = gr.Textbox(label="Processing Info", lines=4)
1219
-
1220
- with gr.Group(visible=False) as viability_section:
1221
- gr.Markdown("### Viability Assessment (Trypan Blue)")
1222
-
1223
- viab_run_btn = gr.Button("Run Viability Analysis", variant="primary")
1224
-
1225
- with gr.Row():
1226
- live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
1227
- dead_count_out = gr.Number(label="Dead Cells (Red)", precision=0)
1228
-
1229
- viab_overlay = gr.Image(type="pil", label="Viability (Green=Live · Red=Dead)")
1230
- viab_percent_out = gr.Number(label="Viability (%)", precision=1)
1231
- viab_info = gr.Textbox(label="Analysis Results", lines=4)
1232
-
1233
- gr.Markdown("### Label Correction & Export")
1234
- gr.Markdown(
1235
- "After running viability, click **Build correction grid** to review every cell. "
1236
- "**Green border = Live, Red border = Dead** (model predictions). "
1237
- "Tap any thumbnail to flip its label — the counts and overlay update instantly. "
1238
- "Export the corrected CSV for retraining."
1239
- )
1240
-
1241
- build_grid_btn = gr.Button("🔲 Build correction grid", variant="secondary")
1242
- labelled_state = gr.State(value=[])
1243
- label_map_state = gr.State(value={})
1244
-
1245
- correction_grid = gr.Image(
1246
- type="pil",
1247
- label="Tap a cell to flip its label (green=live · red=dead)",
1248
- interactive=True,
1249
- visible=False,
1250
- )
1251
- correction_status = gr.Markdown(visible=False)
1252
-
1253
- with gr.Row():
1254
- export_btn = gr.Button("⬇️ Export corrected CSV", variant="secondary")
1255
- export_info = gr.Textbox(label="Export status", lines=2, interactive=False)
1256
- export_file = gr.File(label="Download CSV", visible=False)
1257
-
1258
- # ---- Event handlers ------------------------------------------------
1259
-
1260
- use_stereo.change(
1261
- fn=toggle_stereological_mode,
1262
- inputs=[use_stereo],
1263
- outputs=[stereo_controls]
1264
- )
1265
-
1266
- def on_image_upload(img):
1267
- if img is None:
1268
- return None, None, "*Upload an image to enable cropping*"
1269
- return img, img, "*Image loaded — click up to 4 points to define crop region*"
1270
-
1271
- img_input.change(
1272
- fn=on_image_upload,
1273
- inputs=[img_input],
1274
- outputs=[crop_display, base_image_state, crop_status]
1275
- ).then(fn=lambda: [], outputs=[crop_points_state])
1276
-
1277
- img_input.change(fn=update_exclusion_preview,
1278
- inputs=[img_input, left_excl, top_excl], outputs=[excl_preview])
1279
- left_excl.change(fn=update_exclusion_preview,
1280
- inputs=[img_input, left_excl, top_excl], outputs=[excl_preview])
1281
- top_excl.change(fn=update_exclusion_preview,
1282
- inputs=[img_input, left_excl, top_excl], outputs=[excl_preview])
1283
-
1284
- def on_crop_click(base_img, points, evt: gr.SelectData):
1285
- updated_img, updated_pts = add_crop_point(base_img, points, evt)
1286
- n = len(updated_pts)
1287
- status = (f"*{n} / 4 points set — keep clicking*" if n < 4
1288
- else "*4 points set ✓ — click **✕ Clear** to redo, or run segmentation*")
1289
- return updated_img, updated_pts, status
1290
-
1291
- crop_display.select(fn=on_crop_click,
1292
- inputs=[base_image_state, crop_points_state],
1293
- outputs=[crop_display, crop_points_state, crop_status])
1294
-
1295
- def on_clear_crop(base_img):
1296
- img, pts = clear_crop_points(base_img)
1297
- return img, pts, "*Points cleared — click to set new vertices*"
1298
-
1299
- clear_crop_btn.click(fn=on_clear_crop,
1300
- inputs=[base_image_state],
1301
- outputs=[crop_display, crop_points_state, crop_status])
1302
-
1303
- segment_btn.click(
1304
- fn=run_segmentation,
1305
- inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
1306
- use_min_filter, use_max_filter,
1307
- use_stereo, left_excl, top_excl, crop_points_state],
1308
- outputs=[cell_count_out, overlay_out, info_out, viability_section,
1309
- masks_state, image_state, confluency_out, min_size_recommendation, raw_image_state]
1310
- )
1311
-
1312
- # ---- Run Viability button -------------------------------------------
1313
- def on_run_viability(stored_masks, stored_image):
1314
- overlay, alive, dead, viab_pct, info, label_map = run_viability(stored_masks, stored_image)
1315
- return overlay, alive, dead, viab_pct, info, label_map
1316
-
1317
- viab_run_btn.click(
1318
- fn=on_run_viability,
1319
- inputs=[masks_state, image_state],
1320
- outputs=[viab_overlay, live_count_out, dead_count_out,
1321
- viab_percent_out, viab_info, label_map_state]
1322
- ).then(
1323
- fn=save_tab_result,
1324
- inputs=[cell_count_out, confluency_out, viab_percent_out],
1325
- outputs=[result_state]
1326
- )
1327
-
1328
- # ---- Build correction grid -----------------------------------------
1329
- def on_build_grid(stored_masks, stored_image, label_map, stored_raw_image):
1330
- if stored_masks is None or stored_image is None or not label_map:
1331
- return (gr.update(visible=False), [],
1332
- gr.update(value="*Run viability analysis first.*", visible=True))
1333
- masks = unpack_array(stored_masks)
1334
- image_np = unpack_array(stored_image)
1335
- raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1336
- features = extract_cell_features(image_np, masks)
1337
- labelled = attach_viability_labels(features, masks, image_np, label_map)
1338
- if not labelled:
1339
- return (gr.update(visible=False), [],
1340
- gr.update(value="*No cells found.*", visible=True))
1341
- grid = build_correction_grid(image_np, masks, labelled, raw_image_np)
1342
- n = len(labelled)
1343
- dead = sum(1 for r in labelled if r["label"] == 1)
1344
- msg = (f"*{n} cells — {n-dead} live (green), {dead} dead (red). "
1345
- f"Tap any thumbnail to flip its label.*")
1346
- return gr.update(value=grid, visible=True), labelled, gr.update(value=msg, visible=True)
1347
-
1348
- build_grid_btn.click(
1349
- fn=on_build_grid,
1350
- inputs=[masks_state, image_state, label_map_state, raw_image_state],
1351
- outputs=[correction_grid, labelled_state, correction_status]
1352
- )
1353
-
1354
- # ---- Grid tap — flip label, update overlay + counts ----------------
1355
- def on_grid_tap(labelled, stored_masks, stored_image, stored_raw_image, evt: gr.SelectData):
1356
- if not labelled or stored_masks is None:
1357
- return None, labelled, "", 0, 0, 0.0, None, {}
1358
- masks = unpack_array(stored_masks)
1359
- image_np = unpack_array(stored_image)
1360
- raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1361
- grid, updated, msg = toggle_cell_label(labelled, image_np, masks, raw_image_np, evt)
1362
-
1363
- # Rebuild label_map from corrected labelled list
1364
- new_label_map = {int(f["cell_id"]): int(f["label"]) for f in updated}
1365
- overlay_np = draw_viability_overlay(image_np, masks, new_label_map)
1366
- dead = sum(1 for f in updated if f["label"] == 1)
1367
- alive = len(updated) - dead
1368
- total = alive + dead
1369
- viab_pct = (alive / total * 100) if total > 0 else 0.0
1370
-
1371
- return (grid, updated, f"*{msg}*",
1372
- alive, dead, viab_pct,
1373
- Image.fromarray(overlay_np), new_label_map)
1374
-
1375
- correction_grid.select(
1376
- fn=on_grid_tap,
1377
- inputs=[labelled_state, masks_state, image_state, raw_image_state],
1378
- outputs=[correction_grid, labelled_state, correction_status,
1379
- live_count_out, dead_count_out, viab_percent_out,
1380
- viab_overlay, label_map_state]
1381
- )
1382
-
1383
- # ---- Export --------------------------------------------------------
1384
- def on_export(stored_masks, stored_image, labelled, label_map):
1385
- path, msg = prepare_export_corrected(stored_masks, stored_image, labelled, label_map)
1386
- if path is None:
1387
- return gr.update(visible=False), msg
1388
- return gr.update(value=path, visible=True), msg
1389
-
1390
- export_btn.click(
1391
- fn=on_export,
1392
- inputs=[masks_state, image_state, labelled_state, label_map_state],
1393
- outputs=[export_file, export_info]
1394
- )
1395
-
1396
-
1397
-
1398
- # Gradio interface
1399
-
1400
- with gr.Blocks(
1401
- title="CellposeCellCounter",
1402
- theme=gr.themes.Soft(),
1403
- ) as demo:
1404
- gr.Markdown("# CellposeCellCounter")
1405
- gr.Markdown("For accurate cell confluency, crop the image to display only desired area. Note that some image file types are not yet supported. PNG and JPEG are preferred.")
1406
-
1407
- # Shared mask/image state (one pair per tab so tabs don't clobber each other)
1408
- masks_states = [gr.State(value=None) for _ in range(4)]
1409
- image_states = [gr.State(value=None) for _ in range(4)]
1410
- result_states = [gr.State(value=None) for _ in range(4)]
1411
-
1412
- # Build Tabs 1–4 with a loop
1413
- for i in range(4):
1414
- build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
1415
-
1416
- # -------------------------------------------------------------------------
1417
- # Tab 5 — Summary
1418
- # -------------------------------------------------------------------------
1419
- with gr.Tab("Tab 5 — Summary"):
1420
- gr.Markdown("## Average Results Across All Tabs")
1421
- gr.Markdown(
1422
- "Run segmentation in one or more tabs, "
1423
- "then click **Refresh Summary** to see the averages."
1424
- )
1425
-
1426
- refresh_btn = gr.Button("🔄 Refresh Summary", variant="primary", size="lg")
1427
-
1428
- with gr.Row():
1429
- avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
1430
- avg_conf_out = gr.Number(label="Avg Confluency (%)", precision=1)
1431
- avg_viab_out = gr.Number(label="Avg Viability (%)", precision=1)
1432
-
1433
- summary_box = gr.Textbox(label="Per-Tab Breakdown", lines=10)
1434
-
1435
- refresh_btn.click(
1436
- fn=compute_summary,
1437
- inputs=result_states, # list of 4 gr.State components
1438
- outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
1439
- )
1440
-
1441
-
1442
-
1443
- if __name__ == "__main__":
1444
- demo.launch()