skeletonpaths 0.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.
- skeletonpaths/__init__.py +53 -0
- skeletonpaths/_version.py +24 -0
- skeletonpaths/core.py +783 -0
- skeletonpaths-0.1.0.dist-info/METADATA +318 -0
- skeletonpaths-0.1.0.dist-info/RECORD +8 -0
- skeletonpaths-0.1.0.dist-info/WHEEL +5 -0
- skeletonpaths-0.1.0.dist-info/licenses/LICENSE +201 -0
- skeletonpaths-0.1.0.dist-info/top_level.txt +1 -0
skeletonpaths/core.py
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from math import hypot
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from planarcurve.polyline import (
|
|
10
|
+
mean_squared_turn_angle,
|
|
11
|
+
polyline_length,
|
|
12
|
+
symmetric_polyline_distance,
|
|
13
|
+
)
|
|
14
|
+
from scipy.ndimage import distance_transform_edt
|
|
15
|
+
from scipy.ndimage import label as label_components
|
|
16
|
+
from skimage.morphology import skeletonize
|
|
17
|
+
|
|
18
|
+
Point = tuple[float, float]
|
|
19
|
+
Pixel = tuple[int, int]
|
|
20
|
+
|
|
21
|
+
DEFAULT_MAX_PATHS = 10_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class SkeletonNode:
|
|
26
|
+
id: int
|
|
27
|
+
xy: Point
|
|
28
|
+
degree: int
|
|
29
|
+
radius: float
|
|
30
|
+
confidence: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SkeletonEdge:
|
|
35
|
+
a: int
|
|
36
|
+
b: int
|
|
37
|
+
pixels: np.ndarray
|
|
38
|
+
length_px: float
|
|
39
|
+
mean_radius_px: float
|
|
40
|
+
confidence: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class SkeletonGraph:
|
|
45
|
+
nodes: tuple[SkeletonNode, ...]
|
|
46
|
+
edges: tuple[SkeletonEdge, ...]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def component_count(self) -> int:
|
|
50
|
+
if not self.nodes:
|
|
51
|
+
return 0
|
|
52
|
+
adjacency = {node.id: set() for node in self.nodes}
|
|
53
|
+
for edge in self.edges:
|
|
54
|
+
adjacency[edge.a].add(edge.b)
|
|
55
|
+
adjacency[edge.b].add(edge.a)
|
|
56
|
+
remaining = set(adjacency)
|
|
57
|
+
count = 0
|
|
58
|
+
while remaining:
|
|
59
|
+
count += 1
|
|
60
|
+
stack = [remaining.pop()]
|
|
61
|
+
while stack:
|
|
62
|
+
current = stack.pop()
|
|
63
|
+
for neighbor in adjacency[current] & remaining:
|
|
64
|
+
remaining.remove(neighbor)
|
|
65
|
+
stack.append(neighbor)
|
|
66
|
+
return count
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cycle_count(self) -> int:
|
|
70
|
+
return max(0, len(self.edges) - len(self.nodes) + self.component_count)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def endpoint_count(self) -> int:
|
|
74
|
+
return sum(node.degree == 1 for node in self.nodes)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def junction_count(self) -> int:
|
|
78
|
+
return sum(node.degree > 2 for node in self.nodes)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class PathScore:
|
|
83
|
+
guide_distance_px: float | None
|
|
84
|
+
endpoint_orientation_error_px: float | None
|
|
85
|
+
measured_confidence: float
|
|
86
|
+
length_error_px: float | None
|
|
87
|
+
turning_cost: float | None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NoFeasiblePathError(ValueError):
|
|
91
|
+
"""Raised when no route satisfies the requested topology and constraints."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PathSearchLimitError(RuntimeError):
|
|
95
|
+
"""Raised when topology route enumeration exceeds its candidate limit."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_max_paths(max_paths: int) -> int:
|
|
99
|
+
if isinstance(max_paths, bool) or not isinstance(max_paths, (int, np.integer)) or max_paths < 1:
|
|
100
|
+
raise ValueError("max_paths must be a positive integer")
|
|
101
|
+
return int(max_paths)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class SkeletonPath:
|
|
106
|
+
xy: np.ndarray
|
|
107
|
+
edge_ids: tuple[int, ...]
|
|
108
|
+
start_node: int
|
|
109
|
+
end_node: int
|
|
110
|
+
length_px: float
|
|
111
|
+
confidence: float
|
|
112
|
+
cycle_count: int
|
|
113
|
+
ambiguous: bool
|
|
114
|
+
orientation_ambiguous: bool
|
|
115
|
+
alternatives: int
|
|
116
|
+
score: PathScore
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class TopologyPathInventory:
|
|
121
|
+
graph: SkeletonGraph
|
|
122
|
+
paths_by_kind: dict[
|
|
123
|
+
Literal["open", "loop", "open-with-loop"],
|
|
124
|
+
tuple[tuple[tuple[int, ...], int, int, int], ...],
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _pixel_adjacency(skeleton: np.ndarray) -> dict[Pixel, tuple[Pixel, ...]]:
|
|
129
|
+
"""Use diagonal neighbors only when neither orthogonal bridge pixel exists."""
|
|
130
|
+
pixels = {tuple(map(int, pixel)) for pixel in np.argwhere(skeleton)}
|
|
131
|
+
adjacency: dict[Pixel, tuple[Pixel, ...]] = {}
|
|
132
|
+
for row, column in sorted(pixels):
|
|
133
|
+
neighbors = []
|
|
134
|
+
for dy in (-1, 0, 1):
|
|
135
|
+
for dx in (-1, 0, 1):
|
|
136
|
+
if dx == 0 and dy == 0:
|
|
137
|
+
continue
|
|
138
|
+
neighbor = (row + dy, column + dx)
|
|
139
|
+
if dx and dy and ((row, column + dx) in pixels or (row + dy, column) in pixels):
|
|
140
|
+
continue
|
|
141
|
+
if neighbor in pixels:
|
|
142
|
+
neighbors.append(neighbor)
|
|
143
|
+
adjacency[(row, column)] = tuple(sorted(neighbors))
|
|
144
|
+
return adjacency
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _components(adjacency: dict[Pixel, tuple[Pixel, ...]]) -> list[set[Pixel]]:
|
|
148
|
+
remaining = set(adjacency)
|
|
149
|
+
components = []
|
|
150
|
+
while remaining:
|
|
151
|
+
start = min(remaining)
|
|
152
|
+
remaining.remove(start)
|
|
153
|
+
component = {start}
|
|
154
|
+
queue = deque([start])
|
|
155
|
+
while queue:
|
|
156
|
+
current = queue.popleft()
|
|
157
|
+
for neighbor in adjacency[current]:
|
|
158
|
+
if neighbor in remaining:
|
|
159
|
+
remaining.remove(neighbor)
|
|
160
|
+
component.add(neighbor)
|
|
161
|
+
queue.append(neighbor)
|
|
162
|
+
components.append(component)
|
|
163
|
+
return components
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def prune_spurs(skeleton: np.ndarray, min_length_px: float) -> np.ndarray:
|
|
167
|
+
"""Remove endpoint-to-junction branches shorter than the configured length."""
|
|
168
|
+
source = np.asarray(skeleton, dtype=bool)
|
|
169
|
+
if source.ndim != 2:
|
|
170
|
+
raise ValueError("skeleton must be a two-dimensional mask")
|
|
171
|
+
if not np.isfinite(min_length_px) or min_length_px < 0.0:
|
|
172
|
+
raise ValueError("minimum spur length must be finite and nonnegative")
|
|
173
|
+
result = source.copy()
|
|
174
|
+
if min_length_px == 0.0:
|
|
175
|
+
return result
|
|
176
|
+
while True:
|
|
177
|
+
adjacency = _pixel_adjacency(result)
|
|
178
|
+
degrees = {pixel: len(neighbors) for pixel, neighbors in adjacency.items()}
|
|
179
|
+
remove: set[Pixel] = set()
|
|
180
|
+
for endpoint in sorted(pixel for pixel, degree in degrees.items() if degree == 1):
|
|
181
|
+
path = [endpoint]
|
|
182
|
+
previous = None
|
|
183
|
+
current = endpoint
|
|
184
|
+
length = 0.0
|
|
185
|
+
while True:
|
|
186
|
+
next_pixels = [pixel for pixel in adjacency[current] if pixel != previous]
|
|
187
|
+
if len(next_pixels) != 1:
|
|
188
|
+
break
|
|
189
|
+
following = next_pixels[0]
|
|
190
|
+
length += hypot(following[0] - current[0], following[1] - current[1])
|
|
191
|
+
path.append(following)
|
|
192
|
+
previous, current = current, following
|
|
193
|
+
if degrees.get(current, 0) > 2 and length < min_length_px:
|
|
194
|
+
remove.update(path[:-1])
|
|
195
|
+
if not remove:
|
|
196
|
+
return result
|
|
197
|
+
for row, column in remove:
|
|
198
|
+
result[row, column] = False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _radius_and_confidence(
|
|
202
|
+
point: Pixel,
|
|
203
|
+
radius_map: np.ndarray | None,
|
|
204
|
+
confidence_map: np.ndarray,
|
|
205
|
+
) -> tuple[float, float]:
|
|
206
|
+
row, column = point
|
|
207
|
+
radius = float(radius_map[row, column]) if radius_map is not None else 0.0
|
|
208
|
+
return radius, float(confidence_map[row, column])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_skeleton_graph(
|
|
212
|
+
observed_ink: np.ndarray,
|
|
213
|
+
*,
|
|
214
|
+
min_spur_length_px: float = 3.0,
|
|
215
|
+
radius: np.ndarray | None = None,
|
|
216
|
+
confidence: np.ndarray | None = None,
|
|
217
|
+
already_skeleton: bool = False,
|
|
218
|
+
) -> SkeletonGraph:
|
|
219
|
+
"""Skeletonize observed ink and collapse degree-two pixel chains into graph edges."""
|
|
220
|
+
ink = np.asarray(observed_ink, dtype=bool)
|
|
221
|
+
if ink.ndim != 2:
|
|
222
|
+
raise ValueError("observed ink must be a two-dimensional mask")
|
|
223
|
+
if radius is not None:
|
|
224
|
+
radius = np.asarray(radius, dtype=float)
|
|
225
|
+
if radius.shape != ink.shape or not np.isfinite(radius).all() or np.any(radius < 0.0):
|
|
226
|
+
raise ValueError("radius map must be finite, nonnegative, and match observed ink")
|
|
227
|
+
if confidence is None:
|
|
228
|
+
confidence_map = np.ones(ink.shape, dtype=float)
|
|
229
|
+
else:
|
|
230
|
+
confidence_map = np.asarray(confidence, dtype=float)
|
|
231
|
+
if (
|
|
232
|
+
confidence_map.shape != ink.shape
|
|
233
|
+
or not np.isfinite(confidence_map).all()
|
|
234
|
+
or np.any((confidence_map < 0.0) | (confidence_map > 1.0))
|
|
235
|
+
):
|
|
236
|
+
raise ValueError("confidence map must be between zero and one and match observed ink")
|
|
237
|
+
|
|
238
|
+
thin = ink.copy() if already_skeleton else skeletonize(ink)
|
|
239
|
+
thin = prune_spurs(thin, min_spur_length_px)
|
|
240
|
+
adjacency = _pixel_adjacency(thin)
|
|
241
|
+
degrees = {pixel: len(neighbors) for pixel, neighbors in adjacency.items()}
|
|
242
|
+
components = _components(adjacency)
|
|
243
|
+
node_pixels = {pixel for pixel, degree in degrees.items() if degree != 2}
|
|
244
|
+
for component in components:
|
|
245
|
+
if component and all(degrees[pixel] == 2 for pixel in component):
|
|
246
|
+
node_pixels.add(min(component))
|
|
247
|
+
|
|
248
|
+
ordered_nodes = sorted(node_pixels)
|
|
249
|
+
node_ids = {pixel: index for index, pixel in enumerate(ordered_nodes)}
|
|
250
|
+
nodes = []
|
|
251
|
+
for pixel in ordered_nodes:
|
|
252
|
+
radius_value, confidence_value = _radius_and_confidence(pixel, radius, confidence_map)
|
|
253
|
+
nodes.append(
|
|
254
|
+
SkeletonNode(
|
|
255
|
+
id=node_ids[pixel],
|
|
256
|
+
xy=(float(pixel[1]), float(pixel[0])),
|
|
257
|
+
degree=degrees[pixel],
|
|
258
|
+
radius=radius_value,
|
|
259
|
+
confidence=confidence_value,
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
visited: set[frozenset[Pixel]] = set()
|
|
264
|
+
edges = []
|
|
265
|
+
for start in ordered_nodes:
|
|
266
|
+
for neighbor in adjacency[start]:
|
|
267
|
+
first_key = frozenset((start, neighbor))
|
|
268
|
+
if first_key in visited:
|
|
269
|
+
continue
|
|
270
|
+
chain = [start, neighbor]
|
|
271
|
+
visited.add(first_key)
|
|
272
|
+
previous, current = start, neighbor
|
|
273
|
+
while current not in node_pixels:
|
|
274
|
+
following_pixels = [pixel for pixel in adjacency[current] if pixel != previous]
|
|
275
|
+
if len(following_pixels) != 1:
|
|
276
|
+
break
|
|
277
|
+
following = following_pixels[0]
|
|
278
|
+
key = frozenset((current, following))
|
|
279
|
+
if key in visited:
|
|
280
|
+
break
|
|
281
|
+
visited.add(key)
|
|
282
|
+
chain.append(following)
|
|
283
|
+
previous, current = current, following
|
|
284
|
+
if current not in node_ids:
|
|
285
|
+
continue
|
|
286
|
+
xy = np.asarray([(column, row) for row, column in chain], dtype=float)
|
|
287
|
+
length = float(np.linalg.norm(np.diff(xy, axis=0), axis=1).sum())
|
|
288
|
+
radius_values = [
|
|
289
|
+
_radius_and_confidence(pixel, radius, confidence_map)[0] for pixel in chain
|
|
290
|
+
]
|
|
291
|
+
confidence_values = [
|
|
292
|
+
_radius_and_confidence(pixel, radius, confidence_map)[1] for pixel in chain
|
|
293
|
+
]
|
|
294
|
+
edges.append(
|
|
295
|
+
SkeletonEdge(
|
|
296
|
+
a=node_ids[start],
|
|
297
|
+
b=node_ids[current],
|
|
298
|
+
pixels=xy,
|
|
299
|
+
length_px=length,
|
|
300
|
+
mean_radius_px=float(np.mean(radius_values)),
|
|
301
|
+
confidence=float(np.mean(confidence_values)),
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return SkeletonGraph(tuple(nodes), tuple(edges))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _other_node(edge: SkeletonEdge, node_id: int) -> int:
|
|
309
|
+
return edge.b if edge.a == node_id else edge.a
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _edge_route(
|
|
313
|
+
graph: SkeletonGraph, edge_ids: tuple[int, ...], start: int
|
|
314
|
+
) -> tuple[np.ndarray, int]:
|
|
315
|
+
current = start
|
|
316
|
+
points: list[np.ndarray] = []
|
|
317
|
+
for edge_id in edge_ids:
|
|
318
|
+
edge = graph.edges[edge_id]
|
|
319
|
+
if edge.a == current:
|
|
320
|
+
edge_points = edge.pixels
|
|
321
|
+
elif edge.b == current:
|
|
322
|
+
edge_points = edge.pixels[::-1]
|
|
323
|
+
else:
|
|
324
|
+
raise ValueError("edge sequence is not connected")
|
|
325
|
+
points.extend(edge_points if not points else edge_points[1:])
|
|
326
|
+
current = _other_node(edge, current)
|
|
327
|
+
return np.asarray(points, dtype=float), current
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _open_paths(
|
|
331
|
+
graph: SkeletonGraph,
|
|
332
|
+
path_kind: Literal["open", "open-with-loop"],
|
|
333
|
+
*,
|
|
334
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
335
|
+
) -> list[tuple[tuple[int, ...], int, int, int]]:
|
|
336
|
+
max_paths = _validate_max_paths(max_paths)
|
|
337
|
+
incident: dict[int, list[int]] = {node.id: [] for node in graph.nodes}
|
|
338
|
+
for edge_id, edge in enumerate(graph.edges):
|
|
339
|
+
incident[edge.a].append(edge_id)
|
|
340
|
+
if edge.b != edge.a:
|
|
341
|
+
incident[edge.b].append(edge_id)
|
|
342
|
+
endpoints = sorted(node.id for node in graph.nodes if node.degree == 1)
|
|
343
|
+
paths: dict[tuple[tuple[int, ...], int], tuple[tuple[int, ...], int, int, int]] = {}
|
|
344
|
+
allow_one_cycle = path_kind == "open-with-loop"
|
|
345
|
+
|
|
346
|
+
def walk(
|
|
347
|
+
start: int,
|
|
348
|
+
current: int,
|
|
349
|
+
used_edges: tuple[int, ...],
|
|
350
|
+
visits: dict[int, int],
|
|
351
|
+
cycle_count: int,
|
|
352
|
+
) -> None:
|
|
353
|
+
if current != start and current in endpoints:
|
|
354
|
+
if (path_kind == "open" and cycle_count == 0) or (allow_one_cycle and cycle_count == 1):
|
|
355
|
+
key = (tuple(sorted(used_edges)), start)
|
|
356
|
+
if key not in paths:
|
|
357
|
+
if len(paths) >= max_paths:
|
|
358
|
+
raise PathSearchLimitError(
|
|
359
|
+
f"{path_kind} route enumeration exceeded max_paths={max_paths}"
|
|
360
|
+
)
|
|
361
|
+
paths[key] = (used_edges, start, current, cycle_count)
|
|
362
|
+
return
|
|
363
|
+
used = set(used_edges)
|
|
364
|
+
for edge_id in sorted(incident[current]):
|
|
365
|
+
if edge_id in used:
|
|
366
|
+
continue
|
|
367
|
+
next_node = _other_node(graph.edges[edge_id], current)
|
|
368
|
+
next_visits = visits.get(next_node, 0)
|
|
369
|
+
if next_visits >= 2:
|
|
370
|
+
continue
|
|
371
|
+
added_cycle = int(next_visits > 0)
|
|
372
|
+
if cycle_count + added_cycle > 1:
|
|
373
|
+
continue
|
|
374
|
+
walk(
|
|
375
|
+
start,
|
|
376
|
+
next_node,
|
|
377
|
+
(*used_edges, edge_id),
|
|
378
|
+
visits | {next_node: next_visits + 1},
|
|
379
|
+
cycle_count + added_cycle,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
for start in endpoints:
|
|
383
|
+
walk(start, start, (), {start: 1}, 0)
|
|
384
|
+
return list(paths.values())
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _loop_paths(
|
|
388
|
+
graph: SkeletonGraph, *, max_paths: int = DEFAULT_MAX_PATHS
|
|
389
|
+
) -> list[tuple[tuple[int, ...], int, int, int]]:
|
|
390
|
+
max_paths = _validate_max_paths(max_paths)
|
|
391
|
+
incident: dict[int, list[int]] = {node.id: [] for node in graph.nodes}
|
|
392
|
+
for edge_id, edge in enumerate(graph.edges):
|
|
393
|
+
incident[edge.a].append(edge_id)
|
|
394
|
+
if edge.b != edge.a:
|
|
395
|
+
incident[edge.b].append(edge_id)
|
|
396
|
+
paths: dict[tuple[int, ...], tuple[tuple[int, ...], int, int, int]] = {}
|
|
397
|
+
|
|
398
|
+
def walk(
|
|
399
|
+
start: int,
|
|
400
|
+
current: int,
|
|
401
|
+
used_edges: tuple[int, ...],
|
|
402
|
+
visited_nodes: set[int],
|
|
403
|
+
) -> None:
|
|
404
|
+
for edge_id in sorted(incident[current]):
|
|
405
|
+
if edge_id in used_edges:
|
|
406
|
+
continue
|
|
407
|
+
next_node = _other_node(graph.edges[edge_id], current)
|
|
408
|
+
if next_node == start:
|
|
409
|
+
sequence = (*used_edges, edge_id)
|
|
410
|
+
key = tuple(sorted(sequence))
|
|
411
|
+
if key not in paths:
|
|
412
|
+
if len(paths) >= max_paths:
|
|
413
|
+
raise PathSearchLimitError(
|
|
414
|
+
f"loop route enumeration exceeded max_paths={max_paths}"
|
|
415
|
+
)
|
|
416
|
+
paths[key] = (sequence, start, start, 1)
|
|
417
|
+
elif next_node not in visited_nodes:
|
|
418
|
+
walk(start, next_node, (*used_edges, edge_id), visited_nodes | {next_node})
|
|
419
|
+
|
|
420
|
+
for node in graph.nodes:
|
|
421
|
+
walk(node.id, node.id, (), {node.id})
|
|
422
|
+
return list(paths.values())
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def prepare_topology_paths(
|
|
426
|
+
graph: SkeletonGraph, *, max_paths: int = DEFAULT_MAX_PATHS
|
|
427
|
+
) -> TopologyPathInventory:
|
|
428
|
+
"""Enumerate routes once, capped at ``max_paths`` per route kind.
|
|
429
|
+
|
|
430
|
+
Exhaustive route enumeration may grow exponentially on branched or cyclic
|
|
431
|
+
graphs. The cap counts distinct routes, including both orientations for open
|
|
432
|
+
paths, and raises :class:`PathSearchLimitError` instead of returning a partial
|
|
433
|
+
inventory.
|
|
434
|
+
"""
|
|
435
|
+
max_paths = _validate_max_paths(max_paths)
|
|
436
|
+
return TopologyPathInventory(
|
|
437
|
+
graph=graph,
|
|
438
|
+
paths_by_kind={
|
|
439
|
+
"open": tuple(_open_paths(graph, "open", max_paths=max_paths)),
|
|
440
|
+
"loop": tuple(_loop_paths(graph, max_paths=max_paths)),
|
|
441
|
+
"open-with-loop": tuple(_open_paths(graph, "open-with-loop", max_paths=max_paths)),
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _score_landscapes_overlap(first: PathScore, second: PathScore) -> bool:
|
|
447
|
+
if (
|
|
448
|
+
first.guide_distance_px is not None
|
|
449
|
+
and second.guide_distance_px is not None
|
|
450
|
+
and abs(first.guide_distance_px - second.guide_distance_px) >= 0.5
|
|
451
|
+
):
|
|
452
|
+
return False
|
|
453
|
+
if (
|
|
454
|
+
first.endpoint_orientation_error_px is not None
|
|
455
|
+
and second.endpoint_orientation_error_px is not None
|
|
456
|
+
and abs(first.endpoint_orientation_error_px - second.endpoint_orientation_error_px) >= 0.5
|
|
457
|
+
):
|
|
458
|
+
return False
|
|
459
|
+
if abs(first.measured_confidence - second.measured_confidence) >= 0.05:
|
|
460
|
+
return False
|
|
461
|
+
if (
|
|
462
|
+
first.length_error_px is not None
|
|
463
|
+
and second.length_error_px is not None
|
|
464
|
+
and abs(first.length_error_px - second.length_error_px) >= 1.0
|
|
465
|
+
):
|
|
466
|
+
return False
|
|
467
|
+
return (
|
|
468
|
+
first.turning_cost is None
|
|
469
|
+
or second.turning_cost is None
|
|
470
|
+
or abs(first.turning_cost - second.turning_cost) < 0.1
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def rank_topology_paths(
|
|
475
|
+
graph: SkeletonGraph,
|
|
476
|
+
*,
|
|
477
|
+
path_kind: Literal["open", "loop", "open-with-loop"] = "open",
|
|
478
|
+
expected_cycles: int | None = None,
|
|
479
|
+
expected_endpoints: tuple[Point | None, Point | None] | None = None,
|
|
480
|
+
endpoint_tolerance_px: float = 3.0,
|
|
481
|
+
expected_length_px: float | None = None,
|
|
482
|
+
candidate_xy: np.ndarray | None = None,
|
|
483
|
+
path_inventory: TopologyPathInventory | None = None,
|
|
484
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
485
|
+
) -> tuple[SkeletonPath, ...]:
|
|
486
|
+
"""Rank feasible routes by candidate geometry, confidence, and later tie-breakers.
|
|
487
|
+
|
|
488
|
+
Feasibility constraints are applied before scoring. With a candidate curve,
|
|
489
|
+
symmetric guide distance is primary, endpoint orientation breaks geometric
|
|
490
|
+
ties, and measured confidence is secondary. Expected length, sampled turning
|
|
491
|
+
cost, and edge IDs provide deterministic later tie-breakers.
|
|
492
|
+
"""
|
|
493
|
+
if path_kind not in {"open", "loop", "open-with-loop"}:
|
|
494
|
+
raise ValueError(f"unsupported path kind: {path_kind}")
|
|
495
|
+
if path_inventory is not None and path_inventory.graph is not graph:
|
|
496
|
+
raise ValueError("topology path inventory belongs to a different skeleton graph")
|
|
497
|
+
if expected_cycles is not None and (
|
|
498
|
+
isinstance(expected_cycles, bool)
|
|
499
|
+
or not isinstance(expected_cycles, (int, np.integer))
|
|
500
|
+
or expected_cycles < 0
|
|
501
|
+
):
|
|
502
|
+
raise ValueError("expected cycle count must be a nonnegative integer")
|
|
503
|
+
if not np.isfinite(endpoint_tolerance_px) or endpoint_tolerance_px < 0.0:
|
|
504
|
+
raise ValueError("endpoint tolerance must be finite and nonnegative")
|
|
505
|
+
if expected_length_px is not None and (
|
|
506
|
+
not np.isfinite(expected_length_px) or expected_length_px <= 0.0
|
|
507
|
+
):
|
|
508
|
+
raise ValueError("expected path length must be positive and finite")
|
|
509
|
+
candidate = None if candidate_xy is None else np.asarray(candidate_xy, dtype=float)
|
|
510
|
+
if candidate is not None:
|
|
511
|
+
if candidate.ndim != 2 or candidate.shape[1] != 2 or len(candidate) < 2:
|
|
512
|
+
raise ValueError("candidate curve must contain at least two finite xy points")
|
|
513
|
+
if not np.isfinite(candidate).all() or polyline_length(candidate) <= 1e-12:
|
|
514
|
+
raise ValueError("candidate curve must contain at least two distinct finite xy points")
|
|
515
|
+
|
|
516
|
+
max_paths = _validate_max_paths(max_paths)
|
|
517
|
+
if path_inventory is not None:
|
|
518
|
+
candidates = path_inventory.paths_by_kind[path_kind]
|
|
519
|
+
if len(candidates) > max_paths:
|
|
520
|
+
raise PathSearchLimitError(f"{path_kind} route inventory exceeds max_paths={max_paths}")
|
|
521
|
+
elif path_kind == "loop":
|
|
522
|
+
candidates = _loop_paths(graph, max_paths=max_paths)
|
|
523
|
+
else:
|
|
524
|
+
candidates = _open_paths(graph, path_kind, max_paths=max_paths)
|
|
525
|
+
if expected_endpoints is None:
|
|
526
|
+
start_expected, end_expected = None, None
|
|
527
|
+
else:
|
|
528
|
+
if len(expected_endpoints) != 2:
|
|
529
|
+
raise ValueError("expected endpoints must contain two xy pairs or None")
|
|
530
|
+
start_expected, end_expected = expected_endpoints
|
|
531
|
+
expected_points: list[np.ndarray | None] = []
|
|
532
|
+
for point in (start_expected, end_expected):
|
|
533
|
+
if point is None:
|
|
534
|
+
expected_points.append(None)
|
|
535
|
+
continue
|
|
536
|
+
value = np.asarray(point, dtype=float)
|
|
537
|
+
if value.shape != (2,) or not np.isfinite(value).all():
|
|
538
|
+
raise ValueError("expected endpoint coordinates must be finite xy pairs")
|
|
539
|
+
expected_points.append(value)
|
|
540
|
+
start_expected_xy, end_expected_xy = expected_points
|
|
541
|
+
start_nodes = {node.id: np.asarray(node.xy) for node in graph.nodes}
|
|
542
|
+
scored: list[tuple[tuple[int, ...], int, int, int, np.ndarray, float, PathScore]] = []
|
|
543
|
+
for edge_ids, start, end, cycles in candidates:
|
|
544
|
+
if expected_cycles is not None and cycles != expected_cycles:
|
|
545
|
+
continue
|
|
546
|
+
if (
|
|
547
|
+
start_expected_xy is not None
|
|
548
|
+
and np.linalg.norm(start_nodes[start] - start_expected_xy) > endpoint_tolerance_px
|
|
549
|
+
):
|
|
550
|
+
continue
|
|
551
|
+
if (
|
|
552
|
+
end_expected_xy is not None
|
|
553
|
+
and np.linalg.norm(start_nodes[end] - end_expected_xy) > endpoint_tolerance_px
|
|
554
|
+
):
|
|
555
|
+
continue
|
|
556
|
+
xy, routed_end = _edge_route(graph, edge_ids, start)
|
|
557
|
+
if routed_end != end:
|
|
558
|
+
continue
|
|
559
|
+
length = float(sum(graph.edges[edge_id].length_px for edge_id in edge_ids))
|
|
560
|
+
confidence = float(
|
|
561
|
+
np.average(
|
|
562
|
+
[graph.edges[edge_id].confidence for edge_id in edge_ids],
|
|
563
|
+
weights=[max(graph.edges[edge_id].length_px, 1e-12) for edge_id in edge_ids],
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
guide_distance = (
|
|
567
|
+
symmetric_polyline_distance(xy, candidate) if candidate is not None else None
|
|
568
|
+
)
|
|
569
|
+
orientation_error = (
|
|
570
|
+
float(np.linalg.norm(xy[0] - candidate[0]) + np.linalg.norm(xy[-1] - candidate[-1]))
|
|
571
|
+
if candidate is not None
|
|
572
|
+
else None
|
|
573
|
+
)
|
|
574
|
+
score = PathScore(
|
|
575
|
+
guide_distance_px=guide_distance,
|
|
576
|
+
endpoint_orientation_error_px=orientation_error,
|
|
577
|
+
measured_confidence=confidence,
|
|
578
|
+
length_error_px=(
|
|
579
|
+
abs(length - expected_length_px) if expected_length_px is not None else None
|
|
580
|
+
),
|
|
581
|
+
turning_cost=mean_squared_turn_angle(xy, samples=64),
|
|
582
|
+
)
|
|
583
|
+
scored.append((edge_ids, start, end, cycles, xy, length, score))
|
|
584
|
+
|
|
585
|
+
def sort_key(
|
|
586
|
+
item: tuple[tuple[int, ...], int, int, int, np.ndarray, float, PathScore],
|
|
587
|
+
) -> tuple[float, float, float, float, float, tuple[int, ...], int, int]:
|
|
588
|
+
edge_ids, start, end, _, _, _, score = item
|
|
589
|
+
return (
|
|
590
|
+
round(score.guide_distance_px, 9) if score.guide_distance_px is not None else 0.0,
|
|
591
|
+
score.endpoint_orientation_error_px
|
|
592
|
+
if score.endpoint_orientation_error_px is not None
|
|
593
|
+
else 0.0,
|
|
594
|
+
-score.measured_confidence,
|
|
595
|
+
score.length_error_px if score.length_error_px is not None else 0.0,
|
|
596
|
+
score.turning_cost if score.turning_cost is not None else 0.0,
|
|
597
|
+
edge_ids,
|
|
598
|
+
start,
|
|
599
|
+
end,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
scored.sort(key=sort_key)
|
|
603
|
+
if not scored:
|
|
604
|
+
return ()
|
|
605
|
+
|
|
606
|
+
selected = scored[0]
|
|
607
|
+
selected_route = tuple(sorted(selected[0]))
|
|
608
|
+
ambiguous = any(
|
|
609
|
+
tuple(sorted(item[0])) != selected_route and _score_landscapes_overlap(selected[6], item[6])
|
|
610
|
+
for item in scored[1:]
|
|
611
|
+
)
|
|
612
|
+
orientation_ambiguous = any(
|
|
613
|
+
tuple(sorted(item[0])) == selected_route
|
|
614
|
+
and np.allclose(item[4], selected[4][::-1])
|
|
615
|
+
and (
|
|
616
|
+
selected[6].endpoint_orientation_error_px is None
|
|
617
|
+
or item[6].endpoint_orientation_error_px is None
|
|
618
|
+
or abs(
|
|
619
|
+
selected[6].endpoint_orientation_error_px - item[6].endpoint_orientation_error_px
|
|
620
|
+
)
|
|
621
|
+
< 0.5
|
|
622
|
+
)
|
|
623
|
+
for item in scored[1:]
|
|
624
|
+
)
|
|
625
|
+
paths = []
|
|
626
|
+
for index, (edge_ids, start, end, cycles, xy, length, score) in enumerate(scored):
|
|
627
|
+
paths.append(
|
|
628
|
+
SkeletonPath(
|
|
629
|
+
xy=xy,
|
|
630
|
+
edge_ids=edge_ids,
|
|
631
|
+
start_node=start,
|
|
632
|
+
end_node=end,
|
|
633
|
+
length_px=length,
|
|
634
|
+
confidence=score.measured_confidence,
|
|
635
|
+
cycle_count=cycles,
|
|
636
|
+
ambiguous=index == 0 and ambiguous,
|
|
637
|
+
orientation_ambiguous=index == 0 and orientation_ambiguous,
|
|
638
|
+
alternatives=len(scored),
|
|
639
|
+
score=score,
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
return tuple(paths)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def select_topology_path(
|
|
646
|
+
graph: SkeletonGraph,
|
|
647
|
+
*,
|
|
648
|
+
path_kind: Literal["open", "loop", "open-with-loop"] = "open",
|
|
649
|
+
expected_cycles: int | None = None,
|
|
650
|
+
expected_endpoints: tuple[Point | None, Point | None] | None = None,
|
|
651
|
+
endpoint_tolerance_px: float = 3.0,
|
|
652
|
+
expected_length_px: float | None = None,
|
|
653
|
+
candidate_xy: np.ndarray | None = None,
|
|
654
|
+
path_inventory: TopologyPathInventory | None = None,
|
|
655
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
656
|
+
) -> SkeletonPath:
|
|
657
|
+
"""Choose the highest-ranked route satisfying route and endpoint constraints."""
|
|
658
|
+
ranked = rank_topology_paths(
|
|
659
|
+
graph,
|
|
660
|
+
path_kind=path_kind,
|
|
661
|
+
expected_cycles=expected_cycles,
|
|
662
|
+
expected_endpoints=expected_endpoints,
|
|
663
|
+
endpoint_tolerance_px=endpoint_tolerance_px,
|
|
664
|
+
expected_length_px=expected_length_px,
|
|
665
|
+
candidate_xy=candidate_xy,
|
|
666
|
+
path_inventory=path_inventory,
|
|
667
|
+
max_paths=max_paths,
|
|
668
|
+
)
|
|
669
|
+
if ranked:
|
|
670
|
+
return ranked[0]
|
|
671
|
+
raise NoFeasiblePathError(
|
|
672
|
+
f"no path satisfies topology kind {path_kind!r}, expected cycle count "
|
|
673
|
+
f"{expected_cycles!r}, and endpoint constraints"
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def graph_from_mask(
|
|
678
|
+
mask: np.ndarray,
|
|
679
|
+
*,
|
|
680
|
+
min_spur_length: float = 3.0,
|
|
681
|
+
radius: np.ndarray | None = None,
|
|
682
|
+
confidence: np.ndarray | None = None,
|
|
683
|
+
min_component_area: int = 0,
|
|
684
|
+
) -> SkeletonGraph:
|
|
685
|
+
"""Build a graph from a binary mask, deriving raster radius when omitted.
|
|
686
|
+
|
|
687
|
+
Components smaller than ``min_component_area`` pixels are removed. The
|
|
688
|
+
default of zero keeps all components. Public coordinates are always
|
|
689
|
+
``(x=column, y=row)``.
|
|
690
|
+
"""
|
|
691
|
+
source = np.asarray(mask, dtype=bool)
|
|
692
|
+
if source.ndim != 2:
|
|
693
|
+
raise ValueError("mask must be a two-dimensional image")
|
|
694
|
+
if (
|
|
695
|
+
isinstance(min_component_area, bool)
|
|
696
|
+
or not isinstance(min_component_area, (int, np.integer))
|
|
697
|
+
or min_component_area < 0
|
|
698
|
+
):
|
|
699
|
+
raise ValueError("minimum component area must be a nonnegative integer")
|
|
700
|
+
if min_component_area > 0:
|
|
701
|
+
labels, _ = label_components(source, structure=np.ones((3, 3), dtype=np.uint8))
|
|
702
|
+
areas = np.bincount(labels.ravel())
|
|
703
|
+
keep = areas >= min_component_area
|
|
704
|
+
keep[0] = False
|
|
705
|
+
source = keep[labels]
|
|
706
|
+
if radius is None:
|
|
707
|
+
radius = distance_transform_edt(source)
|
|
708
|
+
return build_skeleton_graph(
|
|
709
|
+
source,
|
|
710
|
+
min_spur_length_px=min_spur_length,
|
|
711
|
+
radius=radius,
|
|
712
|
+
confidence=confidence,
|
|
713
|
+
already_skeleton=False,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def graph_from_skeleton(
|
|
718
|
+
skeleton: np.ndarray,
|
|
719
|
+
*,
|
|
720
|
+
min_spur_length: float = 3.0,
|
|
721
|
+
radius: np.ndarray | None = None,
|
|
722
|
+
confidence: np.ndarray | None = None,
|
|
723
|
+
) -> SkeletonGraph:
|
|
724
|
+
"""Build a compact graph from an already-thinned 2-D skeleton."""
|
|
725
|
+
return build_skeleton_graph(
|
|
726
|
+
skeleton,
|
|
727
|
+
min_spur_length_px=min_spur_length,
|
|
728
|
+
radius=radius,
|
|
729
|
+
confidence=confidence,
|
|
730
|
+
already_skeleton=True,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def rank_paths(
|
|
735
|
+
graph: SkeletonGraph,
|
|
736
|
+
*,
|
|
737
|
+
path_kind: Literal["open", "loop", "open-with-loop"] = "open",
|
|
738
|
+
expected_cycles: int | None = None,
|
|
739
|
+
expected_endpoints: tuple[Point | None, Point | None] | None = None,
|
|
740
|
+
endpoint_tolerance_px: float = 3.0,
|
|
741
|
+
expected_length_px: float | None = None,
|
|
742
|
+
candidate_xy: np.ndarray | None = None,
|
|
743
|
+
path_inventory: TopologyPathInventory | None = None,
|
|
744
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
745
|
+
) -> tuple[SkeletonPath, ...]:
|
|
746
|
+
"""Typed public alias for :func:`rank_topology_paths`."""
|
|
747
|
+
return rank_topology_paths(
|
|
748
|
+
graph,
|
|
749
|
+
path_kind=path_kind,
|
|
750
|
+
expected_cycles=expected_cycles,
|
|
751
|
+
expected_endpoints=expected_endpoints,
|
|
752
|
+
endpoint_tolerance_px=endpoint_tolerance_px,
|
|
753
|
+
expected_length_px=expected_length_px,
|
|
754
|
+
candidate_xy=candidate_xy,
|
|
755
|
+
path_inventory=path_inventory,
|
|
756
|
+
max_paths=max_paths,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def select_path(
|
|
761
|
+
graph: SkeletonGraph,
|
|
762
|
+
*,
|
|
763
|
+
path_kind: Literal["open", "loop", "open-with-loop"] = "open",
|
|
764
|
+
expected_cycles: int | None = None,
|
|
765
|
+
expected_endpoints: tuple[Point | None, Point | None] | None = None,
|
|
766
|
+
endpoint_tolerance_px: float = 3.0,
|
|
767
|
+
expected_length_px: float | None = None,
|
|
768
|
+
candidate_xy: np.ndarray | None = None,
|
|
769
|
+
path_inventory: TopologyPathInventory | None = None,
|
|
770
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
771
|
+
) -> SkeletonPath:
|
|
772
|
+
"""Typed public alias for :func:`select_topology_path`."""
|
|
773
|
+
return select_topology_path(
|
|
774
|
+
graph,
|
|
775
|
+
path_kind=path_kind,
|
|
776
|
+
expected_cycles=expected_cycles,
|
|
777
|
+
expected_endpoints=expected_endpoints,
|
|
778
|
+
endpoint_tolerance_px=endpoint_tolerance_px,
|
|
779
|
+
expected_length_px=expected_length_px,
|
|
780
|
+
candidate_xy=candidate_xy,
|
|
781
|
+
path_inventory=path_inventory,
|
|
782
|
+
max_paths=max_paths,
|
|
783
|
+
)
|