LiangLabUMB commited on
Commit
b50c3b9
·
verified ·
1 Parent(s): 5a0a3b2

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1766
app.py DELETED
@@ -1,1766 +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 size limits (aggressive for Safari) ----
39
- MAX_SIDE = 1024
40
- MAX_PIXELS = 1024 * 1024
41
-
42
-
43
- def safe_resize(image_np):
44
- """
45
- Downscale image to fit within MAX_SIDE and MAX_PIXELS while
46
- preserving aspect ratio. Works for RGB / RGBA / grayscale.
47
- """
48
- h, w = image_np.shape[:2]
49
- total = h * w
50
-
51
- if max(h, w) <= MAX_SIDE and total <= MAX_PIXELS:
52
- return image_np
53
-
54
- # compute scale
55
- scale_side = MAX_SIDE / max(h, w)
56
- scale_pixels = (MAX_PIXELS / total) ** 0.5
57
- scale = min(scale_side, scale_pixels)
58
-
59
- new_w = max(1, int(w * scale))
60
- new_h = max(1, int(h * scale))
61
-
62
- return cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
63
-
64
-
65
- def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
66
-
67
- h, w = image_np.shape[:2]
68
-
69
- # Convert to PIL for drawing
70
- img_pil = Image.fromarray(image_np)
71
- draw = ImageDraw.Draw(img_pil, 'RGBA')
72
-
73
- # Calculate pixel widths from percentages
74
- left_px = int(w * left_width_pct / 100)
75
- top_px = int(h * top_width_pct / 100)
76
-
77
- # Draw overlays for exclusion zones
78
- if left_px > 0:
79
- # Left exclusion zone
80
- draw.rectangle(
81
- [(0, 0), (left_px, h)],
82
- fill=(255, 0, 0, 80) # Semi-transparent red
83
- )
84
- # border line
85
- draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
86
-
87
- if top_px > 0:
88
- # Top exclusion zone
89
- draw.rectangle(
90
- [(0, 0), (w, top_px)],
91
- fill=(255, 0, 0, 80) # Semi-transparent red
92
- )
93
- # border line
94
- draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
95
-
96
- return np.array(img_pil)
97
-
98
-
99
- def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
100
- """
101
- Exclude every cell touching the left or top exclusion zone.
102
-
103
- A cell intersects the half-plane x < left_px exactly when its leftmost
104
- pixel does, so per-label bounding boxes give an exact answer -- no need to
105
- approximate with centroids and radii. scipy's find_objects collects every
106
- bounding box in a single pass, instead of one full-image comparison per
107
- cell, which is what makes this cheap enough to re-run interactively.
108
-
109
- Cell ids are NOT renumbered: stable ids let the exclusion be re-applied
110
- after viability classification without invalidating its label map.
111
- """
112
- from scipy import ndimage
113
-
114
- h, w = masks.shape
115
- left_px = int(w * left_width_pct / 100)
116
- top_px = int(h * top_width_pct / 100)
117
-
118
- if left_px <= 0 and top_px <= 0:
119
- n = len(np.unique(masks)) - (1 if (masks == 0).any() else 0)
120
- return masks.copy(), 0, n
121
-
122
- boxes = ndimage.find_objects(masks)
123
- excluded_ids = []
124
- included_ids = []
125
- for idx, box in enumerate(boxes):
126
- if box is None: # id absent from the label image
127
- continue
128
- cell_id = idx + 1
129
- row_slice, col_slice = box
130
- touches_left = left_px > 0 and col_slice.start < left_px
131
- touches_top = top_px > 0 and row_slice.start < top_px
132
- if touches_left or touches_top:
133
- excluded_ids.append(cell_id)
134
- else:
135
- included_ids.append(cell_id)
136
-
137
- filtered_masks = masks.copy()
138
- if excluded_ids:
139
- drop = np.zeros(int(masks.max()) + 1, dtype=bool)
140
- drop[np.asarray(excluded_ids, dtype=np.int64)] = True
141
- filtered_masks[drop[masks]] = 0
142
-
143
- return filtered_masks, len(excluded_ids), len(included_ids)
144
-
145
-
146
- def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
147
-
148
- h, w = image_np.shape[:2]
149
-
150
- # Convert to PIL for drawing
151
- img_pil = Image.fromarray(image_np)
152
- draw = ImageDraw.Draw(img_pil, 'RGBA')
153
-
154
- # Calculate pixel widths from percentages
155
- left_px = int(w * left_width_pct / 100)
156
- top_px = int(h * top_width_pct / 100)
157
-
158
- # Draw overlays for exclusion zones
159
- if left_px > 0:
160
- # Left exclusion zone
161
- draw.rectangle(
162
- [(0, 0), (left_px, h)],
163
- fill=(255, 0, 0, 80) # Semi-transparent red
164
- )
165
- # border line
166
- draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
167
-
168
- if top_px > 0:
169
- # Top exclusion zone
170
- draw.rectangle(
171
- [(0, 0), (w, top_px)],
172
- fill=(255, 0, 0, 80) # Semi-transparent red
173
- )
174
- # border line
175
- draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
176
-
177
- return np.array(img_pil)
178
-
179
-
180
- def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
181
- h, w = masks.shape
182
-
183
- # Calculate pixel widths from percentages
184
- left_px = int(w * left_width_pct / 100)
185
- top_px = int(h * top_width_pct / 100)
186
-
187
- filtered_masks = masks.copy()
188
- cell_ids = np.unique(masks)
189
- cell_ids = cell_ids[cell_ids > 0]
190
-
191
- excluded_cells = []
192
- included_cells = []
193
-
194
- for cell_id in cell_ids:
195
- cell_mask = (masks == cell_id)
196
-
197
- # Get cell boundary coordinates
198
- rows, cols = np.where(cell_mask)
199
-
200
- # Check if cell touches left exclusion zone
201
- touches_left = np.any(cols < left_px) if left_px > 0 else False
202
-
203
- # Check if cell touches top exclusion zone
204
- touches_top = np.any(rows < top_px) if top_px > 0 else False
205
-
206
- # Exclude if touching left or top
207
- if touches_left or touches_top:
208
- filtered_masks[cell_mask] = 0
209
- excluded_cells.append(cell_id)
210
- else:
211
- included_cells.append(cell_id)
212
-
213
- # Renumber remaining cells
214
- unique_ids = np.unique(filtered_masks)
215
- unique_ids = unique_ids[unique_ids > 0]
216
-
217
- renumbered_masks = np.zeros_like(filtered_masks)
218
- for new_id, old_id in enumerate(unique_ids, start=1):
219
- renumbered_masks[filtered_masks == old_id] = new_id
220
-
221
- return renumbered_masks, len(excluded_cells), len(included_cells)
222
-
223
-
224
-
225
- FEATURE_COLS_INFERENCE = [
226
- "mean_r", "mean_g", "mean_b", "std_r", "std_g", "std_b",
227
- "mean_h", "mean_s", "mean_v", "std_s", "std_v",
228
- "blue_red_ratio", "blue_green_ratio", "rg_ratio",
229
- "inner_brightness", "peak_brightness",
230
- "bright_spot_fraction", "ring_darkness",
231
- "centre_periphery_ratio", "brightness_std_normalised",
232
- ]
233
-
234
-
235
- def classify_cells_by_model(image_np, masks):
236
- """
237
- Run the trained LogisticRegression classifier to predict live/dead per cell.
238
- Returns (dead_count, alive_count, overlay_np, {cell_id: label}).
239
- Requires VIABILITY_CLF and VIABILITY_SCALER to be loaded.
240
- """
241
- import numpy as np
242
- cell_ids = np.unique(masks)
243
- cell_ids = cell_ids[cell_ids > 0]
244
- if len(cell_ids) == 0:
245
- return 0, 0, image_np.copy(), {}
246
-
247
- features = extract_cell_features(image_np, masks)
248
- if not features:
249
- return 0, 0, image_np.copy(), {}
250
-
251
- import numpy as np
252
- X = np.array([[f[c] for c in FEATURE_COLS_INFERENCE] for f in features], dtype=np.float32)
253
-
254
- # replace any NaN/Inf with column median
255
- for j in range(X.shape[1]):
256
- bad = ~np.isfinite(X[:, j])
257
- if bad.any():
258
- X[bad, j] = float(np.nanmedian(X[:, j]))
259
-
260
- X_scaled = VIABILITY_SCALER.transform(X)
261
- predictions = VIABILITY_CLF.predict(X_scaled) # 0=live, 1=dead
262
-
263
- label_map = {int(f["cell_id"]): int(p) for f, p in zip(features, predictions)}
264
- overlay = draw_viability_overlay(image_np, masks, label_map)
265
-
266
- dead = int(sum(predictions))
267
- alive = int(len(predictions) - dead)
268
- return dead, alive, overlay, label_map
269
-
270
-
271
- def draw_viability_overlay(image_np, masks, label_map):
272
- """
273
- Draw coloured cell outlines onto image_np: green = live, red = dead.
274
- label_map: {cell_id: 0=live, 1=dead}
275
- Returns a uint8 numpy array.
276
- """
277
- overlay = image_np.copy()
278
- cell_ids = np.unique(masks)
279
- cell_ids = cell_ids[cell_ids > 0]
280
-
281
- for cid in cell_ids:
282
- label = label_map.get(int(cid), 0)
283
- color = (220, 50, 50) if label == 1 else (50, 220, 80)
284
- cell_mask = (masks == cid).astype(np.uint8)
285
- contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
286
- cv2.drawContours(overlay, contours, -1, color, thickness=2)
287
-
288
- return overlay
289
-
290
-
291
- def classify_cells_by_blueness(image_np, masks, threshold_bias):
292
- """
293
- Classify cells as dead (blue) or alive using an adaptive Otsu threshold
294
- on per-cell blueness scores, with a user bias to fine-tune.
295
-
296
- Args:
297
- image_np: RGB image array
298
- masks: Cellpose segmentation masks
299
- threshold_bias: Slider value -50..+50; shifts Otsu threshold up/down.
300
- Negative = more cells classified dead (looser).
301
- Positive = fewer cells classified dead (stricter).
302
- 0 = pure Otsu (fully automatic).
303
-
304
- Returns:
305
- dead_count, alive_count, colored_overlay, otsu_threshold, final_threshold
306
- """
307
-
308
- if len(image_np.shape) == 2:
309
- image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
310
- elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
311
- image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
312
-
313
- hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
314
-
315
- hue = hsv[:, :, 0].astype(np.float32)
316
- saturation = hsv[:, :, 1].astype(np.float32)
317
-
318
- # Raw blueness: hue proximity to 115° × saturation
319
- hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
320
- hue_score = np.maximum(0, 1 - hue_distance / 65)
321
- blueness = hue_score * (saturation / 255.0)
322
-
323
- # --- Compute per-cell mean blueness scores ---
324
- cell_ids = np.unique(masks)
325
- cell_ids = cell_ids[cell_ids > 0]
326
-
327
- if len(cell_ids) == 0:
328
- blank = image_np.copy()
329
- return 0, 0, blank, 0.0, 0.0
330
-
331
- cell_scores = np.array([np.mean(blueness[masks == cid]) for cid in cell_ids])
332
-
333
- # --- Otsu on the distribution of per-cell scores ---
334
- # cv2.threshold expects uint8; scale 0-1 → 0-255
335
- scores_u8 = (np.clip(cell_scores, 0, 1) * 255).astype(np.uint8)
336
-
337
- if scores_u8.max() == scores_u8.min():
338
- # All cells identical → Otsu is undefined; use midpoint
339
- otsu_threshold = float(scores_u8[0]) / 255.0
340
- else:
341
- # Reshape to a single-column image so cv2.threshold works
342
- thresh_val, _ = cv2.threshold(
343
- scores_u8.reshape(-1, 1), 0, 255,
344
- cv2.THRESH_BINARY + cv2.THRESH_OTSU
345
- )
346
- otsu_threshold = thresh_val / 255.0
347
-
348
- # --- Apply user bias: slider -50..+50 maps to ±0.20 shift ---
349
- bias = (threshold_bias / 50.0) * 0.20
350
- final_threshold = float(np.clip(otsu_threshold + bias, 0.0, 1.0))
351
-
352
- # --- Classify ---
353
- dead_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s > final_threshold]
354
- alive_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s <= final_threshold]
355
-
356
- # --- Outline-only overlay on raw image with enumerated labels ---
357
- final_overlay = image_np.copy()
358
-
359
- # Compute a consistent enumeration order (cell_ids is already sorted ascending)
360
- cell_enum = {cid: idx + 1 for idx, cid in enumerate(cell_ids)}
361
-
362
- dead_set = set(dead_cells)
363
- alive_set = set(alive_cells)
364
-
365
- for cid in cell_ids:
366
- cell_mask = (masks == cid).astype(np.uint8)
367
- contours, _ = cv2.findContours(cell_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
368
- color = (220, 50, 50) if cid in dead_set else (50, 220, 80)
369
- cv2.drawContours(final_overlay, contours, -1, color, thickness=2)
370
-
371
- # Draw enumeration label at centroid
372
- ys, xs = np.where(cell_mask)
373
- if len(ys) > 0:
374
- cx, cy = int(xs.mean()), int(ys.mean())
375
- label_str = str(cell_enum[cid])
376
- font = cv2.FONT_HERSHEY_SIMPLEX
377
- font_scale = 0.35
378
- thickness = 1
379
- (tw, th), _ = cv2.getTextSize(label_str, font, font_scale, thickness)
380
- # Dark background rectangle for readability
381
- cv2.rectangle(
382
- final_overlay,
383
- (cx - tw // 2 - 1, cy - th // 2 - 1),
384
- (cx + tw // 2 + 1, cy + th // 2 + 1),
385
- (0, 0, 0),
386
- -1
387
- )
388
- cv2.putText(
389
- final_overlay, label_str,
390
- (cx - tw // 2, cy + th // 2),
391
- font, font_scale, color, thickness, cv2.LINE_AA
392
- )
393
-
394
- return len(dead_cells), len(alive_cells), final_overlay, otsu_threshold, final_threshold
395
-
396
-
397
- def measure_confluency(masks, image_np):
398
- tot_pixels = image_np.shape[0] * image_np.shape[1]
399
- cell_pixels = np.count_nonzero(masks)
400
- confluency = cell_pixels / tot_pixels * 100
401
- return confluency
402
-
403
- def filter_mask_by_size(masks, minimum_pixels):
404
- filtered_masks = masks.copy()
405
- cell_ids = np.unique(masks)
406
- cell_ids = cell_ids[cell_ids > 0]
407
-
408
- removed_count = 0
409
-
410
- for cell_id in cell_ids:
411
- cell_mask = (masks == cell_id)
412
- cell_pixels = np.count_nonzero(cell_mask)
413
- if cell_pixels < minimum_pixels:
414
- filtered_masks[cell_mask] = 0
415
- removed_count += 1
416
-
417
- unique_ids = np.unique(filtered_masks)
418
- unique_ids = unique_ids[unique_ids > 0]
419
-
420
- renumbered_masks = np.zeros_like(filtered_masks)
421
- for new_id, old_id in enumerate(unique_ids, start=1):
422
- renumbered_masks[filtered_masks == old_id] = new_id
423
-
424
- return renumbered_masks, removed_count
425
-
426
-
427
- def filter_mask_by_maxsize(masks, maximum_pixels):
428
- filtered_masks = masks.copy()
429
- cell_ids = np.unique(masks)
430
- cell_ids = cell_ids[cell_ids > 0]
431
-
432
- removed_count = 0
433
- for cell_id in cell_ids:
434
- cell_mask = (masks == cell_id)
435
- cell_pixels = np.count_nonzero(cell_mask)
436
- if cell_pixels > maximum_pixels:
437
- filtered_masks[cell_mask] = 0
438
- removed_count += 1
439
-
440
- unique_ids = np.unique(filtered_masks)
441
- unique_ids = unique_ids[unique_ids > 0]
442
-
443
- renumbered_masks = np.zeros_like(filtered_masks)
444
- for new_id, old_id in enumerate(unique_ids, start=1):
445
- renumbered_masks[filtered_masks == old_id] = new_id
446
-
447
- return renumbered_masks, removed_count
448
-
449
-
450
- def rec_min_size(masks, q=25):
451
- ids = np.unique(masks)
452
- ids = ids[ids > 0]
453
- if len(ids) == 0:
454
- return 0
455
- sizes = np.array([np.count_nonzero(masks == cid) for cid in ids])
456
- return int(round(np.percentile(sizes, q)))
457
-
458
-
459
- def apply_polygon_mask(image_pil, points_json):
460
- """
461
- Given a PIL image and a JSON string of [[x,y],...] points,
462
- zero out everything outside the polygon and return a PIL image.
463
- """
464
- import json
465
- if not points_json or points_json.strip() in ("", "[]"):
466
- return image_pil
467
- try:
468
- pts = json.loads(points_json)
469
- except Exception:
470
- return image_pil
471
- if len(pts) < 3:
472
- return image_pil
473
-
474
- image_np = np.array(image_pil)
475
- h, w = image_np.shape[:2]
476
- poly = np.array(pts, dtype=np.int32)
477
- poly[:, 0] = np.clip(poly[:, 0], 0, w - 1)
478
- poly[:, 1] = np.clip(poly[:, 1], 0, h - 1)
479
- mask = np.zeros((h, w), dtype=np.uint8)
480
- cv2.fillPoly(mask, [poly], 255)
481
- if len(image_np.shape) == 3:
482
- result = np.where(mask[:, :, np.newaxis] == 255, image_np, 0).astype(np.uint8)
483
- else:
484
- result = np.where(mask == 255, image_np, 0).astype(np.uint8)
485
- return Image.fromarray(result)
486
-
487
- def order_quad(pts):
488
- """
489
- Order 4 points as (top-left, top-right, bottom-right, bottom-left).
490
- Sorts by angle about the centroid so the result stays correct for
491
- rotated quads, where the sum/difference heuristic breaks down.
492
- """
493
- pts = np.asarray(pts, dtype=np.float32)
494
- centre = pts.mean(axis=0)
495
- angles = np.arctan2(pts[:, 1] - centre[1], pts[:, 0] - centre[0])
496
- # Clockwise on screen (y grows downward) == increasing angle here
497
- ordered = pts[np.argsort(angles)]
498
- # Rotate so the corner nearest the top-left of the quad comes first
499
- start = np.argmin(ordered.sum(axis=1))
500
- return np.roll(ordered, -start, axis=0)
501
-
502
-
503
- def quad_output_size(points):
504
- """
505
- Width and height, in source pixels, of the rectangle a 4-point quad
506
- warps to. Shared by the warp itself and the exclusion-zone preview so
507
- the two can never disagree about the ROI's dimensions.
508
- """
509
- tl, tr, br, bl = order_quad(points)
510
- out_w = max(1, int(round(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)))))
511
- out_h = max(1, int(round(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl)))))
512
- return out_w, out_h
513
-
514
-
515
- def warp_polygon_to_square(image_np, points):
516
- src = order_quad(points)
517
- out_w, out_h = quad_output_size(points)
518
-
519
- dst = np.array(
520
- [[0, 0],
521
- [out_w - 1, 0],
522
- [out_w - 1, out_h - 1],
523
- [0, out_h - 1]],
524
- dtype=np.float32)
525
-
526
- M = cv2.getPerspectiveTransform(src, dst)
527
- warped = cv2.warpPerspective(image_np, M, (out_w, out_h))
528
- return warped
529
-
530
-
531
- def toggle_stereological_mode(use_stereology):
532
- """Show/hide stereological controls based on checkbox"""
533
- return gr.update(visible=use_stereology)
534
-
535
-
536
- def exclusion_pct_for_preview(image_shape, crop_points, left_pct, top_pct):
537
- """
538
- Exclusion percentages restated relative to the FULL image.
539
-
540
- The exclusion zones are applied to the cropped/warped ROI, so a given
541
- percentage covers far fewer pixels than the same percentage of the whole
542
- upload. Rescaling by the ROI's dimensions lets the full-image preview draw
543
- a band of the true width instead of a misleadingly wide one.
544
- """
545
- h, w = image_shape[:2]
546
- if not crop_points or len(crop_points) != 4 or w == 0 or h == 0:
547
- return left_pct, top_pct
548
- out_w, out_h = quad_output_size(crop_points)
549
- return left_pct * out_w / float(w), top_pct * out_h / float(h)
550
-
551
-
552
- def update_exclusion_preview(image, left_width, top_width, crop_points=None):
553
- """Update the preview image with exclusion zone overlay"""
554
- if image is None:
555
- return None
556
-
557
- image_np = np.array(image)
558
- left_shown, top_shown = exclusion_pct_for_preview(
559
- image_np.shape, crop_points, left_width, top_width
560
- )
561
- overlay = draw_exclusion_overlay(image_np, left_shown, top_shown)
562
- return Image.fromarray(overlay)
563
-
564
-
565
- # ---------------------------------------------------------------------------
566
- # Patch segmentation
567
- # ---------------------------------------------------------------------------
568
- PATCH_SIZE = 512 # target patch side length
569
- PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
570
- MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
571
-
572
-
573
- def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
574
- """
575
- Split image into overlapping patches.
576
- Returns list of (patch_np, row_start, col_start) tuples.
577
- """
578
- h, w = image_np.shape[:2]
579
- patches = []
580
- row = 0
581
- while row < h:
582
- row_end = min(row + patch_size, h)
583
- col = 0
584
- while col < w:
585
- col_end = min(col + patch_size, w)
586
- patch = image_np[row:row_end, col:col_end]
587
- patches.append((patch, row, col))
588
- if col_end == w:
589
- break
590
- col += patch_size - overlap
591
- if row_end == h:
592
- break
593
- row += patch_size - overlap
594
- return patches
595
-
596
-
597
- def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
598
- """
599
- Stitch per-patch masks into a single full-image mask.
600
-
601
- Strategy:
602
- - Each patch gets a unique ID offset so cell IDs never collide.
603
- - Patches are pasted into the canvas using a priority canvas that
604
- gives interior pixels precedence over overlap-border pixels.
605
- - After pasting, cells whose centroids fall in the overlap zone
606
- of two adjacent patches are deduplicated: if two cells from
607
- different patches share >50% IoU they are the same cell — keep
608
- the one whose centroid is furthest from a patch edge.
609
- """
610
- full_mask = np.zeros((full_h, full_w), dtype=np.int32)
611
- # track which patch_idx owns each pixel (used for overlap resolution)
612
- owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
613
- # distance-to-nearest-edge for the owning patch (higher = more central)
614
- priority = np.zeros((full_h, full_w), dtype=np.float32)
615
-
616
- id_offset = 0
617
- patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
618
-
619
- for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
620
- ph, pw = mask_patch.shape
621
- # offset all non-zero IDs so they're globally unique.
622
- # Widen BEFORE adding: cellpose returns uint16 masks, and adding the
623
- # offset in that dtype wraps once ids pass 65535.
624
- mask_patch = mask_patch.astype(np.int32, copy=False)
625
- shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
626
-
627
- # compute per-pixel priority = min distance to any patch edge
628
- rows_idx = np.arange(ph)
629
- cols_idx = np.arange(pw)
630
- dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
631
- dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
632
- pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
633
-
634
- roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
635
- roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
636
- roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
637
-
638
- # where this patch has higher priority, overwrite
639
- better = pri_patch > roi_pri
640
- roi_full [better] = shifted [better]
641
- roi_owner[better] = patch_idx
642
- roi_pri [better] = pri_patch [better]
643
-
644
- max_id = int(mask_patch.max())
645
- patch_meta.append((id_offset, row_start, col_start, ph, pw))
646
- id_offset += max_id + 1
647
-
648
- # --- Renumber to compact sequential IDs ---
649
- unique_ids = np.unique(full_mask)
650
- unique_ids = unique_ids[unique_ids > 0]
651
- renumbered = np.zeros_like(full_mask)
652
- for new_id, old_id in enumerate(unique_ids, start=1):
653
- renumbered[full_mask == old_id] = new_id
654
-
655
- return renumbered
656
-
657
-
658
- def _segment_patch(args):
659
- """Worker: run cellpose on a single patch. Called from a thread pool."""
660
- patch_np, row_start, col_start, model_filename, hf_repo = args
661
- # Each thread uses the shared loaded_models cache (GIL-safe for reads;
662
- # model.eval() releases the GIL during GPU work so threads overlap.)
663
- model_path = hf_hub_download(repo_id=hf_repo, filename=model_filename)
664
- if model_filename in loaded_models:
665
- model = loaded_models[model_filename]
666
- else:
667
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
668
- loaded_models[model_filename] = model
669
-
670
- mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
671
- return mask, row_start, col_start
672
-
673
-
674
- def run_segmentation_patched(image_np, model_filename):
675
- """
676
- Split image into overlapping patches, run Cellpose on each in parallel,
677
- then stitch back into a single full-resolution mask.
678
- Falls back to whole-image segmentation if the image is small enough
679
- that patching adds overhead without benefit.
680
- """
681
- h, w = image_np.shape[:2]
682
- model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
683
- if model_filename in loaded_models:
684
- model = loaded_models[model_filename]
685
- else:
686
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
687
- loaded_models[model_filename] = model
688
-
689
- # Small images: no benefit from patching
690
- if max(h, w) <= MIN_PATCH_DIM * 2:
691
- mask, _, _ = model.eval(image_np, diameter=None, channels=[0, 0])
692
- return mask, 1 # 1 patch
693
-
694
- patches = _split_patches(image_np)
695
- n_patches = len(patches)
696
-
697
- # Build argument list for the thread pool
698
- args_list = [
699
- (patch, r, c, model_filename, HF_REPO_ID)
700
- for patch, r, c in patches
701
- ]
702
-
703
- patch_results = [] # (mask, row_start, col_start) in submission order
704
-
705
- # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
706
- with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
707
- futures = {pool.submit(_segment_patch, a): a for a in args_list}
708
- for future in as_completed(futures):
709
- mask_patch, row_start, col_start = future.result()
710
- patch_results.append((mask_patch, row_start, col_start))
711
-
712
- # Re-sort by (row, col) so stitching is deterministic
713
- patch_results.sort(key=lambda x: (x[1], x[2]))
714
-
715
- full_mask = _merge_patch_masks(patch_results, h, w)
716
- return full_mask, n_patches
717
-
718
-
719
- def render_segmentation(base_masks, processed_image_np,
720
- use_stereology, left_exclusion, top_exclusion):
721
- """
722
- Apply the stereological exclusion to already-segmented masks and rebuild
723
- the overlay. Split out of run_segmentation so the exclusion zones can be
724
- adjusted after segmentation without re-running Cellpose.
725
-
726
- Returns (masks, cell_count, confluency, overlay_pil, excluded_count).
727
- """
728
- if use_stereology:
729
- masks, excluded_count, _ = apply_stereological_exclusion(
730
- base_masks, left_exclusion, top_exclusion
731
- )
732
- else:
733
- masks, excluded_count = base_masks.copy(), 0
734
-
735
- cell_count = int(len(np.unique(masks)) - (1 if (masks == 0).any() else 0))
736
- confluency = measure_confluency(masks, processed_image_np)
737
-
738
- overlay = processed_image_np.copy().astype(np.float32)
739
- if masks.max() > 0:
740
- np.random.seed(42) # For consistent random colors
741
- colors = np.random.randint(0, 255, size=(int(masks.max()) + 1, 3))
742
- colors[0] = [0, 0, 0]
743
- colored_mask = colors[masks]
744
- alpha = 0.4
745
- overlay = (1 - alpha) * overlay + alpha * colored_mask
746
- overlay = np.clip(overlay, 0, 255).astype(np.uint8)
747
-
748
- if use_stereology:
749
- overlay = draw_exclusion_overlay(overlay, left_exclusion, top_exclusion)
750
-
751
- return masks, cell_count, confluency, Image.fromarray(overlay), excluded_count
752
-
753
-
754
- @spaces.GPU
755
- def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
756
- use_stereology, left_exclusion, top_exclusion,
757
- crop_points=None, use_min_filter=False, use_max_filter=False):
758
- image_np = np.array(image)
759
-
760
- # Crop BEFORE downscaling, so the ROI is sampled from the original pixels
761
- # rather than upscaled out of a ≤MAX_SIDE working copy. The crop points were
762
- # clicked on the full-resolution upload, so they are already in this space.
763
- # (Need ≥3 points for a polygon.)
764
- if crop_points and len(crop_points) >= 3:
765
- import json
766
- if len(crop_points) == 4:
767
- # The perspective warp already discards everything outside the quad,
768
- # so masking first would only round the corners off.
769
- image_np = warp_polygon_to_square(image_np, crop_points)
770
- else:
771
- pts_json = json.dumps([[float(x), float(y)] for x, y in crop_points])
772
- image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), pts_json)
773
- image_np = np.array(image_pil_masked)
774
-
775
- # Cap the working image only after cropping — a small ROI now keeps its
776
- # native detail, and a large one is still bounded for segmentation.
777
- image_np = safe_resize(image_np)
778
-
779
- # Un-annotated copy of whatever we actually segment, kept for cell thumbnails.
780
- # Must be taken after the crop so it stays index-compatible with the masks.
781
- raw_image_np = image_np.copy()
782
-
783
-
784
- try:
785
- model_filename = MODEL_OPTIONS[model_choice]
786
-
787
- # Process image format to RGB
788
- if len(image_np.shape) == 2:
789
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
790
- elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
791
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
792
- else:
793
- processed_image_np = image_np
794
-
795
- # Run patch-parallel Cellpose segmentation
796
- masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
797
-
798
- ids = np.unique(masks_raw)
799
- ids = ids[ids > 0]
800
-
801
- sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
802
-
803
- print("num_cells:", len(ids))
804
- print("mean:", sizes.mean() if len(sizes) > 0 else 0)
805
- print("median:", np.median(sizes) if len(sizes) > 0 else 0)
806
- print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
807
- print("max:", sizes.max() if len(sizes) > 0 else 0)
808
-
809
- # Compute recommendation from RAW masks
810
- recommend_min = rec_min_size(masks_raw)
811
-
812
- # Surface the recommendation as text rather than writing it into the
813
- # slider, which would overwrite a threshold the user had chosen.
814
- if recommend_min > 0:
815
- rec_msg = (f"*Recommended minimum: **{recommend_min}** pixels "
816
- f"(25th percentile of detected object sizes). "
817
- f"Enable the filter and leave the slider at 0 to use it.*")
818
- else:
819
- rec_msg = "*No objects detected — no minimum size recommendation.*"
820
-
821
- # Size filters only run when their checkbox is ticked. When the minimum
822
- # filter is on but the slider is still at 0, fall back to the
823
- # recommendation; when it is off, nothing is filtered at all.
824
- min_used = 0
825
- if use_min_filter:
826
- min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
827
-
828
- masks = masks_raw.copy()
829
- removed_small = 0
830
- removed_large = 0
831
-
832
- if use_min_filter and min_used > 0:
833
- masks, removed_small = filter_mask_by_size(masks, min_used)
834
-
835
- if use_max_filter and max_cell_size > 0:
836
- masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
837
-
838
- # Masks before exclusion are kept in state so the zones stay adjustable
839
- base_masks = masks
840
- masks, cell_count, confluency, overlay_pil, excluded_count = render_segmentation(
841
- base_masks, processed_image_np, use_stereology, left_exclusion, top_exclusion
842
- )
843
-
844
- filter_msg = ""
845
- if removed_small:
846
- filter_msg += f"Removed {removed_small} small objects (< {min_used} pixels).\n"
847
- if removed_large:
848
- filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
849
- if use_stereology and excluded_count > 0:
850
- filter_msg += f"Stereological exclusion: {excluded_count} cells excluded (touching left/top zones).\n"
851
-
852
- info_msg = ""
853
- if filter_msg:
854
- info_msg += filter_msg
855
- info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
856
- info_msg += f"Confluency: {confluency:.1f}%\n"
857
- info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
858
- if use_stereology:
859
- info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
860
- info_msg += "Exclusion zones stay adjustable below without re-segmenting.\n"
861
- info_msg += "Now run the viability classification model for viability assessment."
862
-
863
- return (
864
- cell_count,
865
- overlay_pil,
866
- info_msg,
867
- gr.update(visible=True),
868
- pack_array(masks),
869
- pack_array(processed_image_np),
870
- confluency,
871
- gr.update(value=rec_msg),
872
- pack_array(raw_image_np),
873
- pack_array(base_masks),
874
- )
875
-
876
- except Exception as e:
877
- import traceback
878
- traceback.print_exc()
879
- return (
880
- 0,
881
- None,
882
- f"Error during segmentation: {str(e)}",
883
- gr.update(visible=False),
884
- None,
885
- None,
886
- 0.0,
887
- gr.update(),
888
- None,
889
- None,
890
- )
891
-
892
-
893
- def run_viability(stored_masks, stored_image_np):
894
- """Run model-based viability classification. Returns overlay + counts + label_map."""
895
- if stored_masks is None or stored_image_np is None:
896
- return None, 0, 0, 0.0, "Please run segmentation first.", {}
897
- if VIABILITY_CLF is None:
898
- return None, 0, 0, 0.0, "No viability model found. Add viability_clf.pkl and viability_scaler.pkl to the app directory.", {}
899
-
900
- masks = unpack_array(stored_masks)
901
- image_np = unpack_array(stored_image_np)
902
-
903
- try:
904
- dead, alive, overlay_np, label_map = classify_cells_by_model(image_np, masks)
905
- total = alive + dead
906
- viab_pct = (alive / total * 100) if total > 0 else 0.0
907
- confluency = measure_confluency(masks, image_np)
908
- info_msg = f"Total cells: {total}\nLive (green): {alive}\nDead (red): {dead}\n"
909
- info_msg += f"Viability: {viab_pct:.1f}%\nConfluency: {confluency:.1f}%"
910
- return Image.fromarray(overlay_np), alive, dead, viab_pct, info_msg, label_map
911
- except Exception as e:
912
- import traceback; traceback.print_exc()
913
- return None, 0, 0, 0.0, f"Error: {str(e)}", {}
914
-
915
-
916
- def pack_array(arr):
917
- """
918
- Serialise an array for gr.State.
919
-
920
- Uses np.save rather than a PNG: label masks are int32 and routinely carry
921
- more than 255 cell ids, which a uint8 PNG silently wraps (id 256 becomes
922
- background). Dtype and values are preserved exactly.
923
- """
924
- buf = io.BytesIO()
925
- np.save(buf, arr, allow_pickle=False)
926
- return buf.getvalue()
927
-
928
-
929
- def unpack_array(data):
930
- buf = io.BytesIO(data)
931
- try:
932
- return np.load(buf, allow_pickle=False)
933
- except ValueError:
934
- # Legacy PNG-encoded state from an older session
935
- buf.seek(0)
936
- return np.array(Image.open(buf))
937
-
938
-
939
- def save_tab_result(cell_count, confluency, viab_percent):
940
- """Package per-tab results into a dict for Tab 5 summary."""
941
- return {
942
- "cell_count": float(cell_count) if cell_count is not None else None,
943
- "confluency": float(confluency) if confluency is not None else None,
944
- "viab_percent": float(viab_percent) if viab_percent is not None else None,
945
- }
946
-
947
-
948
- def compute_summary(r1, r2, r3, r4):
949
- """Average cell count, confluency, and viability across tabs that have data."""
950
- all_results = [r1, r2, r3, r4]
951
- valid = [(i + 1, r) for i, r in enumerate(all_results)
952
- if r is not None and r.get("cell_count") is not None]
953
-
954
- if not valid:
955
- return (
956
- 0.0, 0.0, 0.0,
957
- "No data yet — run segmentation in at least one tab, then click Refresh Summary."
958
- )
959
-
960
- avg_count = sum(r["cell_count"] for _, r in valid) / len(valid)
961
- avg_conf = sum(r["confluency"] for _, r in valid) / len(valid)
962
- avg_viab = sum(r["viab_percent"] for _, r in valid) / len(valid)
963
-
964
- lines = [f"Tab {tab_num}: {r['cell_count']:.0f} cells | "
965
- f"{r['confluency']:.1f}% confluency | "
966
- f"{r['viab_percent']:.1f}% viability"
967
- for tab_num, r in valid]
968
- lines.append(f"\nAverages ({len(valid)} tab{'s' if len(valid) > 1 else ''}):")
969
- lines.append(f" Cell count: {avg_count:.1f}")
970
- lines.append(f" Confluency: {avg_conf:.1f}%")
971
- lines.append(f" Viability: {avg_viab:.1f}%")
972
-
973
- return avg_count, avg_conf, avg_viab, "\n".join(lines)
974
-
975
-
976
- # ---------------------------------------------------------------------------
977
- # Training data export — feature extraction per cell
978
- # ---------------------------------------------------------------------------
979
-
980
- def extract_cell_features(image_np, masks):
981
- """
982
- For every segmented cell, extract a fixed feature vector from the pixels
983
- inside its mask. Returns a list of dicts, one per cell.
984
-
985
- Features:
986
- RGB channels — mean_r, mean_g, mean_b, std_r, std_g, std_b
987
- HSV channels — mean_h, mean_s, mean_v, std_s, std_v
988
- Ratios — blue_red_ratio, blue_green_ratio, rg_ratio
989
- Morphology — area_px, circularity
990
- Centre/edge profile — inner_brightness, peak_brightness,
991
- bright_spot_fraction, ring_darkness,
992
- centre_periphery_ratio, brightness_std_normalised
993
-
994
- Profile zones are tuned to hemocytometer live-cell morphology:
995
- a small intense specular highlight at the centre surrounded by a dark
996
- navy membrane ring. Dead cells are pale blue-grey blobs with no ring
997
- and no bright spot.
998
- """
999
- if len(image_np.shape) == 2:
1000
- image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
1001
- elif image_np.shape[2] == 4:
1002
- image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
1003
-
1004
- hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV).astype(np.float32)
1005
-
1006
- h_img, w_img = image_np.shape[:2]
1007
- grid_y, grid_x = np.mgrid[:h_img, :w_img]
1008
-
1009
- cell_ids = np.unique(masks)
1010
- cell_ids = cell_ids[cell_ids > 0]
1011
- rows = []
1012
-
1013
- for cid in cell_ids:
1014
- cell_mask = (masks == cid)
1015
- pixels_rgb = image_np[cell_mask].astype(np.float32)
1016
- pixels_hsv = hsv[cell_mask]
1017
-
1018
- r, g, b = pixels_rgb[:, 0], pixels_rgb[:, 1], pixels_rgb[:, 2]
1019
- h, s, v = pixels_hsv[:, 0], pixels_hsv[:, 1], pixels_hsv[:, 2]
1020
-
1021
- eps = 1e-6
1022
- blue_red_ratio = b.mean() / (r.mean() + eps)
1023
- blue_green_ratio = b.mean() / (g.mean() + eps)
1024
- rg_ratio = r.mean() / (g.mean() + eps)
1025
-
1026
- area_px = int(cell_mask.sum())
1027
- contours, _ = cv2.findContours(
1028
- cell_mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
1029
- )
1030
- perimeter = cv2.arcLength(contours[0], True) if contours else 1.0
1031
- circularity = (4 * np.pi * area_px / (perimeter ** 2 + eps)) if perimeter > 0 else 0.0
1032
-
1033
- ys_cell = grid_y[cell_mask].astype(np.float32)
1034
- xs_cell = grid_x[cell_mask].astype(np.float32)
1035
- centroid_y = ys_cell.mean()
1036
- centroid_x = xs_cell.mean()
1037
-
1038
- cell_radius = np.sqrt(area_px / np.pi) + eps
1039
- dist_norm = np.sqrt((xs_cell - centroid_x)**2 + (ys_cell - centroid_y)**2) / cell_radius
1040
-
1041
- v_all = hsv[:, :, 2][cell_mask]
1042
-
1043
- # Tight inner core (15% radius) — captures specular highlight spot only
1044
- inner_mask = dist_norm < 0.15
1045
- # Membrane ring zone (20-60%) — dark navy ring on live cells
1046
- ring_mask = (dist_norm >= 0.20) & (dist_norm <= 0.60)
1047
- # Outer zone (>60%) — denominator for centre ratio
1048
- outer_mask = dist_norm > 0.60
1049
-
1050
- inner_brightness = float(v_all[inner_mask].mean()) if inner_mask.any() else float(v.mean())
1051
- ring_brightness = float(v_all[ring_mask].mean()) if ring_mask.any() else float(v.mean())
1052
- outer_brightness = float(v_all[outer_mask].mean()) if outer_mask.any() else float(v.mean())
1053
-
1054
- # Peak V — specular spot is just a few pixels so mean dilutes it
1055
- peak_brightness = float(v_all.max())
1056
-
1057
- # Fraction of cell pixels with V > 200 (specular highlight region)
1058
- bright_spot_fraction = float((v_all > 200).sum()) / (len(v_all) + eps)
1059
-
1060
- # Ring darkness: ratio of ring zone to outer zone brightness
1061
- # Live: ring << outer (dark membrane ring) -> ratio < 1
1062
- # Dead: uniform blob -> ratio ~ 1
1063
- ring_darkness = ring_brightness / (outer_brightness + eps)
1064
-
1065
- centre_periphery_ratio = inner_brightness / (outer_brightness + eps)
1066
-
1067
- brightness_std_normalised = float(v.std()) / (float(v.mean()) + eps)
1068
-
1069
- rows.append({
1070
- "cell_id": int(cid),
1071
- "mean_r": float(r.mean()),
1072
- "mean_g": float(g.mean()),
1073
- "mean_b": float(b.mean()),
1074
- "std_r": float(r.std()),
1075
- "std_g": float(g.std()),
1076
- "std_b": float(b.std()),
1077
- "mean_h": float(h.mean()),
1078
- "mean_s": float(s.mean()),
1079
- "mean_v": float(v.mean()),
1080
- "std_s": float(s.std()),
1081
- "std_v": float(v.std()),
1082
- "blue_red_ratio": round(blue_red_ratio, 5),
1083
- "blue_green_ratio": round(blue_green_ratio, 5),
1084
- "rg_ratio": round(rg_ratio, 5),
1085
- "area_px": area_px,
1086
- "circularity": round(float(circularity), 5),
1087
- "inner_brightness": round(inner_brightness, 3),
1088
- "peak_brightness": round(peak_brightness, 3),
1089
- "bright_spot_fraction": round(bright_spot_fraction, 6),
1090
- "ring_darkness": round(ring_darkness, 5),
1091
- "centre_periphery_ratio": round(centre_periphery_ratio, 5),
1092
- "brightness_std_normalised": round(brightness_std_normalised, 5),
1093
- })
1094
-
1095
- return rows
1096
-
1097
- def attach_viability_labels(cell_features, masks, image_np, label_map=None):
1098
- """
1099
- Attach model predictions (from label_map) to each feature dict.
1100
- label_map: {cell_id: 0=live, 1=dead} from classify_cells_by_model.
1101
- If label_map is None, defaults all labels to 0 (live).
1102
- """
1103
- if not cell_features:
1104
- return []
1105
- labelled = []
1106
- for feat in cell_features:
1107
- row = dict(feat)
1108
- cid = int(feat["cell_id"])
1109
- row["label"] = int(label_map.get(cid, 0)) if label_map else 0
1110
- row["corrected"] = False
1111
- labelled.append(row)
1112
- return labelled
1113
-
1114
-
1115
- def export_cell_data_csv(cell_data):
1116
- """Write cell_data list-of-dicts to a temp CSV and return the file path."""
1117
- if not cell_data:
1118
- return None
1119
- tmp = tempfile.NamedTemporaryFile(
1120
- mode="w", suffix=".csv", delete=False, newline=""
1121
- )
1122
- # Union of all keys across rows so any late-added keys (e.g. "corrected") are included
1123
- fieldnames = list(dict.fromkeys(k for row in cell_data for k in row.keys()))
1124
- writer = csv.DictWriter(tmp, fieldnames=fieldnames, extrasaction="ignore")
1125
- writer.writeheader()
1126
- writer.writerows(cell_data)
1127
- tmp.close()
1128
- return tmp.name
1129
-
1130
-
1131
- def prepare_export(stored_masks, stored_image, threshold_bias):
1132
- """
1133
- Called by the Export button. Unpacks state, extracts features,
1134
- attaches labels, writes CSV, returns (path, status_message).
1135
- """
1136
- if stored_masks is None or stored_image is None:
1137
- return None, "Run segmentation first before exporting."
1138
-
1139
- masks = unpack_array(stored_masks)
1140
- image_np = unpack_array(stored_image)
1141
-
1142
- features = extract_cell_features(image_np, masks)
1143
- if not features:
1144
- return None, "No cells found to export."
1145
-
1146
- labelled = attach_viability_labels(features, masks, image_np, threshold_bias)
1147
- path = export_cell_data_csv(labelled)
1148
-
1149
- n = len(labelled)
1150
- dead = sum(1 for r in labelled if r["label"] == 1)
1151
- alive = n - dead
1152
- msg = (f"Exported {n} cells ({alive} live, {dead} dead) — "
1153
- f"threshold bias={threshold_bias:+d}.\n"
1154
- f"Columns: {', '.join(list(labelled[0].keys())[:6])}… "
1155
- f"({len(labelled[0])} total).")
1156
- return path, msg
1157
-
1158
-
1159
- # ---------------------------------------------------------------------------
1160
- # Tab builder
1161
- # ---------------------------------------------------------------------------
1162
-
1163
- def draw_polygon_overlay(image_pil, points):
1164
- """
1165
- Draw numbered vertex dots and polygon edges onto a copy of image_pil.
1166
- points: list of (x, y) tuples in original image pixel space.
1167
- Returns a new PIL image.
1168
- """
1169
- img = image_pil.copy().convert("RGBA")
1170
- overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
1171
- draw = ImageDraw.Draw(overlay)
1172
-
1173
- if len(points) >= 2:
1174
- # Draw edges
1175
- for i in range(len(points) - 1):
1176
- draw.line([points[i], points[i + 1]], fill=(74, 170, 255, 220), width=3)
1177
- if len(points) == 4:
1178
- draw.line([points[-1], points[0]], fill=(74, 170, 255, 220), width=3)
1179
- # Semi-transparent fill
1180
- draw.polygon(points, fill=(74, 170, 255, 50))
1181
-
1182
- # Draw vertex dots + numbers
1183
- r = max(8, min(img.width, img.height) // 60)
1184
- for i, (x, y) in enumerate(points):
1185
- draw.ellipse([x - r, y - r, x + r, y + r],
1186
- fill=(74, 170, 255, 255), outline=(255, 255, 255, 255))
1187
- draw.text((x, y), str(i + 1), fill=(255, 255, 255, 255), anchor="mm")
1188
-
1189
- combined = Image.alpha_composite(img, overlay)
1190
- return combined.convert("RGB")
1191
-
1192
-
1193
- def add_crop_point(image_pil, points, evt: gr.SelectData):
1194
- """
1195
- Called by gr.Image .select(). Appends the clicked coordinate,
1196
- redraws the overlay, returns (updated_image, updated_points).
1197
- Ignores clicks once 4 points are set.
1198
- """
1199
- if image_pil is None:
1200
- return image_pil, points
1201
- if points is None:
1202
- points = []
1203
- if len(points) >= 4:
1204
- return draw_polygon_overlay(image_pil, points), points
1205
-
1206
- x, y = int(evt.index[0]), int(evt.index[1])
1207
- new_points = points + [(x, y)]
1208
- return draw_polygon_overlay(image_pil, new_points), new_points
1209
-
1210
-
1211
- def clear_crop_points(image_pil):
1212
- """Reset polygon — return original image with no overlay and empty points."""
1213
- return image_pil, []
1214
-
1215
-
1216
-
1217
-
1218
-
1219
- # ---------------------------------------------------------------------------
1220
- # Label correction grid
1221
- # ---------------------------------------------------------------------------
1222
-
1223
- THUMB_SIZE = 80 # each cell thumbnail is THUMB_SIZE × THUMB_SIZE px
1224
- GRID_COLS = 8 # thumbnails per row
1225
- BORDER = 4 # coloured border thickness in px
1226
- LABEL_H = 16 # height of the text label strip at the bottom of each thumb
1227
-
1228
- def _crop_cell_thumb(image_np, masks, cid):
1229
- """
1230
- Return a tight square crop of the cell, padded to THUMB_SIZE × THUMB_SIZE.
1231
- """
1232
- ys, xs = np.where(masks == cid)
1233
- if len(ys) == 0:
1234
- return Image.fromarray(np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8))
1235
-
1236
- y0, y1 = ys.min(), ys.max() + 1
1237
- x0, x1 = xs.min(), xs.max() + 1
1238
-
1239
- # add a small context border around the tight bounding box
1240
- pad = max(4, int(max(y1 - y0, x1 - x0) * 0.15))
1241
- h, w = image_np.shape[:2]
1242
- y0c = max(0, y0 - pad)
1243
- y1c = min(h, y1 + pad)
1244
- x0c = max(0, x0 - pad)
1245
- x1c = min(w, x1 + pad)
1246
-
1247
- crop = image_np[y0c:y1c, x0c:x1c].copy()
1248
-
1249
- # dim pixels that don't belong to this cell
1250
- dim_mask = (masks[y0c:y1c, x0c:x1c] != cid)
1251
- crop[dim_mask] = (crop[dim_mask] * 0.3).astype(np.uint8)
1252
-
1253
- pil = Image.fromarray(crop).resize((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
1254
- return pil
1255
-
1256
-
1257
- def build_correction_grid(image_np, masks, labelled_features, raw_image_np=None):
1258
- """
1259
- Render all cell thumbnails into a single PIL image grid.
1260
- Each thumbnail has a coloured border: green=live(0), red=dead(1).
1261
- A small number in the corner identifies the cell_id.
1262
-
1263
- Returns the PIL grid image.
1264
- Cell order in the grid matches the order of labelled_features.
1265
- """
1266
- if not labelled_features:
1267
- placeholder = Image.fromarray(
1268
- np.zeros((THUMB_SIZE, THUMB_SIZE, 3), dtype=np.uint8)
1269
- )
1270
- return placeholder
1271
-
1272
- thumb_src = raw_image_np if raw_image_np is not None else image_np
1273
-
1274
- n = len(labelled_features)
1275
- n_cols = GRID_COLS
1276
- n_rows = (n + n_cols - 1) // n_cols
1277
-
1278
- cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1279
- cell_w = THUMB_SIZE + 2 * BORDER
1280
-
1281
- grid_w = n_cols * cell_w
1282
- grid_h = n_rows * cell_h
1283
-
1284
- grid = Image.new("RGB", (grid_w, grid_h), (30, 30, 30))
1285
- draw = ImageDraw.Draw(grid)
1286
-
1287
- for idx, feat in enumerate(labelled_features):
1288
- cid = feat["cell_id"]
1289
- label = feat["label"] # 0=live, 1=dead (may have been corrected)
1290
- color = (220, 50, 50) if label == 1 else (50, 200, 80)
1291
-
1292
- thumb = _crop_cell_thumb(thumb_src, masks, cid)
1293
-
1294
- col = idx % n_cols
1295
- row = idx // n_cols
1296
- x0 = col * cell_w
1297
- y0 = row * cell_h
1298
-
1299
- # coloured border rectangle
1300
- draw.rectangle([x0, y0, x0 + cell_w - 1, y0 + cell_h - 1], outline=color, width=BORDER)
1301
-
1302
- # paste thumbnail inside border
1303
- grid.paste(thumb, (x0 + BORDER, y0 + BORDER))
1304
-
1305
- # small cell-id label strip
1306
- strip_y = y0 + BORDER + THUMB_SIZE
1307
- draw.rectangle([x0, strip_y, x0 + cell_w - 1, y0 + cell_h - 1],
1308
- fill=(20, 20, 20))
1309
- draw.text((x0 + BORDER + 2, strip_y + 1),
1310
- f"#{cid} {'D' if label == 1 else 'L'}",
1311
- fill=color)
1312
-
1313
- return grid
1314
-
1315
-
1316
- def toggle_cell_label(labelled_features, image_np, masks, raw_image_np, evt: gr.SelectData):
1317
- """
1318
- Called when user taps the correction grid image.
1319
- Maps the tap pixel coordinate back to which thumbnail was tapped,
1320
- flips that cell's label, rebuilds and returns the updated grid.
1321
- """
1322
- if not labelled_features or image_np is None:
1323
- return build_correction_grid(image_np, masks, labelled_features), labelled_features
1324
-
1325
- cell_w = THUMB_SIZE + 2 * BORDER
1326
- cell_h = THUMB_SIZE + 2 * BORDER + LABEL_H
1327
-
1328
- px, py = int(evt.index[0]), int(evt.index[1])
1329
- col = px // cell_w
1330
- row = py // cell_h
1331
- idx = row * GRID_COLS + col
1332
-
1333
- if idx < 0 or idx >= len(labelled_features):
1334
- return build_correction_grid(image_np, masks, labelled_features, raw_image_np), labelled_features
1335
-
1336
- # Flip the label
1337
- updated = list(labelled_features) # shallow copy of list
1338
- cell = dict(updated[idx]) # copy the dict so we don't mutate in place
1339
- cell["label"] = 1 - cell["label"] # 0→1 or 1→0
1340
- cell["corrected"] = True
1341
- updated[idx] = cell
1342
-
1343
- grid = build_correction_grid(image_np, masks, updated, raw_image_np)
1344
- n_corrected = sum(1 for f in updated if f.get("corrected"))
1345
- return grid, updated, f"Tapped cell #{cell['cell_id']} → {'Dead' if cell['label']==1 else 'Live'}. {n_corrected} correction(s) total."
1346
-
1347
-
1348
- def prepare_export_corrected(stored_masks, stored_image, labelled_features, label_map):
1349
- """Export CSV using labelled_features with any manual corrections applied."""
1350
- if stored_masks is None or stored_image is None:
1351
- return None, "Run segmentation first before exporting."
1352
- masks = unpack_array(stored_masks)
1353
- image_np = unpack_array(stored_image)
1354
- if not labelled_features:
1355
- features = extract_cell_features(image_np, masks)
1356
- labelled_features = attach_viability_labels(features, masks, image_np, label_map)
1357
- if not labelled_features:
1358
- return None, "No cells found to export."
1359
- path = export_cell_data_csv(labelled_features)
1360
- n = len(labelled_features)
1361
- dead = sum(1 for r in labelled_features if r["label"] == 1)
1362
- alive = n - dead
1363
- corrected = sum(1 for r in labelled_features if r.get("corrected"))
1364
- msg = (f"Exported {n} cells ({alive} live, {dead} dead). "
1365
- f"{corrected} label(s) manually corrected.")
1366
- return path, msg
1367
-
1368
- def build_tab(tab_index, masks_state, image_state, result_state):
1369
- with gr.Tab(f"Tab {tab_index}"):
1370
- gr.Markdown("Run segmentation")
1371
-
1372
- # Per-tab state: list of (x,y) crop polygon points
1373
- crop_points_state = gr.State(value=[])
1374
- # Clean copy of the uploaded image (no polygon drawn on it)
1375
- base_image_state = gr.State(value=None)
1376
- #raw image state
1377
- raw_image_state = gr.State(value=None)
1378
- # Masks as segmented, before stereological exclusion — lets the zones
1379
- # be re-chosen after segmentation without re-running the model.
1380
- base_masks_state = gr.State(value=None)
1381
-
1382
- with gr.Row():
1383
- with gr.Column():
1384
- img_input = gr.Image(
1385
- type="pil",
1386
- label="Upload image",
1387
- image_mode="RGB",
1388
- height=512
1389
- )
1390
-
1391
- gr.Markdown(
1392
- "### Crop region (optional)\n"
1393
- "Click/tap up to **4 points** on the image below to define the region "
1394
- "to segment. The polygon will be drawn as you click. "
1395
- "Leave empty to segment the full image."
1396
- )
1397
-
1398
- crop_display = gr.Image(
1399
- type="pil",
1400
- label="Click to set crop vertices (up to 4)",
1401
- interactive=True,
1402
- height=400,
1403
- )
1404
-
1405
- crop_status = gr.Markdown("*Upload an image to enable cropping*")
1406
-
1407
- clear_crop_btn = gr.Button("✕ Clear crop points", size="sm")
1408
-
1409
- model_dropdown = gr.Dropdown(
1410
- choices=list(MODEL_OPTIONS.keys()),
1411
- label="Select Model",
1412
- value="Hemocytometer Model"
1413
- )
1414
-
1415
- gr.Markdown("### Size Filters")
1416
-
1417
- use_min_filter = gr.Checkbox(
1418
- label="Enable minimum size filter",
1419
- value=False,
1420
- info="Remove objects smaller than the threshold below"
1421
- )
1422
- min_size_slider = gr.Slider(
1423
- minimum=0,
1424
- maximum=500,
1425
- value=0,
1426
- step=10,
1427
- label="Minimum Cell Size (pixels)",
1428
- interactive=False,
1429
- )
1430
- min_size_recommendation = gr.Markdown(
1431
- value="*Run segmentation to see recommended minimum*",
1432
- )
1433
-
1434
- use_max_filter = gr.Checkbox(
1435
- label="Enable maximum size filter",
1436
- value=False,
1437
- info="Remove objects larger than the threshold below"
1438
- )
1439
- max_size_slider = gr.Slider(
1440
- minimum=0,
1441
- maximum=10000,
1442
- value=10000,
1443
- step=10,
1444
- label="Maximum Cell Size (pixels)",
1445
- interactive=False,
1446
- )
1447
-
1448
- gr.Markdown("### Stereological Counting")
1449
- use_stereo = gr.Checkbox(
1450
- label="Enable Stereological Counting",
1451
- value=False,
1452
- info="Use unbiased stereological rules for cell counting"
1453
- )
1454
-
1455
- with gr.Group(visible=False) as stereo_controls:
1456
- gr.Markdown("""
1457
- **Stereological Counting Rules:**
1458
- - Cells touching LEFT or TOP exclusion zones are EXCLUDED
1459
- - Cells touching RIGHT or BOTTOM edges are INCLUDED
1460
- - This provides unbiased counting for quantification
1461
- """)
1462
-
1463
- excl_preview = gr.Image(
1464
- type="pil",
1465
- label="Exclusion Zone Preview (Red = Excluded)",
1466
- height=500
1467
- )
1468
-
1469
- left_excl = gr.Slider(
1470
- minimum=0,
1471
- maximum=50,
1472
- value=10,
1473
- step=1,
1474
- label="Left Exclusion Width (%)",
1475
- info="Width of left exclusion zone"
1476
- )
1477
-
1478
- top_excl = gr.Slider(
1479
- minimum=0,
1480
- maximum=50,
1481
- value=10,
1482
- step=1,
1483
- label="Top Exclusion Width (%)",
1484
- info="Width of top exclusion zone"
1485
- )
1486
-
1487
- segment_btn = gr.Button("🔬 Run Segmentation", variant="primary", size="lg")
1488
-
1489
- with gr.Column():
1490
- cell_count_out = gr.Number(label="Total Cells Detected", precision=0)
1491
- confluency_out = gr.Number(label="Confluency (%)", precision=1)
1492
- overlay_out = gr.Image(type="pil", label="Segmentation Result")
1493
- info_out = gr.Textbox(label="Processing Info", lines=4)
1494
-
1495
- with gr.Group(visible=False) as viability_section:
1496
- gr.Markdown("### Viability Assessment (Trypan Blue)")
1497
-
1498
- viab_run_btn = gr.Button("Run Viability Analysis", variant="primary")
1499
-
1500
- with gr.Row():
1501
- live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
1502
- dead_count_out = gr.Number(label="Dead Cells (Red)", precision=0)
1503
-
1504
- viab_overlay = gr.Image(type="pil", label="Viability (Green=Live · Red=Dead)")
1505
- viab_percent_out = gr.Number(label="Viability (%)", precision=1)
1506
- viab_info = gr.Textbox(label="Analysis Results", lines=4)
1507
-
1508
- gr.Markdown("### Label Correction & Export")
1509
- gr.Markdown(
1510
- "After running viability, click **Build correction grid** to review every cell. "
1511
- "**Green border = Live, Red border = Dead** (model predictions). "
1512
- "Tap any thumbnail to flip its label — the counts and overlay update instantly. "
1513
- "Export the corrected CSV for retraining."
1514
- )
1515
-
1516
- build_grid_btn = gr.Button("🔲 Build correction grid", variant="secondary")
1517
- labelled_state = gr.State(value=[])
1518
- label_map_state = gr.State(value={})
1519
-
1520
- correction_grid = gr.Image(
1521
- type="pil",
1522
- label="Tap a cell to flip its label (green=live · red=dead)",
1523
- interactive=True,
1524
- visible=False,
1525
- )
1526
- correction_status = gr.Markdown(visible=False)
1527
-
1528
- with gr.Row():
1529
- export_btn = gr.Button("⬇️ Export corrected CSV", variant="secondary")
1530
- export_info = gr.Textbox(label="Export status", lines=2, interactive=False)
1531
- export_file = gr.File(label="Download CSV", visible=False)
1532
-
1533
- # ---- Event handlers ------------------------------------------------
1534
-
1535
- use_stereo.change(
1536
- fn=toggle_stereological_mode,
1537
- inputs=[use_stereo],
1538
- outputs=[stereo_controls]
1539
- )
1540
-
1541
- def on_image_upload(img):
1542
- if img is None:
1543
- return None, None, "*Upload an image to enable cropping*"
1544
- return img, img, "*Image loaded — click up to 4 points to define crop region*"
1545
-
1546
- img_input.change(
1547
- fn=on_image_upload,
1548
- inputs=[img_input],
1549
- outputs=[crop_display, base_image_state, crop_status]
1550
- ).then(fn=lambda: [], outputs=[crop_points_state])
1551
-
1552
- # Grey the size sliders out while their filter is disabled, so the
1553
- # checkbox state is visible rather than silently inferred.
1554
- use_min_filter.change(
1555
- fn=lambda on: gr.update(interactive=bool(on)),
1556
- inputs=[use_min_filter], outputs=[min_size_slider]
1557
- )
1558
- use_max_filter.change(
1559
- fn=lambda on: gr.update(interactive=bool(on)),
1560
- inputs=[use_max_filter], outputs=[max_size_slider]
1561
- )
1562
-
1563
- def on_exclusion_change(stored_base, stored_image, use_stereo_on,
1564
- left_pct, top_pct):
1565
- """Re-apply the exclusion zones to existing masks — no re-segmentation."""
1566
- if stored_base is None or stored_image is None:
1567
- return (gr.update(), gr.update(), gr.update(),
1568
- gr.update(), gr.update())
1569
- base_masks = unpack_array(stored_base)
1570
- image_np = unpack_array(stored_image)
1571
- masks, cell_count, confluency, overlay_pil, excluded = render_segmentation(
1572
- base_masks, image_np, use_stereo_on, left_pct, top_pct
1573
- )
1574
- if use_stereo_on:
1575
- msg = (f"Stereological exclusion (Left: {left_pct}%, Top: {top_pct}%): "
1576
- f"{excluded} cells excluded, {cell_count} counted.\n"
1577
- f"Confluency: {confluency:.1f}%\n"
1578
- f"Re-run viability classification to update live/dead counts.")
1579
- else:
1580
- msg = (f"Stereological exclusion off — {cell_count} cells counted.\n"
1581
- f"Confluency: {confluency:.1f}%")
1582
- return cell_count, overlay_pil, confluency, pack_array(masks), msg
1583
-
1584
- exclusion_inputs = [base_masks_state, image_state, use_stereo,
1585
- left_excl, top_excl]
1586
- exclusion_outputs = [cell_count_out, overlay_out, confluency_out,
1587
- masks_state, info_out]
1588
- for _component in (use_stereo, left_excl, top_excl):
1589
- _component.change(fn=on_exclusion_change,
1590
- inputs=exclusion_inputs, outputs=exclusion_outputs)
1591
-
1592
- excl_preview_inputs = [img_input, left_excl, top_excl, crop_points_state]
1593
- img_input.change(fn=update_exclusion_preview,
1594
- inputs=excl_preview_inputs, outputs=[excl_preview])
1595
- left_excl.change(fn=update_exclusion_preview,
1596
- inputs=excl_preview_inputs, outputs=[excl_preview])
1597
- top_excl.change(fn=update_exclusion_preview,
1598
- inputs=excl_preview_inputs, outputs=[excl_preview])
1599
-
1600
- def on_crop_click(base_img, points, evt: gr.SelectData):
1601
- updated_img, updated_pts = add_crop_point(base_img, points, evt)
1602
- n = len(updated_pts)
1603
- status = (f"*{n} / 4 points set — keep clicking*" if n < 4
1604
- else "*4 points set ✓ — click **✕ Clear** to redo, or run segmentation*")
1605
- return updated_img, updated_pts, status
1606
-
1607
- crop_display.select(fn=on_crop_click,
1608
- inputs=[base_image_state, crop_points_state],
1609
- outputs=[crop_display, crop_points_state, crop_status]
1610
- ).then(fn=update_exclusion_preview,
1611
- inputs=excl_preview_inputs, outputs=[excl_preview])
1612
-
1613
- def on_clear_crop(base_img):
1614
- img, pts = clear_crop_points(base_img)
1615
- return img, pts, "*Points cleared — click to set new vertices*"
1616
-
1617
- clear_crop_btn.click(fn=on_clear_crop,
1618
- inputs=[base_image_state],
1619
- outputs=[crop_display, crop_points_state, crop_status]
1620
- ).then(fn=update_exclusion_preview,
1621
- inputs=excl_preview_inputs, outputs=[excl_preview])
1622
-
1623
- segment_btn.click(
1624
- fn=run_segmentation,
1625
- inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
1626
- use_stereo, left_excl, top_excl, crop_points_state,
1627
- use_min_filter, use_max_filter],
1628
- outputs=[cell_count_out, overlay_out, info_out, viability_section,
1629
- masks_state, image_state, confluency_out,
1630
- min_size_recommendation, raw_image_state, base_masks_state]
1631
- )
1632
-
1633
- # ---- Run Viability button -------------------------------------------
1634
- def on_run_viability(stored_masks, stored_image):
1635
- overlay, alive, dead, viab_pct, info, label_map = run_viability(stored_masks, stored_image)
1636
- return overlay, alive, dead, viab_pct, info, label_map
1637
-
1638
- viab_run_btn.click(
1639
- fn=on_run_viability,
1640
- inputs=[masks_state, image_state],
1641
- outputs=[viab_overlay, live_count_out, dead_count_out,
1642
- viab_percent_out, viab_info, label_map_state]
1643
- ).then(
1644
- fn=save_tab_result,
1645
- inputs=[cell_count_out, confluency_out, viab_percent_out],
1646
- outputs=[result_state]
1647
- )
1648
-
1649
- # ---- Build correction grid -----------------------------------------
1650
- def on_build_grid(stored_masks, stored_image, label_map, stored_raw_image):
1651
- if stored_masks is None or stored_image is None or not label_map:
1652
- return (gr.update(visible=False), [],
1653
- gr.update(value="*Run viability analysis first.*", visible=True))
1654
- masks = unpack_array(stored_masks)
1655
- image_np = unpack_array(stored_image)
1656
- raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1657
- features = extract_cell_features(image_np, masks)
1658
- labelled = attach_viability_labels(features, masks, image_np, label_map)
1659
- if not labelled:
1660
- return (gr.update(visible=False), [],
1661
- gr.update(value="*No cells found.*", visible=True))
1662
- grid = build_correction_grid(image_np, masks, labelled, raw_image_np)
1663
- n = len(labelled)
1664
- dead = sum(1 for r in labelled if r["label"] == 1)
1665
- msg = (f"*{n} cells — {n-dead} live (green), {dead} dead (red). "
1666
- f"Tap any thumbnail to flip its label.*")
1667
- return gr.update(value=grid, visible=True), labelled, gr.update(value=msg, visible=True)
1668
-
1669
- build_grid_btn.click(
1670
- fn=on_build_grid,
1671
- inputs=[masks_state, image_state, label_map_state, raw_image_state],
1672
- outputs=[correction_grid, labelled_state, correction_status]
1673
- )
1674
-
1675
- # ---- Grid tap — flip label, update overlay + counts ----------------
1676
- def on_grid_tap(labelled, stored_masks, stored_image, stored_raw_image, evt: gr.SelectData):
1677
- if not labelled or stored_masks is None:
1678
- return None, labelled, "", 0, 0, 0.0, None, {}
1679
- masks = unpack_array(stored_masks)
1680
- image_np = unpack_array(stored_image)
1681
- raw_image_np = unpack_array(stored_raw_image) if stored_raw_image is not None else None
1682
- grid, updated, msg = toggle_cell_label(labelled, image_np, masks, raw_image_np, evt)
1683
-
1684
- # Rebuild label_map from corrected labelled list
1685
- new_label_map = {int(f["cell_id"]): int(f["label"]) for f in updated}
1686
- overlay_np = draw_viability_overlay(image_np, masks, new_label_map)
1687
- dead = sum(1 for f in updated if f["label"] == 1)
1688
- alive = len(updated) - dead
1689
- total = alive + dead
1690
- viab_pct = (alive / total * 100) if total > 0 else 0.0
1691
-
1692
- return (grid, updated, f"*{msg}*",
1693
- alive, dead, viab_pct,
1694
- Image.fromarray(overlay_np), new_label_map)
1695
-
1696
- correction_grid.select(
1697
- fn=on_grid_tap,
1698
- inputs=[labelled_state, masks_state, image_state, raw_image_state],
1699
- outputs=[correction_grid, labelled_state, correction_status,
1700
- live_count_out, dead_count_out, viab_percent_out,
1701
- viab_overlay, label_map_state]
1702
- )
1703
-
1704
- # ---- Export --------------------------------------------------------
1705
- def on_export(stored_masks, stored_image, labelled, label_map):
1706
- path, msg = prepare_export_corrected(stored_masks, stored_image, labelled, label_map)
1707
- if path is None:
1708
- return gr.update(visible=False), msg
1709
- return gr.update(value=path, visible=True), msg
1710
-
1711
- export_btn.click(
1712
- fn=on_export,
1713
- inputs=[masks_state, image_state, labelled_state, label_map_state],
1714
- outputs=[export_file, export_info]
1715
- )
1716
-
1717
-
1718
-
1719
- # ---------------------------------------------------------------------------
1720
- # Gradio interface
1721
- # ---------------------------------------------------------------------------
1722
- with gr.Blocks(
1723
- title="CellposeCellCounter",
1724
- theme=gr.themes.Soft(),
1725
- ) as demo:
1726
- gr.Markdown("# CellposeCellCounter")
1727
- 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.")
1728
-
1729
- # Shared mask/image state (one pair per tab so tabs don't clobber each other)
1730
- masks_states = [gr.State(value=None) for _ in range(4)]
1731
- image_states = [gr.State(value=None) for _ in range(4)]
1732
- result_states = [gr.State(value=None) for _ in range(4)]
1733
-
1734
- # Build Tabs 1–4 with a loop
1735
- for i in range(4):
1736
- build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
1737
-
1738
- # -------------------------------------------------------------------------
1739
- # Tab 5 — Summary
1740
- # -------------------------------------------------------------------------
1741
- with gr.Tab("Tab 5 — Summary"):
1742
- gr.Markdown("## Average Results Across All Tabs")
1743
- gr.Markdown(
1744
- "Run segmentation in one or more tabs, "
1745
- "then click **Refresh Summary** to see the averages."
1746
- )
1747
-
1748
- refresh_btn = gr.Button("🔄 Refresh Summary", variant="primary", size="lg")
1749
-
1750
- with gr.Row():
1751
- avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
1752
- avg_conf_out = gr.Number(label="Avg Confluency (%)", precision=1)
1753
- avg_viab_out = gr.Number(label="Avg Viability (%)", precision=1)
1754
-
1755
- summary_box = gr.Textbox(label="Per-Tab Breakdown", lines=10)
1756
-
1757
- refresh_btn.click(
1758
- fn=compute_summary,
1759
- inputs=result_states, # list of 4 gr.State components
1760
- outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
1761
- )
1762
-
1763
-
1764
-
1765
- if __name__ == "__main__":
1766
- demo.launch()