LiangLabUMB commited on
Commit
a5b41e7
·
verified ·
1 Parent(s): 14483a4

Upload app (2).py

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