LiangLabUMB commited on
Commit
ebb2890
·
verified ·
1 Parent(s): 7ee5f6b

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1035
app.py DELETED
@@ -1,1035 +0,0 @@
1
- import gradio as gr
2
- import spaces
3
- from cellpose import models
4
- import numpy as np
5
- import cv2
6
- import matplotlib.pyplot as plt
7
- import tempfile
8
- from PIL import Image, ImageDraw
9
- import io
10
- from huggingface_hub import hf_hub_download
11
- import base64
12
- from concurrent.futures import ThreadPoolExecutor, as_completed
13
-
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()