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 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)
@@ -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
+ )
Binary file