eiko-skelfm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: eiko-skelfm
3
+ Version: 0.1.0
4
+ Summary: Fast Marching Method - Python port of Kroon's toolbox
5
+ Author: Fernando Julian Chaure
6
+ License: BSD-2-Clause
7
+ Keywords: fast marching method,skeleton
8
+ Classifier: Intended Audience :: Science/Research
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: C
16
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: numpy>=1.19
20
+ Requires-Dist: scipy>=1.5
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=6.0; extra == "dev"
23
+ Requires-Dist: pytest-cov; extra == "dev"
24
+ Requires-Dist: matplotlib>=3.5; extra == "dev"
25
+
26
+ # eiko_skelfm
27
+
28
+ Fast-marching skeleton extraction for 2D and 3D binary images.
29
+ Port of Kroon's MATLAB/MEX FastMarching toolbox to standalone C + Python/NumPy.
30
+
31
+ ![Skeleton extraction result](docs/figures/skeleton_2d_result.png)
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install -e .
37
+
38
+ # with dev dependencies (pytest, matplotlib)
39
+ pip install -e ".[dev]"
40
+ ```
41
+
42
+ ## Quick Example — 2D skeleton
43
+
44
+ ```python
45
+ import numpy as np
46
+ from scipy.ndimage import gaussian_filter
47
+ import matplotlib.pyplot as plt
48
+ from eiko_skelfm.skeleton import skeleton
49
+
50
+ # 1. Create a thin skeleton: center dot + 4 radiating lines
51
+ shape = (100, 100)
52
+ skel = np.zeros(shape, dtype=bool)
53
+ cy, cx = 50, 50
54
+ skel[cy, cx] = True
55
+ for t in range(1, 40):
56
+ skel[cy + int(4 * (1 - np.cos(t / 4.0))), cx + t] = True # line → +x (cosine)
57
+ skel[cy + t, cx - t // 5] = True # line → +y
58
+ skel[cy - t, cx + t // 5] = True # line → −y
59
+ for t in range(1, 30):
60
+ skel[cy - t // 4, cx - t] = True # line → −x (seg 1)
61
+ skel[cy - 7 - t, cx - 29 - t // 3] = True # line → −x (seg 2)
62
+ for t in range(1, 20):
63
+ skel[cy + int(4 * (1 - np.cos(5.0))) + t, cx + 20 + t // 2] = True # branch 1
64
+ skel[cy - 7 + t // 3, cx - 29 - t] = True # branch 2
65
+ skel[cy + 20 + t // 3, cx - 4 + t] = True # branch 3
66
+ skel[cy - 20 - t // 3, cx + 4 - t] = True # branch 4
67
+
68
+ # 2. Convolve with a Gaussian to create a tubular binary mask
69
+ convolved = gaussian_filter(skel.astype(float), sigma=3.0)
70
+ binary_mask = convolved > convolved.max() * 0.15
71
+
72
+ # 3. Extract the skeleton
73
+ segments = skeleton(binary_mask, verbose=True)
74
+
75
+ # 4. Plot the result
76
+ fig, axes = plt.subplots(1, 4, figsize=(18, 4.5))
77
+ axes[0].imshow(skel, cmap="gray", origin="lower")
78
+ axes[0].set_title("Ground-truth skeleton")
79
+
80
+ axes[1].imshow(convolved, cmap="inferno", origin="lower")
81
+ axes[1].set_title("After Gaussian blur (σ=3)")
82
+
83
+ axes[2].imshow(binary_mask, cmap="gray", origin="lower")
84
+ axes[2].set_title("Binary mask")
85
+
86
+ axes[3].imshow(binary_mask, cmap="gray", origin="lower", alpha=0.35)
87
+ for seg in segments:
88
+ axes[3].plot(seg[:, 1], seg[:, 0], linewidth=2)
89
+ axes[3].set_title("Extracted skeleton")
90
+
91
+ plt.tight_layout()
92
+ plt.savefig("skeleton_2d.png", dpi=150)
93
+ plt.show()
94
+ ```
95
+
96
+ ![Four-panel overview](docs/figures/skeleton_2d_overview.png)
97
+
98
+ See [`docs/example_skeleton_2d.py`](docs/example_skeleton_2d.py) for the full runnable script.
99
+
100
+ ## Build Standalone C Library
101
+
102
+ ### Linux (GCC)
103
+ ```bash
104
+ gcc -O2 -std=c99 -c src/common.c src/msfm2d.c src/msfm3d.c -lm
105
+ ar rcs libeiko_skelfm.a common.o msfm2d.o msfm3d.o
106
+ ```
107
+
108
+ ### macOS (Clang)
109
+ ```bash
110
+ clang -O2 -std=c99 -c src/common.c src/msfm2d.c src/msfm3d.c
111
+ ar rcs libeiko_skelfm.a common.o msfm2d.o msfm3d.o
112
+ ```
113
+
114
+ ### Windows (MSVC)
115
+ ```cmd
116
+ cl /O2 /c src\common.c src\msfm2d.c src\msfm3d.c
117
+ lib /OUT:fastmarching.lib common.obj msfm2d.obj msfm3d.obj
118
+ ```
119
+
120
+ ### Windows (MinGW)
121
+ ```bash
122
+ gcc -O2 -std=c99 -c src/common.c src/msfm2d.c src/msfm3d.c
123
+ ar rcs libeiko_skelfm.a common.o msfm2d.o msfm3d.o
124
+ ```
125
+
126
+ ### CMake
127
+ ```bash
128
+ cmake -B build -S .
129
+ cmake --build build
130
+ ```
131
+
@@ -0,0 +1,106 @@
1
+ # eiko_skelfm
2
+
3
+ Fast-marching skeleton extraction for 2D and 3D binary images.
4
+ Port of Kroon's MATLAB/MEX FastMarching toolbox to standalone C + Python/NumPy.
5
+
6
+ ![Skeleton extraction result](docs/figures/skeleton_2d_result.png)
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install -e .
12
+
13
+ # with dev dependencies (pytest, matplotlib)
14
+ pip install -e ".[dev]"
15
+ ```
16
+
17
+ ## Quick Example — 2D skeleton
18
+
19
+ ```python
20
+ import numpy as np
21
+ from scipy.ndimage import gaussian_filter
22
+ import matplotlib.pyplot as plt
23
+ from eiko_skelfm.skeleton import skeleton
24
+
25
+ # 1. Create a thin skeleton: center dot + 4 radiating lines
26
+ shape = (100, 100)
27
+ skel = np.zeros(shape, dtype=bool)
28
+ cy, cx = 50, 50
29
+ skel[cy, cx] = True
30
+ for t in range(1, 40):
31
+ skel[cy + int(4 * (1 - np.cos(t / 4.0))), cx + t] = True # line → +x (cosine)
32
+ skel[cy + t, cx - t // 5] = True # line → +y
33
+ skel[cy - t, cx + t // 5] = True # line → −y
34
+ for t in range(1, 30):
35
+ skel[cy - t // 4, cx - t] = True # line → −x (seg 1)
36
+ skel[cy - 7 - t, cx - 29 - t // 3] = True # line → −x (seg 2)
37
+ for t in range(1, 20):
38
+ skel[cy + int(4 * (1 - np.cos(5.0))) + t, cx + 20 + t // 2] = True # branch 1
39
+ skel[cy - 7 + t // 3, cx - 29 - t] = True # branch 2
40
+ skel[cy + 20 + t // 3, cx - 4 + t] = True # branch 3
41
+ skel[cy - 20 - t // 3, cx + 4 - t] = True # branch 4
42
+
43
+ # 2. Convolve with a Gaussian to create a tubular binary mask
44
+ convolved = gaussian_filter(skel.astype(float), sigma=3.0)
45
+ binary_mask = convolved > convolved.max() * 0.15
46
+
47
+ # 3. Extract the skeleton
48
+ segments = skeleton(binary_mask, verbose=True)
49
+
50
+ # 4. Plot the result
51
+ fig, axes = plt.subplots(1, 4, figsize=(18, 4.5))
52
+ axes[0].imshow(skel, cmap="gray", origin="lower")
53
+ axes[0].set_title("Ground-truth skeleton")
54
+
55
+ axes[1].imshow(convolved, cmap="inferno", origin="lower")
56
+ axes[1].set_title("After Gaussian blur (σ=3)")
57
+
58
+ axes[2].imshow(binary_mask, cmap="gray", origin="lower")
59
+ axes[2].set_title("Binary mask")
60
+
61
+ axes[3].imshow(binary_mask, cmap="gray", origin="lower", alpha=0.35)
62
+ for seg in segments:
63
+ axes[3].plot(seg[:, 1], seg[:, 0], linewidth=2)
64
+ axes[3].set_title("Extracted skeleton")
65
+
66
+ plt.tight_layout()
67
+ plt.savefig("skeleton_2d.png", dpi=150)
68
+ plt.show()
69
+ ```
70
+
71
+ ![Four-panel overview](docs/figures/skeleton_2d_overview.png)
72
+
73
+ See [`docs/example_skeleton_2d.py`](docs/example_skeleton_2d.py) for the full runnable script.
74
+
75
+ ## Build Standalone C Library
76
+
77
+ ### Linux (GCC)
78
+ ```bash
79
+ gcc -O2 -std=c99 -c src/common.c src/msfm2d.c src/msfm3d.c -lm
80
+ ar rcs libeiko_skelfm.a common.o msfm2d.o msfm3d.o
81
+ ```
82
+
83
+ ### macOS (Clang)
84
+ ```bash
85
+ clang -O2 -std=c99 -c src/common.c src/msfm2d.c src/msfm3d.c
86
+ ar rcs libeiko_skelfm.a common.o msfm2d.o msfm3d.o
87
+ ```
88
+
89
+ ### Windows (MSVC)
90
+ ```cmd
91
+ cl /O2 /c src\common.c src\msfm2d.c src\msfm3d.c
92
+ lib /OUT:fastmarching.lib common.obj msfm2d.obj msfm3d.obj
93
+ ```
94
+
95
+ ### Windows (MinGW)
96
+ ```bash
97
+ gcc -O2 -std=c99 -c src/common.c src/msfm2d.c src/msfm3d.c
98
+ ar rcs libeiko_skelfm.a common.o msfm2d.o msfm3d.o
99
+ ```
100
+
101
+ ### CMake
102
+ ```bash
103
+ cmake -B build -S .
104
+ cmake --build build
105
+ ```
106
+
@@ -0,0 +1,3 @@
1
+ from .msfm import msfm
2
+
3
+ __all__ = ['msfm']
@@ -0,0 +1,46 @@
1
+ import numpy as np
2
+ from . import _msfm
3
+
4
+ def msfm(speed, source_points, use_second=True, use_cross=True, return_y=False):
5
+ """
6
+ Fast Marching Method solver.
7
+
8
+ Parameters
9
+ ----------
10
+ speed : ndarray (2D or 3D), float64
11
+ Speed image. Must be Fortran-contiguous.
12
+ source_points : ndarray, shape (ndim, N), float64
13
+ Source points (0-indexed). Must be Fortran-contiguous with
14
+ 2 rows for 2D or 3 rows for 3D.
15
+ use_second : bool
16
+ Use second-order derivatives.
17
+ use_cross : bool
18
+ Use cross/diagonal stencils.
19
+ return_y : bool
20
+ Also return Euclidean distance (for skeleton extraction).
21
+
22
+ Returns
23
+ -------
24
+ T : ndarray, same shape as speed (distance field, Fortran order)
25
+ Y : ndarray or None (Euclidean distance, Fortran order)
26
+ """
27
+ speed = np.asfortranarray(speed, dtype=np.float64)
28
+ speed = np.clip(speed, 1e-8, None)
29
+ source_points = np.asfortranarray(source_points, dtype=np.float64)
30
+
31
+ if speed.ndim == 3:
32
+ if source_points.shape[0] != 3:
33
+ raise ValueError("source_points must be 3xN for 3D speed")
34
+ result = _msfm.msfm3d(speed, source_points,
35
+ use_second,use_cross,
36
+ return_y)
37
+ elif speed.ndim == 2:
38
+ if source_points.shape[0] != 2:
39
+ raise ValueError("source_points must be 2xN for 2D speed")
40
+ result = _msfm.msfm2d(speed, source_points,
41
+ use_second,use_cross,
42
+ return_y)
43
+ else:
44
+ raise ValueError("speed must be 2D or 3D")
45
+
46
+ return result
@@ -0,0 +1,198 @@
1
+ """
2
+ shortestpath.py
3
+ ---------------
4
+ Python port of the MATLAB shortestpath function by Dirk-Jan Kroon.
5
+
6
+ Traces the shortest path from a start point to a source point in a 2D or 3D
7
+ distance map using Runge-Kutta 4 (RK4) on the negative gradient field.
8
+
9
+ Design decisions
10
+ ----------------
11
+ Normalisation (two-stage):
12
+ Gradients are normalised on the integer grid *before* building the
13
+ interpolators (stability in flat regions where raw values are tiny),
14
+ then re-normalised after each interpolation call (because bilinear/
15
+ trilinear interpolation of unit vectors does not preserve unit length).
16
+
17
+ Termination (four independent guards, checked in order):
18
+ 1. Source proximity — Euclidean distance <= stepsize.
19
+ 2. Out-of-bounds — next point lies outside the map extents.
20
+ 3. Velocity collapse — interpolated velocity norm < 1e-6 (stuck /
21
+ left the map into the fill_value=0 region).
22
+ 4. Monotone descent — distance stops decreasing by more than a small
23
+ epsilon (1e-12). The epsilon avoids a spurious
24
+ stop on quantisation plateaux in low-resolution
25
+ or integer-valued maps.
26
+
27
+ Final step:
28
+ When the source-proximity guard fires, the last appended point is a
29
+ proportional micro-step toward source_point rather than a hard teleport,
30
+ so the path length and direction are physically consistent at the end.
31
+
32
+ Performance note:
33
+ Each RK4 iteration calls the interpolators 4 times (once per sub-step),
34
+ once per spatial dimension. For a single path this is negligible.
35
+ For thousands of paths in a tight loop, consider vectorising the
36
+ interpolation calls or pre-building a raw gradient array and using
37
+ scipy.ndimage.map_coordinates, which has lower per-call overhead.
38
+
39
+ RK4 vs simpler integrators:
40
+ RK4 is technically overkill for a smooth distance map. Euler or RK2
41
+ would converge too, but RK4 produces noticeably smoother paths in
42
+ curved corridors and near saddle points, at a cost of 4x the
43
+ interpolation calls per step.
44
+ """
45
+
46
+ import numpy as np
47
+ from scipy.interpolate import RegularGridInterpolator
48
+
49
+
50
+ def shortestpath(
51
+ distance_map,
52
+ start_point,
53
+ source_point=None,
54
+ stepsize: float = 0.5,
55
+ max_iter: int = 10_000,
56
+ ) -> np.ndarray:
57
+ """
58
+ Trace the shortest path in a 2-D or 3-D distance map using RK4 integration
59
+ on the normalised negative gradient field.
60
+
61
+ Parameters
62
+ ----------
63
+ distance_map : array-like, shape (R, C) or (R, C, D)
64
+ Distance map produced by a fast-marching / eikonal solver.
65
+ Axis order: (row, col) for 2-D, (row, col, depth) for 3-D.
66
+ start_point : array-like, length ndim
67
+ Starting position in index coordinates, e.g. [row, col].
68
+ source_point : array-like, length ndim, optional
69
+ End position. Integration stops when the path arrives within
70
+ `stepsize` of this point. If None, the monotone-descent guard
71
+ acts as the termination criterion.
72
+ stepsize : float, optional
73
+ RK4 step size in pixels. Default 0.5.
74
+ Large values (e.g. 2.0) speed up tracing but produce a coarser
75
+ path and a more visible "snap" at the very end.
76
+ max_iter : int, optional
77
+ Hard iteration cap. Default 10 000.
78
+
79
+ Returns
80
+ -------
81
+ path : np.ndarray, shape (M, ndim)
82
+ Ordered positions from start_point to source_point (inclusive).
83
+ """
84
+ # ------------------------------------------------------------------
85
+ # 0. Validate & coerce inputs
86
+ # ------------------------------------------------------------------
87
+ distance_map = np.asarray(distance_map, dtype=float)
88
+ ndim = distance_map.ndim
89
+ if ndim not in (2, 3):
90
+ raise ValueError("distance_map must be 2-D or 3-D.")
91
+
92
+ start_point = np.asarray(start_point, dtype=float).ravel()
93
+ if start_point.size != ndim:
94
+ raise ValueError(f"start_point must have {ndim} elements.")
95
+
96
+ source_point = (
97
+ np.asarray(source_point, dtype=float).T # (K, ndim), K>=1
98
+ if source_point is not None
99
+ else None
100
+ )
101
+
102
+ shape = distance_map.shape
103
+ axes = tuple(np.arange(s) for s in shape)
104
+ bounds = np.array([[0.0, s - 1.0] for s in shape]) # (ndim, 2)
105
+
106
+ # ------------------------------------------------------------------
107
+ # 1. Precompute normalised gradient field on the integer grid
108
+ # ------------------------------------------------------------------
109
+ grads = np.gradient(distance_map)
110
+ if isinstance(grads, np.ndarray): # guard: ndim==1 edge case
111
+ grads = [grads]
112
+
113
+ grad_mag = np.sqrt(sum(g ** 2 for g in grads))
114
+ grad_mag[grad_mag < 1e-10] = 1.0 # avoid division by zero in flat zones
115
+
116
+ norm_grads = [-g / grad_mag for g in grads] # point toward decreasing distance
117
+
118
+ # ------------------------------------------------------------------
119
+ # 2. Build interpolators
120
+ # fill_value=0.0 → velocity collapses to zero outside the map,
121
+ # which is caught by the norm < 1e-6 guard (termination guard 3).
122
+ # ------------------------------------------------------------------
123
+ vel_interps = [
124
+ RegularGridInterpolator(axes, ng, method="linear",
125
+ bounds_error=False, fill_value=0.0)
126
+ for ng in norm_grads
127
+ ]
128
+
129
+ dist_interp = RegularGridInterpolator(
130
+ axes, distance_map, method="linear",
131
+ bounds_error=False, fill_value=np.inf
132
+ )
133
+
134
+ # ------------------------------------------------------------------
135
+ # 3. Velocity helper
136
+ # Re-normalise after interpolation: bilinear/trilinear interpolation
137
+ # of unit vectors does NOT preserve unit length; the re-normalisation
138
+ # keeps the trace speed constant throughout integration.
139
+ # ------------------------------------------------------------------
140
+ def velocity(p: np.ndarray) -> np.ndarray:
141
+ q = p[np.newaxis, :] # shape (1, ndim)
142
+ v = np.array([f(q)[0] for f in vel_interps])
143
+ mag = np.linalg.norm(v)
144
+ return v / mag if mag > 1e-6 else np.zeros(ndim)
145
+
146
+ # ------------------------------------------------------------------
147
+ # 4. RK4 integration
148
+ # ------------------------------------------------------------------
149
+ path = [start_point.copy()]
150
+ current = start_point.copy()
151
+ h = stepsize
152
+ prev_dist = dist_interp(current[np.newaxis, :])[0]
153
+
154
+ for _ in range(max_iter):
155
+
156
+ # RK4 sub-steps -------------------------------------------------------
157
+ k1 = velocity(current)
158
+
159
+ # Guard 3 — velocity collapse ---------
160
+ if np.linalg.norm(k1) < 1e-6:
161
+ break
162
+
163
+ k2 = velocity(current + 0.5 * h * k1)
164
+ k3 = velocity(current + 0.5 * h * k2)
165
+ k4 = velocity(current + h * k3)
166
+
167
+ next_point = current + (h / 6.0) * (k1 + 2*k2 + 2*k3 + k4)
168
+
169
+ # Guard 2 — out-of-bounds check (on the NEW point, like MATLAB) -------
170
+ if not np.all((next_point >= bounds[:, 0]) & (next_point <= bounds[:, 1])):
171
+ break
172
+
173
+ # Guard 4 — monotone descent, evaluated on the NEW point --------------
174
+ curr_dist = dist_interp(next_point[np.newaxis, :])[0]
175
+ if curr_dist > prev_dist + 1e-12:
176
+ break
177
+ prev_dist = curr_dist
178
+
179
+ current = next_point
180
+ path.append(current.copy())
181
+
182
+ # Guard 1 — source proximity
183
+ if source_point is not None:
184
+ deltas = source_point - current[np.newaxis, :] # (K, ndim)
185
+ dists = np.linalg.norm(deltas, axis=1)
186
+ idx = np.argmin(dists)
187
+ dist_to_source = dists[idx]
188
+
189
+ if dist_to_source <= h:
190
+ nearest_source = source_point[idx]
191
+ direction = nearest_source - current
192
+ dir_norm = np.linalg.norm(direction)
193
+ if dir_norm > 1e-10:
194
+ path.append(current + direction * (dist_to_source / dir_norm))
195
+ else:
196
+ path.append(nearest_source.copy())
197
+ break
198
+ return np.array(path)