LiangLabUMB commited on
Commit
1d5eee4
·
verified ·
1 Parent(s): 3f8563f

Delete app.py

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