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.
- eiko_skelfm-0.1.0/PKG-INFO +131 -0
- eiko_skelfm-0.1.0/README.md +106 -0
- eiko_skelfm-0.1.0/eiko_skelfm/__init__.py +3 -0
- eiko_skelfm-0.1.0/eiko_skelfm/msfm.py +46 -0
- eiko_skelfm-0.1.0/eiko_skelfm/shortestpath.py +198 -0
- eiko_skelfm-0.1.0/eiko_skelfm/skeleton.py +232 -0
- eiko_skelfm-0.1.0/eiko_skelfm.egg-info/PKG-INFO +131 -0
- eiko_skelfm-0.1.0/eiko_skelfm.egg-info/SOURCES.txt +24 -0
- eiko_skelfm-0.1.0/eiko_skelfm.egg-info/dependency_links.txt +1 -0
- eiko_skelfm-0.1.0/eiko_skelfm.egg-info/requires.txt +7 -0
- eiko_skelfm-0.1.0/eiko_skelfm.egg-info/top_level.txt +1 -0
- eiko_skelfm-0.1.0/pyproject.toml +42 -0
- eiko_skelfm-0.1.0/setup.cfg +4 -0
- eiko_skelfm-0.1.0/setup.py +27 -0
- eiko_skelfm-0.1.0/src/common.c +270 -0
- eiko_skelfm-0.1.0/src/msfm2d.c +450 -0
- eiko_skelfm-0.1.0/src/msfm3d.c +549 -0
- eiko_skelfm-0.1.0/src/msfm_module.c +167 -0
- eiko_skelfm-0.1.0/tests/test_lib_build.py +66 -0
- eiko_skelfm-0.1.0/tests/test_memory_order.py +94 -0
- eiko_skelfm-0.1.0/tests/test_skeleton_2d.py +229 -0
- eiko_skelfm-0.1.0/tests/test_skeleton_3d.py +256 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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,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)
|