Skip to content

Commit d12d5c9

Browse files
stmartineau99GOESTERN-1107078constantinpapeclaude
authored
instance segmentation pipeline - graph post processing (#69)
* instance segmentation pipeline - graph postprocessing, unit tests, example script * skeleton postprocessing - edits to code style and documentation * fix init file merge conflict * Update src/bioimage_cpp/skeleton/postprocessing.py * skeleton postprocessing cosmetic changes --------- Co-authored-by: GOESTERN-1107078 <u25914@glogin10.usr.hpc.gwdg.de> Co-authored-by: Constantin Pape <c.pape@gmx.net> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f364b89 commit d12d5c9

4 files changed

Lines changed: 781 additions & 1 deletion

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python
2+
"""Split a skeletonized mask into filament instances with bioimage_cpp.
3+
4+
Skeletonize a binary mask with TEASAR, clean the skeleton graph so each filament
5+
becomes its own connected component, and save the labeled instances.
6+
"""
7+
8+
import argparse
9+
from pathlib import Path
10+
11+
import numpy as np
12+
13+
from bioimage_cpp.graph import connected_components
14+
from bioimage_cpp.skeleton import clean_filament_graph, draw_instances, skeleton_to_graph, teasar
15+
16+
17+
def parse_args():
18+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
19+
parser.add_argument("--mask_path", required=True,
20+
help="Binary mask to skeletonize; an .h5 or MRC volume.")
21+
parser.add_argument("--seg_key", default=None,
22+
help="HDF5 dataset holding the mask; required for .h5 inputs.")
23+
parser.add_argument("--output_dir", default=".",
24+
help="Directory for the skeleton and instance .npz files.")
25+
parser.add_argument("--pixel_size", type=float, default=10.0,
26+
help="Isotropic voxel spacing for teasar; graph coordinates use these units.")
27+
parser.add_argument("--teasar_scale", type=float, default=0.0,
28+
help="teasar invalidation-radius scale.")
29+
parser.add_argument("--teasar_const", type=float, default=70.0,
30+
help="teasar invalidation-radius constant.")
31+
parser.add_argument("--number_of_threads", type=int, default=8,
32+
help="Threads for teasar.")
33+
parser.add_argument("--direction_span", type=int, default=10,
34+
help="Nodes walked along each arm to estimate its direction at a junction.")
35+
parser.add_argument("--min_through_angle", type=float, default=170.0,
36+
help="Min through-pair angle (degrees) for a degree-4 crossing to split.")
37+
parser.add_argument("--min_branch_angle", type=float, default=30.0,
38+
help="Min branch angle (degrees) for a degree-3 odd arm to be separated.")
39+
parser.add_argument("--tick_length", 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,
42+
help="Join collinear endpoints across gaps up to this distance; 0 disables.")
43+
parser.add_argument("--min_join_angle", type=float, default=175.0,
44+
help="Min straightness (degrees) for a join; 180 is collinear.")
45+
parser.add_argument("--circle_size", type=float, default=70.0,
46+
help="Instance tube diameter for the --view render, in --pixel_size units.")
47+
parser.add_argument("--view", action="store_true",
48+
help="Open the binary mask and instances in napari.")
49+
return parser.parse_args()
50+
51+
52+
def load_mask(mask_path, seg_key):
53+
path = Path(mask_path)
54+
if path.suffix in (".h5", ".hdf5"):
55+
import h5py
56+
57+
if seg_key is None:
58+
raise ValueError("--seg_key is required for HDF5 inputs")
59+
with h5py.File(path, "r") as f:
60+
if seg_key not in f:
61+
raise KeyError(f"{seg_key} not in {path}")
62+
return np.asarray(f[seg_key][:])
63+
64+
import mrcfile
65+
66+
with mrcfile.mmap(path, mode="r", permissive=True) as mrc:
67+
return np.asarray(mrc.data)
68+
69+
70+
def main():
71+
args = parse_args()
72+
mask = load_mask(args.mask_path, args.seg_key)
73+
binary = (mask > 0).astype(np.uint8)
74+
75+
spacing = (args.pixel_size,) * 3
76+
raw_vertices, raw_edges, raw_radii = teasar(
77+
binary, spacing=spacing, scale=args.teasar_scale, constant=args.teasar_const,
78+
number_of_threads=args.number_of_threads,
79+
)
80+
81+
vertices, edges, radii = clean_filament_graph(
82+
raw_vertices, raw_edges, radii=raw_radii,
83+
direction_span=args.direction_span, min_through_angle=args.min_through_angle,
84+
min_branch_angle=args.min_branch_angle, tick_length=args.tick_length,
85+
join_dist=args.join_dist, min_join_angle=args.min_join_angle,
86+
)
87+
labels = connected_components(skeleton_to_graph(vertices, edges))
88+
89+
name = Path(args.mask_path).stem
90+
output_dir = Path(args.output_dir)
91+
output_dir.mkdir(parents=True, exist_ok=True)
92+
93+
skeleton = dict(vertices=raw_vertices, edges=raw_edges)
94+
if raw_radii is not None:
95+
skeleton["radii"] = raw_radii
96+
instances = dict(vertices=vertices, edges=edges, labels=labels)
97+
if radii is not None:
98+
instances["radii"] = radii
99+
np.savez_compressed(output_dir / f"{name}_skeleton.npz", **skeleton)
100+
np.savez_compressed(output_dir / f"{name}_instances.npz", **instances)
101+
print(f"{name}: {len(np.unique(labels))} instances -> {output_dir}")
102+
103+
if args.view:
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()
112+
113+
114+
if __name__ == "__main__":
115+
main()

src/bioimage_cpp/skeleton/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .._validation import strict_index
1111
from ..distance._distance import _as_binary_input, _normalize_sampling, _normalize_threads
1212
from ._graph import skeleton_to_graph
13+
from .postprocessing import clean_filament_graph, draw_instances, join_close_components, remove_ticks
1314

1415

1516
_TEASAR_LABELS_BY_DTYPE = {
@@ -205,4 +206,4 @@ def teasar_labels(
205206
from . import distributed
206207

207208

208-
__all__ = ["distributed", "skeleton_to_graph", "teasar", "teasar_labels"]
209+
__all__ = ["clean_filament_graph", "distributed", "draw_instances", "join_close_components", "remove_ticks", "skeleton_to_graph", "teasar", "teasar_labels"]

0 commit comments

Comments
 (0)