Skip to content

Commit 503ff98

Browse files
author
GOESTERN-1107078
committed
skeleton postprocessing - edits to code style and documentation
1 parent 662f5b4 commit 503ff98

3 files changed

Lines changed: 81 additions & 125 deletions

File tree

examples/skeleton/instance_segmentation.py

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,35 @@
1717
def parse_args():
1818
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
1919
parser.add_argument("--mask_path", required=True,
20-
help="Binary mask to skeletonize; an .h5/.hdf5 or MRC volume.")
20+
help="Binary mask to skeletonize; an .h5 or MRC volume.")
2121
parser.add_argument("--seg_key", default=None,
22-
help="HDF5 dataset holding the mask; required for .h5/.hdf5 inputs.")
22+
help="HDF5 dataset holding the mask; required for .h5 inputs.")
2323
parser.add_argument("--output_dir", default=".",
2424
help="Directory for the skeleton and instance .npz files.")
25-
parser.add_argument("--tag", default="",
26-
help="Suffix appended to the output filenames.")
2725
parser.add_argument("--pixel_size", type=float, default=10.0,
2826
help="Isotropic voxel spacing for teasar; graph coordinates use these units.")
29-
parser.add_argument("--scale", type=float, default=0.0,
27+
parser.add_argument("--teasar_scale", type=float, default=0.0,
3028
help="teasar invalidation-radius scale.")
31-
parser.add_argument("--constant", type=float, default=70.0,
29+
parser.add_argument("--teasar_const", type=float, default=70.0,
3230
help="teasar invalidation-radius constant.")
3331
parser.add_argument("--number_of_threads", type=int, default=8,
3432
help="Threads for teasar.")
3533
parser.add_argument("--direction_span", type=int, default=10,
3634
help="Nodes walked along each arm to estimate its direction at a junction.")
3735
parser.add_argument("--min_through_angle", type=float, default=170.0,
38-
help="Min through-pair angle (deg) for a degree-4 crossing to split.")
36+
help="Min through-pair angle (degrees) for a degree-4 crossing to split.")
3937
parser.add_argument("--min_branch_angle", type=float, default=30.0,
40-
help="Min branch angle (deg) for a degree-3 odd arm to be separated.")
38+
help="Min branch angle (degrees) for a degree-3 odd arm to be separated.")
4139
parser.add_argument("--tick_length", type=float, default=50.0,
42-
help="Prune dead-end branches shorter than this; 0 disables.")
43-
parser.add_argument("--join_radius", type=float, default=50.0,
40+
help="Prune dead-end branches shorter than this distance; 0 disables.")
41+
parser.add_argument("--join_dist", type=float, default=50.0,
4442
help="Join collinear endpoints across gaps up to this distance; 0 disables.")
4543
parser.add_argument("--min_join_angle", type=float, default=175.0,
46-
help="Min straightness (deg) for a join; 180 is collinear.")
47-
parser.add_argument("--circle_size", type=float, default=40.0,
44+
help="Min straightness (degrees) for a join; 180 is collinear.")
45+
parser.add_argument("--circle_size", type=float, default=70.0,
4846
help="Instance tube diameter for the --view render, in --pixel_size units.")
4947
parser.add_argument("--view", action="store_true",
50-
help="Open the pipeline stages in napari.")
48+
help="Open the binary mask and instances in napari.")
5149
return parser.parse_args()
5250

5351

@@ -69,43 +67,26 @@ def load_mask(mask_path, seg_key):
6967
return np.asarray(mrc.data)
7068

7169

72-
def view_stages(binary, stages, pixel_size, circle_size):
73-
import napari
74-
75-
radius = (circle_size / 2) / pixel_size
76-
viewer = napari.Viewer(ndisplay=3)
77-
viewer.add_labels(binary, name="mask", opacity=0.4)
78-
for index, (name, vertices, edges, _) in enumerate(stages):
79-
labels = connected_components(skeleton_to_graph(vertices, edges))
80-
volume = draw_instances(vertices / pixel_size, edges, labels, binary.shape, radius)
81-
viewer.add_labels(volume, name=f"instances ({name})", visible=index == len(stages) - 1)
82-
napari.run()
83-
84-
8570
def main():
8671
args = parse_args()
8772
mask = load_mask(args.mask_path, args.seg_key)
8873
binary = (mask > 0).astype(np.uint8)
8974

9075
spacing = (args.pixel_size,) * 3
9176
raw_vertices, raw_edges, raw_radii = teasar(
92-
binary, spacing=spacing, scale=args.scale, constant=args.constant,
77+
binary, spacing=spacing, scale=args.teasar_scale, constant=args.teasar_const,
9378
number_of_threads=args.number_of_threads,
9479
)
9580

96-
stages = []
9781
vertices, edges, radii = clean_graph(
9882
raw_vertices, raw_edges, radii=raw_radii,
9983
direction_span=args.direction_span, min_through_angle=args.min_through_angle,
10084
min_branch_angle=args.min_branch_angle, tick_length=args.tick_length,
101-
join_radius=args.join_radius, min_join_angle=args.min_join_angle,
102-
save_intermediates=stages,
85+
join_dist=args.join_dist, min_join_angle=args.min_join_angle,
10386
)
10487
labels = connected_components(skeleton_to_graph(vertices, edges))
10588

10689
name = Path(args.mask_path).stem
107-
if args.tag:
108-
name = f"{name}_{args.tag}"
10990
output_dir = Path(args.output_dir)
11091
output_dir.mkdir(parents=True, exist_ok=True)
11192

@@ -120,7 +101,14 @@ def main():
120101
print(f"{name}: {len(np.unique(labels))} instances -> {output_dir}")
121102

122103
if args.view:
123-
view_stages(binary, stages, args.pixel_size, args.circle_size)
104+
import napari
105+
106+
radius = (args.circle_size / 2) / args.pixel_size
107+
volume = draw_instances(vertices / args.pixel_size, edges, labels, binary.shape, radius)
108+
viewer = napari.Viewer(ndisplay=3)
109+
viewer.add_labels(binary, name="mask", opacity=0.4)
110+
viewer.add_labels(volume, name="instances")
111+
napari.run()
124112

125113

126114
if __name__ == "__main__":

src/bioimage_cpp/skeleton/postprocessing.py

Lines changed: 38 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Resolve junction nodes so each filament becomes its own connected component.
44
Degree-3 and degree-4 nodes are split or pruned based on the angles between their
55
incident 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

99
from __future__ import annotations
@@ -13,6 +13,7 @@
1313
import numpy as np
1414

1515
from ..graph import connected_components
16+
from ..utils import UnionFind
1617
from ._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+
3648
def 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

210214
def 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-
440419
def 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

Comments
 (0)