Skip to content

Add split-narrow-bridges: split accidentally-merged objects at thin necks - #86

Merged
davidackerman merged 12 commits into
mainfrom
split-narrow-bridges
Sep 9, 2026
Merged

davidackerman merged 12 commits into
mainfrom
split-narrow-bridges

Conversation

@davidackerman

@davidackerman davidackerman commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

@stuarteberg

Summary

Main feature: split-narrow-bridges — splits accidentally-merged instances at thin necks, preserving every original foreground voxel and only relabeling. Works on any existing instance segmentation.

  • neck_radius_nm: physical (nm) threshold — any cross-section narrower than this is a candidate neck to cut; thicker connections are left alone. neck_radius_mode="adaptive" can derive this per-object instead of using one fixed value for every object.
  • minimum_object_volume_to_split_nm_3: skips objects too small to attempt a split at all, before any per-object work happens.
  • Ships one validated strategy, edt_watershed; two others (skeleton_graph, tube_direction) were built but removed for lack of test coverage.
  • Companion compute-edt CLI tool precomputes the distance transform once for reuse across repeated runs.

Unrelated improvements, but also included

  • Consensus-vote mode for ConnectedComponents.
  • EDT boundary-thinness gate + fix for a 2.4hr perf bug in boundary merging.
  • Fair-share concurrency scaling; morphological-op edge/halo fix.
  • Padded/fill voxels excluded from raw intensity stats; voxel-size-mismatch grid-snapping bug fixed in to_ndarray_tensorstore.
  • Version bumped to 0.5.0.

Implements the edt_watershed strategy from docs/split_narrow_bridges_plan.md
for splitting accidentally-merged blob-like objects (nuclei, cells) at thin
necks, preserving all original foreground voxels. Per-object dispatch reuses
the Skeletonize/Measure bbox pattern; the two-phase compute-then-write design
avoids concurrent chunk writes by computing per-object split labels into
scratch files first, then applying them in a single spatially-disjoint
blockwise pass. New split pieces get IDs strictly above the dataset's max
existing ID, so they never collide with untouched objects.

SkeletonGraphSplit (for thin/branched mitochondria) is stubbed with
NotImplementedError pending the graph-based design.

Wires up the `split-narrow-bridges` CLI entry point following the existing
RunProperties convention.
Adds the skeleton_graph strategy for splitting merged objects: builds the
object's skeleton graph and cuts at nodes that are a genuine local minimum
of EDT radius (thinner than every neighbor), not just nodes below a flat
threshold. A flat threshold also flags an ordinary monotonic taper (e.g. a
blob with a thin spur) as a candidate neck, since radius shrinks steadily
to the spur's tip without ever dipping and rising again -- exactly the
over-splitting failure mode this feature is meant to avoid. Requiring a
true local minimum (thick-thin-thick) fixes that.

