Skip to content

Commit ea6f0c9

Browse files
authored
Merge pull request #7 from yanchaoz/codex/segneuron-reliability
Make SegNeuron inference usable and preserve the original code in legacy
2 parents ccb0ba2 + 6bb4460 commit ea6f0c9

125 files changed

Lines changed: 15509 additions & 391 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Keep the archived bytes (including historical line endings) unchanged.
2+
/legacy/** -text -whitespace linguist-vendored

‎.github/workflows/tests.yml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: SegNeuron tests
2+
3+
on:
4+
push:
5+
branches: [main, 'codex/**']
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
cpu:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 20
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: '3.10'
21+
cache: pip
22+
- run: python -m pip install torch==2.9.1 --index-url https://download.pytorch.org/whl/cpu
23+
- run: python -m pip install -r requirements-training.txt
24+
- run: python -m unittest discover -s tests -v
25+
26+
frmc:
27+
runs-on: ubuntu-latest
28+
timeout-minutes: 20
29+
steps:
30+
- uses: actions/checkout@v4
31+
- uses: mamba-org/setup-micromamba@v2
32+
with:
33+
environment-file: environment-postprocess.yml
34+
cache-environment: true
35+
init-shell: bash
36+
- name: Test the real ELF multicut backend
37+
shell: bash -el {0}
38+
env:
39+
SEGNEURON_REQUIRE_ELF: '1'
40+
run: python -m unittest discover -s tests -p test_postprocess.py -v

‎.gitignore‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/Train_and_Inference/**/__pycache__/
2+
/Pretrain/**/__pycache__/
3+
/Postprocess/__pycache__/
4+
/tests/__pycache__/
5+
/runs/
6+
/weights/
7+
/.venv/

‎Postprocess/FRMC_post.py‎

Lines changed: 165 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,180 @@
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+
67
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
834

935

1036
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)
1459
offset = 0
1560
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+
2072
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)
2684
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)
2889

2990

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)
35113

36-
gt_seg = imageio.volread(gt_root)
37-
gt_seg = np.uint32(gt_seg)
38114

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
43128

44-
pred_seg = post_mc(affine, beta)
45129

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)
48139

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())

‎Pretrain/config/SegNeuron.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
NAME: 'SegNeuron'
22

3-
MODEL:
3+
MODEL:
4+
model_type: 'superhuman' # Same-size prediction; disable the legacy MALA crop.
45
pre_train: True
56
pretrain_path: '/***/***'
67
continue_train: False

‎Pretrain/pretrain.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@
44

55
import os
66

7-
os.environ['CUDA_VISIBLE_DEVICES'] = "0, 1"
87
import sys
98
import yaml
109
import time
1110
import logging
1211
import argparse
1312
import numpy as np
14-
from attrdict import AttrDict
13+
from addict import Dict as AttrDict
1514
from tensorboardX import SummaryWriter
1615
from collections import OrderedDict
1716
import torch
@@ -78,7 +77,7 @@ def load_dataset(cfg):
7877
t1 = time.time()
7978
train_provider = Provider('train', cfg)
8079
print('Done (time: %.2fs)' % (time.time() - t1))
81-
return train_provider, valid_provider
80+
return train_provider
8281

8382

8483
def build_model(cfg, writer):
@@ -154,7 +153,7 @@ def loop(cfg, train_provider, model, optimizer, iters, writer):
154153
sum_labeled_loss = 0
155154
sum_unlabel_loss = 0
156155

157-
while iters <= cfg.TRAIN.total_iters:
156+
while iters < cfg.TRAIN.total_iters:
158157
# train
159158
model.train()
160159
iters += 1
@@ -260,4 +259,4 @@ def loop(cfg, train_provider, model, optimizer, iters, writer):
260259
writer.close()
261260
else:
262261
pass
263-
print('***Done***')
262+
print('***Done***')

‎Pretrain/pretrain_provider.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(self, cfg):
4141

4242
def __getitem__(self, index):
4343

44-
k = random.randint(0, len(self.dataset))
44+
k = random.randrange(len(self.dataset))
4545
used_data = self.dataset[k]
4646
raw_data_shape = used_data.shape
4747

0 commit comments

Comments
 (0)