LiangLabUMB commited on
Commit
0bc2d41
·
verified ·
1 Parent(s): e03bc05

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1102
app.py DELETED
@@ -1,1102 +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],...] canvas-space points
271
- (already mapped to original image coordinates by the JS), zero out
272
- everything outside the polygon and return a PIL image.
273
- Returns the original image if points_json is empty / invalid.
274
- """
275
- import json
276
- if not points_json or points_json.strip() in ("", "[]"):
277
- return image_pil
278
-
279
- try:
280
- pts = json.loads(points_json)
281
- except Exception:
282
- return image_pil
283
-
284
- if len(pts) < 3:
285
- return image_pil
286
-
287
- image_np = np.array(image_pil)
288
- h, w = image_np.shape[:2]
289
-
290
- poly = np.array(pts, dtype=np.int32)
291
- poly[:, 0] = np.clip(poly[:, 0], 0, w - 1)
292
- poly[:, 1] = np.clip(poly[:, 1], 0, h - 1)
293
-
294
- mask = np.zeros((h, w), dtype=np.uint8)
295
- cv2.fillPoly(mask, [poly], 255)
296
-
297
- if len(image_np.shape) == 3:
298
- mask3 = mask[:, :, np.newaxis]
299
- result = np.where(mask3 == 255, image_np, 0).astype(np.uint8)
300
- else:
301
- result = np.where(mask == 255, image_np, 0).astype(np.uint8)
302
-
303
- return Image.fromarray(result)
304
-
305
-
306
- def toggle_stereological_mode(use_stereology):
307
- """Show/hide stereological controls based on checkbox"""
308
- return gr.update(visible=use_stereology)
309
-
310
-
311
- def update_exclusion_preview(image, left_width, top_width):
312
- """Update the preview image with exclusion zone overlay"""
313
- if image is None:
314
- return None
315
-
316
- image_np = np.array(image)
317
- overlay = draw_exclusion_overlay(image_np, left_width, top_width)
318
- return Image.fromarray(overlay)
319
-
320
-
321
- # ---------------------------------------------------------------------------
322
- # Patch-parallel segmentation helpers (Option A)
323
- # ---------------------------------------------------------------------------
324
- PATCH_SIZE = 512 # target patch side length
325
- PATCH_OVERLAP = 64 # overlap border on each edge (pixels)
326
- MIN_PATCH_DIM = 256 # don't bother patching if image fits comfortably
327
-
328
-
329
- def _split_patches(image_np, patch_size=PATCH_SIZE, overlap=PATCH_OVERLAP):
330
- """
331
- Split image into overlapping patches.
332
- Returns list of (patch_np, row_start, col_start) tuples.
333
- """
334
- h, w = image_np.shape[:2]
335
- patches = []
336
- row = 0
337
- while row < h:
338
- row_end = min(row + patch_size, h)
339
- col = 0
340
- while col < w:
341
- col_end = min(col + patch_size, w)
342
- patch = image_np[row:row_end, col:col_end]
343
- patches.append((patch, row, col))
344
- if col_end == w:
345
- break
346
- col += patch_size - overlap
347
- if row_end == h:
348
- break
349
- row += patch_size - overlap
350
- return patches
351
-
352
-
353
- def _merge_patch_masks(patch_results, full_h, full_w, overlap=PATCH_OVERLAP):
354
- """
355
- Stitch per-patch masks into a single full-image mask.
356
-
357
- Strategy:
358
- - Each patch gets a unique ID offset so cell IDs never collide.
359
- - Patches are pasted into the canvas using a priority canvas that
360
- gives interior pixels precedence over overlap-border pixels.
361
- - After pasting, cells whose centroids fall in the overlap zone
362
- of two adjacent patches are deduplicated: if two cells from
363
- different patches share >50% IoU they are the same cell — keep
364
- the one whose centroid is furthest from a patch edge.
365
- """
366
- full_mask = np.zeros((full_h, full_w), dtype=np.int32)
367
- # track which patch_idx owns each pixel (used for overlap resolution)
368
- owner_map = np.full((full_h, full_w), -1, dtype=np.int32)
369
- # distance-to-nearest-edge for the owning patch (higher = more central)
370
- priority = np.zeros((full_h, full_w), dtype=np.float32)
371
-
372
- id_offset = 0
373
- patch_meta = [] # (offset, row_start, col_start, patch_h, patch_w)
374
-
375
- for patch_idx, (mask_patch, row_start, col_start) in enumerate(patch_results):
376
- ph, pw = mask_patch.shape
377
- # offset all non-zero IDs so they're globally unique
378
- shifted = np.where(mask_patch > 0, mask_patch + id_offset, 0).astype(np.int32)
379
-
380
- # compute per-pixel priority = min distance to any patch edge
381
- rows_idx = np.arange(ph)
382
- cols_idx = np.arange(pw)
383
- dist_r = np.minimum(rows_idx, ph - 1 - rows_idx) # (ph,)
384
- dist_c = np.minimum(cols_idx, pw - 1 - cols_idx) # (pw,)
385
- pri_patch = np.minimum(dist_r[:, None], dist_c[None, :]) # (ph, pw)
386
-
387
- roi_full = full_mask [row_start:row_start+ph, col_start:col_start+pw]
388
- roi_owner = owner_map [row_start:row_start+ph, col_start:col_start+pw]
389
- roi_pri = priority [row_start:row_start+ph, col_start:col_start+pw]
390
-
391
- # where this patch has higher priority, overwrite
392
- better = pri_patch > roi_pri
393
- roi_full [better] = shifted [better]
394
- roi_owner[better] = patch_idx
395
- roi_pri [better] = pri_patch [better]
396
-
397
- max_id = int(mask_patch.max())
398
- patch_meta.append((id_offset, row_start, col_start, ph, pw))
399
- id_offset += max_id + 1
400
-
401
- # --- Renumber to compact sequential IDs ---
402
- unique_ids = np.unique(full_mask)
403
- unique_ids = unique_ids[unique_ids > 0]
404
- renumbered = np.zeros_like(full_mask)
405
- for new_id, old_id in enumerate(unique_ids, start=1):
406
- renumbered[full_mask == old_id] = new_id
407
-
408
- return renumbered
409
-
410
-
411
- def _segment_patch(args):
412
- """Worker: run cellpose on a single patch. Called from a thread pool."""
413
- patch_np, row_start, col_start, model_filename, hf_repo = args
414
- # Each thread uses the shared loaded_models cache (GIL-safe for reads;
415
- # model.eval() releases the GIL during GPU work so threads overlap.)
416
- model_path = hf_hub_download(repo_id=hf_repo, filename=model_filename)
417
- if model_filename in loaded_models:
418
- model = loaded_models[model_filename]
419
- else:
420
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
421
- loaded_models[model_filename] = model
422
-
423
- mask, _, _ = model.eval(patch_np, diameter=None, channels=[0, 0])
424
- return mask, row_start, col_start
425
-
426
-
427
- def run_segmentation_patched(image_np, model_filename):
428
- """
429
- Split image into overlapping patches, run Cellpose on each in parallel,
430
- then stitch back into a single full-resolution mask.
431
- Falls back to whole-image segmentation if the image is small enough
432
- that patching adds overhead without benefit.
433
- """
434
- h, w = image_np.shape[:2]
435
- model_path = hf_hub_download(repo_id=HF_REPO_ID, filename=model_filename)
436
- if model_filename in loaded_models:
437
- model = loaded_models[model_filename]
438
- else:
439
- model = models.CellposeModel(gpu=True, pretrained_model=model_path)
440
- loaded_models[model_filename] = model
441
-
442
- # Small images: no benefit from patching
443
- if max(h, w) <= MIN_PATCH_DIM * 2:
444
- mask, _, _ = model.eval(image_np, diameter=None, channels=[0, 0])
445
- return mask, 1 # 1 patch
446
-
447
- patches = _split_patches(image_np)
448
- n_patches = len(patches)
449
-
450
- # Build argument list for the thread pool
451
- args_list = [
452
- (patch, r, c, model_filename, HF_REPO_ID)
453
- for patch, r, c in patches
454
- ]
455
-
456
- patch_results = [] # (mask, row_start, col_start) in submission order
457
-
458
- # ThreadPoolExecutor: GPU kernels release the GIL so threads overlap on GPU
459
- with ThreadPoolExecutor(max_workers=min(n_patches, 4)) as pool:
460
- futures = {pool.submit(_segment_patch, a): a for a in args_list}
461
- for future in as_completed(futures):
462
- mask_patch, row_start, col_start = future.result()
463
- patch_results.append((mask_patch, row_start, col_start))
464
-
465
- # Re-sort by (row, col) so stitching is deterministic
466
- patch_results.sort(key=lambda x: (x[1], x[2]))
467
-
468
- full_mask = _merge_patch_masks(patch_results, h, w)
469
- return full_mask, n_patches
470
-
471
-
472
- @spaces.GPU
473
- def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
474
- use_stereology, left_exclusion, top_exclusion,
475
- crop_points_json=""):
476
- image_np = np.array(image)
477
- image_np = safe_resize(image_np)
478
-
479
- # Apply polygon crop mask if the user drew one
480
- if crop_points_json and crop_points_json.strip() not in ("", "[]"):
481
- image_pil_masked = apply_polygon_mask(Image.fromarray(image_np), crop_points_json)
482
- image_np = np.array(image_pil_masked)
483
-
484
- try:
485
- model_filename = MODEL_OPTIONS[model_choice]
486
-
487
- # Process image format to RGB
488
- if len(image_np.shape) == 2:
489
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_GRAY2RGB)
490
- elif len(image_np.shape) == 3 and image_np.shape[2] == 4:
491
- processed_image_np = cv2.cvtColor(image_np, cv2.COLOR_RGBA2RGB)
492
- else:
493
- processed_image_np = image_np
494
-
495
- # Run patch-parallel Cellpose segmentation
496
- masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
497
-
498
- ids = np.unique(masks_raw)
499
- ids = ids[ids > 0]
500
-
501
- sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
502
-
503
- print("num_cells:", len(ids))
504
- print("mean:", sizes.mean() if len(sizes) > 0 else 0)
505
- print("median:", np.median(sizes) if len(sizes) > 0 else 0)
506
- print("p90:", np.percentile(sizes, 90) if len(sizes) > 0 else 0)
507
- print("max:", sizes.max() if len(sizes) > 0 else 0)
508
-
509
- # Compute recommendation from RAW masks
510
- recommend_min = rec_min_size(masks_raw)
511
-
512
- # If user sets slider to 0, use the recommendation
513
- min_used = recommend_min if (min_cell_size == 0) else int(min_cell_size)
514
-
515
- # Apply filters
516
- masks = masks_raw.copy()
517
- removed_small = 0
518
- removed_large = 0
519
-
520
- if min_used > 0:
521
- masks, removed_small = filter_mask_by_size(masks, min_used)
522
-
523
- if max_cell_size > 0:
524
- masks, removed_large = filter_mask_by_maxsize(masks, int(max_cell_size))
525
-
526
- # Apply stereological exclusion if enabled
527
- excluded_count = 0
528
- if use_stereology:
529
- masks, excluded_count, included_count = apply_stereological_exclusion(
530
- masks, left_exclusion, top_exclusion
531
- )
532
-
533
- filter_msg = ""
534
- if removed_small:
535
- filter_msg += f"Removed {removed_small} small objects (< {min_used} pixels).\n"
536
- if removed_large:
537
- filter_msg += f"Removed {removed_large} large objects (> {int(max_cell_size)} pixels).\n"
538
- if use_stereology and excluded_count > 0:
539
- filter_msg += f"Stereological exclusion: {excluded_count} cells excluded (touching left/top zones).\n"
540
-
541
- cell_count = len(np.unique(masks)) - 1
542
- confluency = measure_confluency(masks, processed_image_np)
543
-
544
- # Create a basic segmentation overlay (without viability)
545
- segmentation_overlay = processed_image_np.copy().astype(np.float32)
546
- if masks.max() > 0:
547
- np.random.seed(42) # For consistent random colors
548
- colors = np.random.randint(0, 255, size=(masks.max() + 1, 3))
549
- colors[0] = [0, 0, 0]
550
- colored_mask = colors[masks]
551
- alpha = 0.4
552
- segmentation_overlay = (1 - alpha) * segmentation_overlay + alpha * colored_mask
553
- segmentation_overlay = np.clip(segmentation_overlay, 0, 255).astype(np.uint8)
554
-
555
- # Add exclusion zone overlay if stereology is enabled
556
- if use_stereology:
557
- segmentation_overlay = draw_exclusion_overlay(segmentation_overlay, left_exclusion, top_exclusion)
558
-
559
- info_msg = ""
560
- if filter_msg:
561
- info_msg += filter_msg
562
- info_msg += f"Segmentation complete! Found {cell_count} cells.\n"
563
- info_msg += f"Confluency: {confluency:.1f}%\n"
564
- info_msg += f"Processed as {n_patches} patch{'es' if n_patches > 1 else ''} (parallel).\n"
565
- if use_stereology:
566
- info_msg += f"Stereological counting enabled (Left: {left_exclusion}%, Top: {top_exclusion}%)\n"
567
- info_msg += "Now adjust the Blue Threshold for viability assessment."
568
-
569
- return (
570
- cell_count,
571
- Image.fromarray(segmentation_overlay),
572
- info_msg,
573
- gr.update(visible=True),
574
- pack_array(masks),
575
- pack_array(processed_image_np),
576
- confluency,
577
- gr.update(value=recommend_min), # update slider display to recommended
578
- )
579
-
580
- except Exception as e:
581
- import traceback
582
- traceback.print_exc()
583
- return (
584
- 0,
585
- None,
586
- f"Error during segmentation: {str(e)}",
587
- gr.update(visible=False),
588
- None,
589
- None,
590
- 0.0,
591
- gr.update(),
592
- )
593
-
594
-
595
- def update_viability_realtime(threshold_bias, stored_masks, stored_image_np):
596
- # avoid unpacking None (e.g. slider moved before segmentation)
597
- if stored_masks is None or stored_image_np is None:
598
- return None, 0, 0, 0.0, "Please run segmentation first."
599
-
600
- stored_masks = unpack_array(stored_masks)
601
- stored_image_np = unpack_array(stored_image_np)
602
-
603
- try:
604
- dead_count, alive_count, viability_overlay_np, otsu_thresh, final_thresh = \
605
- classify_cells_by_blueness(stored_image_np, stored_masks, threshold_bias)
606
-
607
- total_count = alive_count + dead_count
608
- viability_percent = (alive_count / total_count * 100) if total_count > 0 else 0.0
609
- confluency = measure_confluency(stored_masks, stored_image_np)
610
-
611
- overlay_image = Image.fromarray(viability_overlay_np)
612
- info_msg = f"Total cells: {total_count}\nLive (green): {alive_count}\nDead (red): {dead_count}\n"
613
- info_msg += f"Viability: {viability_percent:.1f}%\nConfluency: {confluency:.1f}%\n"
614
- info_msg += f"Otsu threshold (auto): {otsu_thresh:.4f}\n"
615
- info_msg += f"Final threshold (with bias): {final_thresh:.4f}\n"
616
- info_msg += f"Sensitivity bias: {threshold_bias:+d}"
617
-
618
- return overlay_image, alive_count, dead_count, viability_percent, info_msg
619
-
620
- except Exception as e:
621
- return None, 0, 0, 0.0, f"Error updating viability: {str(e)}"
622
-
623
-
624
- def pack_array(arr):
625
- pil = Image.fromarray(arr.astype(np.uint8))
626
- buf = io.BytesIO()
627
- pil.save(buf, format="PNG")
628
- return buf.getvalue()
629
-
630
-
631
- def unpack_array(data):
632
- return np.array(Image.open(io.BytesIO(data)))
633
-
634
-
635
- def save_tab_result(cell_count, confluency, viab_percent):
636
- """Package per-tab results into a dict for Tab 5 summary."""
637
- return {
638
- "cell_count": float(cell_count) if cell_count is not None else None,
639
- "confluency": float(confluency) if confluency is not None else None,
640
- "viab_percent": float(viab_percent) if viab_percent is not None else None,
641
- }
642
-
643
-
644
- def compute_summary(r1, r2, r3, r4):
645
- """Average cell count, confluency, and viability across tabs that have data."""
646
- all_results = [r1, r2, r3, r4]
647
- valid = [(i + 1, r) for i, r in enumerate(all_results)
648
- if r is not None and r.get("cell_count") is not None]
649
-
650
- if not valid:
651
- return (
652
- 0.0, 0.0, 0.0,
653
- "No data yet — run segmentation in at least one tab, then click Refresh Summary."
654
- )
655
-
656
- avg_count = sum(r["cell_count"] for _, r in valid) / len(valid)
657
- avg_conf = sum(r["confluency"] for _, r in valid) / len(valid)
658
- avg_viab = sum(r["viab_percent"] for _, r in valid) / len(valid)
659
-
660
- lines = [f"Tab {tab_num}: {r['cell_count']:.0f} cells | "
661
- f"{r['confluency']:.1f}% confluency | "
662
- f"{r['viab_percent']:.1f}% viability"
663
- for tab_num, r in valid]
664
- lines.append(f"\nAverages ({len(valid)} tab{'s' if len(valid) > 1 else ''}):")
665
- lines.append(f" Cell count: {avg_count:.1f}")
666
- lines.append(f" Confluency: {avg_conf:.1f}%")
667
- lines.append(f" Viability: {avg_viab:.1f}%")
668
-
669
- return avg_count, avg_conf, avg_viab, "\n".join(lines)
670
-
671
-
672
- # ---------------------------------------------------------------------------
673
- # Tab builder — creates one full analysis tab and wires all event handlers.
674
- # Returns the per-tab gr.State used by Tab 5 to collect summary data.
675
- # ---------------------------------------------------------------------------
676
- # ---------------------------------------------------------------------------
677
- # Polygon crop canvas — lightweight HTML5 canvas, no ImageEditor
678
- # ---------------------------------------------------------------------------
679
- def make_crop_canvas_html(tab_idx):
680
- """
681
- Static HTML+JS for the polygon crop canvas.
682
- Image data is pushed from Python via push_image_to_canvas().
683
- A global function window.loadCropCanvas_{uid}(dataURL) is registered
684
- so the gr.HTML update triggered by img_input.change can load the image.
685
- """
686
- uid = f"cropcanvas_{tab_idx}"
687
- pts_id = f"crop_pts_{tab_idx}"
688
- return f"""
689
- <div style="position:relative;width:100%;user-select:none;">
690
- <canvas id="{uid}"
691
- style="width:100%;max-height:400px;object-fit:contain;
692
- border:2px solid #4a90d9;border-radius:8px;
693
- touch-action:none;cursor:crosshair;background:#111;display:block;">
694
- </canvas>
695
- <div style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
696
- <button id="{uid}_clear"
697
- style="padding:6px 18px;border-radius:6px;border:none;
698
- background:#e05;color:#fff;font-size:14px;cursor:pointer;">
699
- ✕ Clear points
700
- </button>
701
- <span id="{uid}_label" style="font-size:13px;color:#aaa;">
702
- Upload an image, then tap up to 4 vertices
703
- </span>
704
- </div>
705
- </div>
706
-
707
- <script>
708
- (function(){{
709
- const CANVAS_ID = "{uid}";
710
- const PTS_ID = "{pts_id}";
711
- const MAX_PTS = 4;
712
-
713
- let imgNaturalW = 0, imgNaturalH = 0;
714
- let points = [];
715
- let imgObj = new Image();
716
-
717
- const canvas = document.getElementById(CANVAS_ID);
718
- const ctx = canvas.getContext("2d");
719
- const clearBtn = document.getElementById(CANVAS_ID + "_clear");
720
- const label = document.getElementById(CANVAS_ID + "_label");
721
-
722
- function getPtsTextbox() {{
723
- return document.getElementById(PTS_ID)?.querySelector("textarea")
724
- || document.querySelector(`#${{PTS_ID}} textarea`);
725
- }}
726
-
727
- function writePts(pts_orig) {{
728
- const ta = getPtsTextbox();
729
- if (!ta) return;
730
- ta.value = JSON.stringify(pts_orig);
731
- ta.dispatchEvent(new Event("input", {{bubbles:true}}));
732
- ta.dispatchEvent(new Event("change", {{bubbles:true}}));
733
- }}
734
-
735
- function canvasToOriginal(cx, cy) {{
736
- return [Math.round(cx * imgNaturalW / canvas.width),
737
- Math.round(cy * imgNaturalH / canvas.height)];
738
- }}
739
-
740
- function draw() {{
741
- ctx.clearRect(0, 0, canvas.width, canvas.height);
742
- if (imgObj.complete && imgObj.naturalWidth)
743
- ctx.drawImage(imgObj, 0, 0, canvas.width, canvas.height);
744
- if (!points.length) return;
745
-
746
- ctx.beginPath();
747
- ctx.moveTo(points[0].cx, points[0].cy);
748
- for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].cx, points[i].cy);
749
- if (points.length === MAX_PTS) ctx.closePath();
750
- ctx.strokeStyle = "#4af"; ctx.lineWidth = 2; ctx.stroke();
751
- if (points.length === MAX_PTS) {{
752
- ctx.fillStyle = "rgba(74,170,255,0.18)"; ctx.fill();
753
- }}
754
- points.forEach((p, i) => {{
755
- ctx.beginPath(); ctx.arc(p.cx, p.cy, 7, 0, Math.PI*2);
756
- ctx.fillStyle = "#4af"; ctx.fill();
757
- ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5; ctx.stroke();
758
- ctx.fillStyle = "#fff"; ctx.font = "bold 11px sans-serif";
759
- ctx.textAlign = "center"; ctx.textBaseline = "middle";
760
- ctx.fillText(i+1, p.cx, p.cy);
761
- }});
762
- }}
763
-
764
- // ── called by Python push via gr.HTML update ──────────────────────────
765
- function loadFromDataURL(dataURL) {{
766
- imgObj = new Image();
767
- imgObj.onload = function() {{
768
- imgNaturalW = imgObj.naturalWidth;
769
- imgNaturalH = imgObj.naturalHeight;
770
- const maxSide = 1024;
771
- let cw = imgNaturalW, ch = imgNaturalH;
772
- if (Math.max(cw,ch) > maxSide) {{
773
- const s = maxSide / Math.max(cw,ch);
774
- cw = Math.round(cw*s); ch = Math.round(ch*s);
775
- }}
776
- canvas.width = cw; canvas.height = ch;
777
- points = []; writePts([]);
778
- label.textContent = "Tap up to 4 vertices to define your crop region";
779
- draw();
780
- }};
781
- imgObj.src = dataURL;
782
- }}
783
-
784
- // Register globally so the injected <script> snippet can call it
785
- window["loadCropCanvas_{uid}"] = loadFromDataURL;
786
-
787
- // ── pointer / touch events ────────────────────────────────────────────
788
- canvas.addEventListener("pointerdown", function(e) {{
789
- e.preventDefault();
790
- if (!imgNaturalW || points.length >= MAX_PTS) return;
791
- const rect = canvas.getBoundingClientRect();
792
- const bx = (e.clientX - rect.left) * (canvas.width / rect.width);
793
- const by = (e.clientY - rect.top) * (canvas.height / rect.height);
794
- points.push({{cx: bx, cy: by}});
795
- draw();
796
- writePts(points.map(p => canvasToOriginal(p.cx, p.cy)));
797
- label.textContent = points.length === MAX_PTS
798
- ? "4 points set — clear to redraw"
799
- : `${{points.length}} / ${{MAX_PTS}} points`;
800
- }});
801
-
802
- clearBtn.addEventListener("click", function() {{
803
- points = []; writePts([]);
804
- label.textContent = "Points cleared — tap to add new vertices";
805
- draw();
806
- }});
807
- }})();
808
- </script>
809
- """
810
-
811
-
812
- def push_image_to_canvas(image_pil, tab_idx):
813
- """
814
- Convert the uploaded PIL image to a base64 data URL and return a
815
- gr.HTML snippet that calls the canvas loader function registered by
816
- make_crop_canvas_html. Returned value is set on a hidden gr.HTML
817
- component whose change fires the JS.
818
- """
819
- if image_pil is None:
820
- return ""
821
- uid = f"cropcanvas_{tab_idx}"
822
- buf = io.BytesIO()
823
- # Downscale for the preview only — keep it lightweight
824
- thumb = image_pil.copy()
825
- thumb.thumbnail((1024, 1024), Image.LANCZOS)
826
- thumb.save(buf, format="JPEG", quality=85)
827
- b64 = base64.b64encode(buf.getvalue()).decode()
828
- data_url = f"data:image/jpeg;base64,{b64}"
829
- return f"""<script>
830
- (function(){{
831
- const fn = window["loadCropCanvas_{uid}"];
832
- if (fn) {{ fn("{data_url}"); }}
833
- else {{ setTimeout(()=>{{ const f=window["loadCropCanvas_{uid}"]; if(f) f("{data_url}"); }}, 400); }}
834
- }})();
835
- </script>"""
836
-
837
-
838
- def build_tab(tab_index, masks_state, image_state, result_state):
839
- with gr.Tab(f"Tab {tab_index}"):
840
- gr.Markdown("Run segmentation")
841
-
842
- with gr.Row():
843
- with gr.Column():
844
- img_input = gr.Image(
845
- type="pil",
846
- label="Upload image",
847
- image_mode="RGB",
848
- height=512
849
- )
850
-
851
- # Lightweight polygon crop selector (no ImageEditor)
852
- gr.HTML(make_crop_canvas_html(tab_index))
853
-
854
- # Hidden HTML component — Python pushes a <script> snippet here
855
- # on every img_input change to load the image into the canvas
856
- canvas_pusher = gr.HTML(value="", visible=False)
857
-
858
- # Hidden textbox that receives [[x,y],...] from JS
859
- crop_points = gr.Textbox(
860
- value="",
861
- visible=False,
862
- elem_id=f"crop_pts_{tab_index}",
863
- label="Crop polygon points (set by canvas)"
864
- )
865
-
866
- model_dropdown = gr.Dropdown(
867
- choices=list(MODEL_OPTIONS.keys()),
868
- label="Select Model",
869
- value="Hemocytometer Model"
870
- )
871
-
872
- min_size_slider = gr.Slider(
873
- minimum=0,
874
- maximum=500,
875
- value=0,
876
- step=10,
877
- label="Minimum Cell Size (pixels). Leave at zero for automated recommendation",
878
- )
879
-
880
- max_size_slider = gr.Slider(
881
- minimum=0,
882
- maximum=10000,
883
- value=10000,
884
- step=10,
885
- label="Maximum Cell Size (pixels)",
886
- )
887
-
888
- gr.Markdown("### Stereological Counting")
889
- use_stereo = gr.Checkbox(
890
- label="Enable Stereological Counting",
891
- value=False,
892
- info="Use unbiased stereological rules for cell counting"
893
- )
894
-
895
- with gr.Group(visible=False) as stereo_controls:
896
- gr.Markdown("""
897
- **Stereological Counting Rules:**
898
- - Cells touching LEFT or TOP exclusion zones are EXCLUDED
899
- - Cells touching RIGHT or BOTTOM edges are INCLUDED
900
- - This provides unbiased counting for quantification
901
- """)
902
-
903
- excl_preview = gr.Image(
904
- type="pil",
905
- label="Exclusion Zone Preview (Red = Excluded)",
906
- height=500
907
- )
908
-
909
- left_excl = gr.Slider(
910
- minimum=0,
911
- maximum=50,
912
- value=10,
913
- step=1,
914
- label="Left Exclusion Width (%)",
915
- info="Width of left exclusion zone"
916
- )
917
-
918
- top_excl = gr.Slider(
919
- minimum=0,
920
- maximum=50,
921
- value=10,
922
- step=1,
923
- label="Top Exclusion Width (%)",
924
- info="Width of top exclusion zone"
925
- )
926
-
927
- segment_btn = gr.Button("🔬 Run Segmentation", variant="primary", size="lg")
928
-
929
- with gr.Column():
930
- cell_count_out = gr.Number(label="Total Cells Detected", precision=0)
931
- confluency_out = gr.Number(label="Confluency (%)", precision=1)
932
- overlay_out = gr.Image(type="pil", label="Segmentation Result")
933
- info_out = gr.Textbox(label="Processing Info", lines=4)
934
-
935
- with gr.Group(visible=False) as viability_section:
936
- gr.Markdown("### Viability Assessment (Trypan Blue)")
937
- gr.Markdown(
938
- "The threshold is set **automatically per image** using Otsu's method. "
939
- "Use the sensitivity slider to fine-tune if needed."
940
- )
941
-
942
- with gr.Row():
943
- with gr.Column():
944
- sensitivity = gr.Slider(
945
- minimum=-50,
946
- maximum=50,
947
- value=0,
948
- step=1,
949
- label="Sensitivity",
950
- info="← more dead cells · auto (0) · fewer dead cells →"
951
- )
952
- with gr.Column():
953
- live_count_out = gr.Number(label="Live Cells (Green)", precision=0)
954
- dead_count_out = gr.Number(label="Dead Cells (Red)", precision=0)
955
-
956
- viab_overlay = gr.Image(type="pil", label="Viability Assessment (Green=Live, Red=Dead)")
957
- viab_percent_out = gr.Number(label="Viability (%)", precision=1)
958
- viab_info = gr.Textbox(label="Analysis Results", lines=6)
959
-
960
- # ---- Event handlers ------------------------------------------------
961
-
962
- use_stereo.change(
963
- fn=toggle_stereological_mode,
964
- inputs=[use_stereo],
965
- outputs=[stereo_controls]
966
- )
967
-
968
- img_input.change(
969
- fn=update_exclusion_preview,
970
- inputs=[img_input, left_excl, top_excl],
971
- outputs=[excl_preview]
972
- )
973
- img_input.change(
974
- fn=lambda img: push_image_to_canvas(img, tab_index),
975
- inputs=[img_input],
976
- outputs=[canvas_pusher]
977
- )
978
- left_excl.change(
979
- fn=update_exclusion_preview,
980
- inputs=[img_input, left_excl, top_excl],
981
- outputs=[excl_preview]
982
- )
983
- top_excl.change(
984
- fn=update_exclusion_preview,
985
- inputs=[img_input, left_excl, top_excl],
986
- outputs=[excl_preview]
987
- )
988
-
989
- segment_btn.click(
990
- fn=run_segmentation,
991
- inputs=[img_input, model_dropdown, min_size_slider, max_size_slider,
992
- use_stereo, left_excl, top_excl, crop_points],
993
- outputs=[cell_count_out, overlay_out, info_out, viability_section,
994
- masks_state, image_state, confluency_out, min_size_slider]
995
- ).then(
996
- fn=update_viability_realtime,
997
- inputs=[sensitivity, masks_state, image_state],
998
- outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
999
- ).then(
1000
- fn=save_tab_result,
1001
- inputs=[cell_count_out, confluency_out, viab_percent_out],
1002
- outputs=[result_state]
1003
- )
1004
-
1005
- sensitivity.change(
1006
- fn=update_viability_realtime,
1007
- inputs=[sensitivity, masks_state, image_state],
1008
- outputs=[viab_overlay, live_count_out, dead_count_out, viab_percent_out, viab_info]
1009
- ).then(
1010
- fn=save_tab_result,
1011
- inputs=[cell_count_out, confluency_out, viab_percent_out],
1012
- outputs=[result_state]
1013
- )
1014
-
1015
-
1016
- # ---------------------------------------------------------------------------
1017
- # Gradio interface
1018
- # ---------------------------------------------------------------------------
1019
- with gr.Blocks(
1020
- title="CellposeCellCounter",
1021
- theme=gr.themes.Soft(),
1022
- ) as demo:
1023
- gr.Markdown("# CellposeCellCounter")
1024
- 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.")
1025
-
1026
- # Shared mask/image state (one pair per tab so tabs don't clobber each other)
1027
- masks_states = [gr.State(value=None) for _ in range(4)]
1028
- image_states = [gr.State(value=None) for _ in range(4)]
1029
- result_states = [gr.State(value=None) for _ in range(4)]
1030
-
1031
- # Build Tabs 1–4 with a loop
1032
- for i in range(4):
1033
- build_tab(i + 1, masks_states[i], image_states[i], result_states[i])
1034
-
1035
- # -------------------------------------------------------------------------
1036
- # Tab 5 — Summary
1037
- # -------------------------------------------------------------------------
1038
- with gr.Tab("Tab 5 — Summary"):
1039
- gr.Markdown("## Average Results Across All Tabs")
1040
- gr.Markdown(
1041
- "Run segmentation (and optionally adjust the blue threshold) in one or more tabs, "
1042
- "then click **Refresh Summary** to see the averages."
1043
- )
1044
-
1045
- refresh_btn = gr.Button("🔄 Refresh Summary", variant="primary", size="lg")
1046
-
1047
- with gr.Row():
1048
- avg_count_out = gr.Number(label="Avg Cell Count", precision=1)
1049
- avg_conf_out = gr.Number(label="Avg Confluency (%)", precision=1)
1050
- avg_viab_out = gr.Number(label="Avg Viability (%)", precision=1)
1051
-
1052
- summary_box = gr.Textbox(label="Per-Tab Breakdown", lines=10)
1053
-
1054
- refresh_btn.click(
1055
- fn=compute_summary,
1056
- inputs=result_states, # list of 4 gr.State components
1057
- outputs=[avg_count_out, avg_conf_out, avg_viab_out, summary_box]
1058
- )
1059
-
1060
- # Instructions
1061
- with gr.Accordion("Instructions", open=False):
1062
- gr.Markdown("""
1063
- ### How to use:
1064
-
1065
- 1. **Upload and Segment**:
1066
- - Upload your microscopy image.
1067
- - Select a Cellpose model (e.g., "Hemocytometer Model" for suspension culture).
1068
- - **(Optional)** Enable Stereological Counting for unbiased quantification.
1069
- - Click "Run Segmentation".
1070
-
1071
- 2. **Stereological Counting** (Optional):
1072
- - Check "Enable Stereological Counting" to use unbiased counting rules.
1073
- - Adjust the Left and Top exclusion zone widths using the sliders.
1074
- - Preview shows excluded areas in red.
1075
- - **Counting Rules**:
1076
- - Cells touching LEFT or TOP exclusion zones are EXCLUDED
1077
- - Cells touching RIGHT or BOTTOM edges are INCLUDED
1078
- - This ensures unbiased, systematic counting
1079
-
1080
- 3. **Analysis Results**:
1081
- - **Cell Count**: Total number of detected cells (after exclusions if using stereology)
1082
- - **Confluency**: Percentage of image area covered by cells
1083
-
1084
- 4. **Real-time Viability Assessment (Trypan Blue)**:
1085
- - After segmentation, the viability section will become visible.
1086
- - Adjust the **"Blue Threshold (%)"** slider in real-time.
1087
- - **Lower values (10-20%)** are more sensitive.
1088
- - **Higher values (30-50%)** are more selective.
1089
- - Green cells = Live, Red cells = Dead.
1090
-
1091
- 5. **Summary (Tab 5)**:
1092
- - After running segmentation in one or more tabs, switch to **Tab 5**.
1093
- - Click **Refresh Summary** to compute averages across all completed tabs.
1094
- - Displays average cell count, confluency, and viability, plus a per-tab breakdown.
1095
-
1096
- 6. **Interpreting Results**:
1097
- - The app displays total, live, and dead cell counts, viability percentage, and confluency.
1098
- - If stereological counting is enabled, excluded cells are noted in the processing info.
1099
- """)
1100
-
1101
- if __name__ == "__main__":
1102
- demo.launch()