Skip to content

Commit 10dd419

Browse files
Watershed (#25)
* Implement watershed * Optimize watershed implementation * Add watershed from affinities
1 parent 3174610 commit 10dd419

15 files changed

Lines changed: 2965 additions & 1 deletion

MIGRATION_GUIDE.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,44 @@ Notes:
841841
- Signed integer inputs must not contain negative labels.
842842
- Inputs are converted to contiguous `uint64` arrays before entering C++.
843843

844+
## Marker-controlled Watershed
845+
846+
`bioimage-cpp` ships two marker-controlled watershed entry points: one that
847+
consumes a node-valued heightmap and one that consumes an edge-valued
848+
nearest-neighbour affinity map. Both share the same flooding scaffolding
849+
internally — a 65536-bucket Meyer-style monotone queue — and have the same
850+
public-facing semantics: markers are mandatory, connectivity is 1
851+
(4-neighbour in 2D, 6-neighbour in 3D), an optional foreground mask is
852+
supported, and tie-breaking on equal heights / equal affinities is
853+
unspecified.
854+
855+
```python
856+
# Heightmap-driven (analogous to skimage.segmentation.watershed)
857+
labels = bic.segmentation.watershed(image, markers, mask=optional_mask)
858+
859+
# Affinity-driven: edge priorities, no heightmap derivation needed
860+
labels = bic.segmentation.watershed_from_affinities(
861+
affinities, # (C, *spatial), C == spatial_ndim
862+
offsets=[(-1, 0), (0, -1)], # one NN offset per channel, same sign
863+
markers=markers,
864+
mask=optional_mask,
865+
)
866+
```
867+
868+
Notes for `watershed_from_affinities`:
869+
870+
- Each channel must encode a single nearest-neighbour edge (exactly one
871+
±1 entry, the rest zero). All offsets must have the same sign — mixing
872+
positive and negative directions is rejected. The function dispatches
873+
to a positive-direction or negative-direction specialisation at the C++
874+
layer so the inner loop has no per-channel sign branches.
875+
- Offsets may be passed in any axis order; the channel ↔ axis mapping is
876+
rebuilt internally.
877+
- Higher affinity is processed first (high affinity = strong bond).
878+
- Compared to `affogato.segmentation.compute_mws_segmentation`, there are
879+
no mutex (repulsive) channels and no long-range offsets — use
880+
`bic.segmentation.mutex_watershed` for that.
881+
844882
## Mutex Watershed
845883

846884
`bioimage-cpp` ships two mutex-watershed entry points, mirroring the two
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Watershed — performance notes
2+
3+
Optimization log for `bioimage_cpp.segmentation.watershed`. Re-run with:
4+
5+
```bash
6+
python development/segmentation/check_watershed_2d.py --repeats 5
7+
python development/segmentation/check_watershed_3d.py --repeats 3
8+
```
9+
10+
Both scripts build the same heightmap from the cached ISBI affinity volume
11+
(1 − mean of the nearest-neighbour affinity channels), generate seeds by
12+
labelling local minima of a Gaussian-smoothed copy, then time
13+
`bioimage_cpp.segmentation.watershed` against
14+
`skimage.segmentation.watershed(connectivity=1)` on identical inputs with one
15+
untimed warmup and interleaved repeats. Partition agreement is measured with
16+
Rand Index / VI / adapted-rand-error from `elf.evaluation`. Exact label
17+
equality is not expected — tie-breaking on equal heights is documented as
18+
unspecified for `bioimage_cpp.watershed`.
19+
20+
## Setup
21+
22+
- CPU: 11th Gen Intel Core i7-1185G7 (Tiger Lake, 4C/8T)
23+
- Compiler: gcc 14.3.0 (conda-forge), `-O3`, no `-march=native`
24+
- Python 3.12.12 on Linux x86_64
25+
- `scikit-image 0.25.2`, `numpy 1.26.4`, `bioimage_cpp 0.1.0`
26+
27+
## Headline numbers
28+
29+
Median wall-clock per call on the ISBI test volume.
30+
31+
| problem | shape | seeds | baseline (v1) | optimized | speedup over baseline | skimage | speedup vs skimage | Rand Index |
32+
|---|---|---:|---:|---:|---:|---:|---:|---:|
33+
| 2D | (512, 512) | 1162 | 47.1 ms | **8.1 ms** | **5.8×** | 66.0 ms | **8.1×** | 0.982 |
34+
| 3D | (6, 512, 512) | 2407 | 720 ms | **77.3 ms** | **9.3×** | 937 ms | **12.1×** | 0.997 |
35+
36+
`baseline (v1)` is the heap-based version from the initial watershed
37+
implementation, before this optimization round. `optimized` is the
38+
65536-bucket Meyer-flooding variant landed below. Rand Index is measured
39+
against `skimage.segmentation.watershed` — values above 0.97 indicate
40+
near-identical partitions with boundary jitter of a few percent.
41+
42+
## Optimization phases
43+
44+
### Baseline profile
45+
46+
With `BIOIMAGE_PROFILE=ON` on a single 3D run:
47+
48+
```
49+
init_output 0.0003 s ( 0.0%)
50+
seed_pass 0.0024 s ( 0.4%)
51+
main_loop 0.6183 s ( 99.6%)
52+
total 0.6210 s
53+
```
54+
55+
Everything is in the heap loop. The inner loop calls
56+
`detail::valid_offset_target` once per neighbour, which does `ndim`
57+
divisions and modulos per call — 18 div+mod per heap pop in 3D.
58+
59+
### Phase 2+3 — Precomputed offsets + 2D/3D specialization
60+
61+
`watershed<HeightT, LabelT>` split into `detail_ws::watershed_2d` and
62+
`detail_ws::watershed_3d` with manually-unrolled 4- / 6-neighbour blocks.
63+
Replaced `valid_offset_target` (which does per-axis div+mod every call) with:
64+
65+
- one (2D) or two (3D) div+mods per heap pop to decompose `node` into
66+
`(y, x)` or `(z, y, x)`;
67+
- branchless boundary checks of the form `coord[axis] > 0` /
68+
`coord[axis] + 1 < shape[axis]`;
69+
- flat-index neighbour arithmetic via `node ± strides[axis]`.
70+
71+
Heap stays as `std::priority_queue<std::pair<HeightT, uint64_t>>`.
72+
73+
| problem | before | after | delta |
74+
|---|---:|---:|---:|
75+
| 2D | 38.8 ms | 36.1 ms | 1.07× |
76+
| 3D | 602 ms | 484 ms | 1.24× |
77+
78+
3D got the bigger win because it had more div+mod to remove per pop (18 → 2)
79+
than 2D (8 → 1). Rand Index unchanged (algorithm semantics preserved).
80+
81+
### Phase 4 — Smaller heap entries
82+
83+
Skipped. The profile showed the entire cost is in `main_loop` and the next
84+
phase replaces the heap entirely, so the 10–20% potential from a tighter
85+
heap entry isn't worth the binding-layer churn.
86+
87+
### Phase 5 — Bucket-queue Meyer flooding
88+
89+
Replaced `std::priority_queue` with a 65536-bucket queue. Algorithm:
90+
91+
1. Scan the heightmap (skipping masked pixels) to find `[h_min, h_max]`.
92+
2. Quantize each pixel to `uint16_t` with
93+
`level = floor((image[i] - h_min) / (h_max - h_min) * 65535)`.
94+
3. For each level, maintain a `std::vector<uint64_t>` of pending pixel
95+
indices. A `current_level` cursor advances monotonically.
96+
4. Pop pixels from `buckets[current_level]` via `pop_back` (LIFO inside a
97+
level). For each unlabeled neighbour, set its label and push it into
98+
`buckets[max(level[neighbour], current_level)]` — the Meyer monotone
99+
semantic: a neighbour pushed from above never lands below the cursor.
100+
5. Advance `current_level` when the current bucket drains.
101+
102+
`O(N + L)` total work where `L = 65536` levels. For `N >> L` this is
103+
effectively `O(N)` instead of the heap's `O(N log N)`.
104+
105+
| problem | before (heap+specialization) | after (bucket) | delta |
106+
|---|---:|---:|---:|
107+
| 2D | 36.1 ms | 8.1 ms | 4.5× |
108+
| 3D | 484 ms | 77.3 ms | 6.3× |
109+
110+
Post-Phase-5 3D profile:
111+
112+
```
113+
init_output 0.0002 s ( 0.3%)
114+
range 0.0023 s ( 2.9%)
115+
quantize 0.0020 s ( 2.5%)
116+
seed_pass 0.0013 s ( 1.7%)
117+
main_loop 0.0721 s ( 92.6%)
118+
total 0.0779 s
119+
```
120+
121+
`main_loop` is still ~93% of the time; the per-pixel work in the bucket
122+
queue is dramatically lower than in the heap, but it remains the dominant
123+
phase. `range`+`quantize`+`seed_pass` together are ~7% and aren't worth
124+
chasing further at this stage — saving all of them would be ~5 ms.
125+
126+
## Tradeoff: partition agreement
127+
128+
Rand Index drops from `0.998 / 0.999` (heap) to `0.982 / 0.997` (bucket).
129+
2D agreement dropped more because the ISBI 2D crop has small regions
130+
(avg ~225 pixels per seed) where boundary tie-breaking dominates the
131+
metric.
132+
133+
Two semantic differences cause this:
134+
135+
1. **Quantization.** 65536 levels can't distinguish ULP-close floats. Two
136+
pixels at heights `1e-6` apart end up in the same level instead of being
137+
strictly ordered as the heap would. Tested at 65536 levels; bumping to
138+
1M didn't materially improve agreement (tried during this round) but
139+
costs more memory, so kept at 65536.
140+
2. **Meyer monotone flooding.** A "valley pixel" reached from a higher
141+
cursor is processed at the cursor's level, not its own. The heap-based
142+
watershed would pop it immediately and let it propagate from there. The
143+
bucket-queue version delays its processing until the cursor naturally
144+
reaches it, which can change which seed claims it.
145+
146+
Both are documented as unspecified tie-breaking in the Python wrapper.
147+
The 21-test correctness suite (deterministic ridge tests, mask handling,
148+
dtype matrix, error cases) still passes; the agreement metric vs
149+
`skimage.segmentation.watershed` quantifies the boundary jitter on a real
150+
problem.
151+
152+
The benchmark script's `min_rand_index` gate is set to 0.97 to reflect
153+
this — below that we'd treat it as a real regression to investigate.
154+
155+
## What was tried and not landed
156+
157+
- **More quantization levels (1M).** Marginal improvement in agreement
158+
(0.982 → 0.985 on 2D), 4× memory cost for `levels[]`, and the bucket
159+
vector grows to 24 MB of empty `std::vector` shells. Not worth it.
160+
- **`std::deque` for FIFO inside a level.** Doesn't change Meyer semantics
161+
(the issue isn't intra-level order). Adds per-op overhead. Discarded.
162+
- **`uint32_t` indices in the bucket queue.** Cuts bucket storage in half
163+
on the inner index path; preserves the existing `uint64_t` flat-index
164+
semantics in the rest of the codebase. Skipped to keep the change
165+
minimal; revisit if a single watershed > 2³² voxels ever shows up
166+
(currently ruled out by memory anyway).
167+
168+
## How to reproduce
169+
170+
```bash
171+
# Clean build (production)
172+
pip install -e . --no-build-isolation
173+
python -m pytest tests/segmentation/test_watershed.py -q # 21 pass
174+
python development/segmentation/check_watershed_2d.py --repeats 5
175+
python development/segmentation/check_watershed_3d.py --repeats 3
176+
177+
# Profile build (per-phase breakdown to stderr)
178+
pip install -e . --no-build-isolation -C cmake.define.BIOIMAGE_PROFILE=ON
179+
python development/segmentation/check_watershed_3d.py --repeats 1
180+
```
181+
182+
The profile macros (`BIOIMAGE_PROFILE_INIT/SCOPE/REPORT`) are gated by the
183+
`BIOIMAGE_PROFILE` cmake option and are no-ops in production builds, so
184+
they're left in `watershed.hxx` for the next round of work.

0 commit comments

Comments
 (0)