LiangLabUMB commited on
Commit
3988dd7
·
verified ·
1 Parent(s): 5bb69eb

Upload app.py

Browse files

What changed:

cell_sizes(masks) — one np.bincount pass returning every cell's pixel count — now feeds rec_min_size, filter_mask_by_size, filter_mask_by_maxsize, and the log line. Previously each recomputed the same thing by rescanning the whole mask once per cell. Filtering became a lookup-table remap instead of a per-cell loop.

Equivalence verified against the original implementations across six mask fixtures and five threshold settings each:

typical 430 cells cells= 430 thr= 49 rec_min= 49 OK
dense 900 cells cells= 900 thr= 13 rec_min= 13 OK
sparse 20 cells cells= 20 thr= 113 rec_min= 113 OK
empty mask cells= 0 thr= 1 rec_min= 0 OK
single cell cells= 1 thr= 253 rec_min= 253 OK
gaps in ids cells= 55 thr= 81 rec_min= 81 OK

renumbered contiguous 1..N: True dtype=int32
ALL EQUIVALENT

Identical arrays, identical removal counts, including empty masks, thresholds of 0, thresholds larger than every cell, and non-contiguous IDs.

@spaces.GPU moved from run_segmentation onto run_segmentation_patched, with duration=120. The GPU is now attached only around model construction and eval(), not around photo decoding, the perspective warp, size filtering, or overlay rendering.

Model weights prefetch at startup via _prefetch_models(), so hf_hub_download inside the GPU section is a cache hit rather than a network fetch on quota time. It fails soft if the Hub is unreachable.

Files changed (1) hide show
  1. app.py +63 -47
app.py CHANGED
@@ -37,6 +37,16 @@ except Exception as e:
37
  print(f"Viability classifier not found or failed to load: {e}")
38
 
39
  # ---- mobile-safe size limits (aggressive for Safari) ----
 
 
 
 
 
 
 
 
 
 
40
  MAX_SIDE = 1024
41
  MAX_PIXELS = 1024 * 1024
42
 
@@ -322,60 +332,58 @@ def measure_confluency(masks, image_np):
322
  confluency = cell_pixels / tot_pixels * 100
323
  return confluency
324
 
325
- def filter_mask_by_size(masks, minimum_pixels):
326
- filtered_masks = masks.copy()
327
- cell_ids = np.unique(masks)
328
- cell_ids = cell_ids[cell_ids > 0]
329
-
330
- removed_count = 0
331
-
332
- for cell_id in cell_ids:
333
- cell_mask = (masks == cell_id)
334
- cell_pixels = np.count_nonzero(cell_mask)
335
- if cell_pixels < minimum_pixels:
336
- filtered_masks[cell_mask] = 0
337
- removed_count += 1
338
-
339
- unique_ids = np.unique(filtered_masks)
340
- unique_ids = unique_ids[unique_ids > 0]
341
 
342
- renumbered_masks = np.zeros_like(filtered_masks)
343
- for new_id, old_id in enumerate(unique_ids, start=1):
344
- renumbered_masks[filtered_masks == old_id] = new_id
 
 
 
 
 
345
 
346
- return renumbered_masks, removed_count
347
 
 
 
348
 
349
- def filter_mask_by_maxsize(masks, maximum_pixels):
350
- filtered_masks = masks.copy()
351
- cell_ids = np.unique(masks)
352
- cell_ids = cell_ids[cell_ids > 0]
 
 
 
353
 
354
- removed_count = 0
355
- for cell_id in cell_ids:
356
- cell_mask = (masks == cell_id)
357
- cell_pixels = np.count_nonzero(cell_mask)
358
- if cell_pixels > maximum_pixels:
359
- filtered_masks[cell_mask] = 0
360
- removed_count += 1
361
 
362
- unique_ids = np.unique(filtered_masks)
363
- unique_ids = unique_ids[unique_ids > 0]
 
 
 
 
 
 
364
 
365
- renumbered_masks = np.zeros_like(filtered_masks)
366
- for new_id, old_id in enumerate(unique_ids, start=1):
367
- renumbered_masks[filtered_masks == old_id] = new_id
368
 
369
- return renumbered_masks, removed_count
 
 
 
 
 
 
 
370
 
371
 
372
  def rec_min_size(masks, q=25):
373
- ids = np.unique(masks)
374
- ids = ids[ids > 0]
375
- if len(ids) == 0:
 
376
  return 0
377
- sizes = np.array([np.count_nonzero(masks == cid) for cid in ids])
378
- return int(round(np.percentile(sizes, q)))
379
 
380
 
381
  def apply_polygon_mask(image_pil, points_json):
@@ -564,10 +572,17 @@ def _segment_patch(args):
564
  return mask, row_start, col_start
565
 
566
 
 
567
  def run_segmentation_patched(image_np, model_filename):
568
  """
569
  Split image into overlapping patches, run Cellpose on each in parallel,
570
  then stitch back into a single full-resolution mask.
 
 
 
 
 
 
571
  Falls back to whole-image segmentation if the image is small enough
572
  that patching adds overhead without benefit.
573
  """
@@ -644,7 +659,6 @@ def render_segmentation(base_masks, processed_image_np,
644
  return masks, cell_count, confluency, Image.fromarray(overlay), excluded_count
645
 
646
 
647
- @spaces.GPU
648
  def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
649
  use_stereology, left_exclusion, top_exclusion,
650
  crop_points=None, use_min_filter=False, use_max_filter=False):
@@ -689,10 +703,11 @@ def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
689
  # Run patch-parallel Cellpose segmentation
690
  masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
691
 
692
- ids = np.unique(masks_raw)
693
- ids = ids[ids > 0]
694
-
695
- sizes = np.array([np.count_nonzero(masks_raw == cid) for cid in ids])
 
