Skip to content

Commit fdcf071

Browse files
Add support for geodesic distances and bump version (#59)
* Implement scaffold for geodesic distance functions * Optimize geodesic distance implementations * Add example scripts for geodesic distances * Bump version
1 parent 42650ae commit fdcf071

22 files changed

Lines changed: 2738 additions & 4 deletions

MIGRATION_GUIDE.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,98 @@ Important differences:
21882188
pairwise distance matrix; threshold the distance map first to keep the
21892189
candidate count modest.
21902190

2191+
## scikit-fmm
2192+
2193+
`scikit-fmm` computes geodesic distances on regular grids with the fast
2194+
marching method. `bioimage-cpp` groups geodesic distances under `bic.distance`
2195+
with two operations — a **distance field** to a set of sources, and the full
2196+
**pairwise distance matrix** between a set of points — for two geometry types:
2197+
regular-grid **masks** (the scikit-fmm equivalent) and triangle **meshes**
2198+
(which scikit-fmm does not support; the reference for those is the exact MMP
2199+
algorithm in `pygeodesic`).
2200+
2201+
Both are solved with a first-order fast marching method (Godunov/Eikonal on the
2202+
grid, Kimmel–Sethian on triangles). Masks match scikit-fmm's `order=1` scheme;
2203+
mesh distances are first-order approximations of the exact surface geodesics.
2204+
2205+
scikit-fmm:
2206+
2207+
```python
2208+
import numpy as np
2209+
import skfmm
2210+
2211+
# Distance from a set of seed voxels, constrained to a mask (domain).
2212+
phi = np.ones(mask.shape)
2213+
phi[tuple(sources.T)] = -1 # zero contour marks the seeds
2214+
phi = np.ma.MaskedArray(phi, ~mask) # obstacles / outside-domain
2215+
field = np.abs(skfmm.distance(phi, dx=sampling))
2216+
# Weighted (travel-time) variant:
2217+
tt = skfmm.travel_time(phi, speed, dx=sampling)
2218+
```
2219+
2220+
bioimage-cpp:
2221+
2222+
```python
2223+
import bioimage_cpp as bic
2224+
2225+
# masks (regular grid); sources/points are (n, ndim) int64 voxel coordinates
2226+
field = bic.distance.geodesic_distance_field(mask, sources, sampling=None,
2227+
speed=None) # -> mask.shape, float64
2228+
matrix = bic.distance.geodesic_distances(mask, points) # -> (N, N) float64
2229+
2230+
# optional per-axis gradient of the field (like vector_difference_transform)
2231+
field, grad = bic.distance.geodesic_distance_field(mask, sources,
2232+
return_gradient=True)
2233+
# grad: (*mask.shape, ndim) float32, d(field)/d(axis), points away from source
2234+
gradient = bic.distance.geodesic_gradient_field(mask, sources) # just the gradient
2235+
2236+
# surfaces (triangle mesh); sources/points are 1-D int64 vertex indices
2237+
field = bic.distance.geodesic_distance_field_mesh(vertices, faces, sources,
2238+
speed=None) # -> (n_vertices,) float64
2239+
matrix = bic.distance.geodesic_distances_mesh(vertices, faces, points) # -> (N, N) float64
2240+
```
2241+
2242+
Name mapping:
2243+
2244+
| scikit-fmm / pygeodesic | bioimage-cpp name |
2245+
| --- | --- |
2246+
| `skfmm.distance` / `skfmm.travel_time` (from seed voxels, masked) | `geodesic_distance_field` |
2247+
| gradient of the distance field (per-axis) | `geodesic_distance_field(..., return_gradient=True)` / `geodesic_gradient_field` |
2248+
| pairwise via repeated `skfmm.distance` | `geodesic_distances` |
2249+
| `pygeodesic … geodesicDistances(sources, None)` | `geodesic_distance_field_mesh` |
2250+
| `pygeodesic … geodesicDistances` (pairwise) | `geodesic_distances_mesh` |
2251+
2252+
Important differences:
2253+
2254+
- Explicit, geometry-specific functions rather than a level-set encoding: the
2255+
domain is passed directly as a `mask` (nonzero = inside) or as a
2256+
`(vertices, faces)` mesh, and the sources are given as coordinates / vertex
2257+
indices instead of being baked into a signed `phi`.
2258+
- Outputs are `float64`. Voxels outside the mask and points/vertices
2259+
unreachable from any source are `+inf`; pairwise matrices are symmetric with
2260+
a zero diagonal.
2261+
- `sampling` (per-axis voxel spacing, scalar or per-axis) maps to scikit-fmm's
2262+
`dx` and applies to masks only — meshes carry real vertex coordinates.
2263+
- `speed` is optional (`None` = unit-speed geodesic distance). When supplied it
2264+
generalizes to a weighted travel time, matching `skfmm.travel_time`; for
2265+
masks it has the mask's shape, for meshes it is per-vertex.
2266+
- `number_of_threads` follows the `bic.distance` convention (`1` default,
2267+
`0` = hardware concurrency); the pairwise solves parallelize over sources.
2268+
- `geodesic_distance_field(..., return_gradient=True)` also returns the per-axis
2269+
gradient `∇T` of the field (or use `geodesic_gradient_field` for the gradient
2270+
alone), analogous to `vector_difference_transform`. It is `float32` with shape
2271+
`(*mask.shape, ndim)` (channel-last, NumPy axis order); component `i` is
2272+
`d(field)/d(axis_i)`, pointing **away** from the nearest source with
2273+
`‖∇T‖ ≈ 1/speed`. Negate it to trace back toward the source — e.g. feed
2274+
`-grad` (transposed to channel-first) to `bic.flow.compute_flow_density`. It
2275+
is zero at sources, background, and unreachable voxels. Masks only.
2276+
- Mesh geodesics are surface (2-manifold) distances from the Kimmel–Sethian
2277+
triangle fast-marching method — a first-order approximation (a few % mean
2278+
error vs the exact `pygeodesic` MMP algorithm, larger on very obtuse
2279+
triangles, since obtuse-angle unfolding is not done yet). Like the mask
2280+
solver it slightly overestimates. See `development/distance/` for the
2281+
reference oracles and `benchmark_geodesic.py` for timings.
2282+
21912283
## I/O and Build Dependencies
21922284

21932285
`bioimage-cpp` intentionally does not replace nifty or affogato I/O helpers.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Geodesic FMM performance notes
2+
3+
Optimization of the two geodesic Fast Marching Method solvers, with the
4+
before/after measurements that justify the changes. Both changes are pure
5+
reorganizations of identical arithmetic — the solved fields are **bitwise
6+
identical** to the pre-optimization solvers on every code path tested.
7+
8+
## What changed
9+
10+
- **Lead A — mask/grid solver** (`distance/detail/fast_marching.hxx`): the FMM
11+
march no longer calls `detail::valid_offset_target` (one integer division +
12+
modulo *per axis, per call*, ~42 calls / popped voxel in 3D). Each popped voxel
13+
is decoded **once** with the new `detail::coords_from_index` (divide-and-subtract,
14+
no modulo); axis neighbours are reached with a single `p ± strides_[d]` add and an
15+
integer bounds compare, and the neighbour's coordinates are handed to
16+
`solve_eikonal` so it never re-decodes. The `offsets_` `vector<vector>` is gone.
17+
- **Lead B — mesh solver** (`distance/detail/mesh_fast_marching.hxx`): when a
18+
vertex `v` freezes, each incident face relaxes its other corners from *that face
19+
alone* instead of calling `update_vertex`, which rescanned every incident face of
20+
every touched vertex. `dist_[w]` is a running minimum that already reflects the
21+
other faces' contributions (applied when their corners froze), so the single-face
22+
update reaches the same value. `update_vertex` was removed. Operands are passed to
23+
`triangle_update` in face-storage order so the acute update is bit-identical, not
24+
just equal to within rounding.
25+
26+
## Measurement setup
27+
28+
8-core machine, `powersave` governor (frequency-scaling noise present; `perf`
29+
unavailable, `perf_event_paranoid=4`). Mitigations: 1 warmup call + 20 timed
30+
repeats, **min** used as the headline estimator (least-interference), spread
31+
reported. Single-source **field** solve, `--threads 1` — the single-source solve is
32+
the whole kernel (pairwise = N × field). Baseline and optimized were built
33+
separately; the **`mesh/field` case is a null control for Lead A** (its code is
34+
untouched between the baseline and A-only builds) and moved <1%, bounding machine
35+
drift below the measured mask gains. Lead A's mask numbers also reproduced within
36+
~1% across two independent builds (A-only and A+B).
37+
38+
Reproduce (from `development/distance/`):
39+
40+
```
41+
python benchmark_geodesic.py --large --only field --threads 1 --repeats 20 --no-ref --json base.json
42+
python benchmark_geodesic.py --xlarge --only field --threads 1 --repeats 20 --no-ref --json opt.json
43+
python /path/to/compare.py base.json opt.json # or diff the JSON directly
44+
```
45+
46+
## Wall-clock: baseline → optimized (single-thread field, min of 20)
47+
48+
| case | baseline min | optimized min | speedup |
49+
|---|---|---|---|
50+
| mask2d/field (1024²) | 294.0 ms | 208.9 ms | **1.41×** |
51+
| mask2d/field (1536²) | 685.6 ms | 492.7 ms | **1.39×** |
52+
| mask3d/field (64·128²) | 745.1 ms | 370.2 ms | **2.01×** |
53+
| mask3d/field (128³) | 1811.6 ms | 912.4 ms | **1.99×** |
54+
| mesh/field (V=20000) | 23.2 ms | 7.9 ms | **2.94×** |
55+
| mesh/field (V=40000) | 49.1 ms | 17.5 ms | **2.81×** |
56+
57+
Lead A: ~2.0× in 3D (matches the div/mod arithmetic dominating 3 axes), ~1.4× in
58+
2D. Lead B: ~2.8–2.9× on the mesh. Both exceed the pre-work predictions (A: 1.5–2×
59+
3D; B: ~2×).
60+
61+
## Mechanism confirmation (BIOIMAGE_PROFILE=ON, one field solve)
62+
63+
Coarse phases in `solve()`: `pop` (heap pop, untouched by either change) vs `relax`
64+
(the neighbour/face loop where the removed work lived).
65+
66+
| solve | phase | baseline | optimized | change |
67+
|---|---|---|---|---|
68+
| mask 128³ | pop | 0.319 s | 0.316 s | unchanged |
69+
| mask 128³ | relax | 1.700 s | 0.762 s | **2.23× smaller** |
70+
| mesh 40k | pop | 0.0035 s | 0.0034 s | unchanged |
71+
| mesh 40k | relax | 0.0500 s | 0.0163 s | **3.07× smaller** |
72+
73+
The heap phase is invariant; the targeted `relax` phase shrank by 2.2× (mask) and
74+
3.1× (mesh). `pop`'s *share* rose (15.8%→29.3% mask; 6.5%→17.1% mesh) only because
75+
`relax` shrank around it — the gain came from exactly the phase each change targeted.
76+
77+
## Versus reference (single-thread field, min of 10)
78+
79+
| case | bioimage-cpp | reference | speedup |
80+
|---|---|---|---|
81+
| mask2d/field (1024²) | 210.5 ms | scikit-fmm 331.8 ms | **1.58×** |
82+
| mask3d/field (64·128²) | 368.9 ms | scikit-fmm 761.4 ms | **2.06×** |
83+
| mesh/field (V=20000) | 8.4 ms | pygeodesic 1745 ms | 208× (apples-to-oranges: pygeodesic is *exact* MMP, ours is first-order) |
84+
85+
The mask solver went from parity with scikit-fmm's compiled C (pre-work ~1.0–1.1×)
86+
to a clear lead (1.58× in 2D, 2.06× in 3D).
87+
88+
## Correctness (all green on the shipping build)
89+
90+
- `pytest tests/distance/ -q` — 64 passed. The tight external cross-checks ran
91+
(not skipped): `test_mask_matches_scikit_fmm` (residual < 1e-6),
92+
`test_mesh_matches_pygeodesic` (mean rel < 0.06, p95 < 0.12).
93+
- `python check_geodesic_distance.py` — all 6 cases OK, exit 0 (mask residuals
94+
~1e-12 vs scikit-fmm; mesh rel mean 0.027).
95+
- **Bitwise equivalence**: 13 solver outputs (2D/3D field, anisotropic `sampling`,
96+
`speed`, gradient, mask + mesh pairwise, mesh `speed`) are exactly equal
97+
(`np.array_equal`) between the baseline and optimized builds. Determinism verified
98+
(repeat solves identical). This is the dev-time check behind the "bitwise
99+
identical" claim; the pytest suite guards correctness against the external
100+
references on every run.
101+
102+
The profiler instrumentation (`BIOIMAGE_PROFILE_SCOPE` "pop"/"relax") is left in both
103+
solvers; it is a no-op unless built with `-C cmake.define.BIOIMAGE_PROFILE=ON`.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Reference geodesic-distance implementations for development cross-checks.
2+
3+
These are the oracles that ``bic.distance`` geodesic functions are validated
4+
against. There is one backend per geometry, because scikit-fmm only supports
5+
regular Cartesian grids:
6+
7+
- **masks** -> scikit-fmm (``skfmm``): fast marching / Eikonal solver.
8+
- **meshes** -> pygeodesic: exact Mitchell-Mount-Papadimitriou (MMP) geodesics.
9+
10+
Not part of the pytest suite; requires ``scikit-fmm`` and/or ``pygeodesic``.
11+
Importing this module is cheap (numpy only); each function imports its backend
12+
lazily and raises a clear ``ImportError`` if the backend is missing.
13+
14+
Note on the scikit-fmm point-source idiom: to get the distance to a set of
15+
seed voxels, we set ``phi = +1`` everywhere and ``phi = -1`` at the seeds and
16+
take ``|skfmm.distance(phi)|``. The zero contour then sits roughly half a cell
17+
away from each seed, so the reference is offset from a "distance is exactly 0 at
18+
the seed" convention by ~0.5 * dx near the sources. Compare with a tolerance,
19+
or exclude the immediate neighbourhood of the seeds, when checking against an
20+
implementation that initialises the seeds to exactly 0.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import numpy as np
26+
27+
28+
# --------------------------------------------------------------------------- #
29+
# masks: scikit-fmm
30+
# --------------------------------------------------------------------------- #
31+
32+
33+
def _skfmm():
34+
try:
35+
import skfmm
36+
except ImportError as error: # pragma: no cover - dev script
37+
raise ImportError(
38+
"scikit-fmm is required for the mask geodesic reference "
39+
"(`pip install scikit-fmm`)"
40+
) from error
41+
return skfmm
42+
43+
44+
def _normalize_dx(sampling, ndim):
45+
if sampling is None:
46+
return 1.0
47+
if np.isscalar(sampling):
48+
return float(sampling)
49+
dx = [float(s) for s in sampling]
50+
if len(dx) != ndim:
51+
raise ValueError(f"sampling must have length {ndim}, got {len(dx)}")
52+
return dx
53+
54+
55+
def reference_geodesic_field_mask(mask, sources, sampling=None, speed=None):
56+
"""Geodesic distance field within a mask from a set of source coordinates.
57+
58+
Mirrors ``bic.distance.geodesic_distance_field``: for every voxel, the
59+
shortest-path distance to the nearest source, constrained to the nonzero
60+
region of ``mask``. Voxels outside the mask (and voxels unreachable from any
61+
source) are returned as ``+inf``.
62+
"""
63+
skfmm = _skfmm()
64+
mask = np.ascontiguousarray(mask) != 0
65+
sources = np.atleast_2d(np.asarray(sources, dtype=np.int64))
66+
dx = _normalize_dx(sampling, mask.ndim)
67+
68+
phi = np.ones(mask.shape, dtype=np.float64)
69+
phi[tuple(sources.T)] = -1.0
70+
phi = np.ma.MaskedArray(phi, mask=~mask)
71+
72+
# order=1 matches bioimage-cpp's first-order Godunov scheme (so agreement is
73+
# limited only by the ~0.5-cell seed offset documented above, not by the
74+
# stencil order).
75+
if speed is None:
76+
field = np.ma.abs(skfmm.distance(phi, dx=dx, order=1))
77+
else:
78+
speed = np.ascontiguousarray(speed, dtype=np.float64)
79+
field = skfmm.travel_time(phi, speed, dx=dx, order=1)
80+
81+
out = np.full(mask.shape, np.inf, dtype=np.float64)
82+
field_data = np.ma.getdata(field)
83+
valid = ~np.ma.getmaskarray(field)
84+
out[valid] = field_data[valid]
85+
return out
86+
87+
88+
def reference_geodesic_distances_mask(mask, points, sampling=None, speed=None):
89+
"""Full pairwise geodesic distance matrix between points within a mask.
90+
91+
Runs the field solve once per point and reads the field at the other
92+
points. The result is symmetrized (per-source solves can differ by a tiny
93+
numerical amount) with a zero diagonal.
94+
"""
95+
points = np.atleast_2d(np.asarray(points, dtype=np.int64))
96+
n = len(points)
97+
out = np.full((n, n), np.inf, dtype=np.float64)
98+
index = tuple(points.T)
99+
for i in range(n):
100+
field = reference_geodesic_field_mask(mask, points[i][None, :], sampling, speed)
101+
out[i] = field[index]
102+
out = 0.5 * (out + out.T)
103+
np.fill_diagonal(out, 0.0)
104+
return out
105+
106+
107+
# --------------------------------------------------------------------------- #
108+
# meshes: pygeodesic (exact MMP)
109+
# --------------------------------------------------------------------------- #
110+
111+
112+
def _pygeodesic():
113+
try:
114+
import pygeodesic.geodesic as geodesic
115+
except ImportError as error: # pragma: no cover - dev script
116+
raise ImportError(
117+
"pygeodesic is required for the mesh geodesic reference "
118+
"(`pip install pygeodesic`)"
119+
) from error
120+
return geodesic
121+
122+
123+
def _mesh_algorithm(vertices, faces):
124+
geodesic = _pygeodesic()
125+
vertices = np.ascontiguousarray(vertices, dtype=np.float64)
126+
faces = np.ascontiguousarray(faces, dtype=np.int32)
127+
return geodesic.PyGeodesicAlgorithmExact(vertices, faces)
128+
129+
130+
def reference_geodesic_field_mesh(vertices, faces, sources):
131+
"""Geodesic distance field on a triangle mesh from a set of source vertices.
132+
133+
Mirrors ``bic.distance.geodesic_distance_field_mesh``. Returns a
134+
``(n_vertices,)`` array of surface geodesic distance to the nearest source.
135+
"""
136+
algo = _mesh_algorithm(vertices, faces)
137+
sources = np.atleast_1d(np.asarray(sources, dtype=np.int32))
138+
distances, _ = algo.geodesicDistances(sources, None)
139+
return np.asarray(distances, dtype=np.float64)
140+
141+
142+
def reference_geodesic_distances_mesh(vertices, faces, points):
143+
"""Full pairwise geodesic distance matrix between mesh vertices."""
144+
algo = _mesh_algorithm(vertices, faces)
145+
points = np.atleast_1d(np.asarray(points, dtype=np.int32))
146+
n = len(points)
147+
out = np.full((n, n), np.inf, dtype=np.float64)
148+
for i in range(n):
149+
# Read the full single-source field and index the targets; passing an
150+
# explicit target array to pygeodesic can overflow on some meshes.
151+
distances, _ = algo.geodesicDistances(points[i : i + 1], None)
152+
out[i] = np.asarray(distances, dtype=np.float64)[points]
153+
out = 0.5 * (out + out.T)
154+
np.fill_diagonal(out, 0.0)
155+
return out

0 commit comments

Comments
 (0)