Skip to content

Commit 4b4680d

Browse files
Implement multi-component skeletonization
1 parent b980b74 commit 4b4680d

11 files changed

Lines changed: 2230 additions & 263 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2502,8 +2502,9 @@ Important differences:
25022502
## kimimaro / TEASAR skeletonization
25032503

25042504
Kimimaro skeletonizes densely labelled volumes and splits labels into connected
2505-
components internally. The first `bioimage-cpp` TEASAR entry point is narrower:
2506-
it accepts one binary 3D object and returns its graph directly.
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.
25072508

25082509
Kimimaro:
25092510

@@ -2536,17 +2537,33 @@ vertices, edges, radii = bic.skeleton.teasar(
25362537
pdrf_exponent=4,
25372538
number_of_threads=4,
25382539
)
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), ...}
25392552
```
25402553

25412554
Important differences and current scope:
25422555

2543-
- `mask` is binary (nonzero means foreground), must be three-dimensional, and
2544-
a nonempty mask must contain exactly one 26-connected component. Component
2545-
splitting and multi-label dispatch are intentionally deferred.
2556+
- `teasar(mask)` treats every nonzero value as one foreground class. It
2557+
skeletonizes every 26-connected component independently and returns their
2558+
deterministic concatenation as one forest tuple.
2559+
- `teasar_labels(labels)` accepts native-endian `uint8`, `uint16`, `uint32`,
2560+
`uint64`, `int32`, or `int64` arrays. It returns one dictionary entry per
2561+
original non-background label. Disconnected occurrences of one label form
2562+
one forest, while touching distinct labels remain separate.
25462563
- Coordinates follow NumPy axis order. `vertices` is `float64` physical
25472564
`(z, y, x)` coordinates, `edges` is `(E, 2)` `uint64`, and `radii` is
2548-
per-vertex physical distance-to-boundary in `float32`. The graph is a
2549-
deterministic tree; an empty mask returns typed empty arrays.
2565+
per-vertex physical distance-to-boundary in `float32`. Empty binary masks
2566+
return typed empty arrays; all-background labeled inputs return `{}`.
25502567
- The implementation follows the core TEASAR loop: a padded exact Euclidean
25512568
distance-to-boundary field, a deterministic two-sweep root, a physical
25522569
Dijkstra distance-from-root field, penalized repeated Dijkstra paths, and
@@ -2557,21 +2574,23 @@ Important differences and current scope:
25572574
cross-section metadata, or postprocessing heuristics. These differences can
25582575
change branch positions and vertex counts, so output is not expected to be
25592576
vertex-for-vertex identical to kimimaro.
2560-
- The C++ core remains dependency-free. `number_of_threads=1` is the default;
2561-
`0` uses hardware concurrency. The thread budget controls the exact distance
2562-
transform. Compact-foreground root sweeps and rails remain ordered on the
2563-
optimized heap: threshold benchmarks showed that staged delta stepping nearly
2564-
doubled runtime for large solid objects. Compact IDs avoid full-volume
2565-
shortest-path fields. The public Dijkstra functions remain dense and exact
2566-
FP64, with delta stepping reserved for broad physical multi-source fields.
2567-
2568-
Correctness tests are under `tests/skeleton/test_teasar.py`. The independent
2577+
- The C++ core remains dependency-free. Component discovery uses x-runs and a
2578+
union-find rather than dense component-label images. `number_of_threads=1`
2579+
is the default and `0` uses hardware concurrency; one shared budget covers
2580+
component fan-out and each component's exact distance transform without
2581+
nested oversubscription.
2582+
- Compact-foreground root sweeps and rails remain ordered on the optimized
2583+
heap. Compact IDs avoid full-volume shortest-path fields; the public Dijkstra
2584+
functions remain dense and exact FP64.
2585+
2586+
Correctness tests are under `tests/skeleton/test_teasar.py` and
2587+
`tests/skeleton/test_teasar_labels.py`. The independent
25692588
Dijkstra benchmark is `development/distance/benchmark_dijkstra.py`; the
2570-
end-to-end synthetic branching-tube benchmark is
2571-
`development/skeleton/benchmark_teasar.py` and can optionally add kimimaro with
2572-
`--kimimaro` when it is installed. Pass `--sequential-backends` to reproduce
2573-
the dense/compact FP64 design matrix; extended density, spacing, and PDRF
2574-
regimes are in `development/skeleton/benchmark_teasar_sequential.py`.
2589+
end-to-end benchmark is `development/skeleton/benchmark_teasar.py`. Use
2590+
`--suite all --kimimaro` to compare paired binary and multi-label scenarios,
2591+
`--memory` for fresh-process peak-RSS probes, or `--sequential-backends` for
2592+
the dense/compact FP64 binary design matrix. Extended density, spacing, and
2593+
PDRF regimes are in `development/skeleton/benchmark_teasar_sequential.py`.
25752594

25762595
## I/O and Build Dependencies
25772596

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Image processing and segmentation functionality in C++ with light-weight python
1010
The package includes dependency-free triangle-mesh extraction from 3D volumes
1111
and segmentation masks, plus Laplacian mesh smoothing, under `bioimage_cpp.mesh`.
1212
It also includes exact masked-grid Dijkstra paths under `bioimage_cpp.distance`
13-
and binary 3D TEASAR skeletonization under `bioimage_cpp.skeleton`.
13+
and binary-forest plus semantic multi-label 3D TEASAR skeletonization under
14+
`bioimage_cpp.skeleton`.
1415

1516
The `bioimage_cpp` python library can be installed via pip:
1617
```bash