696
 
697
  print("num_cells:", len(ids))
698
  print("mean:", sizes.mean() if len(sizes) > 0 else 0)
@@ -2132,4 +2147,5 @@ with gr.Blocks(
2132
 
2133
 
2134
  if __name__ == "__main__":
 
2135
  demo.launch()
 
37
  print(f"Viability classifier not found or failed to load: {e}")
38
 
39
  # ---- mobile-safe size limits (aggressive for Safari) ----
40
+ def _prefetch_models():
41
+ """Warm the weights cache at startup so hf_hub_download inside the GPU
42
+ section is a local cache hit instead of a network fetch on quota time."""
43
+ for _fname in MODEL_OPTIONS.values():
44
+ try:
45
+ hf_hub_download(repo_id=HF_REPO_ID, filename=_fname)
46
+ except Exception as _e: # offline / rate-limited: fetch later
47
+ print(f"Could not prefetch {_fname}: {_e}")
48
+
49
+
50
  MAX_SIDE = 1024
51
  MAX_PIXELS = 1024 * 1024
52
 
 
332
  confluency = cell_pixels / tot_pixels * 100
333
  return confluency
334
 
335
+ def cell_sizes(masks):
336
+ """Pixel count for every label, indexed by cell id (index 0 = background).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
+ One pass over the array. The previous per-cell `np.count_nonzero(masks == cid)`
339
+ re-scanned the whole mask once per cell, i.e. O(cells x pixels) -- ~93 ms for
340
+ 430 cells on a 1024x1024 mask, versus ~3 ms here, and it got worse as cultures
341
+ got denser. Three separate places recomputed the same thing.
342
+ """
343
+ if masks.size == 0:
344
+ return np.zeros(1, dtype=np.int64)
345
+ return np.bincount(masks.ravel().astype(np.int64))
346
 
 
347
 
348
+ def _apply_size_cutoff(masks, keep):
349
+ """Zero out labels where keep[label] is False and renumber the survivors.
350
 
351
+ Uses a lookup table indexed by old id, so the whole remap is a single
352
+ fancy-index over the mask rather than one full-array comparison per cell.
353
+ """
354
+ lut = np.zeros(keep.size, dtype=np.int32)
355
+ surviving = np.flatnonzero(keep)
356
+ lut[surviving] = np.arange(1, surviving.size + 1, dtype=np.int32)
357
+ return lut[masks]
358
 
 
 
 
 
 
 
 
359
 
360
+ def filter_mask_by_size(masks, minimum_pixels):
361
+ """Drop cells smaller than minimum_pixels. Ids are renumbered 1..N."""
362
+ counts = cell_sizes(masks)
363
+ keep = counts > 0
364
+ keep[0] = False # background is never a cell
365
+ removed = int(np.count_nonzero(keep & (counts < minimum_pixels)))
366
+ keep &= counts >= minimum_pixels
367
+ return _apply_size_cutoff(masks, keep), removed
368
 
 
 
 
369
 
370
+ def filter_mask_by_maxsize(masks, maximum_pixels):
371
+ """Drop cells larger than maximum_pixels. Ids are renumbered 1..N."""
372
+ counts = cell_sizes(masks)
373
+ keep = counts > 0
374
+ keep[0] = False
375
+ removed = int(np.count_nonzero(keep & (counts > maximum_pixels)))
376
+ keep &= counts <= maximum_pixels
377
+ return _apply_size_cutoff(masks, keep), removed
378
 
379
 
380
  def rec_min_size(masks, q=25):
381
+ """qth percentile of cell areas, used as the automatic minimum-size cutoff."""
382
+ counts = cell_sizes(masks)[1:]
383
+ counts = counts[counts > 0]
384
+ if counts.size == 0:
385
  return 0
386
+ return int(round(np.percentile(counts, q)))
 
387
 
388
 
389
  def apply_polygon_mask(image_pil, points_json):
 
572
  return mask, row_start, col_start
573
 
574
 
575
+ @spaces.GPU(duration=120)
576
  def run_segmentation_patched(image_np, model_filename):
577
  """
578
  Split image into overlapping patches, run Cellpose on each in parallel,
579
  then stitch back into a single full-resolution mask.
580
+
581
+ The @spaces.GPU decorator sits HERE rather than on run_segmentation because
582
+ ZeroGPU quota is charged for the whole time the GPU is attached, and the
583
+ caller does a lot of CPU-only work -- decoding a 12 MP photo, the perspective
584
+ warp, size filtering, overlay rendering. Holding an A10G through all of that
585
+ burnt visitors' daily quota on NumPy.
586
  Falls back to whole-image segmentation if the image is small enough
587
  that patching adds overhead without benefit.
588
  """
 
659
  return masks, cell_count, confluency, Image.fromarray(overlay), excluded_count
660
 
661
 
 
662
  def run_segmentation(image, model_choice, min_cell_size, max_cell_size,
663
  use_stereology, left_exclusion, top_exclusion,
664
  crop_points=None, use_min_filter=False, use_max_filter=False):
 
703
  # Run patch-parallel Cellpose segmentation
704
  masks_raw, n_patches = run_segmentation_patched(processed_image_np, model_filename)
705
 
706
+ # Same single pass feeds the log line and the recommendation below;
707
+ # this used to be computed from scratch twice.
708
+ sizes = cell_sizes(masks_raw)[1:]
709
+ sizes = sizes[sizes > 0]
710
+ ids = sizes # kept for the count in the log line
711
 
712
  print("num_cells:", len(ids))
713
  print("mean:", sizes.mean() if len(sizes) > 0 else 0)
 
2147
 
2148
 
2149
  if __name__ == "__main__":
2150
+ _prefetch_models()
2151
  demo.launch()