LiangLabUMB commited on
Commit
7ee5f6b
Β·
verified Β·
1 Parent(s): 0bc2d41

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1035 -0
app.py ADDED
@@ -0,0 +1,1035 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
14
+ HF_REPO_ID = "myang4218/cellposemodel"
15
+ MODEL_OPTIONS = {
16
+ "Hemocytometer Model": "hemocytometermodel.npy",
17
+ "General Model": "generalmodel.npy"
18
+ }
19
+
20
+ loaded_models = {}
21
+
22
+ # ---- mobile-safe size limits (aggressive for Safari) ----
23
+ MAX_SIDE = 1024
24
+ MAX_PIXELS = 1024 * 1024
25
+
26
+
27
+ def safe_resize(image_np):
28
+ """
29
+ Downscale image to fit within MAX_SIDE and MAX_PIXELS while
30
+ preserving aspect ratio. Works for RGB / RGBA / grayscale.
31
+ """
32
+ h, w = image_np.shape[:2]
33
+ total = h * w
34
+
35
+ if max(h, w) <= MAX_SIDE and total <= MAX_PIXELS:
36
+ return image_np
37
+
38
+ # compute scale
39
+ scale_side = MAX_SIDE / max(h, w)
40
+ scale_pixels = (MAX_PIXELS / total) ** 0.5
41
+ scale = min(scale_side, scale_pixels)
42
+
43
+ new_w = max(1, int(w * scale))
44
+ new_h = max(1, int(h * scale))
45
+
46
+ return cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
47
+
48
+
49
+ def draw_exclusion_overlay(image_np, left_width_pct, top_width_pct):
50
+
51
+ h, w = image_np.shape[:2]
52
+
53
+ # Convert to PIL for drawing
54
+ img_pil = Image.fromarray(image_np)
55
+ draw = ImageDraw.Draw(img_pil, 'RGBA')
56
+
57
+ # Calculate pixel widths from percentages
58
+ left_px = int(w * left_width_pct / 100)
59
+ top_px = int(h * top_width_pct / 100)
60
+
61
+ # Draw overlays for exclusion zones
62
+ if left_px > 0:
63
+ # Left exclusion zone
64
+ draw.rectangle(
65
+ [(0, 0), (left_px, h)],
66
+ fill=(255, 0, 0, 80) # Semi-transparent red
67
+ )
68
+ # border line
69
+ draw.line([(left_px, 0), (left_px, h)], fill=(255, 0, 0, 255), width=3)
70
+
71
+ if top_px > 0:
72
+ # Top exclusion zone
73
+ draw.rectangle(
74
+ [(0, 0), (w, top_px)],
75
+ fill=(255, 0, 0, 80) # Semi-transparent red
76
+ )
77
+ # border line
78
+ draw.line([(0, top_px), (w, top_px)], fill=(255, 0, 0, 255), width=3)
79
+
80
+ return np.array(img_pil)
81
+
82
+
83
+ def apply_stereological_exclusion(masks, left_width_pct, top_width_pct):
84
+ h, w = masks.shape
85
+
86
+ # Calculate pixel widths from percentages
87
+ left_px = int(w * left_width_pct / 100)
88
+ top_px = int(h * top_width_pct / 100)
89
+
90
+ filtered_masks = masks.copy()
91
+ cell_ids = np.unique(masks)
92
+ cell_ids = cell_ids[cell_ids > 0]
93
+
94
+ excluded_cells = []
95
+ included_cells = []
96
+
97
+ for cell_id in cell_ids:
98
+ cell_mask = (masks == cell_id)
99
+
100
+ # Get cell boundary coordinates
101
+ rows, cols = np.where(cell_mask)
102
+
103
+ # Check if cell touches left exclusion zone
104
+ touches_left = np.any(cols < left_px) if left_px > 0 else False
105
+
106
+ # Check if cell touches top exclusion zone
107
+ touches_top = np.any(rows < top_px) if top_px > 0 else False
108
+
109
+ # Exclude if touching left or top
110
+ if touches_left or touches_top:
111
+ filtered_masks[cell_mask] = 0
112
+ excluded_cells.append(cell_id)
113
+ else:
114
+ included_cells.append(cell_id)
115
+
116
+ # Renumber remaining cells
117
+ unique_ids = np.unique(filtered_masks)
118
+ unique_ids = unique_ids[unique_ids > 0]
119
+
120
+ renumbered_masks = np.zeros_like(filtered_masks)
121
+ for new_id, old_id in enumerate(unique_ids, start=1):
122
+ renumbered_masks[filtered_masks == old_id] = new_id
123
+
124
+ return renumbered_masks, len(excluded_cells), len(included_cells)
125
+
126
+
127
+ def classify_cells_by_blueness(image_np, masks, threshold_bias):
128
+ """
129
+ Classify cells as dead (blue) or alive using an adaptive Otsu threshold
130
+ on per-cell blueness scores, with a user bias to fine-tune.
131
+
132
+ Args:
133
+ image_np: RGB image array
134
+ masks: Cellpose segmentation masks
135
+ threshold_bias: Slider value -50..+50; shifts Otsu threshold up/down.
136
+ Negative = more cells classified dead (looser).
137
+ Positive = fewer cells classified dead (stricter).
138
+ 0 = pure Otsu (fully automatic).
139
+
140
+ Returns:
141
+ dead_count, alive_count, colored_overlay, otsu_threshold, final_threshold
142
+ """
143
+
144
+ if len(image_np.shape) == 2:
145
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
146
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
147
+ image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
148
+
149
+ hsv = cv2.cvtColor(image_np, cv2.COLOR_RGB2HSV)
150
+
151
+ hue = hsv[:, :, 0].astype(np.float32)
152
+ saturation = hsv[:, :, 1].astype(np.float32)
153
+
154
+ # Raw blueness: hue proximity to 115Β° Γ— saturation
155
+ hue_distance = np.minimum(np.abs(hue - 115), 180 - np.abs(hue - 115))
156
+ hue_score = np.maximum(0, 1 - hue_distance / 65)
157
+ blueness = hue_score * (saturation / 255.0)
158
+
159
+ # --- Compute per-cell mean blueness scores ---
160
+ cell_ids = np.unique(masks)
161
+ cell_ids = cell_ids[cell_ids > 0]
162
+
163
+ if len(cell_ids) == 0:
164
+ blank = image_np.copy()
165
+ return 0, 0, blank, 0.0, 0.0
166
+
167
+ cell_scores = np.array([np.mean(blueness[masks == cid]) for cid in cell_ids])
168
+
169
+ # --- Otsu on the distribution of per-cell scores ---
170
+ # cv2.threshold expects uint8; scale 0-1 β†’ 0-255
171
+ scores_u8 = (np.clip(cell_scores, 0, 1) * 255).astype(np.uint8)
172
+
173
+ if scores_u8.max() == scores_u8.min():
174
+ # All cells identical β†’ Otsu is undefined; use midpoint
175
+ otsu_threshold = float(scores_u8[0]) / 255.0
176
+ else:
177
+ # Reshape to a single-column image so cv2.threshold works
178
+ thresh_val, _ = cv2.threshold(
179
+ scores_u8.reshape(-1, 1), 0, 255,
180
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU
181
+ )
182
+ otsu_threshold = thresh_val / 255.0
183
+
184
+ # --- Apply user bias: slider -50..+50 maps to Β±0.20 shift ---
185
+ bias = (threshold_bias / 50.0) * 0.20
186
+ final_threshold = float(np.clip(otsu_threshold + bias, 0.0, 1.0))
187
+
188
+ # --- Classify ---
189
+ dead_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s > final_threshold]
190
+ alive_cells = [cid for cid, s in zip(cell_ids, cell_scores) if s <= final_threshold]
191
+
192
+ # --- Colored overlay ---
193
+ overlay = image_np.copy().astype(np.float32)
194
+ for cid in dead_cells:
195
+ overlay[masks == cid] = [255, 0, 0]
196
+ for cid in alive_cells:
197
+ overlay[masks == cid] = [0, 255, 0]
198
+
199
+ alpha = 0.4
200
+ final_overlay = (1 - alpha) * image_np.astype(np.float32) + alpha * overlay
201
+ final_overlay = np.clip(final_overlay, 0, 255).astype(np.uint8)
202
+
203
+ return len(dead_cells), len(alive_cells), final_overlay, otsu_threshold, final_threshold
204
+
205
+
206
+ def measure_confluency(masks, image_np):
207
+ tot_pixels = image_np.shape[0] * image_np.shape[1]
208
+ cell_pixels = np.count_nonzero(masks)
209
+ confluency = cell_pixels / tot_pixels * 100
210
+ return confluency
211
+
212
+ def filter_mask_by_size(masks, minimum_pixels):
213
+ filtered_masks = masks.copy()
214
+ cell_ids = np.unique(masks)
215
+ cell_ids = cell_ids[cell_ids > 0]
216
+
217
+ removed_count = 0
218
+
219
+ for cell_id in cell_ids:
220
+ cell_mask = (masks == cell_id)
221
+ cell_pixels = np.count_nonzero(cell_mask)
222
+ if cell_pixels < minimum_pixels:
223
+ filtered_masks[cell_mask] = 0
224
+ removed_count += 1
225
+
226
+ unique_ids = np.unique(filtered_masks)
227
+ unique_ids = unique_ids[unique_ids > 0]
228
+
229
+ renumbered_masks = np.zeros_like(filtered_masks)
230
+ for new_id, old_id in enumerate(unique_ids, start=1):
231
+ renumbered_masks[filtered_masks == old_id] = new_id
232
+
233
+ return renumbered_masks, removed_count
234
+
235
+
236
+ def filter_mask_by_maxsize(masks, maximum_pixels):
237
+ filtered_masks = masks.copy()
238
+ cell_ids = np.unique(masks)
239
+ cell_ids = cell_ids[cell_ids > 0]
240
+
241
+ removed_count = 0
242
+ for cell_id in cell_ids:
243
+ cell_mask = (masks == cell_id)
244
+ cell_pixels = np.count_nonzero(cell_mask)
245
+ if cell_pixels > maximum_pixels:
246
+ filtered_masks[cell_mask] = 0
247
+ removed_count += 1
248
+
249
+ unique_ids = np.unique(filtered_masks)
250
+ unique_ids = unique_ids[unique_ids > 0]
251
+
252
+ renumbered_masks = np.zeros_like(filtered_masks)
253
+ for new_id, old_id in enumerate(unique_ids, start=1):
254
+ renumbered_masks[filtered_masks == old_id] = new_id
255
+
256
+ return renumbered_masks, removed_count
257
+
258
+
259
+ def rec_min_size(masks, q=25):
260
+ ids = np.unique(masks)
261
+ ids = ids[ids > 0]
262
+ if len(ids) == 0:
263
+ return 0
264
+ sizes = np.array([np.count_nonzero(masks == cid) for cid in ids])
265
+ return int(round(np.percentile(sizes, q)))
266
+
267
+
268
+ def apply_polygon_mask(image_pil, points_json):
269
+ """
270
+ Given a PIL image and a JSON string of [[x,y],...] points,
271
+ zero out everything outside the polygon and return a PIL image.
272
+ """
273
+ import json
274
+ if not points_json or points_json.strip() in ("", "[]"):
275
+ return image_pil
276
+ try:
277
+ pts = json.loads(points_json)
278
+ except Exception:
279
+ return image_pil
280
+ if len(pts) < 3:
281
+ return image_pil
282
+
283
+ image_np = np.array(image_pil)
284
+ h, w = image_np.shape[:2]
285
+ poly = np.array(pts, dtype=np.int32)
286
+ poly[:, 0] = np.clip(poly[:, 0], 0, w - 1)
287
+ poly[:, 1] = np.clip(poly[:, 1], 0, h - 1)
288
+ mask = np.zeros((h, w), dtype=np.uint8)
289
+ cv2.fillPoly(mask, [poly], 255)
290
+ if len(image_np.shape) == 3:
291
+ result = np.where(mask[:, :, np.newaxis] == 255, image_np, 0).astype(np.uint8)
292
+ else:
293
+ result = np.where(mask == 255, image_np, 0).astype(np.uint8)
294
+ return Image.fromarray(result)
295
+
296
+
297
+ def toggle_stereological_mode(use_stereology):
298
+ """Show/hide stereological controls based on checkbox"""
299
+ return gr.update(visible=use_stereology)
300
+
301
+
302
+ def update_exclusion_preview(image, left_width, top_width):
303
+ """Update the preview image with exclusion zone overlay"""
304
+ if image is None:
305
+ return None
306
+
307
+ image_np = np.array(image)
308
+ overlay = draw_exclusion_overlay(image_np, left_width, top_width)
309
+ return Image.fromarray(overlay)
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Patch-parallel segmentation helpers (Option A)
314
+ # ---------------------------------------------------------------------------
315
+ PATCH_SIZE = 512 # target patch side length
316
+ PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
317
+ MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
318
+
319
+
320
+ def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
321
+ """
322
+ Split image into overlapping patches.
323
+ Returns list of (patch_np, row_start, col_start) tuples.
324
+ """
325
+ h, w = image_np.shape[:2]
326
+ patches = []
327
+ row = 0
328
+ while row < h:
329
+ row_end = min(row + patch_size, h)
330
+ col = 0
331
+ while col < w:
332
+ col_end = min(col + patch_size, w)
333
+ patch = image_np[row:row_end, col:col_end]
334
+ patches.append((patch, row, col))
335
+ if col_end == w:
336
+ break
337
+ col += patch_size - overlap
338
+ if row_end == h:
339
+ break
340
+ row += patch_size - overlap
341
+ return patches
342
+
343
+
344
+ def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
345
+ """
346
+ Stitch per-patch masks into a single full-image mask.
347
+
348
+ Strategy:
349
+ - Each patch gets a unique ID offset so cell IDs never collide.
350
+ - Patches are pasted into the canvas using a priority canvas that
351
+ gives interior pixels precedence over overlap-border pixels.
352
+ - After pasting, cells whose centroids fall in the overlap zone
353
+ of two adjacent patches are deduplicated: if two cells from
354
+ different patches share >50% IoU they are the same cell β€” keep
355
+ the one whose centroid is furthest from a patch edge.
356
+ """
357
+ full_mask = np.zeros((full_h, full_w), dtype=np.int32)
358
+ # track which patch_idx owns each pixel (used for overlap resolution)
359
+ owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
360
+ # distance-to-nearest-edge for the owning patch (higher = more central)
361
+ priority = np.zeros((full_h, full_w), dtype=np.float32)
362
+
363
+ id_offset = 0
364
+ patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
365
+
366
+ for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
367
+ ph, pw = mask_patch.shape
368
+ # offset all non-zero IDs so they're globally unique
369
+ shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
370
+
371
+ # compute per-pixel priority = min distance to any patch edge
372
+ rows_idx = np.arange(ph)
373
+ cols_idx = np.arange(pw)
374
+ dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
375
+ dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
376
+ pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
377
+
378
+ roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
379
+ roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
380
+ roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
381
+
382
+ # where this patch has higher priority, overwrite
383
+ better = pri_patch > roi_pri
384
+ roi_full [better] = shifted [better]
385
+ roi_owner[better] = patch_idx
386
+ roi_pri [better] = pri_patch [better]
387
+
388
+ max_id = int(mask_patch.max())
389
+ patch_meta.append((id_offset, row_start, col_start, ph, pw))
390
+ id_offset += max_id + 1
391
+
392
+ # --- Renumber to compact sequential IDs ---
393
+ unique_ids = np.unique(full_mask)
394
+ unique_ids = unique_ids[unique_ids > 0]
395
+ renumbered = np.zeros_like(full_mask)
396
+ for new_id, old_id in enumerate(unique_ids, start=1):
397
+ renumbered[full_mask == old_id] = new_id
398
+
399
+ return renumbered
400
+
401
+
402
+ def _segment_patch(args):
403
+ """Worker: run cellpose on a single patch. Called from a thread pool."""
404
+ patch_np, row_start, col_start, model_filename, hf_repo = args
405
+ # Each thread uses the shared loaded_models cache (GIL-safe for reads;
406
+ # model.eval() releases the GIL during GPU work so threads overlap.)
407
+ model_path = hf_hub_download(repo_id=hf_repo, filename=model_filename)
408
+ if model_filename in loaded_models:
409
+ model = loaded_models[model_filename]
410
+ else:
411
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
412
+ loaded_models[model_filename] = model
413
+
414
+ mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
415
+ return mask, row_start, col_start
416
+
417
+
418
+ def run_segmentation_patched(image_np, model_filename):
419
+ """
420
+ Split image into overlapping patches, run Cellpose on each in parallel,
421
+ then stitch back into a single full-resolution mask.
422
+ Falls back to whole-image segmentation if the image is small enough
423
+ that patching adds overhead without benefit.
424
+ """
425
+ h, w = image_np.shape[:2]
426
+ model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
427
+ if model_filename in loaded_models:
428
+ model = loaded_models[model_filename]
429
+ else:
430
+ model = models.CellposeModel(gpu=True, pretrained_model=model_path)
431
+ loaded_models[model_filename] = model
432
+
433
+ # Small images: no benefit from patching
434
+ if max(h, w) <= MIN_PATCH_DIM * 2:
435
+ mask, _, _ = model.eval(image_np, diameter=None, channels=[0, 0])
436
+ return mask, 1 # 1 patch
437
+
438
+ patches = _split_patches(image_np)
439
+ n_patches = len(patches)
440
+
441
+ # Build argument list for the thread pool
442
+ args_list = [
443
+ (patch, r, c, model_filename, HF_REPO_ID)
444
+ for patch, r, c in patches
445
+ ]
446
+
447
+ patch_results = [] # (mask, row_start, col_start) in submission order
448
+
449
+ # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
450
+ with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
451
+ futures = {pool.submit(_segment_patch, a): a for a in args_list}
452
+ for future in as_completed(futures):
453
+ mask_patch, row_start, col_start = future.result()
454
+ patch_results.append((mask_patch, row_start, col_start))
455
+
456
+ # Re-sort by (row, col) so stitching is deterministic
457
+ patch_results.sort(key=lambda x: (x[1], x[2]))
458
+
459
+ full_mask = _merge_patch_masks(patch_results, h, w)
460
+ return full_mask, n_patches
461
+
462
+
463
+ @spaces.GPU
464
+ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
465
+ use_stereology, left_exclusion, top_exclusion,
466
+ crop_points=None):
467
+ image_np = np.array(image)
468
+ image_np = safe_resize(image_np)
469
+
470
+ # Apply polygon crop mask if the user drew one (need β‰₯3 points for a polygon)
471
+ if crop_points and len(crop_points) >= 3:
472
+ import json
473
+ pts_json = json.dumps(crop_points)
474
+ image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), pts_json)
475
+ image_np = np.array(image_pil_masked)
476
+
477
+ try:
478
+ model_filename = MODEL_OPTIONS[model_choice]
479
+
480
+ # Process image format to RGB
481
+ if len(image_np.shape) == 2:
482
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
483
+ elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
484
+ processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
485
+ else:
486
+ processed_image_np = image_np
487
+
488
+ # Run patch-parallel Cellpose segmentation
489
+ masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
490
+
491
+ ids = np.unique(masks_raw)
492
+ ids = ids[ids > 0]
493
+
494
+ sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
495
+
496
+ print("num_cells:", len(ids))
497
+ print("mean:", sizes.mean() if len(sizes) > 0 else 0)
498
+ print("median:", np.median(sizes) if len(sizes) > 0 else 0)
499
+ print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
500
+ print("max:", sizes.max() if len(sizes) > 0 else 0)
501
+
502
+ # Compute recommendation from RAW masks
503
+ recommend_min = rec_min_size(masks_raw)
504
+
505
+ # If user sets slider to 0, use the recommendation
506
+ min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
507
+
508
+ # Apply filters
509
+ masks = masks_raw.copy()
510
+ removed_small = 0
511
+ removed_large = 0
512
+
513
+ if min_used > 0:
514
+ masks, removed_small = filter_mask_by_size(masks, min_used)
515
+
516
+ if max_cell_size > 0:
517
+ masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
518
+
519
+ # Apply stereological exclusion if enabled
520
+ excluded_count = 0
521
+ if use_stereology:
522
+ masks, excluded_count, included_count = apply_stereological_exclusion(
523
+ masks, left_exclusion, top_exclusion
524
+ )
525
+
526
+ filter_msg = ""
527
+ if removed_small:
528
+ filter_msg += f"Removed {removed_small} small objects (< {min_used} pixels).\n"
529
+ if removed_large:
530
+ filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
531
+ if use_stereology and excluded_count > 0:
532
+ filter_msg += f"Stereological exclusion: {excluded_count} cells excluded (touching left/top zones).\n"
533
+
534
+ cell_count = len(np.unique(masks)) - 1
535
+ confluency = measure_confluency(masks, processed_image_np)
536
+
537
+ # Create a basic segmentation overlay (without viability)
538
+ segmentation_overlay = processed_image_np.copy().astype(np.float32)
539
+ if masks.max() > 0:
540
+ np.random.seed(42) # For consistent random colors
541
+ colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
542
+ colors[0] = [0, 0, 0]
543
+ colored_mask = colors[masks]
544
+ alpha = 0.4
545
+ segmentation_overlay = (1 - alpha) * segmentation_overlay + alpha * colored_mask
546
+ segmentation_overlay = np.clip(segmentation_overlay, 0, 255).astype(np.uint8)
547
+
548
+ # Add exclusion zone overlay if stereology is enabled
549
+ if use_stereology:
550
+ segmentation_overlay = draw_exclusion_overlay(segmentation_overlay, left_exclusion, top_exclusion)
551
+
552
+ info_msg = ""
553
+ if filter_msg:
554
+ info_msg += filter_msg
555
+ info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
556
+ info_msg += f"Confluency: {confluency:.1f}%\n"
557
+ info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
558
+ if use_stereology:
559
+ info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
560
+ info_msg += "Now adjust the Blue Threshold for viability assessment."
561
+
562
+ return (
563
+ cell_count,
564
+ Image.fromarray(segmentation_overlay),
565
+ info_msg,
566
+ gr.update(visible=True),
567
+ pack_array(masks),
568
+ pack_array(processed_image_np),
569
+ confluency,
570
+ gr.update(value=recommend_min), # update slider display to recommended
571
+ )
572
+
573
+ except Exception as e:
574
+ import traceback
575
+ traceback.print_exc()
576
+ return (
577
+ 0,
578
+ None,
579
+ f"Error during segmentation: {str(e)}",
580
+ gr.update(visible=False),
581
+ None,
582
+ None,
583
+ 0.0,
584
+ gr.update(),
585
+ )
586
+
587
+
588
+ def update_viability_realtime(threshold_bias, stored_masks, stored_image_np):
589
+ # avoid unpacking None (e.g. slider moved before segmentation)
590
+ if stored_masks is None or stored_image_np is None:
591
+ return None, 0, 0, 0.0, "Please run segmentation first."
592
+
593
+ stored_masks = unpack_array(stored_masks)
594
+ stored_image_np = unpack_array(stored_image_np)
595
+
596
+ try:
597
+ dead_count, alive_count, viability_overlay_np, otsu_thresh, final_thresh = \
598
+ classify_cells_by_blueness(stored_image_np, stored_masks, threshold_bias)
599
+
600
+ total_count = alive_count + dead_count
601
+ viability_percent = (alive_count / total_count * 100) if total_count > 0 else 0.0
602
+ confluency = measure_confluency(stored_masks, stored_image_np)
603
+
604
+ overlay_image = Image.fromarray(viability_overlay_np)
605
+ info_msg = f"Total cells: {total_count}\nLive (green): {alive_count}\nDead (red): {dead_count}\n"
606
+ info_msg += f"Viability: {viability_percent:.1f}%\nConfluency: {confluency:.1f}%\n"
607
+ info_msg += f"Otsu threshold (auto): {otsu_thresh:.4f}\n"
608
+ info_msg += f"Final threshold (with bias): {final_thresh:.4f}\n"
609
+ info_msg += f"Sensitivity bias: {threshold_bias:+d}"
610
+
611
+ return overlay_image, alive_count, dead_count, viability_percent, info_msg
612
+
613
+ except Exception as e:
614
+ return None, 0, 0, 0.0, f"Error updating viability: {str(e)}"
615
+
616
+
617
+ def pack_array(arr):
618
+ pil = Image.fromarray(arr.astype(np.uint8))
619
+ buf = io.BytesIO()
620
+ pil.save(buf, format="PNG")
621
+ return buf.getvalue()
622
+
623
+
624
+ def unpack_array(data):
625
+ return np.array(Image.open(io.BytesIO(data)))
626
+
627
+
628
+ def save_tab_result(cell_count, confluency, viab_percent):
629
+ """Package per-tab results into a dict for Tab 5 summary."""
630
+ return {
631
+ "cell_count": float(cell_count) if cell_count is not None else None,
632
+ "confluency": float(confluency) if confluency is not None else None,
633
+ "viab_percent": float(viab_percent) if viab_percent is not None else None,
634
+ }
635
+
636
+
637
+ def compute_summary(r1, r2, r3, r4):
638
+ """Average cell count, confluency, and viability across tabs that have data."""
639
+ all_results = [r1, r2, r3, r4]
640
+ valid = [(i + 1, r) for i, r in enumerate(all_results)
641
+ if r is not None and r.get("cell_count") is not None]
642
+
643
+ if not valid:
644
+ return (
645
+ 0.0, 0.0, 0.0,
646
+ "No data yet β€” run segmentation in at least one tab, then click Refresh Summary."
647
+ )
648
+
649
+ avg_count = sum(r["cell_count"] for _, r in valid) / len(valid)
650
+ avg_conf = sum(r["confluency"] for _, r in valid) / len(valid)
651
+ avg_viab = sum(r["viab_percent"] for _, r in valid) / len(valid)
652
+
653
+ lines = [f"Tab {tab_num}: {r['cell_count']:.0f} cells | "
654
+ f"{r['confluency']:.1f}% confluency | "
655
+ f"{r['viab_percent']:.1f}% viability"
656
+ for tab_num, r in valid]
657
+ lines.append(f"\nAverages ({len(valid)} tab{'s' if len(valid) > 1 else ''}):")
658
+ lines.append(f" Cell count: {avg_count:.1f}")
659
+ lines.append(f" Confluency: {avg_conf:.1f}%")
660
+ lines.append(f" Viability: {avg_viab:.1f}%")
661
+
662
+ return avg_count, avg_conf, avg_viab, "\n".join(lines)
663
+
664
+
665
+ # ---------------------------------------------------------------------------
666
+ # Polygon crop β€” gr.Image select-based, no JS
667
+ # ---------------------------------------------------------------------------
668
+
669
+ def draw_polygon_overlay(image_pil, points):
670
+ """
671
+ Draw numbered vertex dots and polygon edges onto a copy of image_pil.
672
+ points: list of (x, y) tuples in original image pixel space.
673
+ Returns a new PIL image.
674
+ """
675
+ img = image_pil.copy().convert("RGBA")
676
+ overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
677
+ draw = ImageDraw.Draw(overlay)
678
+
679
+ if len(points) >= 2:
680
+ # Draw edges
681
+ for i in range(len(points) - 1):
682
+ draw.line([points[i], points[i + 1]], fill=(74, 170, 255, 220), width=3)
683
+ if len(points) == 4:
684
+ draw.line([points[-1], points[0]], fill=(74, 170, 255, 220), width=3)
685
+ # Semi-transparent fill
686
+ draw.polygon(points, fill=(74, 170, 255, 50))
687
+
688
+ # Draw vertex dots + numbers
689
+ r = max(8, min(img.width, img.height) // 60)
690
+ for i, (x, y) in enumerate(points):
691
+ draw.ellipse([x - r, y - r, x + r, y + r],
692
+ fill=(74, 170, 255, 255), outline=(255, 255, 255, 255))
693
+ draw.text((x, y), str(i + 1), fill=(255, 255, 255, 255), anchor="mm")
694
+
695
+ combined = Image.alpha_composite(img, overlay)
696
+ return combined.convert("RGB")
697
+
698
+
699
+ def add_crop_point(image_pil, points, evt: gr.SelectData):
700
+ """
701
+ Called by gr.Image .select(). Appends the clicked coordinate,
702
+ redraws the overlay, returns (updated_image, updated_points).
703
+ Ignores clicks once 4 points are set.
704
+ """
705
+ if image_pil is None:
706
+ return image_pil, points
707
+ if points is None:
708
+ points = []
709
+ if len(points) >= 4:
710
+ return draw_polygon_overlay(image_pil, points), points
711
+
712
+ x, y = int(evt.index[0]), int(evt.index[1])
713
+ new_points = points + [(x, y)]
714
+ return draw_polygon_overlay(image_pil, new_points), new_points
715
+
716
+
717
+ def clear_crop_points(image_pil):
718
+ """Reset polygon β€” return original image with no overlay and empty points."""
719
+ return image_pil, []
720
+
721
+
722
+
723
+
724
+ def build_tab(tab_index, masks_state, image_state, result_state):
725
+ with gr.Tab(f"Tab {tab_index}"):
726
+ gr.Markdown("Run segmentation")
727
+
728
+ # Per-tab state: list of (x,y) crop polygon points
729
+ crop_points_state = gr.State(value=[])
730
+ # Clean copy of the uploaded image (no polygon drawn on it)
731
+ base_image_state = gr.State(value=None)
732
+
733
+ with gr.Row():
734
+ with gr.Column():
735
+ img_input = gr.Image(
736
+ type="pil",
737
+ label="Upload image",
738
+ image_mode="RGB",
739
+ height=512
740
+ )
741
+
742
+ gr.Markdown(
743
+ "### Crop region (optional)\n"
744
+ "Click/tap up to **4 points** on the image below to define the region "
745
+ "to segment. The polygon will be drawn as you click. "
746
+ "Leave empty to segment the full image."
747
+ )
748
+
749
+ crop_display = gr.Image(
750
+ type="pil",
751
+ label="Click to set crop vertices (up to 4)",
752
+ interactive=True,
753
+ height=400,
754
+ )
755
+
756
+ crop_status = gr.Markdown("*Upload an image to enable cropping*")
757
+
758
+ clear_crop_btn = gr.Button("βœ• Clear crop points", size="sm")
759
+
760
+ model_dropdown = gr.Dropdown(
761
+ choices=list(MODEL_OPTIONS.keys()),
762
+ label="Select Model",
763
+ value="Hemocytometer Model"
764
+ )
765
+
766
+ min_size_slider = gr.Slider(
767
+ minimum=0,
768
+ maximum=500,
769
+ value=0,
770
+ step=10,
771
+ label="Minimum Cell Size (pixels). Leave at zero for automated recommendation",
772
+ )
773
+
774
+ max_size_slider = gr.Slider(
775
+ minimum=0,
776
+ maximum=10000,
777
+ value=10000,
778
+ step=10,
779
+ label="Maximum Cell Size (pixels)",
780
+ )
781
+
782
+ gr.Markdown("### Stereological Counting")
783
+ use_stereo = gr.Checkbox(
784
+ label="Enable Stereological Counting",
785
+ value=False,
786
+ info="Use unbiased stereological rules for cell counting"
787
+ )
788
+
789
+ with gr.Group(visible=False) as stereo_controls:
790
+ gr.Markdown("""
791
+ **Stereological Counting Rules:**
792
+ - Cells touching LEFT or TOP exclusion zones are EXCLUDED
793
+ - Cells touching RIGHT or BOTTOM edges are INCLUDED
794
+ - This provides unbiased counting for quantification
795
+ """)
796
+
797
+ excl_preview = gr.Image(
798
+ type="pil",
799
+ label="Exclusion Zone Preview (Red = Excluded)",
800
+ height=500
801
+ )
802
+
803
+ left_excl = gr.Slider(
804
+ minimum=0,
805
+ maximum=50,
806
+ value=10,
807
+ step=1,
808
+ label="Left Exclusion Width (%)",
809
+ info="Width of left exclusion zone"
810
+ )
811
+
812
+ top_excl = gr.Slider(
813
+ minimum=0,
814
+ maximum=50,
815
+ value=10,
816
+ step=1,
817
+ label="Top Exclusion Width (%)",
818
+ info="Width of top exclusion zone"
819
+ )
820
+
821
+ segment_btn = gr.Button("πŸ”¬ Run Segmentation", variant="primary", size="lg")
822
+
823
+ with gr.Column():
824
+ cell_count_out = gr.Number(label="Total Cells Detected", precision=0)
825
+ confluency_out = gr.Number(label="Confluency (%)", precision=1)
826
+ overlay_out = gr.Image(type="pil", label="Segmentation Result")
827
+ info_out = gr.Textbox(label="Processing Info", lines=4)
828
+
829
+ with gr.Group(visible=False) as viability_section:
830
+ gr.Markdown("### Viability Assessment (Trypan Blue)")
831
+ gr.Markdown(
832
+ "The threshold is set **automatically per image** using Otsu's method. "
833
+ "Use the sensitivity slider to fine-tune if needed."
834
+ )
835
+
836
+ with gr.Row():
837
+ with gr.Column():
838
+ sensitivity = gr.Slider(
839
+ minimum=-50,
840
+ maximum=50,
841
+ value=0,
842
+ step=1,
843
+ label="Sensitivity",
844
+ info="← more dead cells Β· auto (0) Β· fewer dead cells β†’"
845
+ )
846
+ with gr.Column():
847
+ live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
848
+ dead_count_out = gr.Number(label="Dead Cells (Red)", precision=0)
849
+
850
+ viab_overlay = gr.Image(type="pil", label="Viability Assessment (Green=Live, Red=Dead)")
851
+ viab_percent_out = gr.Number(label="Viability (%)", precision=1)
852
+ viab_info = gr.Textbox(label="Analysis Results", lines=6)
853
+
854
+ # ---- Event handlers ------------------------------------------------
855
+
856
+ use_stereo.change(
857
+ fn=toggle_stereological_mode,
858
+ inputs=[use_stereo],
859
+ outputs=[stereo_controls]
860
+ )
861
+
862
+ # When image uploaded: populate crop_display and store clean base copy
863
+ def on_image_upload(img):
864
+ if img is None:
865
+ return None, None, "*Upload an image to enable cropping*"
866
+ return img, img, f"*Image loaded β€” click up to 4 points to define crop region*"
867
+
868
+ img_input.change(
869
+ fn=on_image_upload,
870
+ inputs=[img_input],
871
+ outputs=[crop_display, base_image_state, crop_status]
872
+ ).then(
873
+ # also reset crop points on new upload
874
+ fn=lambda: [],
875
+ outputs=[crop_points_state]
876
+ )
877
+
878
+ img_input.change(
879
+ fn=update_exclusion_preview,
880
+ inputs=[img_input, left_excl, top_excl],
881
+ outputs=[excl_preview]
882
+ )
883
+ left_excl.change(
884
+ fn=update_exclusion_preview,
885
+ inputs=[img_input, left_excl, top_excl],
886
+ outputs=[excl_preview]
887
+ )
888
+ top_excl.change(
889
+ fn=update_exclusion_preview,
890
+ inputs=[img_input, left_excl, top_excl],
891
+ outputs=[excl_preview]
892
+ )
893
+
894
+ # Click on crop_display β†’ add point, redraw overlay
895
+ def on_crop_click(base_img, points, evt: gr.SelectData):
896
+ updated_img, updated_pts = add_crop_point(base_img, points, evt)
897
+ n = len(updated_pts)
898
+ if n < 4:
899
+ status = f"*{n} / 4 points set β€” keep clicking*"
900
+ else:
901
+ status = "*4 points set βœ“ β€” click **βœ• Clear** to redo, or run segmentation*"
902
+ return updated_img, updated_pts, status
903
+
904
+ crop_display.select(
905
+ fn=on_crop_click,
906
+ inputs=[base_image_state, crop_points_state],
907
+ outputs=[crop_display, crop_points_state, crop_status]
908
+ )
909
+
910
+ # Clear crop points
911
+ def on_clear_crop(base_img):
912
+ img, pts = clear_crop_points(base_img)
913
+ return img, pts, "*Points cleared β€” click to set new vertices*"
914
+
915
+ clear_crop_btn.click(
916
+ fn=on_clear_crop,
917
+ inputs=[base_image_state],
918
+ outputs=[crop_display, crop_points_state, crop_status]
919
+ )
920
+
921
+ segment_btn.click(
922
+ fn=run_segmentation,
923
+ inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
924
+ use_stereo, left_excl, top_excl, crop_points_state],
925
+ outputs=[cell_count_out, overlay_out, info_out, viability_section,
926
+ masks_state, image_state, confluency_out, min_size_slider]
927
+ ).then(
928
+ fn=update_viability_realtime,
929
+ inputs=[sensitivity, masks_state, image_state],
930
+ outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
931
+ ).then(
932
+ fn=save_tab_result,
933
+ inputs=[cell_count_out, confluency_out, viab_percent_out],
934
+ outputs=[result_state]
935
+ )
936
+
937
+ sensitivity.change(
938
+ fn=update_viability_realtime,
939
+ inputs=[sensitivity, masks_state, image_state],
940
+ outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
941
+ ).then(
942
+ fn=save_tab_result,
943
+ inputs=[cell_count_out, confluency_out, viab_percent_out],
944
+ outputs=[result_state]
945
+ )
946
+
947
+
948
+
949
+ # ---------------------------------------------------------------------------
950
+ # Gradio interface
951
+ # ---------------------------------------------------------------------------
952
+ with gr.Blocks(
953
+ title="CellposeCellCounter",
954
+ theme=gr.themes.Soft(),
955
+ ) as demo:
956
+ gr.Markdown("# CellposeCellCounter")
957
+ 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.")
958
+
959
+ # Shared mask/image state (one pair per tab so tabs don't clobber each other)
960
+ masks_states = [gr.State(value=None) for _ in range(4)]
961
+ image_states = [gr.State(value=None) for _ in range(4)]
962
+ result_states = [gr.State(value=None) for _ in range(4)]
963
+
964
+ # Build Tabs 1–4 with a loop
965
+ for i in range(4):
966
+ build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
967
+
968
+ # -------------------------------------------------------------------------
969
+ # Tab 5 β€” Summary
970
+ # -------------------------------------------------------------------------
971
+ with gr.Tab("Tab 5 β€” Summary"):
972
+ gr.Markdown("## Average Results Across All Tabs")
973
+ gr.Markdown(
974
+ "Run segmentation (and optionally adjust the blue threshold) in one or more tabs, "
975
+ "then click **Refresh Summary** to see the averages."
976
+ )
977
+
978
+ refresh_btn = gr.Button("πŸ”„ Refresh Summary", variant="primary", size="lg")
979
+
980
+ with gr.Row():
981
+ avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
982
+ avg_conf_out = gr.Number(label="Avg Confluency (%)", precision=1)
983
+ avg_viab_out = gr.Number(label="Avg Viability (%)", precision=1)
984
+
985
+ summary_box = gr.Textbox(label="Per-Tab Breakdown", lines=10)
986
+
987
+ refresh_btn.click(
988
+ fn=compute_summary,
989
+ inputs=result_states, # list of 4 gr.State components
990
+ outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
991
+ )
992
+
993
+ # Instructions
994
+ with gr.Accordion("Instructions", open=False):
995
+ gr.Markdown("""
996
+ ### How to use:
997
+
998
+ 1. **Upload and Segment**:
999
+ - Upload your microscopy image.
1000
+ - Select a Cellpose model (e.g., "Hemocytometer Model" for suspension culture).
1001
+ - **(Optional)** Enable Stereological Counting for unbiased quantification.
1002
+ - Click "Run Segmentation".
1003
+
1004
+ 2. **Stereological Counting** (Optional):
1005
+ - Check "Enable Stereological Counting" to use unbiased counting rules.
1006
+ - Adjust the Left and Top exclusion zone widths using the sliders.
1007
+ - Preview shows excluded areas in red.
1008
+ - **Counting Rules**:
1009
+ - Cells touching LEFT or TOP exclusion zones are EXCLUDED
1010
+ - Cells touching RIGHT or BOTTOM edges are INCLUDED
1011
+ - This ensures unbiased, systematic counting
1012
+
1013
+ 3. **Analysis Results**:
1014
+ - **Cell Count**: Total number of detected cells (after exclusions if using stereology)
1015
+ - **Confluency**: Percentage of image area covered by cells
1016
+
1017
+ 4. **Real-time Viability Assessment (Trypan Blue)**:
1018
+ - After segmentation, the viability section will become visible.
1019
+ - Adjust the **"Blue Threshold (%)"** slider in real-time.
1020
+ - **Lower values (10-20%)** are more sensitive.
1021
+ - **Higher values (30-50%)** are more selective.
1022
+ - Green cells = Live, Red cells = Dead.
1023
+
1024
+ 5. **Summary (Tab 5)**:
1025
+ - After running segmentation in one or more tabs, switch to **Tab 5**.
1026
+ - Click **Refresh Summary** to compute averages across all completed tabs.
1027
+ - Displays average cell count, confluency, and viability, plus a per-tab breakdown.
1028
+
1029
+ 6. **Interpreting Results**:
1030
+ - The app displays total, live, and dead cell counts, viability percentage, and confluency.
1031
+ - If stereological counting is enabled, excluded cells are noted in the processing info.
1032
+ """)
1033
+
1034
+ if __name__ == "__main__":
1035
+ demo.launch()