pyneuronj 1.0__py3-none-any.whl
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.
- pyneuronj/__init__.py +31 -0
- pyneuronj/_search.py +194 -0
- pyneuronj/_validation.py +67 -0
- pyneuronj/api.py +225 -0
- pyneuronj/data/test.jpg +0 -0
- pyneuronj/detector.py +242 -0
- pyneuronj/io.py +81 -0
- pyneuronj/ndf.py +340 -0
- pyneuronj/path.py +109 -0
- pyneuronj/py.typed +0 -0
- pyneuronj/session.py +110 -0
- pyneuronj/tracer.py +359 -0
- pyneuronj-1.0.dist-info/METADATA +118 -0
- pyneuronj-1.0.dist-info/RECORD +17 -0
- pyneuronj-1.0.dist-info/WHEEL +5 -0
- pyneuronj-1.0.dist-info/licenses/LICENSE +21 -0
- pyneuronj-1.0.dist-info/top_level.txt +1 -0
pyneuronj/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Two-dimensional fiber tracing from an image and endpoint pairs."""
|
|
2
|
+
|
|
3
|
+
from .api import PyNeuronJ
|
|
4
|
+
from .detector import DetectionResult, NeuronDetector
|
|
5
|
+
from .io import load_trace, rasterize_path, save_csv, save_trace
|
|
6
|
+
from .ndf import NdfDocument, NdfTracing, load_ndf, save_ndf
|
|
7
|
+
from .path import TraceResult, compare_polylines, polyline_length, smooth_and_subsample
|
|
8
|
+
from .session import LiveWireSession
|
|
9
|
+
from .tracer import NeuronTracer, NoPathError, SearchTree
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PyNeuronJ",
|
|
13
|
+
"TraceResult",
|
|
14
|
+
"NoPathError",
|
|
15
|
+
"DetectionResult",
|
|
16
|
+
"NeuronDetector",
|
|
17
|
+
"NeuronTracer",
|
|
18
|
+
"SearchTree",
|
|
19
|
+
"LiveWireSession",
|
|
20
|
+
"polyline_length",
|
|
21
|
+
"smooth_and_subsample",
|
|
22
|
+
"load_trace",
|
|
23
|
+
"save_trace",
|
|
24
|
+
"save_csv",
|
|
25
|
+
"NdfDocument",
|
|
26
|
+
"NdfTracing",
|
|
27
|
+
"load_ndf",
|
|
28
|
+
"save_ndf",
|
|
29
|
+
"rasterize_path",
|
|
30
|
+
"compare_polylines",
|
|
31
|
+
]
|
pyneuronj/_search.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Independent Dijkstra implementations; the same functions run in Python or Numba.
|
|
2
|
+
|
|
3
|
+
Weights have shape (8,N). Invalid edges are +inf. A graph node is y*width+x.
|
|
4
|
+
No Euclidean diagonal multiplier is applied. No positive epsilon is added.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
# Fixed row-major neighbor order; this is an implementation choice.
|
|
11
|
+
DX = np.array([-1, 0, 1, -1, 1, -1, 0, 1], dtype=np.int64)
|
|
12
|
+
DY = np.array([-1, -1, -1, 0, 0, 1, 1, 1], dtype=np.int64)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def heap_search(weights, offsets, source, target, max_edge):
|
|
16
|
+
"""Indexed binary min-heap; ties are broken by flattened pixel index."""
|
|
17
|
+
n = weights.shape[1]
|
|
18
|
+
distance = np.full(n, np.inf, dtype=np.float64)
|
|
19
|
+
parent = np.full(n, -1, dtype=np.int64)
|
|
20
|
+
settled = np.zeros(n, dtype=np.bool_)
|
|
21
|
+
heap = np.empty(n, dtype=np.int64)
|
|
22
|
+
position = np.full(n, -1, dtype=np.int64)
|
|
23
|
+
distance[source] = 0.0
|
|
24
|
+
parent[source] = source
|
|
25
|
+
heap[0], position[source] = source, 0
|
|
26
|
+
size = 1
|
|
27
|
+
while size:
|
|
28
|
+
u = heap[0]
|
|
29
|
+
position[u] = -1
|
|
30
|
+
size -= 1
|
|
31
|
+
if size:
|
|
32
|
+
last = heap[size]
|
|
33
|
+
heap[0], position[last] = last, 0
|
|
34
|
+
j = 0
|
|
35
|
+
while True:
|
|
36
|
+
left = 2 * j + 1
|
|
37
|
+
if left >= size:
|
|
38
|
+
break
|
|
39
|
+
right = left + 1
|
|
40
|
+
best = left
|
|
41
|
+
if right < size:
|
|
42
|
+
a, b = heap[left], heap[right]
|
|
43
|
+
if distance[b] < distance[a] or (
|
|
44
|
+
distance[b] == distance[a] and b < a
|
|
45
|
+
):
|
|
46
|
+
best = right
|
|
47
|
+
a, b = heap[j], heap[best]
|
|
48
|
+
if distance[a] < distance[b] or (distance[a] == distance[b] and a < b):
|
|
49
|
+
break
|
|
50
|
+
heap[j], heap[best] = b, a
|
|
51
|
+
position[b], position[a] = j, best
|
|
52
|
+
j = best
|
|
53
|
+
settled[u] = True
|
|
54
|
+
if u == target:
|
|
55
|
+
break
|
|
56
|
+
du = distance[u]
|
|
57
|
+
for k in range(8):
|
|
58
|
+
w = weights[k, u]
|
|
59
|
+
if not np.isfinite(w):
|
|
60
|
+
continue
|
|
61
|
+
v = u + offsets[k]
|
|
62
|
+
if settled[v]:
|
|
63
|
+
continue
|
|
64
|
+
candidate = du + w
|
|
65
|
+
if candidate < distance[v]:
|
|
66
|
+
distance[v], parent[v] = candidate, u
|
|
67
|
+
j = position[v]
|
|
68
|
+
if j < 0:
|
|
69
|
+
j = size
|
|
70
|
+
size += 1
|
|
71
|
+
heap[j], position[v] = v, j
|
|
72
|
+
while j:
|
|
73
|
+
p = (j - 1) // 2
|
|
74
|
+
a, b = heap[p], heap[j]
|
|
75
|
+
if distance[a] < distance[b] or (
|
|
76
|
+
distance[a] == distance[b] and a < b
|
|
77
|
+
):
|
|
78
|
+
break
|
|
79
|
+
heap[p], heap[j] = b, a
|
|
80
|
+
position[b], position[a] = p, j
|
|
81
|
+
j = p
|
|
82
|
+
# Unsettled tentative distances are NOT valid completed shortest paths.
|
|
83
|
+
for u in range(n):
|
|
84
|
+
if not settled[u]:
|
|
85
|
+
distance[u], parent[u] = np.inf, -1
|
|
86
|
+
return distance, parent, settled
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def dial_search(weights, offsets, source, target, max_edge):
|
|
90
|
+
"""Circular FIFO buckets for nonnegative INTEGER weights, including zero.
|
|
91
|
+
|
|
92
|
+
Intrusive linked lists support decrease-key without duplicate queue entries.
|
|
93
|
+
Quantization levels and FIFO tie rules are not specified by the 2004 paper.
|
|
94
|
+
"""
|
|
95
|
+
n = weights.shape[1]
|
|
96
|
+
ring_size = max_edge + 1
|
|
97
|
+
head = np.full(ring_size, -1, dtype=np.int64)
|
|
98
|
+
tail = np.full(ring_size, -1, dtype=np.int64)
|
|
99
|
+
next_node = np.full(n, -1, dtype=np.int64)
|
|
100
|
+
prev_node = np.full(n, -1, dtype=np.int64)
|
|
101
|
+
bucket_of = np.full(n, -1, dtype=np.int64)
|
|
102
|
+
distance = np.full(n, np.inf, dtype=np.float64)
|
|
103
|
+
parent = np.full(n, -1, dtype=np.int64)
|
|
104
|
+
settled = np.zeros(n, dtype=np.bool_)
|
|
105
|
+
distance[source], parent[source] = 0.0, source
|
|
106
|
+
head[0], tail[0], bucket_of[source] = source, source, 0
|
|
107
|
+
current, active = 0, 1
|
|
108
|
+
while active:
|
|
109
|
+
b = current % ring_size
|
|
110
|
+
u = head[b]
|
|
111
|
+
if u < 0:
|
|
112
|
+
current += 1
|
|
113
|
+
continue
|
|
114
|
+
head[b] = next_node[u]
|
|
115
|
+
if head[b] < 0:
|
|
116
|
+
tail[b] = -1
|
|
117
|
+
else:
|
|
118
|
+
prev_node[head[b]] = -1
|
|
119
|
+
next_node[u], prev_node[u], bucket_of[u] = -1, -1, -1
|
|
120
|
+
active -= 1
|
|
121
|
+
settled[u] = True
|
|
122
|
+
if u == target:
|
|
123
|
+
break
|
|
124
|
+
for k in range(8):
|
|
125
|
+
w = weights[k, u]
|
|
126
|
+
if not np.isfinite(w):
|
|
127
|
+
continue
|
|
128
|
+
v = u + offsets[k]
|
|
129
|
+
if settled[v]:
|
|
130
|
+
continue
|
|
131
|
+
candidate = current + int(w)
|
|
132
|
+
if candidate < distance[v]:
|
|
133
|
+
old_bucket = bucket_of[v]
|
|
134
|
+
if old_bucket >= 0:
|
|
135
|
+
previous, following = prev_node[v], next_node[v]
|
|
136
|
+
if previous < 0:
|
|
137
|
+
head[old_bucket] = following
|
|
138
|
+
else:
|
|
139
|
+
next_node[previous] = following
|
|
140
|
+
if following < 0:
|
|
141
|
+
tail[old_bucket] = previous
|
|
142
|
+
else:
|
|
143
|
+
prev_node[following] = previous
|
|
144
|
+
else:
|
|
145
|
+
active += 1
|
|
146
|
+
distance[v], parent[v] = candidate, u
|
|
147
|
+
new_bucket = candidate % ring_size
|
|
148
|
+
previous = tail[new_bucket]
|
|
149
|
+
prev_node[v], next_node[v] = previous, -1
|
|
150
|
+
if previous < 0:
|
|
151
|
+
head[new_bucket] = v
|
|
152
|
+
else:
|
|
153
|
+
next_node[previous] = v
|
|
154
|
+
tail[new_bucket], bucket_of[v] = v, new_bucket
|
|
155
|
+
for u in range(n):
|
|
156
|
+
if not settled[u]:
|
|
157
|
+
distance[u], parent[u] = np.inf, -1
|
|
158
|
+
return distance, parent, settled
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_COMPILED = {}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_search(
|
|
165
|
+
weights,
|
|
166
|
+
width: int,
|
|
167
|
+
source: int,
|
|
168
|
+
target: int,
|
|
169
|
+
*,
|
|
170
|
+
engine: str,
|
|
171
|
+
queue: str,
|
|
172
|
+
max_edge: int,
|
|
173
|
+
):
|
|
174
|
+
function = heap_search if queue == "heap" else dial_search
|
|
175
|
+
resolved = engine
|
|
176
|
+
if engine == "auto":
|
|
177
|
+
import importlib.util
|
|
178
|
+
|
|
179
|
+
resolved = (
|
|
180
|
+
"numba" if importlib.util.find_spec("numba") is not None else "python"
|
|
181
|
+
)
|
|
182
|
+
if resolved == "numba":
|
|
183
|
+
if queue not in _COMPILED:
|
|
184
|
+
try:
|
|
185
|
+
from numba import njit
|
|
186
|
+
except ImportError as exc:
|
|
187
|
+
raise ImportError(
|
|
188
|
+
"Numba unavailable or incompatible. Install the [fast] extra, or set engine='python'."
|
|
189
|
+
) from exc
|
|
190
|
+
# Keep floating-point arithmetic identical to the Python backend.
|
|
191
|
+
_COMPILED[queue] = njit(cache=True, nogil=True, fastmath=False)(function)
|
|
192
|
+
function = _COMPILED[queue]
|
|
193
|
+
result = function(weights, DY * width + DX, source, target, max_edge)
|
|
194
|
+
return (*result, resolved)
|
pyneuronj/_validation.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Validation shared by the public interfaces. Coordinates always mean (x, y)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import numbers
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def integer(value, name: str, minimum: int = 0) -> int:
|
|
9
|
+
if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Integral):
|
|
10
|
+
raise ValueError(f"{name} must be an integer >= {minimum}")
|
|
11
|
+
value = int(value)
|
|
12
|
+
if value < minimum:
|
|
13
|
+
raise ValueError(f"{name} must be >= {minimum}")
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def real(value, name: str) -> float:
|
|
18
|
+
if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real):
|
|
19
|
+
raise ValueError(f"{name} must be a finite real number")
|
|
20
|
+
value = float(value)
|
|
21
|
+
if not np.isfinite(value):
|
|
22
|
+
raise ValueError(f"{name} must be finite")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def image_array(image) -> np.ndarray:
|
|
27
|
+
a = np.asarray(image)
|
|
28
|
+
if a.ndim != 2 or min(a.shape, default=0) < 1:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"image must be a nonempty 2D grayscale array; select a channel explicitly"
|
|
31
|
+
)
|
|
32
|
+
if a.dtype.kind not in "buif":
|
|
33
|
+
raise ValueError("image must have a real numeric dtype")
|
|
34
|
+
a = np.array(a, dtype=np.float64, order="C", copy=True)
|
|
35
|
+
if not np.isfinite(a).all():
|
|
36
|
+
raise ValueError("image contains NaN or infinity")
|
|
37
|
+
return a
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def pixel(point, shape: tuple[int, int], name: str = "point") -> tuple[int, int]:
|
|
41
|
+
a = np.asarray(point)
|
|
42
|
+
if a.shape != (2,) or a.dtype.kind not in "uif" or not np.isfinite(a).all():
|
|
43
|
+
raise ValueError(f"{name} must be a finite integer (x, y) pair")
|
|
44
|
+
if np.any(a != np.floor(a)):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"{name} must be on the integer pixel grid; round cursor coordinates explicitly"
|
|
47
|
+
)
|
|
48
|
+
x, y = int(a[0]), int(a[1])
|
|
49
|
+
if not (0 <= x < shape[1] and 0 <= y < shape[0]):
|
|
50
|
+
raise ValueError(f"{name} {(x, y)} is outside image shape {shape}")
|
|
51
|
+
return x, y
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def points_array(points, *, integer_only: bool = False) -> np.ndarray:
|
|
55
|
+
a = np.asarray(points)
|
|
56
|
+
if a.ndim != 2 or a.shape[1] != 2 or a.shape[0] < 1 or a.dtype.kind not in "uif":
|
|
57
|
+
raise ValueError("path must be a nonempty N x 2 real array in (x, y) order")
|
|
58
|
+
if not np.isfinite(a).all():
|
|
59
|
+
raise ValueError("path contains NaN or infinity")
|
|
60
|
+
if integer_only and np.any(a != np.floor(a)):
|
|
61
|
+
raise ValueError("raw pixel path must have integer coordinates")
|
|
62
|
+
return np.array(a, dtype=np.int64 if integer_only else np.float64, copy=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def readonly(a: np.ndarray) -> np.ndarray:
|
|
66
|
+
a.setflags(write=False)
|
|
67
|
+
return a
|
pyneuronj/api.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Trace an image, or reuse a search tree while the endpoint moves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
|
|
10
|
+
from ._validation import pixel
|
|
11
|
+
from .detector import NeuronDetector
|
|
12
|
+
from .path import TraceResult, compare_polylines
|
|
13
|
+
from .tracer import NeuronTracer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PyNeuronJ:
|
|
17
|
+
"""Trace between integer (x, y) endpoints in a 2D grayscale image.
|
|
18
|
+
|
|
19
|
+
Image features and graph weights are computed at construction. ``trace``
|
|
20
|
+
searches between two endpoints; ``set_start`` prepares a full search tree
|
|
21
|
+
for repeated ``trace_to`` calls. Endpoints are used exactly as supplied.
|
|
22
|
+
|
|
23
|
+
``paper`` uses the paper formulas and calibrated discretization.
|
|
24
|
+
``practical`` also fixes numerical edge cases and checks a shorter route.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
CANDIDATE_LENGTH_PENALTY = 0.02
|
|
28
|
+
COST_TOLERANCE = 0.005
|
|
29
|
+
MIN_LENGTH_REDUCTION = 0.01
|
|
30
|
+
MAX_LENGTH_REDUCTION = 0.10
|
|
31
|
+
MIN_SEPARATION_PX = 5.0
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
image: ArrayLike,
|
|
36
|
+
*,
|
|
37
|
+
start: ArrayLike | None = None,
|
|
38
|
+
mode: Literal["paper", "practical"] = "practical",
|
|
39
|
+
sigma: float = 2.0,
|
|
40
|
+
gamma: float = 0.7,
|
|
41
|
+
p: int = 5,
|
|
42
|
+
s: int = 5,
|
|
43
|
+
bright_ridges: bool = True,
|
|
44
|
+
interior_only: bool = True,
|
|
45
|
+
engine: Literal["auto", "python", "numba"] = "auto",
|
|
46
|
+
) -> None:
|
|
47
|
+
if mode not in ("paper", "practical"):
|
|
48
|
+
raise ValueError("mode must be 'paper' or 'practical'")
|
|
49
|
+
if not isinstance(interior_only, (bool, np.bool_)):
|
|
50
|
+
raise ValueError("interior_only must be boolean")
|
|
51
|
+
practical = mode == "practical"
|
|
52
|
+
|
|
53
|
+
# A constant image must have zero response in practical mode. Otherwise,
|
|
54
|
+
# a small derivative-kernel residual can be normalized into rho = 1.
|
|
55
|
+
image_array = np.asarray(image)
|
|
56
|
+
constant = (
|
|
57
|
+
image_array.ndim == 2
|
|
58
|
+
and image_array.size > 0
|
|
59
|
+
and np.all(image_array == image_array.flat[0])
|
|
60
|
+
)
|
|
61
|
+
detection = NeuronDetector(
|
|
62
|
+
sigma=sigma,
|
|
63
|
+
bright_ridges=bright_ridges,
|
|
64
|
+
boundary="mirror",
|
|
65
|
+
zero_dc=bool(practical and constant),
|
|
66
|
+
derivative_normalization="continuous",
|
|
67
|
+
smoothing_truncate=3.0,
|
|
68
|
+
)(image)
|
|
69
|
+
|
|
70
|
+
allowed = np.ones(detection.response.shape, dtype=bool)
|
|
71
|
+
if interior_only:
|
|
72
|
+
allowed[[0, -1], :] = False
|
|
73
|
+
allowed[:, [0, -1]] = False
|
|
74
|
+
options = dict(
|
|
75
|
+
gamma=gamma,
|
|
76
|
+
p=p,
|
|
77
|
+
s=s,
|
|
78
|
+
engine=engine,
|
|
79
|
+
allowed_mask=allowed,
|
|
80
|
+
stable_angles=practical,
|
|
81
|
+
queue="dial",
|
|
82
|
+
cost_levels=255,
|
|
83
|
+
quantization="floor",
|
|
84
|
+
coordinate_grid="pixel",
|
|
85
|
+
)
|
|
86
|
+
self._primary = NeuronTracer(detection, **options)
|
|
87
|
+
self._candidate = (
|
|
88
|
+
NeuronTracer(
|
|
89
|
+
detection, length_penalty=self.CANDIDATE_LENGTH_PENALTY, **options
|
|
90
|
+
)
|
|
91
|
+
if practical
|
|
92
|
+
else None
|
|
93
|
+
)
|
|
94
|
+
self._mode = mode
|
|
95
|
+
self._start: tuple[int, int] | None = None
|
|
96
|
+
self._tree = None
|
|
97
|
+
self._candidate_tree = None
|
|
98
|
+
if start is not None:
|
|
99
|
+
self.set_start(start)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def mode(self) -> str:
|
|
103
|
+
"""Tracing mode selected when the object was constructed."""
|
|
104
|
+
return self._mode
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def shape(self) -> tuple[int, int]:
|
|
108
|
+
"""Image shape in ``(height, width)`` order."""
|
|
109
|
+
return self._primary.shape
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def start(self) -> tuple[int, int] | None:
|
|
113
|
+
"""The cached start point, or ``None`` before ``set_start``."""
|
|
114
|
+
return self._start
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def response(self) -> np.ndarray:
|
|
118
|
+
"""Read-only fiber response in [0, 1]."""
|
|
119
|
+
return self._primary.response
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def orientation(self) -> np.ndarray:
|
|
123
|
+
"""Read-only unit tangents in (vx, vy) order."""
|
|
124
|
+
return self._primary.orientation
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def parameters(self) -> dict:
|
|
128
|
+
"""A copy of the numerical configuration."""
|
|
129
|
+
parameters = {**self._primary.parameters, "mode": self.mode}
|
|
130
|
+
if self._candidate is not None:
|
|
131
|
+
parameters.update(
|
|
132
|
+
profile="practical",
|
|
133
|
+
refinement={
|
|
134
|
+
"candidate_length_penalty": self.CANDIDATE_LENGTH_PENALTY,
|
|
135
|
+
"cost_tolerance": self.COST_TOLERANCE,
|
|
136
|
+
"min_raw_length_reduction": self.MIN_LENGTH_REDUCTION,
|
|
137
|
+
"max_raw_length_reduction": self.MAX_LENGTH_REDUCTION,
|
|
138
|
+
"min_sampled_separation_px": self.MIN_SEPARATION_PX,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
return parameters
|
|
142
|
+
|
|
143
|
+
def trace(self, start: ArrayLike, end: ArrayLike) -> TraceResult:
|
|
144
|
+
"""Return coordinates and pixel length between the exact endpoints."""
|
|
145
|
+
primary = self._primary.trace(start, end, snap=False)
|
|
146
|
+
if self._candidate is None:
|
|
147
|
+
return primary
|
|
148
|
+
candidate = self._candidate.trace(start, end, snap=False)
|
|
149
|
+
return self._choose_path(primary, candidate)
|
|
150
|
+
|
|
151
|
+
def set_start(self, start: ArrayLike) -> PyNeuronJ:
|
|
152
|
+
"""Prepare a search tree; reuse it if the start has not changed.
|
|
153
|
+
|
|
154
|
+
Invalid coordinates leave an existing cached start and tree intact.
|
|
155
|
+
Returns ``self`` so ``tracer.set_start(start).trace_to(end)`` is valid.
|
|
156
|
+
"""
|
|
157
|
+
point = pixel(start, self.shape, "start")
|
|
158
|
+
if point != self._start:
|
|
159
|
+
tree = self._primary.compute_tree(point, snap=False)
|
|
160
|
+
candidate_tree = (
|
|
161
|
+
self._candidate.compute_tree(point, snap=False)
|
|
162
|
+
if self._candidate is not None
|
|
163
|
+
else None
|
|
164
|
+
)
|
|
165
|
+
# Replace the cache only after both searches succeed.
|
|
166
|
+
self._tree, self._candidate_tree, self._start = tree, candidate_tree, point
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def trace_to(self, end: ArrayLike) -> TraceResult:
|
|
170
|
+
"""Trace to an endpoint using the tree prepared by ``set_start``."""
|
|
171
|
+
if self._tree is None:
|
|
172
|
+
raise RuntimeError("call set_start(start) before trace_to(end)")
|
|
173
|
+
primary = self._primary.path_from_tree(self._tree, end, snap=False)
|
|
174
|
+
if self._candidate is None:
|
|
175
|
+
return primary
|
|
176
|
+
candidate = self._candidate.path_from_tree(
|
|
177
|
+
self._candidate_tree, end, snap=False
|
|
178
|
+
)
|
|
179
|
+
return self._choose_path(primary, candidate)
|
|
180
|
+
|
|
181
|
+
def clear_start(self) -> None:
|
|
182
|
+
"""Release the cached tree while retaining the image detector field."""
|
|
183
|
+
self._tree, self._candidate_tree, self._start = None, None, None
|
|
184
|
+
|
|
185
|
+
def _choose_path(self, primary: TraceResult, candidate: TraceResult) -> TraceResult:
|
|
186
|
+
"""Accept the shorter route only within the documented bounds."""
|
|
187
|
+
baseline_length = primary.raw_length_pixels
|
|
188
|
+
reduction = (
|
|
189
|
+
0.0
|
|
190
|
+
if baseline_length == 0
|
|
191
|
+
else (baseline_length - candidate.raw_length_pixels) / baseline_length
|
|
192
|
+
)
|
|
193
|
+
candidate_cost = self._primary.raw_path_cost(candidate.raw_xy)
|
|
194
|
+
gap = max(0.0, candidate_cost - primary.graph_cost) / max(
|
|
195
|
+
primary.graph_cost, 1e-12
|
|
196
|
+
)
|
|
197
|
+
use_candidate = False
|
|
198
|
+
separation = None
|
|
199
|
+
if (
|
|
200
|
+
self.MIN_LENGTH_REDUCTION <= reduction <= self.MAX_LENGTH_REDUCTION
|
|
201
|
+
and gap <= self.COST_TOLERANCE
|
|
202
|
+
):
|
|
203
|
+
separation = compare_polylines(
|
|
204
|
+
primary.points_xy, candidate.points_xy, step=0.5
|
|
205
|
+
)["symmetric_mean_sample_distance_px"]
|
|
206
|
+
use_candidate = separation >= self.MIN_SEPARATION_PX
|
|
207
|
+
chosen = candidate if use_candidate else primary
|
|
208
|
+
metadata = {
|
|
209
|
+
**primary.metadata,
|
|
210
|
+
**self.parameters,
|
|
211
|
+
"correction_applied": bool(use_candidate),
|
|
212
|
+
"refinement_diagnostics": {
|
|
213
|
+
"candidate_paper_cost": candidate_cost,
|
|
214
|
+
"relative_paper_cost_increase": gap,
|
|
215
|
+
"raw_length_reduction": reduction,
|
|
216
|
+
"sampled_separation_px": separation,
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
# Report the original graph cost even when choosing the shorter route.
|
|
220
|
+
return TraceResult(
|
|
221
|
+
chosen.raw_xy,
|
|
222
|
+
chosen.points_xy,
|
|
223
|
+
candidate_cost if use_candidate else primary.graph_cost,
|
|
224
|
+
metadata,
|
|
225
|
+
)
|
pyneuronj/data/test.jpg
ADDED
|
Binary file
|