Skip to content

Commit aa41e7a

Browse files
feat(examples): add segmentation & feature extraction examples (#33)
* feat(examples): add 3D monolayer segmentation notebook Add notebook reproducing CellProfiler 3D monolayer segmentation (BBBC034v1, Thirstrup et al. 2018) using cubic. Includes nuclei and cell segmentation pipelines with AP evaluation against CellProfiler reference labels. Data auto-downloaded via pooch from GitHub release v0.7.0a1 assets. Results: 25 nuclei (mAP 0.433), 24 cells (mAP 0.282). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): align pipeline with CellProfiler implementation Cross-referenced CellProfiler source code to identify and fix 4 discrepancies that improve segmentation quality: 1. Median filter: use cubic window (scipy median_filter size=5) instead of spherical ball(5) footprint — matches CP's MedianFilter module 2. Monolayer closing: use plane-by-plane disk(17) instead of 3D ball(17) — matches CP's Closing module which applies 2D structuring elements per-slice 3. Multi-Otsu nbins: pass nbins=128 to match CellProfiler's default 4. Seed creation: erode downsized nuclei (ball(5) at 0.5x) with vanished-object protection — matches CP's ErodeObjects module Results improved from baseline: - Nuclei: 29 objects, mAP 0.468 (was 0.433) - Cells: 25 objects, mAP 0.490 (was 0.282) Discrepancies evaluated but not applied (hurt metrics): - Cell watershed -EDT landscape (cells mAP -0.023) - Cube footprint for nuclei watershed (nuc mAP -0.098) - Nearest-neighbor downscale (hurt combined metrics) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): use EDT-based cell watershed matching CellProfiler Switch cell watershed from membrane intensity landscape to negated distance transform of binary cell mask, matching CellProfiler's shape-based declumping. This fix was re-evaluated on top of the 4 previous CP-alignment changes and now improves cells mAP (+0.016) without affecting nuclei. Results: nuclei mAP 0.468, cells mAP 0.506 (was 0.490). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(segmentation notebook): add old vs new AP comparison plots and summary Replace single AP plot with side-by-side comparison showing old (cubic_paper) vs new AP curves for both nuclei and cells. Update summary table with concrete numbers for CellProfiler, old pipeline, and new pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): add watershed seed dilation to reduce oversegmentation Add binary_dilation(seeds, ball(1)) before labeling in nuclei watershed, matching CellProfiler's Watershed module which dilates seeds to merge nearby peaks before assigning labels. This reduces nuclei oversegmentation (29 -> 27, closer to CP's 25) while significantly improving mAP: - Nuclei: 27 objects, mAP 0.561 (was 29 objects, mAP 0.468) - Cells: 23 objects, mAP 0.558 (was 25 objects, mAP 0.506) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): use mode="constant" for median filter matching CP CellProfiler's MedianFilter passes mode="constant" (zero-padding) to scipy.ndimage.median_filter, while we were using the default "reflect". Zero-padding darkens border pixels, making them fall below the Otsu threshold and producing a cleaner binary mask with fewer edge artifacts. Results: nuclei 26 (was 27), mAP 0.698 (was 0.561). cells 22, mAP 0.579 (was 0.558). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): fix label colormap to show distinct colors per object The old ListedColormap approach mapped labels 1-26 to the first ~10% of tab20's range, causing most labels to share the same 2-3 colors. Replace with a label_cmap() function that maps each label to a distinct tab20 color using modular arithmetic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): use CP-correct hole fill size for cell membrane mask CellProfiler's RemoveHoles(size=20) interprets 20 as diameter, converting to sphere volume: pi * (4/3) * 10^3 = 4189 voxels. We were using area_threshold=20 (only 20 voxels), leaving membrane fragments inside cell interiors unfilled. This produced a noisier cell mask with more internal gaps, leading to fragmented watershed results. Results: cells mAP 0.588 (was 0.579), nuclei unchanged at 0.698/26. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): fix Z-slice index for downscaled nuclei visualization rescale_xy only downscales XY (Z unchanged), so the downscaled binary and watershed images should use z_mid (not z_mid//2) for the mid-slice. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(segmentation notebook): clean up plots and remove old pipeline references - Remove old/new AP comparison, replace with single clean AP curve plot - Merge nuclei and cells slice comparisons into one 4-row figure - Remove cubic_paper/old pipeline references from summary table - Simplify summary to compare only cubic vs CellProfiler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segmentation notebook): clean up imports - Remove unused ListedColormap import - Replace direct skimage imports (peak_local_max, watershed, resize) with cubic.skimage equivalents (feature, segmentation, transform) - Remove duplicate import of resize in cell 8 - All skimage functions now imported through cubic's device-agnostic proxy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): fill small holes per nucleus after cleanup After watershed + upscaling + cleanup, some nuclei have small internal holes (up to 340 voxels) visible as gaps in the XY mid-slice. Add per-nucleus remove_small_holes(area_threshold=500) to fill these. Nuclei mAP: 0.709 (was 0.698), AP@IoU=1.0: 0.378 (was 0.308). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(segmentation notebook): merge AP curves into comparison figure Remove standalone AP plot cell and add AP curves as 4th column in the combined nuclei/cells comparison figure. Each AP curve sits next to its corresponding XY/XZ mask comparisons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segmentation notebook): replace scipy imports with cubic wrappers - Replace scipy.ndimage.median_filter with cubic.scipy.ndimage.median_filter - Replace scipy.ndimage.distance_transform_edt with cubic.image_utils.distance_transform_edt - No direct scipy imports remain — all go through cubic's device-agnostic proxies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segmentation notebook): remove unnecessary asnumpy/to_device calls Let cubic's device-agnostic proxies handle CPU/GPU routing instead of manually converting arrays. Removed: - asnumpy + to_device around ndimage.median_filter (proxy handles it) - asnumpy(nuc_binary) before distance_transform_edt/peak_local_max - to_device after segmentation.watershed (proxy returns on correct device) - asnumpy(nuclei) before transform.resize (proxy handles it) - asnumpy(cell_mask/seeds) before watershed (proxy handles it) - asnumpy in hole-fill loop (boolean indexing works on both devices) Kept: asnumpy(coords) for np.zeros seed creation (needs CPU fancy indexing), asnumpy in planewise closing loop (np.zeros_like needs CPU), asnumpy for matplotlib visualization, asnumpy(old_labels) for Python enumerate loop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(segmentation notebook): update description to emphasize CPU/GPU portability Reframe the notebook intro to highlight cubic's device-agnostic API as the main point, with the CellProfiler reproduction as the demonstration use case. Add links to the CellProfiler tutorial website and GitHub repository. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): correct CellProfiler tutorial URL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): fix GPU compatibility for watershed and footprints - watershed is not in cucim — explicitly asnumpy inputs before calling segmentation.watershed, then to_device the result back - morphology.ball() returns CPU arrays — use to_same_device for footprints passed to cucim morphology operations (binary_dilation, peak_local_max) - Add get_device import for preserving device across CPU fallbacks Verified: runs on both CPU and GPU with identical results. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(segmentation notebook): add CPU vs GPU runtime to summary Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(docs): correct dataset attribution, remove incorrect BBBC034 reference The tutorial data (60x256x256, 3 channels: membrane/mito/DNA) does not match BBBC034 (1024x1024x52, 4 channels: CellMask/GFP/DNA/brightfield). The data is from the Allen Institute for Cell Science, provided with the CellProfiler 3D monolayer tutorial. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segmentation notebook): consolidate imports and add closing comment - Move perf_counter and get_device imports to cell 1 with other imports - Add comment explaining why planewise closing requires CPU roundtrip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): correct CPU vs GPU runtime numbers Benchmark with proper warmup: nuclei 0.2s GPU vs 5.4s CPU (30x speedup), cells ~3.2s on both (watershed/closing CPU-bound), total 3.3s vs 9.0s (2.7x). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(docs): clarify why cell pipeline runs on CPU Watershed is not in cuCIM; planewise closing runs on CPU by design to match CellProfiler's per-slice 2D behavior, not because of a fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): run planewise closing on GPU instead of CPU Keep mono_ds on GPU and call morphology.closing per Z-slice with a GPU disk footprint via to_same_device, instead of asnumpy → CPU loop → to_device. Cell segmentation: 1.86s (was 3.49s on GPU, ~1.9x faster). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segment_utils): add dilate_seeds parameter to segment_watershed Add dilate_seeds option that dilates seed points with ball(1) before labeling, matching CellProfiler's watershed seed dilation behavior. Also fix GPU compatibility: use to_same_device for footprints and asnumpy for watershed inputs (not in cucim). Simplifies notebook nuclei watershed from 12 lines of inline code to: segment_watershed(nuc_binary, ball_size=10, dilate_seeds=True) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segment_utils): add filter_mode to downscale_and_filter, use rescale_xy - Add filter_mode parameter for boundary handling (default "nearest"); when filter_shape="square", uses scipy.ndimage.median_filter with the specified mode instead of skimage.filters.median - Fix: use rescale_xy instead of transform.rescale to only downscale XY (preserving Z dimension for 3D images) - Notebook nuclei step 2 simplified to: downscale_and_filter(dna_norm, filter_size=5, filter_mode="constant") Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(segment_utils): add downscale_xy_only param to downscale_and_filter Allow choosing between XY-only downscaling (default, preserves Z) and uniform downscaling of all dimensions (downscale_xy_only=False). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segment_utils): add mask param to segment_watershed for cell pipeline When markers and mask are both provided, segment_watershed now computes the negated EDT of the mask as the watershed landscape (shape-based partitioning), matching CellProfiler's declumping behavior. Notebook cell pipeline simplified from 6 lines of inline EDT+watershed to: segment_watershed(cell_mask, markers=seeds, mask=cell_mask) Also removed unused cubic.skimage.segmentation import from notebook. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segmentation notebook): use cleanup_segmentation max_hole_size param Replace manual per-label hole-fill loop with the existing max_hole_size parameter of cleanup_segmentation: cleanup_segmentation(nuclei, min_obj_size=50, max_hole_size=500) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segmentation notebook): revert cell cleanup to manual relabeling cleanup_segmentation calls label() which splits disconnected pieces of the same cell into separate objects (22 -> 29 cells, mAP 0.588 -> 0.465). The manual relabeling preserves watershed label identity across disconnected 3D pieces. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add feature extraction notebook, examples extra, notebook CI workflow - Add examples/notebooks/feature_extraction_3d.ipynb: GPU-accelerated regionprops_table on cells3d (18 objects, 136 features) - Add examples/scripts/generated CI workflow: auto-converts notebooks to scripts on push to main - Add [examples] optional dependency group (jupyter, pandas, pooch) - Fix cubic.feature.voxel: convert spacing list to tuple for cucim compat - Remove redundant examples/scripts/regionprops_example.py Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove pandas dependency Replace pandas DataFrame usage in feature_extraction_3d notebook with plain numpy dict + formatted print. Remove pandas from [examples] extra. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review and Copilot feedback on PR #33 Code review fixes: - Fix regionprops() missing spacing tuple conversion for cucim compat - Remove unnecessary asnumpy(distance).shape — .shape works on both devices Copilot feedback fixes: - Make downscale_xy_only/filter_mode keyword-only in downscale_and_filter to preserve positional arg compatibility - Make mask/dilate_seeds keyword-only in segment_watershed, keep ball_size as 3rd positional arg for backward compat - Replace assert with ValueError for filter_shape validation - Fix numpy scalar formatting in feature_extraction_3d notebook (np.issubdtype check instead of isinstance(x, float)) - Use uv sync + uv run in notebooks CI workflow (matches lint-format.yml) - Remove dangling [tool.uv.sources] iohub entry from pyproject.toml Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(segment_utils): remove uint8 truncation in cleanup_segmentation cleanup_segmentation was casting label() output to uint8, silently truncating labels >255 to 0. Now returns the native int32/int64 dtype from label(). Callers that need a specific dtype already cast explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(segmentation notebook): remove redundant uint16 cast after cleanup_segmentation cleanup_segmentation now returns uint16 natively. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add feature extraction notebook to README examples table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Alexandr Kalinin <alxndrkalinin@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c97f84 commit aa41e7a

File tree

9 files changed

+1392
-46
lines changed

9 files changed

+1392
-46
lines changed

.github/workflows/notebooks.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Convert notebooks to scripts
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "examples/notebooks/*.ipynb"
8+
9+
jobs:
10+
convert:
11+
name: nbconvert to scripts
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: write
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: install uv
19+
uses: astral-sh/setup-uv@v5
20+
21+
- name: set up python
22+
run: uv python install 3.11
23+
24+
- name: install dependencies
25+
run: uv sync --extra examples
26+
27+
- name: convert notebooks to scripts
28+
run: |
29+
for nb in examples/notebooks/*.ipynb; do
30+
uv run jupyter nbconvert --to script "$nb" --output-dir examples/scripts
31+
done
32+
33+
- name: commit generated scripts
34+
run: |
35+
git config user.name "github-actions[bot]"
36+
git config user.email "github-actions[bot]@users.noreply.github.com"
37+
git add examples/scripts/
38+
if git diff --cached --quiet; then
39+
echo "No changes to commit"
40+
else
41+
git commit -m "chore: auto-generate scripts from notebooks"
42+
git push
43+
fi

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ pull request on GitHub.
8080
| [Resolution Estimation (2D)](examples/notebooks/resolution_estimation_2d.ipynb) | FRC and DCR on STED microscopy data |
8181
| [Resolution Estimation (3D)](examples/notebooks/resolution_estimation_3d.ipynb) | FSC and DCR on 3D confocal pollen data |
8282
| [Deconvolution Iterations (3D)](examples/notebooks/deconvolution_iterations_3d.ipynb) | RL deconvolution stopping criteria via PSNR, SSIM, FSC, DCR |
83+
| [3D Monolayer Segmentation](examples/notebooks/segmentation_3d_monolayer.ipynb) | 3D nuclei and cell segmentation of hiPSC monolayer |
84+
| [3D Feature Extraction](examples/notebooks/feature_extraction_3d.ipynb) | GPU-accelerated regionprops on 3D fluorescence data |
8385

8486
## Citation
8587
If you use `cubic` in your research, please cite it:

cubic/feature/voxel.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ def regionprops(
1616
spacing: list[float] | None = None,
1717
) -> list:
1818
"""Extract region-based morphological features."""
19+
# cucim requires spacing as tuple for kernel memoization
20+
if spacing is not None:
21+
spacing = tuple(spacing)
1922
return measure.regionprops(label_image, intensity_image, spacing=spacing)
2023

2124

@@ -30,6 +33,9 @@ def regionprops_table(
3033
properties = list(set(properties + ["label"]))
3134
else:
3235
properties = []
36+
# cucim requires spacing as tuple for kernel memoization
37+
if spacing is not None:
38+
spacing = tuple(spacing)
3339
return measure.regionprops_table(
3440
label_image, intensity_image, properties=properties, spacing=spacing
3541
)

cubic/segmentation/segment_utils.py

Lines changed: 124 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,51 +20,77 @@ def downscale_and_filter(
2020
downscale_anti_aliasing: bool = True,
2121
filter_size: int = 3,
2222
filter_shape: str = "square",
23+
*,
24+
downscale_xy_only: bool = True,
25+
filter_mode: str = "nearest",
2326
) -> npt.ArrayLike:
24-
"""Subsample and filter image prior to segmentiation.
27+
"""Subsample and filter image prior to segmentation.
2528
2629
Parameters
2730
----------
2831
image : npt.ArrayLike
2932
Image to be downsampled and filtered.
3033
downscale_factor : float, optional
3134
Factor by which to downscale the image, by default 0.5.
35+
downscale_order : int, optional
36+
Interpolation order for downscaling, by default 3.
37+
downscale_anti_aliasing : bool, optional
38+
Whether to apply anti-aliasing during downscaling, by default True.
39+
downscale_xy_only : bool, optional
40+
If True (default), only downscale XY dimensions, preserving Z for
41+
3D images. If False, downscale all dimensions uniformly.
3242
filter_size : int, optional
3343
Size of median filter kernel, by default 3.
44+
filter_shape : str, optional
45+
Shape of the filter kernel: ``"square"`` (cube in 3D) uses
46+
``scipy.ndimage.median_filter(size=filter_size)`` which supports
47+
boundary modes; ``"circular"`` (ball in 3D) uses
48+
``skimage.filters.median`` with a shaped footprint.
49+
filter_mode : str, optional
50+
Boundary mode for the median filter, by default ``"nearest"``.
51+
Only used when ``filter_shape="square"``. Common values:
52+
``"constant"`` (zero-padding), ``"nearest"``, ``"reflect"``.
3453
3554
Returns
3655
-------
3756
npt.ArrayLike
3857
Filtered and downsampled image.
3958
4059
"""
41-
# cuCIM does not yet support rank-based median filter
42-
# https://github.com/rapidsai/cucim/blob/main/python/cucim/src/cucim/skimage/filters/_median.py#L124
43-
assert filter_shape in [
44-
"square",
45-
"circular",
46-
], "Filter shape must be 'square' or 'circular'."
60+
from ..scipy import ndimage as _ndimage
61+
62+
if filter_shape not in ("square", "circular"):
63+
raise ValueError("filter_shape must be 'square' or 'circular'.")
64+
65+
if downscale_factor < 1.0:
66+
if downscale_xy_only:
67+
from ..image_utils import rescale_xy
68+
69+
image = rescale_xy(
70+
image,
71+
scale=downscale_factor,
72+
order=downscale_order,
73+
anti_aliasing=downscale_anti_aliasing,
74+
)
75+
else:
76+
image = transform.rescale(
77+
image,
78+
downscale_factor,
79+
order=downscale_order,
80+
anti_aliasing=downscale_anti_aliasing,
81+
)
82+
83+
if filter_shape == "square":
84+
return _ndimage.median_filter(image, size=filter_size, mode=filter_mode)
4785

4886
if image.ndim == 2:
49-
skimage_footprint = (
50-
morphology.square if filter_shape == "square" else morphology.disk
51-
)
87+
footprint = morphology.disk(filter_size)
5288
elif image.ndim == 3:
53-
skimage_footprint = (
54-
morphology.cube if filter_shape == "square" else morphology.ball
55-
)
89+
footprint = morphology.ball(filter_size)
5690
else:
5791
raise ValueError("Image must be 2D or 3D.")
5892

59-
if downscale_factor < 1.0:
60-
image = transform.rescale(
61-
image,
62-
downscale_factor,
63-
order=downscale_order,
64-
anti_aliasing=downscale_anti_aliasing,
65-
)
66-
67-
return filters.median(image, footprint=skimage_footprint(filter_size))
93+
return filters.median(image, footprint=footprint)
6894

6995

7096
def check_labeled_binary(image):
@@ -120,7 +146,7 @@ def cleanup_segmentation(
120146
)
121147
label_img[filled_mask] = label_id
122148

123-
return label(label_img).astype(np.uint8)
149+
return label(label_img).astype(np.uint16)
124150

125151

126152
def find_objects(label_image, max_label=None):
@@ -253,26 +279,84 @@ def remove_thin_objects(label_image, min_z=2):
253279
return label_image
254280

255281

256-
def segment_watershed(image, markers=None, ball_size=15):
257-
"""Segment image using watershed algorithm."""
258-
device = get_device(image)
282+
def segment_watershed(
283+
image: npt.ArrayLike,
284+
markers: npt.ArrayLike | None = None,
285+
ball_size: int = 15,
286+
*,
287+
mask: npt.ArrayLike | None = None,
288+
dilate_seeds: bool = False,
289+
) -> npt.ArrayLike:
290+
"""Segment image using watershed algorithm.
259291
260-
distance = distance_transform_edt(image)
261-
coords = feature.peak_local_max(
262-
distance, footprint=morphology.ball(ball_size), labels=image
263-
)
292+
When ``markers`` is None, computes a distance-based watershed:
293+
EDT of the binary image is used to find peaks, which become markers,
294+
and the watershed floods the negated distance.
295+
296+
When ``markers`` is provided, runs a marker-based watershed. By default
297+
the image is used as both the landscape and mask. If ``mask`` is also
298+
provided, the watershed uses the negated EDT of the mask as the
299+
landscape (shape-based partitioning) and restricts flooding to the mask.
264300
265-
# https://github.com/rapidsai/cucim/issues/89
301+
Parameters
302+
----------
303+
image : npt.ArrayLike
304+
Binary image to segment (distance-based) or intensity image
305+
(marker-based when no mask is given).
306+
markers : npt.ArrayLike or None, optional
307+
Pre-computed markers for marker-based watershed. If None,
308+
markers are generated from distance-transform peaks.
309+
mask : npt.ArrayLike or None, optional
310+
Binary mask restricting the watershed. When provided with markers,
311+
the watershed landscape is the negated EDT of the mask (shape-based
312+
partitioning). Only used when ``markers`` is not None.
313+
ball_size : int, optional
314+
Radius of the ball footprint for ``peak_local_max``, by default 15.
315+
Only used when ``markers`` is None.
316+
dilate_seeds : bool, optional
317+
If True, dilate seed points with ``ball(1)`` before labeling.
318+
This merges nearby peaks and reduces over-segmentation.
319+
Only used when ``markers`` is None.
320+
321+
Returns
322+
-------
323+
npt.ArrayLike
324+
Label image on the same device as the input.
325+
326+
"""
327+
from ..cuda import to_same_device
328+
329+
device = get_device(image)
330+
331+
# Distance-based watershed (no markers provided)
266332
if markers is None:
267-
mask = np.zeros(distance.shape, dtype=bool)
268-
mask[tuple(asnumpy(coords.T))] = True
269-
markers = label(mask)
270-
labels = watershed(-asnumpy(distance), markers, mask=asnumpy(image))
271-
else:
272-
labels = watershed(
273-
asnumpy(image), markers=asnumpy(markers), mask=asnumpy(image)
274-
)
275-
# return on the same device as input
333+
distance = distance_transform_edt(image)
334+
footprint = morphology.ball(ball_size)
335+
footprint = to_same_device(footprint, distance)
336+
coords = feature.peak_local_max(distance, footprint=footprint, labels=image)
337+
338+
seed_mask = np.zeros(distance.shape, dtype=bool)
339+
seed_mask[tuple(asnumpy(coords).T)] = True
340+
seed_mask = to_device(seed_mask, device)
341+
if dilate_seeds:
342+
seed_mask = morphology.binary_dilation(
343+
seed_mask, to_same_device(morphology.ball(1), seed_mask)
344+
)
345+
markers = label(seed_mask)
346+
# watershed is not in cucim — run on CPU, return to original device
347+
labels = watershed(-asnumpy(distance), asnumpy(markers), mask=asnumpy(image))
348+
return to_device(labels, device)
349+
350+
# Marker-based watershed with explicit mask (shape-based partitioning)
351+
if mask is not None:
352+
distance = distance_transform_edt(asnumpy(mask))
353+
ws_image = -distance
354+
ws_image = ws_image - ws_image.min()
355+
labels = watershed(ws_image, markers=asnumpy(markers), mask=asnumpy(mask))
356+
return to_device(labels, device)
357+
358+
# Marker-based watershed without mask (image as landscape and mask)
359+
labels = watershed(asnumpy(image), markers=asnumpy(markers), mask=asnumpy(image))
276360
return to_device(labels, device)
277361

278362

examples/data/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,17 @@ Used in `examples/notebooks/resolution_estimation_3d.ipynb`.
3232
- **`40x_TAGoff_z_galvo.nd2`** — Pollen confocal (512x512x181, voxel 78x78x250 nm). Nikon A1 confocal, 40x/1.2 water, 488 nm excitation, GaAsP detector.
3333
[download from figshare](https://ndownloader.figshare.com/files/15203144)
3434
Source: Koho et al. (2019) *Nat. Commun.* 10:3103, [figshare dataset](https://doi.org/10.6084/m9.figshare.8159165.v1).
35+
36+
#### 4. 3D cell monolayer segmentation
37+
38+
Used in `examples/notebooks/segmentation_3d_monolayer.ipynb`. Auto-downloaded on first run using pooch.
39+
40+
- **`3d_monolayer_xy1_ch0.tif`** — Membrane channel (60x256x256, uint16).
41+
- **`3d_monolayer_xy1_ch1.tif`** — Mitochondria channel (60x256x256, uint16).
42+
- **`3d_monolayer_xy1_ch2.tif`** — DNA channel (60x256x256, uint16).
43+
- **`3d_monolayer_xy1_ch2_NucleiLabels.tiff`** — CellProfiler nuclei reference labels.
44+
- **`3d_monolayer_xy1_ch0_CellsLabels.tiff`** — CellProfiler cell reference labels.
45+
46+
Source: hiPSC data from the Allen Institute for Cell Science, provided with the
47+
[CellProfiler 3D monolayer tutorial](https://github.com/CellProfiler/tutorials/tree/master/3d_monolayer).
48+
[GitHub release assets](https://github.com/alxndrkalinin/cubic/releases/tag/v0.7.0a1).

0 commit comments

Comments
 (0)