Skip to content

Commit 45a7c9f

Browse files
Implement scaffold for geodesic distance functions
1 parent 42650ae commit 45a7c9f

10 files changed

Lines changed: 1106 additions & 3 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,81 @@ 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+
> **Status:** the C++ solvers are not implemented yet. The functions below are
2202+
> present and validate their arguments, but calling a well-formed input
2203+
> currently raises `RuntimeError("... not yet implemented")`. This section
2204+
> documents the intended API and behaviour.
2205+
2206+
scikit-fmm:
2207+
2208+
```python
2209+
import numpy as np
2210+
import skfmm
2211+
2212+
# Distance from a set of seed voxels, constrained to a mask (domain).
2213+
phi = np.ones(mask.shape)
2214+
phi[tuple(sources.T)] = -1 # zero contour marks the seeds
2215+
phi = np.ma.MaskedArray(phi, ~mask) # obstacles / outside-domain
2216+
field = np.abs(skfmm.distance(phi, dx=sampling))
2217+
# Weighted (travel-time) variant:
2218+
tt = skfmm.travel_time(phi, speed, dx=sampling)
2219+
```
2220+
2221+
bioimage-cpp:
2222+
2223+
```python
2224+
import bioimage_cpp as bic
2225+
2226+
# masks (regular grid); sources/points are (n, ndim) int64 voxel coordinates
2227+
field = bic.distance.geodesic_distance_field(mask, sources, sampling=None,
2228+
speed=None) # -> mask.shape, float64
2229+
matrix = bic.distance.geodesic_distances(mask, points) # -> (N, N) float64
2230+
2231+
# surfaces (triangle mesh); sources/points are 1-D int64 vertex indices
2232+
field = bic.distance.geodesic_distance_field_mesh(vertices, faces, sources,
2233+
speed=None) # -> (n_vertices,) float64
2234+
matrix = bic.distance.geodesic_distances_mesh(vertices, faces, points) # -> (N, N) float64
2235+
```
2236+
2237+
Name mapping:
2238+
2239+
| scikit-fmm / pygeodesic | bioimage-cpp name |
2240+
| --- | --- |
2241+
| `skfmm.distance` / `skfmm.travel_time` (from seed voxels, masked) | `geodesic_distance_field` |
2242+
| pairwise via repeated `skfmm.distance` | `geodesic_distances` |
2243+
| `pygeodesic … geodesicDistances(sources, None)` | `geodesic_distance_field_mesh` |
2244+
| `pygeodesic … geodesicDistances` (pairwise) | `geodesic_distances_mesh` |
2245+
2246+
Important differences:
2247+
2248+
- Explicit, geometry-specific functions rather than a level-set encoding: the
2249+
domain is passed directly as a `mask` (nonzero = inside) or as a
2250+
`(vertices, faces)` mesh, and the sources are given as coordinates / vertex
2251+
indices instead of being baked into a signed `phi`.
2252+
- Outputs are `float64`. Voxels outside the mask and points/vertices
2253+
unreachable from any source are `+inf`; pairwise matrices are symmetric with
2254+
a zero diagonal.
2255+
- `sampling` (per-axis voxel spacing, scalar or per-axis) maps to scikit-fmm's
2256+
`dx` and applies to masks only — meshes carry real vertex coordinates.
2257+
- `speed` is optional (`None` = unit-speed geodesic distance). When supplied it
2258+
generalizes to a weighted travel time, matching `skfmm.travel_time`; for
2259+
masks it has the mask's shape, for meshes it is per-vertex.
2260+
- `number_of_threads` follows the `bic.distance` convention (`1` default,
2261+
`0` = hardware concurrency); the pairwise solves parallelize over sources.
2262+
- Mesh geodesics are true surface (2-manifold) distances, validated against the
2263+
exact MMP algorithm in `pygeodesic`, not the grid fast-marching used for
2264+
masks. See `development/distance/` for the reference oracles.
2265+
21912266
## I/O and Build Dependencies
21922267

21932268
`bioimage-cpp` intentionally does not replace nifty or affogato I/O helpers.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
if speed is None:
73+
field = np.ma.abs(skfmm.distance(phi, dx=dx))
74+
else:
75+
speed = np.ascontiguousarray(speed, dtype=np.float64)
76+
field = skfmm.travel_time(phi, speed, dx=dx)
77+
78+
out = np.full(mask.shape, np.inf, dtype=np.float64)
79+
field_data = np.ma.getdata(field)
80+
valid = ~np.ma.getmaskarray(field)
81+
out[valid] = field_data[valid]
82+
return out
83+
84+
85+
def reference_geodesic_distances_mask(mask, points, sampling=None, speed=None):
86+
"""Full pairwise geodesic distance matrix between points within a mask.
87+
88+
Runs the field solve once per point and reads the field at the other
89+
points. The result is symmetrized (per-source solves can differ by a tiny
90+
numerical amount) with a zero diagonal.
91+
"""
92+
points = np.atleast_2d(np.asarray(points, dtype=np.int64))
93+
n = len(points)
94+
out = np.full((n, n), np.inf, dtype=np.float64)
95+
index = tuple(points.T)
96+
for i in range(n):
97+
field = reference_geodesic_field_mask(mask, points[i][None, :], sampling, speed)
98+
out[i] = field[index]
99+
out = 0.5 * (out + out.T)
100+
np.fill_diagonal(out, 0.0)
101+
return out
102+
103+
104+
# --------------------------------------------------------------------------- #
105+
# meshes: pygeodesic (exact MMP)
106+
# --------------------------------------------------------------------------- #
107+
108+
109+
def _pygeodesic():
110+
try:
111+
import pygeodesic.geodesic as geodesic
112+
except ImportError as error: # pragma: no cover - dev script
113+
raise ImportError(
114+
"pygeodesic is required for the mesh geodesic reference "
115+
"(`pip install pygeodesic`)"
116+
) from error
117+
return geodesic
118+
119+
120+
def _mesh_algorithm(vertices, faces):
121+
geodesic = _pygeodesic()
122+
vertices = np.ascontiguousarray(vertices, dtype=np.float64)
123+
faces = np.ascontiguousarray(faces, dtype=np.int32)
124+
return geodesic.PyGeodesicAlgorithmExact(vertices, faces)
125+
126+
127+
def reference_geodesic_field_mesh(vertices, faces, sources):
128+
"""Geodesic distance field on a triangle mesh from a set of source vertices.
129+
130+
Mirrors ``bic.distance.geodesic_distance_field_mesh``. Returns a
131+
``(n_vertices,)`` array of surface geodesic distance to the nearest source.
132+
"""
133+
algo = _mesh_algorithm(vertices, faces)
134+
sources = np.atleast_1d(np.asarray(sources, dtype=np.int32))
135+
distances, _ = algo.geodesicDistances(sources, None)
136+
return np.asarray(distances, dtype=np.float64)
137+
138+
139+
def reference_geodesic_distances_mesh(vertices, faces, points):
140+
"""Full pairwise geodesic distance matrix between mesh vertices."""
141+
algo = _mesh_algorithm(vertices, faces)
142+
points = np.atleast_1d(np.asarray(points, dtype=np.int32))
143+
n = len(points)
144+
out = np.full((n, n), np.inf, dtype=np.float64)
145+
for i in range(n):
146+
distances, _ = algo.geodesicDistances(points[i : i + 1], points)
147+
out[i] = np.asarray(distances, dtype=np.float64)
148+
out = 0.5 * (out + out.T)
149+
np.fill_diagonal(out, 0.0)
150+
return out

0 commit comments

Comments
 (0)