Skip to content

Commit c96eb64

Browse files
First implementation of spatial dijkstra and teasar-based skeletonization
1 parent 5027243 commit c96eb64

21 files changed

Lines changed: 2627 additions & 8 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@ venv/
3131

3232
CLAUDE.md
3333
pypi-release.txt
34+
35+
.old

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ nanobind_add_module(_core
2222
src/bindings/label_multiset.cxx
2323
src/bindings/mesh.cxx
2424
src/bindings/segmentation.cxx
25+
src/bindings/skeleton.cxx
2526
src/bindings/transformation.cxx
2627
src/bindings/util.cxx
2728
src/bindings/utils.cxx

MIGRATION_GUIDE.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2339,6 +2339,63 @@ Important differences:
23392339
and `number_of_threads` parallelizes the pairwise evaluation. Still threshold
23402340
the distance map first to keep the candidate count modest.
23412341

2342+
### Discrete Grid Dijkstra
2343+
2344+
`bioimage-cpp` exposes its exact masked-grid shortest-path primitive directly.
2345+
This is useful when a predecessor chain is required, when edge costs are
2346+
naturally attached to voxels, and for independently benchmarking the
2347+
shortest-path phase of algorithms such as TEASAR.
2348+
2349+
```python
2350+
import bioimage_cpp as bic
2351+
2352+
# Multi-source field on an 8-connected 2D or 26-connected 3D voxel graph.
2353+
field = bic.distance.dijkstra_distance_field(
2354+
mask,
2355+
sources, # (N, mask.ndim) integer coordinates
2356+
connectivity=None, # full connectivity by default
2357+
spacing=(2.0, 1.0, 1.0),
2358+
)
2359+
2360+
# Flat C-order predecessor indices can be requested with the same solve.
2361+
field, predecessors = bic.distance.dijkstra_distance_field(
2362+
mask, sources, return_predecessors=True
2363+
)
2364+
2365+
# Stop as soon as the cheapest of several targets is settled.
2366+
path = bic.distance.dijkstra_path(mask, source, targets)
2367+
```
2368+
2369+
Three edge-cost conventions are available:
2370+
2371+
- `cost_mode="physical"` assigns every edge the Euclidean length of its
2372+
neighbour offset under `spacing`; `costs` must be omitted.
2373+
- `cost_mode="node"` assigns directed edge `u -> v` the value `costs[v]`;
2374+
`spacing` must be `None`.
2375+
- `cost_mode="node_times_physical"` assigns `costs[v]` times the physical
2376+
neighbour-step length.
2377+
2378+
All costs must be finite and non-negative. Distances are `float64`; background
2379+
and unreachable voxels are `+inf`. Predecessors are `int64` with the mask shape:
2380+
sources point to their own flat C-order index and background/unreachable voxels
2381+
contain `-1`. Paths are `int64` NumPy-axis-order coordinates from the source to
2382+
the selected target. Ties are deterministic and use flat C-order indices.
2383+
2384+
This Dijkstra field is deliberately distinct from
2385+
`geodesic_distance_field` below. Dijkstra is exact for the chosen 4/8- or
2386+
6/18/26-neighbour voxel graph, supports zero node costs, and provides an exact
2387+
predecessor chain. Its physical metric is still a discrete chamfer metric: a
2388+
continuous direction must be approximated by the available neighbour steps.
2389+
The geodesic implementation instead solves the continuous Eikonal equation on
2390+
the sampled grid with a first-order Godunov fast-marching scheme. It is smoother
2391+
and less tied to the finite neighbour directions, and its `speed` argument has
2392+
travel-time semantics, but it does not retain discrete predecessors. The two
2393+
are close on well-sampled, uniform domains but are not numerically equivalent;
2394+
TEASAR therefore uses Dijkstra where it must reconstruct and join paths.
2395+
2396+
See `development/distance/benchmark_dijkstra.py` for a standalone benchmark of
2397+
the Dijkstra field, predecessor field, weighted field, and early-stopping path.
2398+
23422399
## scikit-fmm
23432400

23442401
`scikit-fmm` computes geodesic distances on regular grids with the fast
@@ -2431,6 +2488,73 @@ Important differences:
24312488
solver it slightly overestimates. See `development/distance/` for the
24322489
reference oracles and `benchmark_geodesic.py` for timings.
24332490

2491+
## kimimaro / TEASAR skeletonization
2492+
2493+
Kimimaro skeletonizes densely labelled volumes and splits labels into connected
2494+
components internally. The first `bioimage-cpp` TEASAR entry point is narrower:
2495+
it accepts one binary 3D object and returns its graph directly.
2496+
2497+
Kimimaro:
2498+
2499+
```python
2500+
import kimimaro
2501+
2502+
skeletons = kimimaro.skeletonize(
2503+
labels,
2504+
teasar_params={
2505+
"scale": 1.5,
2506+
"const": 0,
2507+
"pdrf_scale": 100000,
2508+
"pdrf_exponent": 4,
2509+
},
2510+
anisotropy=(2.0, 1.0, 1.0),
2511+
)
2512+
```
2513+
2514+
bioimage-cpp:
2515+
2516+
```python
2517+
import bioimage_cpp as bic
2518+
2519+
vertices, edges, radii = bic.skeleton.teasar(
2520+
mask,
2521+
spacing=(2.0, 1.0, 1.0),
2522+
scale=1.5,
2523+
constant=0,
2524+
pdrf_scale=100000,
2525+
pdrf_exponent=4,
2526+
)
2527+
```
2528+
2529+
Important differences and current scope:
2530+
2531+
- `mask` is binary (nonzero means foreground), must be three-dimensional, and
2532+
a nonempty mask must contain exactly one 26-connected component. Component
2533+
splitting and multi-label dispatch are intentionally deferred.
2534+
- Coordinates follow NumPy axis order. `vertices` is `float64` physical
2535+
`(z, y, x)` coordinates, `edges` is `(E, 2)` `uint64`, and `radii` is
2536+
per-vertex physical distance-to-boundary in `float32`. The graph is a
2537+
deterministic tree; an empty mask returns typed empty arrays.
2538+
- The implementation follows the core TEASAR loop: a padded exact Euclidean
2539+
distance-to-boundary field, a deterministic two-sweep root, a physical
2540+
Dijkstra distance-from-root field, penalized repeated Dijkstra paths, and
2541+
rolling invalidation with radius `scale * radius + constant`.
2542+
- This first correctness-oriented version uses a physical axis-aligned
2543+
invalidation cube. It does not implement kimimaro's soma handling, hole
2544+
filling, border stitching, manual targets, component dust filtering,
2545+
cross-section metadata, or postprocessing heuristics. These differences can
2546+
change branch positions and vertex counts, so output is not expected to be
2547+
vertex-for-vertex identical to kimimaro.
2548+
- The C++ core is dependency-free and single-threaded. It deliberately reuses
2549+
the same public grid-Dijkstra implementation described above for root fields
2550+
and penalized rail paths.
2551+
2552+
Correctness tests are under `tests/skeleton/test_teasar.py`. The independent
2553+
Dijkstra benchmark is `development/distance/benchmark_dijkstra.py`; the
2554+
end-to-end synthetic branching-tube benchmark is
2555+
`development/skeleton/benchmark_teasar.py` and can optionally add kimimaro with
2556+
`--kimimaro` when it is installed.
2557+
24342558
## I/O and Build Dependencies
24352559

24362560
`bioimage-cpp` intentionally does not replace nifty or affogato I/O helpers.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Image processing and segmentation functionality in C++ with light-weight python
99

1010
The package includes dependency-free triangle-mesh extraction from 3D volumes
1111
and segmentation masks, plus Laplacian mesh smoothing, under `bioimage_cpp.mesh`.
12+
It also includes exact masked-grid Dijkstra paths under `bioimage_cpp.distance`
13+
and binary 3D TEASAR skeletonization under `bioimage_cpp.skeleton`.
1214

1315
The `bioimage_cpp` python library can be installed via pip:
1416
```bash
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# Grid Dijkstra performance notes
2+
3+
Baseline measurements and initial optimization recommendations for the
4+
correctness-first masked-grid Dijkstra implementation in
5+
`include/bioimage_cpp/distance/grid_dijkstra.hxx`. These notes cover the public
6+
`dijkstra_distance_field` and `dijkstra_path` primitives independently of
7+
TEASAR. No Dijkstra-specific optimizations had been applied when these numbers
8+
were collected.
9+
10+
## Measurement setup
11+
12+
Measured on 2026-07-14:
13+
14+
- Intel Core i7-1185G7, 4 cores / 8 hardware threads, up to 4.8 GHz.
15+
- Optimized editable build (`-O3` through the normal CMake configuration).
16+
- Single-threaded. The initial Dijkstra implementation has no threaded path;
17+
the FMM comparison used `number_of_threads=1`.
18+
- One warmup followed by three measured calls; the median is the headline
19+
value and all raw measurements are reported below.
20+
- Full connectivity: 8 neighbors in 2D and 26 neighbors in 3D.
21+
- The mask is one connected, nearly dense domain with two walls and alternating
22+
openings. The source and target lie near opposite corners, forcing a long
23+
detour. This is intentionally a difficult field/path workload.
24+
- `physical-field` uses anisotropic spacing. `node-field` uses a smooth,
25+
positive spatial cost field. `early-path` targets the opposite corner.
26+
- The existing first-order fast-marching geodesic field is included as a
27+
performance reference only. It solves a different continuous approximation
28+
and is not a Dijkstra correctness oracle.
29+
30+
Reproduce from the repository root:
31+
32+
```bash
33+
python development/distance/benchmark_dijkstra.py \
34+
--large --repeats 3 --warmup 1 --include-geodesic \
35+
--json /tmp/dijkstra_large.json
36+
```
37+
38+
The benchmark tiers are:
39+
40+
| tier | 2D | 3D |
41+
| --- | --- | --- |
42+
| small | `256²` | `32×96×96` |
43+
| default | `1024²` | `64×160×160` |
44+
| large | `2048²` | `128×256×256` |
45+
46+
## Wall-clock results
47+
48+
Median of three measured calls:
49+
50+
| operation | `2048²` (4.19M foreground) | ns/fg voxel | `128×256×256` (8.32M foreground) | ns/fg voxel |
51+
| --- | ---: | ---: | ---: | ---: |
52+
| physical field | 1.001 s | 238.9 | 10.114 s | 1215.1 |
53+
| field + predecessors | 1.028 s | 245.4 | 10.002 s | 1201.6 |
54+
| node-cost field | 1.020 s | 243.4 | 8.943 s | 1074.5 |
55+
| early-stopping physical path | 0.999 s | 238.3 | 9.968 s | 1197.6 |
56+
| geodesic FMM field | 1.295 s | 309.1 | 5.709 s | 685.9 |
57+
58+
Raw timings in seconds:
59+
60+
| operation | `2048²` | `128×256×256` |
61+
| --- | --- | --- |
62+
| physical field | 1.001, 1.001, 0.996 | 10.114, 10.132, 9.898 |
63+
| field + predecessors | 1.032, 1.023, 1.028 | 10.002, 10.576, 9.962 |
64+
| node-cost field | 1.020, 1.033, 1.004 | 8.883, 8.943, 9.041 |
65+
| early path | 1.018, 0.999, 0.979 | 9.968, 9.746, 10.040 |
66+
| geodesic FMM field | 1.231, 1.295, 1.373 | 5.619, 5.730, 5.709 |
67+
68+
## Findings
69+
70+
1. **The 3D neighbor loop is the main visible scaling problem.** The physical
71+
field takes 239 ns/foreground voxel in 2D and 1215 ns/foreground voxel in
72+
3D, a 5.1× increase per voxel. The neighbor count grows from 8 to 26, and
73+
the 3D case also puts substantially more pressure on the heap and memory
74+
hierarchy.
75+
76+
2. **Dijkstra beats FMM in this 2D case but loses clearly in 3D.** Physical
77+
Dijkstra is 1.29× faster than FMM at `2048²`; FMM is 1.77× faster at
78+
`128×256×256`. This does not make the algorithms interchangeable: only
79+
Dijkstra provides the exact discrete predecessor chain required by TEASAR.
80+
81+
3. **Writing predecessors is not the bottleneck.** The predecessor result adds
82+
about 2.7% in 2D and is within run-to-run noise in 3D. Avoiding predecessor
83+
writes alone will not materially improve the rail-path use case.
84+
85+
4. **Early stopping does not help when the target is far away.** The opposite
86+
target and wall layout require nearly the whole reachable domain to settle,
87+
so `dijkstra_path` costs essentially the same as a full field. Early
88+
stopping should still help for nearby targets and should not be removed.
89+
90+
5. **The node-cost 3D field is about 11.6% faster than the physical field.**
91+
This may reflect a different priority distribution and relaxation pattern;
92+
it is not yet phase-profiled and should not be treated as a general property
93+
of node costs.
94+
95+
6. **The current solver constructs and initializes dense state for every
96+
call.** For `N` voxels, a solve allocates/fills at least the `float64`
97+
distance field, one-byte settled field, and `size_t` dense heap locator.
98+
Paths additionally allocate an `int64` predecessor field and a one-byte
99+
target bitmap. On the measured 64-bit build, the indexed heap stores up to
100+
one 24-byte entry per active key. At 8.4M voxels this makes allocation,
101+
zero/fill bandwidth, and heap locality important even before neighbor
102+
arithmetic is considered.
103+
104+
7. **Weighted calls validate the full cost volume on every invocation.** This
105+
is correct for the public API but redundant for TEASAR, which owns a valid
106+
PDRF field and invokes repeated path solves on it.
107+
108+
8. **Neighbor metadata and boundary work are dynamic.** Every solve rebuilds
109+
the offset vectors and physical lengths. Every settled voxel calls
110+
`valid_offset_target` for each candidate neighbor. The optimized FMM solver
111+
previously gained substantially by decoding a voxel once and replacing
112+
repeated generic offset validation with direct coordinate/bounds tests; the
113+
Dijkstra loop has the same structural opportunity.
114+
115+
## Initial optimization recommendations
116+
117+
Recommendations are ordered by expected value and implementation risk. They
118+
are hypotheses until confirmed by the repository profiler and repeated
119+
benchmarks.
120+
121+
### 1. Profile initialization, heap, and relaxation separately
122+
123+
Add `BIOIMAGE_PROFILE_SCOPE` regions around:
124+
125+
- input/cost validation;
126+
- dense state initialization;
127+
- heap pop;
128+
- coordinate decoding and neighbor relaxation;
129+
- path reconstruction.
130+
131+
Run the large 3D physical and node cases once with `BIOIMAGE_PROFILE=ON`. This
132+
will distinguish memory initialization, heap maintenance, and neighbor
133+
arithmetic before changing data structures.
134+
135+
### 2. Specialize 2D and 3D neighbor traversal
136+
137+
Decode the popped flat index once, then use fixed-size 2D/3D offset tables and
138+
direct bounds tests. Precompute signed linear deltas and physical edge lengths
139+
once in a reusable solver/workspace. Avoid `vector<vector<ptrdiff_t>>` and
140+
generic `valid_offset_target` calls in the inner loop.
141+
142+
This is the safest first kernel optimization and should especially benefit the
143+
26-neighbor 3D case. Preserve lexicographic neighbor ordering and `(distance,
144+
flat_index)` tie-breaking so paths remain deterministic.
145+
146+
### 3. Add a reusable Dijkstra workspace
147+
148+
Follow the existing workspace pattern used by the graph solvers. Reuse:
149+
150+
- distances and predecessors;
151+
- settled/state storage;
152+
- dense heap locator and heap capacity;
153+
- target marks;
154+
- strides and neighbor metadata.
155+
156+
For repeated solves, use touched-index lists or generation counters so reset
157+
cost is proportional to visited voxels rather than the full bounding volume.
158+
Keep the current allocation-owning public functions as convenient wrappers.
159+
160+
Provide a trusted internal entry point for callers such as TEASAR that have
161+
already validated an unchanged mask and cost field. Public Python calls must
162+
continue to perform full validation.
163+
164+
### 4. Benchmark heap alternatives rather than assuming one winner
165+
166+
The addressable dense heap avoids stale entries but requires an `N`-entry
167+
locator and random locator updates on every swap. Compare it against:
168+
169+
- `std::priority_queue` with lazy stale-entry rejection;
170+
- a Dijkstra-specific indexed heap with a smaller entry/priority layout;
171+
- integer/radix or bucket queues only if realistic costs admit them.
172+
173+
The physical and floating node-cost APIs are general, so bucket/radix schemes
174+
cannot replace the binary heap universally. Measure runtime, peak heap size,
175+
and memory before choosing.
176+
177+
### 5. Reduce dense memory traffic
178+
179+
After profiling, consider:
180+
181+
- merging settled/heap state into a compact byte state where practical;
182+
- avoiding a full target bitmap for small target sets via generation marks or
183+
an indexed target lookup;
184+
- using touched lists to initialize only reached foreground;
185+
- compact foreground indexing for very sparse masks.
186+
187+
Compact foreground indexing adds mapping overhead and is unlikely to help the
188+
nearly dense standalone benchmark, but it may be decisive for sparse TEASAR
189+
objects.
190+
191+
### 6. Defer parallel shortest-path algorithms
192+
193+
The current global priority order is inherently serial. Delta stepping or
194+
other parallel variants would be a substantial algorithmic change with harder
195+
determinism and weighted-cost semantics. First exhaust single-threaded
196+
neighbor, workspace, heap, and memory improvements.
197+
198+
## Correctness and acceptance gates
199+
200+
Any optimization should preserve:
201+
202+
- exact fields against the independent Python heap oracle;
203+
- deterministic paths and target tie-breaking;
204+
- predecessor-chain costs;
205+
- all connectivity, anisotropy, zero-cost, and weighted-mode tests;
206+
- public validation and error behavior.
207+
208+
Current full-suite baseline: `1126 passed`. Re-run
209+
`tests/distance/test_grid_dijkstra.py` after every kernel/data-structure change,
210+
then the full suite before accepting benchmark gains.

0 commit comments

Comments
 (0)