33Resolve junction nodes so each filament becomes its own connected component.
44Degree-3 and degree-4 nodes are split or pruned based on the angles between their
55incident edges: the straightest pair is kept as the through-going filament and
6- the remaining arm(s) are either separated, or for short dead-ends (spurs), removed .
6+ the remaining arm(s) are either separated, or for short dead-ends (spurs), pruned .
77"""
88
99from __future__ import annotations
1313import numpy as np
1414
1515from ..graph import connected_components
16+ from ..utils import UnionFind
1617from ._graph import skeleton_to_graph
1718
1819
@@ -33,6 +34,17 @@ def _pair_angle(di, dj):
3334 return np .degrees (np .arccos (np .clip (di @ dj , - 1.0 , 1.0 )))
3435
3536
37+ def _compact (vertices , edges , radii ):
38+ used = np .zeros (len (vertices ), dtype = bool )
39+ if len (edges ):
40+ used [edges .reshape (- 1 )] = True
41+ remap = np .full (len (vertices ), - 1 , dtype = np .int64 )
42+ remap [used ] = np .arange (int (used .sum ()))
43+ if radii is not None :
44+ radii = radii [used ]
45+ return vertices [used ], remap [edges ], radii
46+
47+
3648def split_degree3 (v , graph , vertices , direction_span = 1 , min_branch_angle = 30.0 ):
3749 adj = np .asarray (graph .node_adjacency (int (v )))
3850 if adj .shape [0 ] != 3 :
@@ -78,7 +90,7 @@ def clean_graph(
7890 min_through_angle : float = 160.0 ,
7991 min_branch_angle : float = 30.0 ,
8092 tick_length : float = 0.0 ,
81- join_radius : float = 0.0 ,
93+ join_dist : float = 0.0 ,
8294 min_join_angle : float = 175.0 ,
8395 save_intermediates : list | None = None ,
8496) -> tuple [np .ndarray , np .ndarray , np .ndarray | None ]:
@@ -92,8 +104,8 @@ def clean_graph(
92104 from the through pair by at least ``min_branch_angle``.
93105 3. Split each degree-4 crossing, separating its two through pairs when they
94106 are collinear to within ``min_through_angle``.
95- 4. If ``join_radius > 0``, reconnect collinear endpoints across gaps less
96- than this distance via :func:`join_close_components`.
107+ 4. If ``join_dist > 0``, join collinear endpoints across gaps up to
108+ this distance via :func:`join_close_components`.
97109
98110 Parameters
99111 ----------
@@ -112,8 +124,8 @@ def clean_graph(
112124 through pair for the odd arm to be separated.
113125 tick_length:
114126 If > 0, prune dead-end branches shorter than this (physical) distance.
115- join_radius :
116- If > 0, reconnect collinear endpoints across gaps up to this (physical)
127+ join_dist :
128+ If > 0, join collinear endpoints across gaps up to this (physical)
117129 distance.
118130 min_join_angle:
119131 Minimum straightness (degrees) required for a join; 180 is collinear.
@@ -175,20 +187,12 @@ def _snapshot(name):
175187 keep [list (prune_edges )] = False
176188 edges = edges [keep ]
177189
178- used = np .zeros (len (vertices ), dtype = bool )
179- if len (edges ):
180- used [edges .reshape (- 1 )] = True
181- remap = np .full (len (vertices ), - 1 , dtype = np .int64 )
182- remap [used ] = np .arange (int (used .sum ()))
183- vertices = vertices [used ]
184- if radii is not None :
185- radii = radii [used ]
186- edges = remap [edges ]
190+ vertices , edges , radii = _compact (vertices , edges , radii )
187191 _snapshot ("split" )
188192
189- if join_radius > 0 :
193+ if join_dist > 0 :
190194 vertices , edges , radii = join_close_components (
191- vertices , edges , join_radius ,
195+ vertices , edges , join_dist ,
192196 min_join_angle = min_join_angle , direction_span = direction_span , radii = radii ,
193197 )
194198 _snapshot ("join" )
@@ -208,15 +212,15 @@ def _adjacency(num_nodes, edges):
208212
209213
210214def remove_ticks (vertices , edges , tick_length , radii = None ):
211- """Remove short dead-end branches ("ticks") from a skeleton graph.
215+ """Prune short dead-end branches ("ticks") from a skeleton graph.
212216
213217 Ports kimimaro's `remove_ticks`. A distance graph is built over the critical
214218 points (terminals, degree 1; branch points, degree >= 3), whose superedges
215219 are the paths between them weighted by physical length. The shortest terminal
216- branch below ``tick_length`` is removed repeatedly; when a branch point drops
220+ branch below ``tick_length`` is pruned repeatedly; when a branch point drops
217221 to degree 2 its two superedges are fused into one, so a real filament end is
218222 re-measured rather than clipped. Standalone paths (both ends terminal) are
219- never removed .
223+ never pruned .
220224
221225 Parameters
222226 ----------
@@ -225,7 +229,7 @@ def remove_ticks(vertices, edges, tick_length, radii=None):
225229 edges:
226230 Integer array with shape ``(E, 2)`` indexing ``vertices``.
227231 tick_length:
228- Maximum branch length (physical) that may be culled .
232+ Maximum branch length (physical) that may be pruned .
229233 radii:
230234 Optional per-vertex radii, carried through the same remapping.
231235
@@ -305,15 +309,7 @@ def remove_ticks(vertices, edges, tick_length, radii=None):
305309 else :
306310 edges = edges .copy ()
307311
308- used = np .zeros (num_nodes , dtype = bool )
309- if len (edges ):
310- used [edges .reshape (- 1 )] = True
311- remap = np .full (num_nodes , - 1 , dtype = np .int64 )
312- remap [used ] = np .arange (int (used .sum ()))
313- vertices = vertices [used ]
314- if radii is not None :
315- radii = radii [used ]
316- edges = remap [edges ]
312+ vertices , edges , radii = _compact (vertices , edges , radii )
317313 return vertices , edges , radii
318314
319315
@@ -333,23 +329,23 @@ def _endpoint_tangent(endpoint, indptr, dst, degrees, vertices, span):
333329 return direction / norm if norm > 0 else None
334330
335331
336- def join_close_components (vertices , edges , radius , * , min_join_angle = 175.0 ,
332+ def join_close_components (vertices , edges , dist , * , min_join_angle = 175.0 ,
337333 direction_span = 5 , radii = None ):
338334 """Reconnect fragmented filaments by joining collinear endpoints across gaps.
339335
340336 Endpoints (degree = 1) of different connected components are joined with a
341- new edge when they are within ``radius `` and the two fragments are nearly
337+ new edge when they are within ``dist `` and the two fragments are nearly
342338 collinear through the gap: the outward tangent at each endpoint must point
343339 along the gap to within ``180 - min_join_angle`` degrees, so a straight
344340 continuation reads ~180 (``min_join_angle``). Joins are made shortest-first
345- with a union-find so each pair of components is linked at most once and each
341+ with a union-find so each pair of components is joined at most once and each
346342 endpoint is used once.
347343
348344 Parameters
349345 ----------
350346 vertices, edges:
351347 Skeleton graph; ``edges`` indexes ``vertices``.
352- radius :
348+ dist :
353349 Maximum gap (physical) across which endpoints may be joined.
354350 min_join_angle:
355351 Minimum straightness (degrees) of the joined path; 180 is perfectly
@@ -381,7 +377,7 @@ def join_close_components(vertices, edges, radius, *, min_join_angle=175.0,
381377 tol = np .deg2rad (180.0 - min_join_angle )
382378 tree = cKDTree (vertices [endpoints ])
383379 candidates = []
384- for ia , ib in tree .query_pairs (radius ):
380+ for ia , ib in tree .query_pairs (dist ):
385381 a , b = int (endpoints [ia ]), int (endpoints [ib ])
386382 if labels [a ] == labels [b ]:
387383 continue
@@ -400,25 +396,15 @@ def join_close_components(vertices, edges, radius, *, min_join_angle=175.0,
400396 candidates .append ((dist , a , b ))
401397
402398 candidates .sort ()
403- parent = {}
404-
405- def find (x ):
406- parent .setdefault (x , x )
407- root = x
408- while parent [root ] != root :
409- root = parent [root ]
410- while parent [x ] != root :
411- parent [x ], x = root , parent [x ]
412- return root
399+ uf = UnionFind (int (labels .max ()) + 1 )
413400
414401 used , new_edges = set (), []
415402 for _ , a , b in candidates :
416403 if a in used or b in used :
417404 continue
418- ra , rb = find (int (labels [a ])), find (int (labels [b ]))
419- if ra == rb :
405+ if uf .find (int (labels [a ])) == uf .find (int (labels [b ])):
420406 continue
421- parent [ ra ] = rb
407+ uf . merge ( int ( labels [ a ]), int ( labels [ b ]))
422408 new_edges .append ([a , b ])
423409 used .add (a )
424410 used .add (b )
@@ -430,13 +416,6 @@ def find(x):
430416 return vertices , edges , radii
431417
432418
433- def _line_voxels (start , stop ):
434- delta = stop - start
435- steps = int (np .abs (delta ).max ()) + 1
436- t = np .linspace (0.0 , 1.0 , steps )
437- return np .rint (start [None , :] + t [:, None ] * delta [None , :]).astype (np .int64 )
438-
439-
440419def draw_instances (vertices , edges , labels , shape , radius = 1 ):
441420 """Rasterize a labeled skeleton graph into a dense instance volume.
442421
@@ -480,7 +459,10 @@ def draw_instances(vertices, edges, labels, shape, radius=1):
480459 vi = np .rint (vertices ).astype (np .int64 )
481460 centers , center_labels = [], []
482461 for a , b in edges :
483- pts = _line_voxels (vi [a ], vi [b ])
462+ delta = vi [b ] - vi [a ]
463+ steps = int (np .abs (delta ).max ()) + 1
464+ t = np .linspace (0.0 , 1.0 , steps )
465+ pts = np .rint (vi [a ][None , :] + t [:, None ] * delta [None , :]).astype (np .int64 )
484466 centers .append (pts )
485467 center_labels .append (np .full (len (pts ), int (labels [a ]) + 1 ))
486468 centers = np .concatenate (centers )
0 commit comments