development/skeleton/PERFORMANCE_NOTES.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,3 +513,95 @@ The following proposed optimizations were deliberately deferred:
513513
calculation was shared.
514514
- Candidate-target and state-reset changes are therefore benchmark follow-ups,
515515
not pending correctness fixes.
516+
517+
## Multi-component and multi-label dispatch
518+
519+
The run-based dispatcher and `teasar_labels` implementation were measured on
520+
2026-07-15 on the same 4-core / 8-hardware-thread Intel Core i7-1185G7 host.
521+
The normal optimized build used Python 3.13.13, NumPy 2.4.6, kimimaro 5.8.1,
522+
and EDT 3.1.1. Every row used one warmup, five measured calls, deterministic
523+
backend-order shuffling, spacing `(1.5, 1.0, 1.0)`, and the established TEASAR
524+
parameters. Kimimaro's soma, border, hole-filling, dust, and progress features
525+
were disabled as in the earlier comparisons.
526+
527+
Reproduce the complete matrix and fresh-process memory probes with:
528+
529+
```bash
530+
python development/skeleton/benchmark_teasar.py \
531+
--large --suite all --kimimaro --threads 1 2 4 8 \
532+
--repeats 5 --warmup 1 --memory --memory-threads 1 4 \
533+
--json /tmp/teasar_multilabel_full.json
534+
```
535+
536+
The table reports five-repeat medians. `loop` is the deliberately naive
537+
`np.unique` plus one full-volume `labels == L` and binary TEASAR call per label.
538+
539+
| scenario | case | components / labels | bio t1 | bio t4 | loop t1 | kimi t1 | kimi t4 |
540+
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
541+
| binary | branching tube `128³` | 1 / 1 | 35.45 ms | 23.52 ms | -- | 132.00 ms | 154.82 ms |
542+
| binary | branching tube `192³` | 1 / 1 | 109.90 ms | 71.65 ms | -- | 350.35 ms | 370.80 ms |
543+
| binary | branching tube `256³` | 1 / 1 | 272.59 ms | 179.95 ms | -- | 903.28 ms | 846.81 ms |
544+
| binary | packed tubes | 27 / 1 | 52.84 ms | 17.70 ms | -- | 203.60 ms | 177.64 ms |
545+
| labels | packed distinct | 27 / 27 | 53.20 ms | 18.47 ms | 264.04 ms | 206.36 ms | 199.80 ms |
546+
| labels | packed fragmented | 27 / 1 | 53.34 ms | 17.86 ms | 108.68 ms | 202.99 ms | 201.72 ms |
547+
| labels | imbalanced | 65 / 65 | 39.65 ms | 40.29 ms | 749.70 ms | 309.99 ms | 347.58 ms |
548+
| labels | dense balls | 8 / 8 | 49.01 ms | 14.17 ms | 69.15 ms | 166.97 ms | 130.41 ms |
549+
550+
The packed binary and 27-label inputs contain exactly the same 33,426
551+
foreground voxels and produced array-exact forests after canonical grouping.
552+
Their t1 medians differ by less than 1%, so preserving semantic labels adds no
553+
material cost beyond equality-aware run scanning. The balanced label case
554+
scales 2.88x from one to four workers and the dense case scales 3.46x. The
555+
imbalanced case does not scale because one large component dominates the 64
556+
three-voxel tasks; the dynamic cursor avoids a regression through four workers,
557+
but eight workers add scheduling overhead.
558+
559+
Native dispatch is 4.96x faster than the repeated-volume loop on the packed
560+
27-label case and 18.9x faster on the 65-label imbalanced case at one worker.
561+
The connected binary medians are also below the pre-dispatch measurements
562+
recorded above, clearing the no-regression gate. Bioimage-cpp is 3.4--7.8x
563+
faster than kimimaro at one worker across the headline multi-label cases and
564+
8.6--11.3x faster at four workers.
565+
566+
### Worst-case runs and memory
567+
568+
The explicit run-count stress case is a `48³` alternating volume: 110,592
569+
voxels, 110,592 x-runs, two semantic labels, and two 26-connected components.
570+
Three-repeat medians were 750.25 ms (t1) and 381.23 ms (t4) for bioimage-cpp,
571+
versus 1.673 s and 937.81 ms for kimimaro. It completed without a dense
572+
component-label fallback.
573+
574+
Linux memory probes start each backend in a fresh worker and sample aggregate
575+
`/proc` RSS for the worker and all descendants; the worker's own `ru_maxrss`
576+
delta covers native calls shorter than the sampling interval. Incremental
577+
process-tree peaks were:
578+
579+
| case | bio t1 / t4 | kimimaro t1 / t4 |
580+
| --- | ---: | ---: |
581+
| binary tube `256³` | 64.2 / 61.8 MiB | 182.0 / 751.7 MiB |
582+
| packed distinct labels | 0.0 / 2.7 MiB | 37.9 / 655.8 MiB |
583+
| packed fragmented label | 0.2 / 2.8 MiB | 37.2 / 654.7 MiB |
584+
| imbalanced labels | 5.6 / 5.6 MiB | 61.0 / 765.9 MiB |
585+
| alternating labels | 3.7 / 7.6 MiB | 28.4 / 429.5 MiB |
586+
587+
A zero incremental value means the short native call stayed below the
588+
post-import/input high-water mark; it does not mean zero allocations. The
589+
important structural check is also enforced in code: component discovery owns
590+
only run metadata, row offsets, union-find state, and concurrently active tight
591+
crops, with no full-volume `uint32`/`uint64` component image.
592+
593+
### Dispatcher profile
594+
595+
A profile build on the 27-label packed case at one worker reported:
596+
597+
| phase | time | share |
598+
| --- | ---: | ---: |
599+
| component scan | 4.6 ms | 8.7% |
600+
| run union | 0.2 ms | 0.4% |
601+
| descriptors | 0.2 ms | 0.4% |
602+
| component TEASAR | 48.0 ms | 90.4% |
603+
| forest assembly | <0.1 ms | <0.1% |
604+
605+
The normal optimized build was restored after profiling. Skeleton outputs were
606+
exact across worker counts `1, 2, 4, 8`, and the benchmark validates `E = V-C`,
607+
semantic keys, paired binary/label parity, and raw samples before writing JSON.

0 commit comments

Comments
 (0)