LiangLabUMB commited on
Commit
8165065
Β·
verified Β·
1 Parent(s): ebb2890

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +730 -0
app.py ADDED
@@ -0,0 +1,730 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
13
+ HF_REPO_ID = "myang4218/cellposemodel"
14
+ MODEL_OPTIONS = {
15
+ "Hemocytometer Model": "hemocytometermodel.npy",
16
+ "General Model": "generalmodel.npy"
17
+ }
18
+
19
+ loaded_models = {}
20
+
21
+ # ---- mobile-safe size limits (aggressive for Safari) ----
22
+ MAX_SIDE = 1024
23
+ MAX_PIXELS = 1024 * 1024
24
+
25
+
26
+ def safe_resize(image_np):
27
+ """
28
+ Downscale image to fit within MAX_SIDE and MAX_PIXELS while
29
+ preserving aspect ratio. Works for RGB / RGBA / grayscale.
30
+ """
31
+ h, w = image_np.shape[:2]
32
+ total = h * w
33
+
34
+ if max(h, w) <= MAX_SIDE and total <= MAX_PIXELS:
35
+ return image_np
36
+
37
+ # compute scale
38
+ scale_side = MAX_SIDE / max(h, w)
39
+ scale_pixels = (MAX_PIXELS / total) ** 0.5
40
+ scale = min(scale_side, scale_pixels)
41
+
42
+ new_w = max(1, int(w * scale))
43
+ new_h = max(1, int(h * scale))
44
+
45
+ return cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
46
+
47
+
48
+ def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
49
+
50
+ h, w = image_np.shape[:2]
51
+
52
+ # Convert to PIL for drawing
53
+ img_pil = Image.fromarray(image_np)
54
+ draw = ImageDraw.Draw(img_pil, 'RGBA')
55
+
56
+ # Calculate pixel widths from percentages
57
+ left_px = int(w * left_width_pct / 100)
58
+ top_px = int(h * top_width_pct / 100)
59
+
60
+ # Draw overlays for exclusion zones
61
+ if left_px > 0:
62
+ # Left exclusion zone
63
+ draw.rectangle(
64
+ [(0, 0), (left_px, h)],
65
+ fill=(255, 0, 0, 80) # Semi-transparent red
66
+ )
67
+ # border line
68
+ draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
69
+
70
+ if top_px > 0:
71
+ # Top exclusion zone
72
+ draw.rectangle(
73
+ [(0, 0), (w, top_px)],
74
+ fill=(255, 0, 0, 80) # Semi-transparent red
75
+ )
76
+ # border line
77
+ draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
78
+
79
+ return np.array(img_pil)
80
+
81
+
82
+ def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
83
+ h, w = masks.shape
84
+
85
+ # Calculate pixel widths from percentages
86
+ left_px = int(w * left_width_pct / 100)
87
+ top_px = int(h * top_width_pct / 100)
88
+
89
+ filtered_masks = masks.copy()
90
+ cell_ids = np.unique(masks)
91
+ cell_ids = cell_ids[cell_ids > 0]
92
+
93
+ excluded_cells = []
94
+ included_cells = []
95
+
96
+ for cell_id in cell_ids:
97
+ cell_mask = (masks == cell_id)
98
+
99
+ # Get cell boundary coordinates
100
+ rows, cols = np.where(cell_mask)
101
+
102
+ # Check if cell touches left exclusion zone
103
+ touches_left = np.any(cols < left_px) if left_px > 0 else False
104
+
105
+ # Check if cell touches top exclusion zone
106
+ touches_top = np.any(rows < top_px) if top_px > 0 else False
107
+
108
+ # Exclude if touching left or top
109
+ if touches_left or touches_top:
110
+ filtered_masks[cell_mask] = 0
111
+ excluded_cells.append(cell_id)
112
+ else:
113
+ included_cells.append(cell_id)
114
+
115
+ # Renumber remaining cells
116
+ unique_ids = np.unique(filtered_masks)
117
+ unique_ids = unique_ids[unique_ids > 0]
118
+
119
+ renumbered_masks = np.zeros_like(filtered_masks)
120
+ for new_id, old_id in enumerate(unique_ids, start=1):
121
+ renumbered_masks[filtered_masks == old_id] = new_id
122
+
123
+ return renumbered_masks, len(excluded_cells), len(included_cells)
124
+
125
+
126
+ def classify_cells_by_blueness(image_np, masks, threshold_bias):
127
+ """
128
+ Classify cells as dead (blue) or alive using an adaptive Otsu threshold
129
+ on per-cell blueness scores, with a user bias to fine-tune.
130
+
131
+ Args:
132
+ image_np: RGB image array
133
+ masks: Cellpose segmentation masks
134
+ threshold_bias: Slider value -50..+50; shifts Otsu threshold up/down.
135
+ Negative = more cells classified dead (looser).
136
+ Positive = fewer cells classified dead (stricter).
137
+ 0 = pure Otsu (fully automatic).
138
+
139
+ Returns:
140
+ dead_count, alive_count, colored_overlay, otsu_threshold, final_threshold
141
+ """
142
+
143
+ if len(image_np.shape) == 2:
144
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
145
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
146
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
147
+
148
+ hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
149
+
150
+ hue = hsv[:, :, 0].astype(np.float32)
151
+ saturation = hsv[:, :, 1].astype(np.float32)
152
+
153
+ # Raw blueness: hue proximity to 115Β° Γ— saturation
154
+ hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
155
+ hue_score = np.maximum(0, 1 - hue_distance / 65)
156
+ blueness = hue_score * (saturation / 255.0)
157
+
158
+ # --- Compute per-cell mean blueness scores ---
159
+ cell_ids = np.unique(masks)
160
+ cell_ids = cell_ids[cell_ids > 0]
161
+
162
+ if len(cell_ids) == 0:
163
+ blank = image_np.copy()
164
+ return 0, 0, blank, 0.0, 0.0
165
+
166
+ cell_scores = np.array([np.mean(blueness[masks == cid]) for cid in cell_ids])
167
+
168
+ # --- Otsu on the distribution of per-cell scores ---
169
+ # cv2.threshold expects uint8; scale 0-1 β†’ 0-255
170
+ scores_u8 = (np.clip(cell_scores, 0, 1) * 255).astype(np.uint8)
171
+
172
+ if scores_u8.max() == scores_u8.min():
173
+ # All cells identical β†’ Otsu is undefined; use midpoint
174
+ otsu_threshold = float(scores_u8[0]) / 255.0
175
+ else:
176
+ # Reshape to a single-column image so cv2.threshold works
177
+ thresh_val, _ = cv2.threshold(
178
+ scores_u8.reshape(-1, 1), 0, 255,
179
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU
180
+ )
181
+ otsu_threshold = thresh_val / 255.0
182
+
183
+ # --- Apply user bias: slider -50..+50 maps to Β±0.20 shift ---
184
+ bias = (threshold_bias / 50.0) * 0.20
185
+ final_threshold = float(np.clip(otsu_threshold + bias, 0.0, 1.0))
186
+
187
+ # --- Classify ---
188
+ dead_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s > final_threshold]
189
+ alive_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s <= final_threshold]
190
+
191
+ # --- Colored overlay ---
192
+ overlay = image_np.copy().astype(np.float32)
193
+ for cid in dead_cells:
194
+ overlay[masks == cid] = [255, 0, 0]
195
+ for cid in alive_cells:
196
+ overlay[masks == cid] = [0, 255, 0]
197
+
198
+ alpha = 0.4
199
+ final_overlay = (1 - alpha) * image_np.astype(np.float32) + alpha * overlay
200
+ final_overlay = np.clip(final_overlay, 0, 255).astype(np.uint8)
201
+
202
+ return len(dead_cells), len(alive_cells), final_overlay, otsu_threshold, final_threshold
203
+
204
+
205
+ def measure_confluency(masks, image_np):
206
+ tot_pixels = image_np.shape[0] * image_np.shape[1]
207
+ cell_pixels = np.count_nonzero(masks)
208
+ confluency = cell_pixels / tot_pixels * 100
209
+ return confluency
210
+
211
+ def filter_mask_by_size(masks, minimum_pixels):
212
+ filtered_masks = masks.copy()
213
+ cell_ids = np.unique(masks)
214
+ cell_ids = cell_ids[cell_ids > 0]
215
+
216
+ removed_count = 0
217
+
218
+ for cell_id in cell_ids:
219
+ cell_mask = (masks == cell_id)
220
+ cell_pixels = np.count_nonzero(cell_mask)
221
+ if cell_pixels < minimum_pixels:
222
+ filtered_masks[cell_mask] = 0
223
+ removed_count += 1
224
+
225
+ unique_ids = np.unique(filtered_masks)
226
+ unique_ids = unique_ids[unique_ids > 0]
227
+
228
+ renumbered_masks = np.zeros_like(filtered_masks)
229
+ for new_id, old_id in enumerate(unique_ids, start=1):
230
+ renumbered_masks[filtered_masks == old_id] = new_id
231
+
232
+ return renumbered_masks, removed_count
233
+
234
+
235
+ def filter_mask_by_maxsize(masks, maximum_pixels):
236
+ filtered_masks = masks.copy()
237
+ cell_ids = np.unique(masks)
238
+ cell_ids = cell_ids[cell_ids > 0]
239
+
240
+ removed_count = 0
241
+ for cell_id in cell_ids:
242
+ cell_mask = (masks == cell_id)
243
+ cell_pixels = np.count_nonzero(cell_mask)
244
+ if cell_pixels > maximum_pixels:
245
+ filtered_masks[cell_mask] = 0
246
+ removed_count += 1
247
+
248
+ unique_ids = np.unique(filtered_masks)
249
+ unique_ids = unique_ids[unique_ids > 0]
250
+
251
+ renumbered_masks = np.zeros_like(filtered_masks)
252
+ for new_id, old_id in enumerate(unique_ids, start=1):
253
+ renumbered_masks[filtered_masks == old_id] = new_id
254
+
255
+ return renumbered_masks, removed_count
256
+
257
+
258
+ def rec_min_size(masks, q=25):
259
+ ids = np.unique(masks)
260
+ ids = ids[ids > 0]
261
+ if len(ids) == 0:
262
+ return 0
263
+ sizes = np.array([np.count_nonzero(masks == cid) for cid in ids])
264
+ return int(round(np.percentile(sizes, q)))
265
+
266
+
267
+ def toggle_stereological_mode(use_stereology):
268
+ """Show/hide stereological controls based on checkbox"""
269
+ return gr.update(visible=use_stereology)
270
+
271
+
272
+ def update_exclusion_preview(image, left_width, top_width):
273
+ """Update the preview image with exclusion zone overlay"""
274
+ if image is None:
275
+ return None
276
+
277
+ image_np = np.array(image)
278
+ overlay = draw_exclusion_overlay(image_np, left_width, top_width)
279
+ return Image.fromarray(overlay)
280
+
281
+
282
+ @spaces.GPU
283
+ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
284
+ use_stereology, left_exclusion, top_exclusion):
285
+ image_np = np.array(image)
286
+ image_np = safe_resize(image_np)
287
+
288
+ try:
289
+ model_filename = MODEL_OPTIONS[model_choice]
290
+ model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
291
+
292
+ if model_filename in loaded_models:
293
+ model = loaded_models[model_filename]
294
+ else:
295
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
296
+ loaded_models[model_filename] = model
297
+
298
+ # Process image format to RGB
299
+ if len(image_np.shape) == 2:
300
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
301
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
302
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
303
+ else:
304
+ processed_image_np = image_np
305
+
306
+ # Run Cellpose segmentation
307
+ masks_raw, flows, styles = model.eval(processed_image_np, diameter=None, channels=[0, 0])
308
+
309
+ ids = np.unique(masks_raw)
310
+ ids = ids[ids > 0]
311
+
312
+ sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
313
+
314
+ print("num_cells:", len(ids))
315
+ print("mean:", sizes.mean() if len(sizes) > 0 else 0)
316
+ print("median:", np.median(sizes) if len(sizes) > 0 else 0)
317
+ print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
318
+ print("max:", sizes.max() if len(sizes) > 0 else 0)
319
+
320
+ # Compute recommendation from RAW masks
321
+ recommend_min = rec_min_size(masks_raw)
322
+
323
+ # If user sets slider to 0, use the recommendation
324
+ min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
325
+
326
+ # Apply filters
327
+ masks = masks_raw.copy()
328
+ removed_small = 0
329
+ removed_large = 0
330
+
331
+ if min_used > 0:
332
+ masks, removed_small = filter_mask_by_size(masks, min_used)
333
+
334
+ if max_cell_size > 0:
335
+ masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
336
+
337
+ # Apply stereological exclusion if enabled
338
+ excluded_count = 0
339
+ if use_stereology:
340
+ masks, excluded_count, included_count = apply_stereological_exclusion(
341
+ masks, left_exclusion, top_exclusion
342
+ )
343
+
344
+ filter_msg = ""
345
+ if removed_small:
346
+ filter_msg += f"Removed {removed_small} small objects (< {min_used} pixels).\n"
347
+ if removed_large:
348
+ filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
349
+ if use_stereology and excluded_count > 0:
350
+ filter_msg += f"Stereological exclusion: {excluded_count} cells excluded (touching left/top zones).\n"
351
+
352
+ cell_count = len(np.unique(masks)) - 1
353
+ confluency = measure_confluency(masks, processed_image_np)
354
+
355
+ # Create a basic segmentation overlay (without viability)
356
+ segmentation_overlay = processed_image_np.copy().astype(np.float32)
357
+ if masks.max() > 0:
358
+ np.random.seed(42) # For consistent random colors
359
+ colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
360
+ colors[0] = [0, 0, 0]
361
+ colored_mask = colors[masks]
362
+ alpha = 0.4
363
+ segmentation_overlay = (1 - alpha) * segmentation_overlay + alpha * colored_mask
364
+ segmentation_overlay = np.clip(segmentation_overlay, 0, 255).astype(np.uint8)
365
+
366
+ # Add exclusion zone overlay if stereology is enabled
367
+ if use_stereology:
368
+ segmentation_overlay = draw_exclusion_overlay(segmentation_overlay, left_exclusion, top_exclusion)
369
+
370
+ info_msg = ""
371
+ if filter_msg:
372
+ info_msg += filter_msg
373
+ info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
374
+ info_msg += f"Confluency: {confluency:.1f}%\n"
375
+ if use_stereology:
376
+ info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
377
+ info_msg += "Now adjust the Blue Threshold for viability assessment."
378
+
379
+ return (
380
+ cell_count,
381
+ Image.fromarray(segmentation_overlay),
382
+ info_msg,
383
+ gr.update(visible=True),
384
+ pack_array(masks),
385
+ pack_array(processed_image_np),
386
+ confluency,
387
+ gr.update(value=recommend_min), # update slider display to recommended
388
+ )
389
+
390
+ except Exception as e:
391
+ import traceback
392
+ traceback.print_exc()
393
+ return (
394
+ 0,
395
+ None,
396
+ f"Error during segmentation: {str(e)}",
397
+ gr.update(visible=False),
398
+ None,
399
+ None,
400
+ 0.0,
401
+ gr.update(),
402
+ )
403
+
404
+
405
+ def update_viability_realtime(threshold_bias, stored_masks, stored_image_np):
406
+ # avoid unpacking None (e.g. slider moved before segmentation)
407
+ if stored_masks is None or stored_image_np is None:
408
+ return None, 0, 0, 0.0, "Please run segmentation first."
409
+
410
+ stored_masks = unpack_array(stored_masks)
411
+ stored_image_np = unpack_array(stored_image_np)
412
+
413
+ try:
414
+ dead_count, alive_count, viability_overlay_np, otsu_thresh, final_thresh = \
415
+ classify_cells_by_blueness(stored_image_np, stored_masks, threshold_bias)
416
+
417
+ total_count = alive_count + dead_count
418
+ viability_percent = (alive_count / total_count * 100) if total_count > 0 else 0.0
419
+ confluency = measure_confluency(stored_masks, stored_image_np)
420
+
421
+ overlay_image = Image.fromarray(viability_overlay_np)
422
+ info_msg = f"Total cells: {total_count}\nLive (green): {alive_count}\nDead (red): {dead_count}\n"
423
+ info_msg += f"Viability: {viability_percent:.1f}%\nConfluency: {confluency:.1f}%\n"
424
+ info_msg += f"Otsu threshold (auto): {otsu_thresh:.4f}\n"
425
+ info_msg += f"Final threshold (with bias): {final_thresh:.4f}\n"
426
+ info_msg += f"Sensitivity bias: {threshold_bias:+d}"
427
+
428
+ return overlay_image, alive_count, dead_count, viability_percent, info_msg
429
+
430
+ except Exception as e:
431
+ return None, 0, 0, 0.0, f"Error updating viability: {str(e)}"
432
+
433
+
434
+ def pack_array(arr):
435
+ pil = Image.fromarray(arr.astype(np.uint8))
436
+ buf = io.BytesIO()
437
+ pil.save(buf, format="PNG")
438
+ return buf.getvalue()
439
+
440
+
441
+ def unpack_array(data):
442
+ return np.array(Image.open(io.BytesIO(data)))
443
+
444
+
445
+ def save_tab_result(cell_count, confluency, viab_percent):
446
+ """Package per-tab results into a dict for Tab 5 summary."""
447
+ return {
448
+ "cell_count": float(cell_count) if cell_count is not None else None,
449
+ "confluency": float(confluency) if confluency is not None else None,
450
+ "viab_percent": float(viab_percent) if viab_percent is not None else None,
451
+ }
452
+
453
+
454
+ def compute_summary(r1, r2, r3, r4):
455
+ """Average cell count, confluency, and viability across tabs that have data."""
456
+ all_results = [r1, r2, r3, r4]
457
+ valid = [(i + 1, r) for i, r in enumerate(all_results)
458
+ if r is not None and r.get("cell_count") is not None]
459
+
460
+ if not valid:
461
+ return (
462
+ 0.0, 0.0, 0.0,
463
+ "No data yet β€” run segmentation in at least one tab, then click Refresh Summary."
464
+ )
465
+
466
+ avg_count = sum(r["cell_count"] for _, r in valid) / len(valid)
467
+ avg_conf = sum(r["confluency"] for _, r in valid) / len(valid)
468
+ avg_viab = sum(r["viab_percent"] for _, r in valid) / len(valid)
469
+
470
+ lines = [f"Tab {tab_num}: {r['cell_count']:.0f} cells | "
471
+ f"{r['confluency']:.1f}% confluency | "
472
+ f"{r['viab_percent']:.1f}% viability"
473
+ for tab_num, r in valid]
474
+ lines.append(f"\nAverages ({len(valid)} tab{'s' if len(valid) > 1 else ''}):")
475
+ lines.append(f" Cell count: {avg_count:.1f}")
476
+ lines.append(f" Confluency: {avg_conf:.1f}%")
477
+ lines.append(f" Viability: {avg_viab:.1f}%")
478
+
479
+ return avg_count, avg_conf, avg_viab, "\n".join(lines)
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Tab builder β€” creates one full analysis tab and wires all event handlers.
484
+ # Returns the per-tab gr.State used by Tab 5 to collect summary data.
485
+ # ---------------------------------------------------------------------------
486
+ def build_tab(tab_index, masks_state, image_state, result_state):
487
+ with gr.Tab(f"Tab {tab_index}"):
488
+ gr.Markdown("Run segmentation")
489
+
490
+ with gr.Row():
491
+ with gr.Column():
492
+ img_input = gr.Image(
493
+ type="pil",
494
+ label="Microscopy image",
495
+ image_mode="RGB",
496
+ height=512
497
+ )
498
+
499
+ model_dropdown = gr.Dropdown(
500
+ choices=list(MODEL_OPTIONS.keys()),
501
+ label="Select Model",
502
+ value="Hemocytometer Model"
503
+ )
504
+
505
+ min_size_slider = gr.Slider(
506
+ minimum=0,
507
+ maximum=500,
508
+ value=0,
509
+ step=10,
510
+ label="Minimum Cell Size (pixels). Leave at zero for automated recommendation",
511
+ )
512
+
513
+ max_size_slider = gr.Slider(
514
+ minimum=0,
515
+ maximum=10000,
516
+ value=10000,
517
+ step=10,
518
+ label="Maximum Cell Size (pixels)",
519
+ )
520
+
521
+ gr.Markdown("### Stereological Counting")
522
+ use_stereo = gr.Checkbox(
523
+ label="Enable Stereological Counting",
524
+ value=False,
525
+ info="Use unbiased stereological rules for cell counting"
526
+ )
527
+
528
+ with gr.Group(visible=False) as stereo_controls:
529
+ gr.Markdown("""
530
+ **Stereological Counting Rules:**
531
+ - Cells touching LEFT or TOP exclusion zones are EXCLUDED
532
+ - Cells touching RIGHT or BOTTOM edges are INCLUDED
533
+ - This provides unbiased counting for quantification
534
+ """)
535
+
536
+ excl_preview = gr.Image(
537
+ type="pil",
538
+ label="Exclusion Zone Preview (Red = Excluded)",
539
+ height=500
540
+ )
541
+
542
+ left_excl = gr.Slider(
543
+ minimum=0,
544
+ maximum=50,
545
+ value=10,
546
+ step=1,
547
+ label="Left Exclusion Width (%)",
548
+ info="Width of left exclusion zone"
549
+ )
550
+
551
+ top_excl = gr.Slider(
552
+ minimum=0,
553
+ maximum=50,
554
+ value=10,
555
+ step=1,
556
+ label="Top Exclusion Width (%)",
557
+ info="Width of top exclusion zone"
558
+ )
559
+
560
+ segment_btn = gr.Button("πŸ”¬ Run Segmentation", variant="primary", size="lg")
561
+
562
+ with gr.Column():
563
+ cell_count_out = gr.Number(label="Total Cells Detected", precision=0)
564
+ confluency_out = gr.Number(label="Confluency (%)", precision=1)
565
+ overlay_out = gr.Image(type="pil", label="Segmentation Result")
566
+ info_out = gr.Textbox(label="Processing Info", lines=4)
567
+
568
+ with gr.Group(visible=False) as viability_section:
569
+ gr.Markdown("### Viability Assessment (Trypan Blue)")
570
+ gr.Markdown(
571
+ "The threshold is set **automatically per image** using Otsu's method. "
572
+ "Use the sensitivity slider to fine-tune if needed."
573
+ )
574
+
575
+ with gr.Row():
576
+ with gr.Column():
577
+ sensitivity = gr.Slider(
578
+ minimum=-50,
579
+ maximum=50,
580
+ value=0,
581
+ step=1,
582
+ label="Sensitivity",
583
+ info="← more dead cells Β· auto (0) Β· fewer dead cells β†’"
584
+ )
585
+ with gr.Column():
586
+ live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
587
+ dead_count_out = gr.Number(label="Dead Cells (Red)", precision=0)
588
+
589
+ viab_overlay = gr.Image(type="pil", label="Viability Assessment (Green=Live, Red=Dead)")
590
+ viab_percent_out = gr.Number(label="Viability (%)", precision=1)
591
+ viab_info = gr.Textbox(label="Analysis Results", lines=6)
592
+
593
+ # ---- Event handlers ------------------------------------------------
594
+
595
+ use_stereo.change(
596
+ fn=toggle_stereological_mode,
597
+ inputs=[use_stereo],
598
+ outputs=[stereo_controls]
599
+ )
600
+
601
+ img_input.change(
602
+ fn=update_exclusion_preview,
603
+ inputs=[img_input, left_excl, top_excl],
604
+ outputs=[excl_preview]
605
+ )
606
+ left_excl.change(
607
+ fn=update_exclusion_preview,
608
+ inputs=[img_input, left_excl, top_excl],
609
+ outputs=[excl_preview]
610
+ )
611
+ top_excl.change(
612
+ fn=update_exclusion_preview,
613
+ inputs=[img_input, left_excl, top_excl],
614
+ outputs=[excl_preview]
615
+ )
616
+
617
+ segment_btn.click(
618
+ fn=run_segmentation,
619
+ inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
620
+ use_stereo, left_excl, top_excl],
621
+ outputs=[cell_count_out, overlay_out, info_out, viability_section,
622
+ masks_state, image_state, confluency_out, min_size_slider]
623
+ ).then(
624
+ fn=update_viability_realtime,
625
+ inputs=[sensitivity, masks_state, image_state],
626
+ outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
627
+ ).then(
628
+ fn=save_tab_result,
629
+ inputs=[cell_count_out, confluency_out, viab_percent_out],
630
+ outputs=[result_state]
631
+ )
632
+
633
+ sensitivity.change(
634
+ fn=update_viability_realtime,
635
+ inputs=[sensitivity, masks_state, image_state],
636
+ outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
637
+ ).then(
638
+ fn=save_tab_result,
639
+ inputs=[cell_count_out, confluency_out, viab_percent_out],
640
+ outputs=[result_state]
641
+ )
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # Gradio interface
646
+ # ---------------------------------------------------------------------------
647
+ with gr.Blocks(
648
+ title="CellposeCellCounter",
649
+ theme=gr.themes.Soft(),
650
+ ) as demo:
651
+ gr.Markdown("# CellposeCellCounter")
652
+ 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.")
653
+
654
+ # Shared mask/image state (one pair per tab so tabs don't clobber each other)
655
+ masks_states = [gr.State(value=None) for _ in range(4)]
656
+ image_states = [gr.State(value=None) for _ in range(4)]
657
+ result_states = [gr.State(value=None) for _ in range(4)]
658
+
659
+ # Build Tabs 1–4 with a loop
660
+ for i in range(4):
661
+ build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
662
+
663
+ # -------------------------------------------------------------------------
664
+ # Tab 5 β€” Summary
665
+ # -------------------------------------------------------------------------
666
+ with gr.Tab("Tab 5 β€” Summary"):
667
+ gr.Markdown("## Average Results Across All Tabs")
668
+ gr.Markdown(
669
+ "Run segmentation (and optionally adjust the blue threshold) in one or more tabs, "
670
+ "then click **Refresh Summary** to see the averages."
671
+ )
672
+
673
+ refresh_btn = gr.Button("πŸ”„ Refresh Summary", variant="primary", size="lg")
674
+
675
+ with gr.Row():
676
+ avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
677
+ avg_conf_out = gr.Number(label="Avg Confluency (%)", precision=1)
678
+ avg_viab_out = gr.Number(label="Avg Viability (%)", precision=1)
679
+
680
+ summary_box = gr.Textbox(label="Per-Tab Breakdown", lines=10)
681
+
682
+ refresh_btn.click(
683
+ fn=compute_summary,
684
+ inputs=result_states, # list of 4 gr.State components
685
+ outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
686
+ )
687
+
688
+ # Instructions
689
+ with gr.Accordion("Instructions", open=False):
690
+ gr.Markdown("""
691
+ ### How to use:
692
+
693
+ 1. **Upload and Segment**:
694
+ - Upload your microscopy image.
695
+ - Select a Cellpose model (e.g., "Hemocytometer Model" for suspension culture).
696
+ - **(Optional)** Enable Stereological Counting for unbiased quantification.
697
+ - Click "Run Segmentation".
698
+
699
+ 2. **Stereological Counting** (Optional):
700
+ - Check "Enable Stereological Counting" to use unbiased counting rules.
701
+ - Adjust the Left and Top exclusion zone widths using the sliders.
702
+ - Preview shows excluded areas in red.
703
+ - **Counting Rules**:
704
+ - Cells touching LEFT or TOP exclusion zones are EXCLUDED
705
+ - Cells touching RIGHT or BOTTOM edges are INCLUDED
706
+ - This ensures unbiased, systematic counting
707
+
708
+ 3. **Analysis Results**:
709
+ - **Cell Count**: Total number of detected cells (after exclusions if using stereology)
710
+ - **Confluency**: Percentage of image area covered by cells
711
+
712
+ 4. **Real-time Viability Assessment (Trypan Blue)**:
713
+ - After segmentation, the viability section will become visible.
714
+ - Adjust the **"Blue Threshold (%)"** slider in real-time.
715
+ - **Lower values (10-20%)** are more sensitive.
716
+ - **Higher values (30-50%)** are more selective.
717
+ - Green cells = Live, Red cells = Dead.
718
+
719
+ 5. **Summary (Tab 5)**:
720
+ - After running segmentation in one or more tabs, switch to **Tab 5**.
721
+ - Click **Refresh Summary** to compute averages across all completed tabs.
722
+ - Displays average cell count, confluency, and viability, plus a per-tab breakdown.
723
+
724
+ 6. **Interpreting Results**:
725
+ - The app displays total, live, and dead cell counts, viability percentage, and confluency.
726
+ - If stereological counting is enabled, excluded cells are noted in the processing info.
727
+ """)
728
+
729
+ if __name__ == "__main__":
730
+ demo.launch()