|
1 | | -from skimage.metrics import adapted_rand_error as adapted_rand_ref |
2 | | -from skimage.metrics import variation_of_information as voi_ref |
3 | | -import elf.segmentation.multicut as mc |
4 | | -import elf.segmentation.features as feats |
5 | | -import elf.segmentation.watershed as ws |
| 1 | +"""ELF watershed + multicut postprocessing for SegNeuron probability outputs.""" |
| 2 | + |
| 3 | +import argparse |
| 4 | +import json |
| 5 | +from pathlib import Path |
| 6 | + |
6 | 7 | import numpy as np |
7 | | -import imageio |
| 8 | + |
| 9 | + |
| 10 | +def _probabilities(values, name, ndim): |
| 11 | + values = np.asarray(values) |
| 12 | + if values.ndim != ndim or any(size == 0 for size in values.shape): |
| 13 | + raise ValueError(f"{name} must be a nonempty {ndim}-D array") |
| 14 | + if values.dtype.kind not in "buif": |
| 15 | + raise ValueError(f"{name} must contain real probabilities") |
| 16 | + if not np.isfinite(values).all() or values.min() < 0 or values.max() > 1: |
| 17 | + raise ValueError(f"{name} must contain finite probabilities in [0, 1]") |
| 18 | + return np.ascontiguousarray(values, dtype=np.float32) |
| 19 | + |
| 20 | + |
| 21 | +def _load_elf(): |
| 22 | + # Keep --help and input validation usable without the compiled ELF backend. |
| 23 | + try: |
| 24 | + import elf.segmentation.features as feats |
| 25 | + import elf.segmentation.multicut as mc |
| 26 | + import elf.segmentation.watershed as ws |
| 27 | + except (ImportError, OSError) as exc: |
| 28 | + raise RuntimeError( |
| 29 | + "ELF multicut is unavailable. Install a compatible python-elf, " |
| 30 | + "nifty and vigra environment (see README); no fallback is used. " |
| 31 | + f"Backend error: {exc}" |
| 32 | + ) from exc |
| 33 | + return feats, mc, ws |
8 | 34 |
|
9 | 35 |
|
10 | 36 | def post_mc(affs, beta=0.25): |
11 | | - affs = 1 - affs |
12 | | - boundary_input = np.maximum(affs[1], affs[2]) |
13 | | - watershed = np.zeros_like(boundary_input, dtype='uint64') |
| 37 | + """Segment merge affinities of shape (3, z, y, x), returning uint32 IDs. |
| 38 | +
|
| 39 | + Channels correspond to offsets (-1,0,0), (0,-1,0), (0,0,-1). |
| 40 | + The original watershed settings and ELF Kernighan-Lin multicut are kept. |
| 41 | + Output IDs start at 1; cluster 0 from the solver is a neuron, not background. |
| 42 | + """ |
| 43 | + affs = _probabilities(affs, "affinities", 4) |
| 44 | + if affs.shape[0] != 3: |
| 45 | + raise ValueError("affinities must have shape (3, z, y, x)") |
| 46 | + if isinstance(beta, (bool, np.bool_)) or not np.isscalar(beta): |
| 47 | + raise ValueError("beta must be a finite number strictly between 0 and 1") |
| 48 | + try: |
| 49 | + beta = float(beta) |
| 50 | + except (TypeError, ValueError) as exc: |
| 51 | + raise ValueError("beta must be a finite number strictly between 0 and 1") from exc |
| 52 | + if not np.isfinite(beta) or not 0 < beta < 1: |
| 53 | + raise ValueError("beta must be a finite number strictly between 0 and 1") |
| 54 | + |
| 55 | + feats, mc, ws = _load_elf() |
| 56 | + split_affs = 1.0 - affs |
| 57 | + boundary_input = np.maximum(split_affs[1], split_affs[2]) |
| 58 | + watershed = np.empty(boundary_input.shape, dtype=np.uint64) |
14 | 59 | offset = 0 |
15 | 60 | for z in range(watershed.shape[0]): |
16 | | - wsz, max_id = ws.distance_transform_watershed(boundary_input[z], threshold=0.25, sigma_seeds=2.0) |
17 | | - wsz += offset |
18 | | - offset += max_id |
19 | | - watershed[z] = wsz |
| 61 | + wsz, _ = ws.distance_transform_watershed( |
| 62 | + boundary_input[z], threshold=0.25, sigma_seeds=2.0 |
| 63 | + ) |
| 64 | + wsz = np.asarray(wsz) |
| 65 | + if wsz.shape != watershed.shape[1:] or wsz.dtype.kind not in "ui" or np.any(wsz <= 0): |
| 66 | + raise RuntimeError(f"ELF watershed returned invalid or unassigned fragments at z={z}") |
| 67 | + # Dense zero-based RAG nodes, disjoint across slices even for sparse IDs. |
| 68 | + ids, inverse = np.unique(wsz, return_inverse=True) |
| 69 | + watershed[z] = inverse.reshape(wsz.shape).astype(np.uint64) + offset |
| 70 | + offset += len(ids) |
| 71 | + |
20 | 72 | rag = feats.compute_rag(watershed) |
21 | | - offsets = [[-1, 0, 0], [0, -1, 0], [0, 0, -1]] |
22 | | - costs = feats.compute_affinity_features(rag, affs, offsets)[:, 0] |
23 | | - edge_sizes = feats.compute_boundary_mean_and_length(rag, boundary_input)[:, 1] |
24 | | - costs = mc.transform_probabilities_to_costs(costs, edge_sizes=edge_sizes, beta=beta) |
25 | | - node_labels = mc.multicut_kernighan_lin(rag, costs) |
| 73 | + if rag.numberOfEdges == 0: |
| 74 | + # Exact edgeless-graph solution; ELF's cost scaling needs nonempty edges. |
| 75 | + node_labels = np.arange(rag.numberOfNodes, dtype=np.uint64) |
| 76 | + else: |
| 77 | + offsets = [[-1, 0, 0], [0, -1, 0], [0, 0, -1]] |
| 78 | + costs = feats.compute_affinity_features(rag, split_affs, offsets)[:, 0] |
| 79 | + edge_sizes = feats.compute_boundary_mean_and_length(rag, boundary_input)[:, 1] |
| 80 | + if not np.isfinite(costs).all() or not np.isfinite(edge_sizes).all() or np.any(edge_sizes <= 0): |
| 81 | + raise RuntimeError("ELF returned invalid edge probabilities or sizes") |
| 82 | + costs = mc.transform_probabilities_to_costs(costs, edge_sizes=edge_sizes, beta=beta) |
| 83 | + node_labels = mc.multicut_kernighan_lin(rag, costs) |
26 | 84 | segmentation = feats.project_node_labels_to_pixels(rag, node_labels) |
27 | | - return segmentation |
| 85 | + labels, inverse = np.unique(segmentation, return_inverse=True) |
| 86 | + if len(labels) > np.iinfo(np.uint32).max: |
| 87 | + raise OverflowError("Too many instances for uint32 output") |
| 88 | + return (inverse.reshape(watershed.shape) + 1).astype(np.uint32) |
28 | 89 |
|
29 | 90 |
|
30 | | -if __name__ == "__main__": |
31 | | - aff_root = '/***/***' |
32 | | - bound_root = '/***/***' |
33 | | - gt_root = '/***/***' |
34 | | - beta = 0.25 |
| 91 | +def _load_volume(path): |
| 92 | + if path.suffix.lower() == ".npy": |
| 93 | + return np.load(path, allow_pickle=False) |
| 94 | + if path.suffix.lower() in (".tif", ".tiff"): |
| 95 | + import tifffile |
| 96 | + with tifffile.TiffFile(path) as image: |
| 97 | + if len(image.series) != 1 or image.series[0].axes not in {"ZYX", "QYX", "IYX"}: |
| 98 | + raise ValueError("TIFF must contain one grayscale ZYX stack") |
| 99 | + return image.series[0].asarray() |
| 100 | + raise ValueError(f"Expected .npy, .tif or .tiff: {path}") |
| 101 | + |
| 102 | + |
| 103 | +def _validate_ground_truth(gt, shape): |
| 104 | + if gt.shape != shape: |
| 105 | + raise ValueError(f"ground truth shape {gt.shape} does not match {shape}") |
| 106 | + if gt.dtype.kind not in "ui" or np.any(gt < 0): |
| 107 | + raise ValueError("ground truth must contain nonnegative integer instance IDs") |
| 108 | + if not np.any(gt): |
| 109 | + raise ValueError("ground truth contains only ignored label 0") |
| 110 | + # Metric implementations index by label: compact sparse IDs without dropping background. |
| 111 | + ids, inverse = np.unique(gt, return_inverse=True) |
| 112 | + return (inverse.reshape(gt.shape) + int(ids[0] != 0)).astype(np.uint64) |
35 | 113 |
|
36 | | - gt_seg = imageio.volread(gt_root) |
37 | | - gt_seg = np.uint32(gt_seg) |
38 | 114 |
|
39 | | - boundary_input = imageio.volread(bound_root) |
40 | | - boundary_input = np.array([boundary_input, boundary_input, boundary_input]) |
41 | | - affine = np.load(aff_root) |
42 | | - affine = np.minimum(boundary_input, affine) |
| 115 | +def _write_labels(path, segmentation): |
| 116 | + # Exclusive creation also protects against a file appearing during computation. |
| 117 | + with path.open("xb") as stream: |
| 118 | + try: |
| 119 | + if path.suffix.lower() == ".npy": |
| 120 | + np.save(stream, segmentation, allow_pickle=False) |
| 121 | + else: |
| 122 | + import tifffile |
| 123 | + tifffile.imwrite(stream, segmentation, photometric="minisblack", metadata={"axes": "ZYX"}) |
| 124 | + except BaseException: |
| 125 | + stream.close() |
| 126 | + path.unlink() |
| 127 | + raise |
43 | 128 |
|
44 | | - pred_seg = post_mc(affine, beta) |
45 | 129 |
|
46 | | - arand = adapted_rand_ref(gt_seg, pred_seg, ignore_labels=(0,))[0] |
47 | | - voi_split, voi_merge = voi_ref(gt_seg, pred_seg, ignore_labels=(0,)) |
| 130 | +def main(argv=None): |
| 131 | + parser = argparse.ArgumentParser(description=__doc__) |
| 132 | + parser.add_argument("--affinities", type=Path, required=True, help="NPY merge probabilities, shape (3,z,y,x)") |
| 133 | + parser.add_argument("--boundaries", type=Path, required=True, |
| 134 | + help="TIFF foreground-head probabilities (original boundary output), shape (z,y,x)") |
| 135 | + parser.add_argument("--output", type=Path, required=True, help="New uint32 labels file (.tif, .tiff or .npy)") |
| 136 | + parser.add_argument("--beta", type=float, default=0.25, help="Multicut bias strictly between 0 and 1 (default: 0.25)") |
| 137 | + parser.add_argument("--ground-truth", type=Path, help="Optional same-grid neuron instance labels; 0 is ignored") |
| 138 | + args = parser.parse_args(argv) |
48 | 139 |
|
49 | | - voi_sum = voi_split + voi_merge |
50 | | - print('voi_split:', voi_split, 'voi_merge:', voi_merge, 'voi:', voi_sum, 'arand', arand) |
| 140 | + try: |
| 141 | + if args.output.suffix.lower() not in (".npy", ".tif", ".tiff"): |
| 142 | + raise ValueError("output must use .npy, .tif or .tiff") |
| 143 | + if args.output.exists(): |
| 144 | + raise FileExistsError(f"Refusing to overwrite {args.output}") |
| 145 | + if not args.output.parent.is_dir(): |
| 146 | + raise ValueError(f"Output directory does not exist: {args.output.parent}") |
| 147 | + if args.affinities.suffix.lower() != ".npy": |
| 148 | + raise ValueError("affinities must be a .npy file") |
| 149 | + if args.boundaries.suffix.lower() not in (".tif", ".tiff"): |
| 150 | + raise ValueError("boundaries must be a .tif or .tiff file") |
| 151 | + affinities = _probabilities(_load_volume(args.affinities), "affinities", 4) |
| 152 | + boundaries = _probabilities(_load_volume(args.boundaries), "boundaries", 3) |
| 153 | + if affinities.shape[0] != 3 or affinities.shape[1:] != boundaries.shape: |
| 154 | + raise ValueError("affinities must be (3,z,y,x) and boundaries must match (z,y,x)") |
| 155 | + gt = None |
| 156 | + if args.ground_truth is not None: |
| 157 | + gt = _validate_ground_truth(_load_volume(args.ground_truth), boundaries.shape) |
| 158 | + from skimage.metrics import adapted_rand_error, variation_of_information |
| 159 | + |
| 160 | + # Keep the original foreground-head fusion and probability direction. |
| 161 | + segmentation = post_mc(np.minimum(affinities, boundaries[None]), args.beta) |
| 162 | + result = {"output": str(args.output), "shape": list(segmentation.shape), |
| 163 | + "dtype": str(segmentation.dtype), "instances": int(segmentation.max()), "beta": args.beta} |
| 164 | + if gt is not None: |
| 165 | + arand = adapted_rand_error(gt, segmentation, ignore_labels=(0,))[0] |
| 166 | + voi_split, voi_merge = variation_of_information(gt, segmentation, ignore_labels=(0,)) |
| 167 | + metrics = {"arand": float(arand), "voi_split": float(voi_split), |
| 168 | + "voi_merge": float(voi_merge), "voi": float(voi_split + voi_merge)} |
| 169 | + if not all(np.isfinite(value) for value in metrics.values()): |
| 170 | + raise ValueError("Ground-truth metrics are undefined for this volume") |
| 171 | + result["metrics"] = metrics |
| 172 | + _write_labels(args.output, segmentation) |
| 173 | + except (ValueError, OSError, RuntimeError, ImportError, OverflowError) as exc: |
| 174 | + parser.exit(1, f"error: {exc}\n") |
| 175 | + print(json.dumps(result, indent=2)) |
| 176 | + return 0 |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == "__main__": |
| 180 | + raise SystemExit(main()) |
0 commit comments