|
| 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() |
0 commit comments