Skip to content

Commit 6b1ccd6

Browse files
Skeletonization (#66)
* First implementation of spatial dijkstra and teasar-based skeletonization * Optimize dijkstra implementation * Implement parallek dijkstra * Further optimize skeletonization * Address review findings * Implement multi-component skeletonization * Further optimization of the teasar implementation * Implement skeleton to graph conversion
1 parent 5027243 commit 6b1ccd6

45 files changed

Lines changed: 8922 additions & 362 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ venv/
2828
# Data
2929
*.h5
3030
*.npz
31+
*.mrc
3132

3233
CLAUDE.md
3334
pypi-release.txt
35+
36+
.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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2339,6 +2339,74 @@ 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+
number_of_threads=4,
2359+
)
2360+
2361+
# Flat C-order predecessor indices can be requested with the same solve.
2362+
field, predecessors = bic.distance.dijkstra_distance_field(
2363+
mask, sources, return_predecessors=True
2364+
)
2365+
2366+
# Stop as soon as the cheapest of several targets is settled.
2367+
path = bic.distance.dijkstra_path(mask, source, targets)
2368+
```
2369+
2370+
Three edge-cost conventions are available:
2371+
2372+
- `cost_mode="physical"` assigns every edge the Euclidean length of its
2373+
neighbour offset under `spacing`; `costs` must be omitted.
2374+
- `cost_mode="node"` assigns directed edge `u -> v` the value `costs[v]`;
2375+
`spacing` must be `None`.
2376+
- `cost_mode="node_times_physical"` assigns `costs[v]` times the physical
2377+
neighbour-step length.
2378+
2379+
All costs must be finite and non-negative. Distances are `float64`; background
2380+
and unreachable voxels are `+inf`. Predecessors are `int64` with the mask shape:
2381+
sources point to their own flat C-order index and background/unreachable voxels
2382+
contain `-1`. Paths are `int64` NumPy-axis-order coordinates from the source to
2383+
the selected target. Target ties are deterministic and use flat C-order indices.
2384+
`number_of_threads=1` retains the specialized sequential heaps; `0` uses
2385+
hardware concurrency. Broad multi-source physical fields can use exact FP64
2386+
delta stepping: distance fields are bitwise-identical to the sequential result,
2387+
while an equal-cost predecessor may differ from the sequential heap but remains
2388+
deterministic. Paths, weighted modes, small workloads, and narrow source sets
2389+
retain the faster specialized heaps.
2390+
2391+
This Dijkstra field is deliberately distinct from
2392+
`geodesic_distance_field` below. Dijkstra is exact for the chosen 4/8- or
2393+
6/18/26-neighbour voxel graph, supports zero node costs, and provides an exact
2394+
predecessor chain. Its physical metric is still a discrete chamfer metric: a
2395+
continuous direction must be approximated by the available neighbour steps.
2396+
The geodesic implementation instead solves the continuous Eikonal equation on
2397+
the sampled grid with a first-order Godunov fast-marching scheme. It is smoother
2398+
and less tied to the finite neighbour directions, and its `speed` argument has
2399+
travel-time semantics, but it does not retain discrete predecessors. The two
2400+
are close on well-sampled, uniform domains but are not numerically equivalent;
2401+
TEASAR therefore uses Dijkstra where it must reconstruct and join paths.
2402+
2403+
See `development/distance/benchmark_dijkstra.py` for a standalone benchmark of
2404+
the Dijkstra field, predecessor field, weighted field, and early-stopping path;
2405+
pass `--broad-multisource` to exercise the delta-stepping dispatch.
2406+
The implementation uses reusable fixed neighbor metadata and specialized heap
2407+
strategies internally, but these do not change dtype, tie-breaking, or public
2408+
results.
2409+
23422410
## scikit-fmm
23432411

23442412
`scikit-fmm` computes geodesic distances on regular grids with the fast
@@ -2431,6 +2499,120 @@ Important differences:
24312499
solver it slightly overestimates. See `development/distance/` for the
24322500
reference oracles and `benchmark_geodesic.py` for timings.
24332501

2502+
## kimimaro / TEASAR skeletonization
2503+
2504+
Kimimaro skeletonizes densely labelled volumes and splits labels into connected
2505+
components internally. `bioimage-cpp` exposes the same two useful input
2506+
semantics explicitly: `teasar` treats all nonzero values as one binary class,
2507+
while `teasar_labels` preserves integer semantic-label identities.
2508+
2509+
Kimimaro:
2510+
2511+
```python
2512+
import kimimaro
2513+
2514+
skeletons = kimimaro.skeletonize(
2515+
labels,
2516+
teasar_params={
2517+
"scale": 1.5,
2518+
"const": 0,
2519+
"pdrf_scale": 100000,
2520+
"pdrf_exponent": 4,
2521+
},
2522+
anisotropy=(2.0, 1.0, 1.0),
2523+
)
2524+
```
2525+
2526+
bioimage-cpp:
2527+
2528+
```python
2529+
import bioimage_cpp as bic
2530+
2531+
vertices, edges, radii = bic.skeleton.teasar(
2532+
mask,
2533+
spacing=(2.0, 1.0, 1.0),
2534+
scale=1.5,
2535+
constant=0,
2536+
pdrf_scale=100000,
2537+
pdrf_exponent=4,
2538+
number_of_threads=4,
2539+
)
2540+
2541+
skeletons = bic.skeleton.teasar_labels(
2542+
labels,
2543+
background=0,
2544+
spacing=(2.0, 1.0, 1.0),
2545+
scale=1.5,
2546+
constant=0,
2547+
pdrf_scale=100000,
2548+
pdrf_exponent=4,
2549+
number_of_threads=4,
2550+
)
2551+
# {original_label: (vertices, edges, radii), ...}
2552+
```
2553+
2554+
Convert the topology of either result to the native undirected graph while
2555+
keeping coordinates and radii in their NumPy arrays:
2556+
2557+
```python
2558+
import numpy as np
2559+
2560+
graph = bic.skeleton.skeleton_to_graph(vertices, edges)
2561+
2562+
# Node ids index vertices and radii directly.
2563+
degrees = np.fromiter(
2564+
(len(graph.node_adjacency(node)) for node in range(graph.number_of_nodes)),
2565+
dtype=np.uint64,
2566+
count=graph.number_of_nodes,
2567+
)
2568+
endpoint_vertices = vertices[degrees <= 1]
2569+
branch_vertices = vertices[degrees > 2]
2570+
```
2571+
2572+
The conversion also preserves disconnected forests and isolated vertices. The
2573+
graph stores topology only; it does not copy or own ``vertices`` or ``radii``.
2574+
2575+
Important differences and current scope:
2576+
2577+
- `teasar(mask)` treats every nonzero value as one foreground class. It
2578+
skeletonizes every 26-connected component independently and returns their
2579+
deterministic concatenation as one forest tuple.
2580+
- `teasar_labels(labels)` accepts native-endian `uint8`, `uint16`, `uint32`,
2581+
`uint64`, `int32`, or `int64` arrays. It returns one dictionary entry per
2582+
original non-background label. Disconnected occurrences of one label form
2583+
one forest, while touching distinct labels remain separate.
2584+
- Coordinates follow NumPy axis order. `vertices` is `float64` physical
2585+
`(z, y, x)` coordinates, `edges` is `(E, 2)` `uint64`, and `radii` is
2586+
per-vertex physical distance-to-boundary in `float32`. Empty binary masks
2587+
return typed empty arrays; all-background labeled inputs return `{}`.
2588+
- The implementation follows the core TEASAR loop: a padded exact Euclidean
2589+
distance-to-boundary field, a deterministic two-sweep root, a physical
2590+
Dijkstra distance-from-root field, penalized repeated Dijkstra paths, and
2591+
rolling invalidation with radius `scale * radius + constant`.
2592+
- This first correctness-oriented version uses a physical axis-aligned
2593+
invalidation cube. It does not implement kimimaro's soma handling, hole
2594+
filling, border stitching, manual targets, component dust filtering,
2595+
cross-section metadata, or postprocessing heuristics. These differences can
2596+
change branch positions and vertex counts, so output is not expected to be
2597+
vertex-for-vertex identical to kimimaro.
2598+
- The C++ core remains dependency-free. Component discovery uses x-runs and a
2599+
union-find rather than dense component-label images. `number_of_threads=1`
2600+
is the default and `0` uses hardware concurrency; one shared budget covers
2601+
component fan-out and each component's exact distance transform without
2602+
nested oversubscription.
2603+
- Compact-foreground root sweeps and rails remain ordered on the optimized
2604+
heap. Compact IDs avoid full-volume shortest-path fields; the public Dijkstra
2605+
functions remain dense and exact FP64.
2606+
2607+
Correctness tests are under `tests/skeleton/test_teasar.py` and
2608+
`tests/skeleton/test_teasar_labels.py`. The independent
2609+
Dijkstra benchmark is `development/distance/benchmark_dijkstra.py`; the
2610+
end-to-end benchmark is `development/skeleton/benchmark_teasar.py`. Use
2611+
`--suite all --kimimaro` to compare paired binary and multi-label scenarios,
2612+
`--memory` for fresh-process peak-RSS probes, or `--sequential-backends` for
2613+
the dense/compact FP64 binary design matrix. Extended density, spacing, and
2614+
PDRF regimes are in `development/skeleton/benchmark_teasar_sequential.py`.
2615+
24342616
## I/O and Build Dependencies
24352617

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

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ 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-forest plus semantic multi-label 3D TEASAR skeletonization under
14+
`bioimage_cpp.skeleton`.
1215

1316
The `bioimage_cpp` python library can be installed via pip:
1417
```bash

0 commit comments

Comments
 (0)