Upload app.py
Browse filesWhat 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.
|
@@ -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
|
| 326 |
-
|
| 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 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
-
return renumbered_masks, removed_count
|
| 347 |
|
|
|
|
|
|
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
|
| 372 |
def rec_min_size(masks, q=25):
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
| 376 |
return 0
|
| 377 |
-
|
| 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 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
sizes =
|
|
|
|
| 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()
|