Getting the size-gate accounting right took two more wrong turns, both now
documented in docs/split_narrow_bridges_plan.md: a pi*r^2*length tube-volume
proxy overestimated a 3-voxel stub as ~6 voxels, and a straight-line nearest-
skeleton-node assignment leaked most of a compact blob's volume onto a thin
stub's nodes. The fix is a marker-based watershed on a flat field restricted
to the mask, which assigns every voxel to its nearest node geodesically
(respecting the mask's actual shape) and gives exact per-node voxel counts.

Neither strategy is organelle-specific -- skeleton_graph is a general
topology-aware alternative to edt_watershed, just better suited to thin/
branched shapes (mitochondria being the motivating case, not a special case).

Adds two more tests: a dumbbell split via skeleton_graph, and a trunk+stub
shape verifying the size gate keeps a spurious small protrusion attached.
EDTWatershedSplit now rejects a split whose boundary isn't actually thin
(_merge_thick_boundaries), not just too small -- catches two density peaks
in one continuously-fat, irregularly-shaped blob that a size gate alone
would miss. Verified against real cerebellum nucleus data: correctly no-ops
when boundaries are genuinely thin, and correctly merges a synthetic
fat-waisted two-lobe blob that a size-only gate would wrongly keep split.

The first version of that gate recomputed every region's mask + dilation +
neighbor boundaries from scratch after every single merge -- fine for a
few small fragments, but 2.4 hours on a real ~100M-voxel merged-nucleus
object with ~50 initial peaks. Rewritten to compute all pairwise boundary
info in one array pass, then do the merging via union-find on that small
label-adjacency graph instead of rescanning the array -- same result
(verified bit-identical), ~40x faster (8650s -> 216s on that object).

Adds ComputeEDT: blockwise, multi-label-aware EDT over an entire
segmentation, persisted as its own dataset. edt.edt() (Seung-lab) computes,
per voxel, distance to the nearest *differently*-labeled voxel when given
the raw labeled array directly -- exact per-instance EDT everywhere in one
pass, not an approximation. A windowed EDT can only overestimate distance
(background outside the window is invisible to it), so a fixed padding
only needs to exceed the thinness threshold callers care about, not any
object's physical scale -- validated on real data (id 2307, 9.25M voxels):
100% agreement with direct per-object EDT on the "<500nm" decision despite
raw-value differences deep in thick interiors where the threshold doesn't
care.
…ume gate

Add dask_util helpers (resolve_concurrency_limit, rescale_idi_concurrency,
wave_uses_shared_job_cpuset) so ImageDataInterface concurrency scales with
actual available cores and per-worker-process fair share under a shared
LSF/SLURM/SGE job cpuset, wired into SplitNarrowBridges and Skeletonize.

Default SplitNarrowBridges's precompute_edt to False after root-causing a
production KilledWorker failure in ComputeEDT's blockwise EDT precompute on
jrc_mus-cerebellum-3 (unbounded padded block size against per-slot memory
budget); per-object wave-scheduled EDT is already memory-safe.

Remove max_object_volume_nm_3 (pre-split whole-object skip filter) --
conceptually at odds with splitting's purpose, since it discards exactly
the oversized objects that need splitting. Use filter_ids or
CleanConnectedComponents after the fact instead.

Default minimum_subregion_volume_nm_3 to minimum_volume_nm_3 when unset, so
a split producing an undersized piece is rejected up front (voxels stay
attached to the parent) instead of being accepted and then silently
deleted by the final volume filter.
Support a list of independent inputs (not necessarily channels of the same
image -- e.g. separate prediction datasets) via consensus_config, each
thresholded on its own intensity_threshold_minimum/maximum (and optionally
its own gaussian_smoothing_sigma_nm/invert), then combined into one mask by
a per-voxel vote: foreground if at least minimum_consensus_count of the
inputs agree. Defaults to unanimous agreement across all inputs. Connected
components then runs on the combined mask exactly as on a single
thresholded input.
…rrectness

TubeDirectionSplit adds simultaneous-cut, prominence, and axis-direction
gates for splitting merged tubular objects (mitochondria) where
radius-only reasoning fails on mesh-like skeleton topology. Also fixes
MorphologicalOperations to support opening/closing with correct halo
sizing and true-edge handling, adds an LRU byte-budgeted subpieces cache
and vectorized spatial index to the write phase (previously ~2.8
blocks/sec on production data), and raises max_pieces_per_object to 1024.
…smatch snapping bug

Measure now tracks a valid mask alongside raw intensity reads so ROI padding
past a dataset's bounds isn't silently counted as intensity 0. Also fixes
to_ndarray_tensorstore's grid snapping to anchor in offset-relative space,
which previously read the wrong native voxels when offset wasn't itself a
multiple of voxel_size.
Skips split attempts entirely for objects below a total-volume threshold,
before any mask/EDT/skeleton work happens, rather than relying solely on
per-cut gating after the fact. Also fixes a latent id-collision bug this
surfaced: new split ids were derived from max(self.ids), which would have
undercounted once small objects got filtered out of that list.
…to 0.5.0

Both CLI tools existed but were never listed in the README's tool table.
Also documents the new minimum_object_volume_to_split_nm_3 pre-filter in
the design doc, including why it doesn't repeat the removed
max_object_volume_nm_3 mistake (skipping small objects up front is safe;
skipping large ones isn't, since "too large" is usually the exact merged-
object signature this tool exists to split).
@codecov

codecov Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.52695% with 90 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.65%. Comparing base (e737d59) to head (a89323b).

Files with missing lines Patch % Lines
...rc/cellmap_analyze/process/split_narrow_bridges.py 91.14% 37 Missing ⚠️
src/cellmap_analyze/util/image_data_interface.py 51.28% 19 Missing ⚠️
...rc/cellmap_analyze/process/connected_components.py 85.88% 12 Missing ⚠️
src/cellmap_analyze/process/compute_edt.py 86.44% 8 Missing ⚠️
src/cellmap_analyze/util/dask_util.py 63.15% 7 Missing ⚠️
src/cellmap_analyze/util/mask_util.py 14.28% 6 Missing ⚠️
...ellmap_analyze/process/morphological_operations.py 95.83% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #86      +/-   ##
==========================================
+ Coverage   84.15%   84.65%   +0.50%     
==========================================
  Files          28       30       +2     
  Lines        4341     4945     +604     
==========================================
+ Hits         3653     4186     +533     
- Misses        688      759      +71     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Neither had real coverage: skeleton_graph had only two synthetic-geometry
unit tests and no test of the cyclic topology it's documented to handle
incorrectly; tube_direction had zero unit tests despite docstring claims
about real mito data that were never captured as a reproducible test.
Ship only edt_watershed, which has real-data validation on record. Design
history for the removed strategies is kept in
docs/split_narrow_bridges_plan.md in case they come back with real tests.

Also clarifies in the README where split-narrow-bridges sits in the
predictions-to-segmentations pipeline (downstream of connected-components/
mws, upstream of clean-connected-components).
docs/split_narrow_bridges_plan.md was a working design journal, not
user-facing documentation -- doesn't belong in main. Inlined the handful of
comments that pointed to it directly instead of leaving dangling references.
@davidackerman
davidackerman merged commit e26bfe2 into main Sep 9, 2026
3 checks passed
@davidackerman
davidackerman deleted the split-narrow-bridges branch September 9, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant