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