Skip to content

Commit 535a846

Browse files
Merge pull request #167 from computational-cell-analytics/bioimage-cpp-migration
Migrate to bioimage-cpp
2 parents 1efa142 + 7da56c5 commit 535a846

14 files changed

Lines changed: 408 additions & 37 deletions

CLAUDE.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to coding agents when working in this repository.
4+
5+
## Overview
6+
7+
SynapseNet (`synapse_net`) is a Python package for deep-learning segmentation and analysis of synapses in electron microscopy, mainly electron tomography. It ships U-Net models for segmenting synaptic vesicles, active zones, compartments, mitochondria, cristae, and ribbon-synapse structures, plus analysis tools (distance measurement, morphometry, vesicle pooling) and IMOD import/export. It is exposed three ways: a **napari plugin**, a **CLI** (console scripts), and a **Python library**.
8+
9+
## Environment & Install
10+
11+
The dependencies for this package are available via conda. We are in the process of removing dependency on nifty and vigra in favor of bioimage-cpp.
12+
This will enable pure pip installation.
13+
14+
Here is how to set-up an environment with a clean installation.
15+
16+
```bash
17+
conda env create -f environment.yaml # creates env "synapse-net"
18+
conda activate synapse-net
19+
pip install -e . # or: pip install --no-deps -e . (as CI does)
20+
```
21+
22+
IMOD export commands additionally require the external IMOD suite installed.
23+
24+
## Testing
25+
26+
```bash
27+
python -m unittest discover -s test -v # full suite (what CI runs)
28+
python -m unittest test.test_inference.TestInference # one test class
29+
python -m unittest test.test_inference.TestInference.test_run_segmentation # one test
30+
```
31+
32+
Tests are **network-dependent**: they download pretrained models (via `pooch` from GWDG ownCloud) and sample data the first time they run, into the OS cache dir (`pooch.os_cache("synapse-net")`). A first run is slow and requires internet; offline runs will fail on model/sample download, not on a code bug.
33+
34+
## Common commands
35+
36+
```bash
37+
# Build API docs (pdoc, google docstring format) — output to tmp/
38+
python build_doc.py -o
39+
40+
# CLI entry points (defined in setup.py console_scripts):
41+
synapse_net.run_segmentation -i <mrc-or-dir> -o <out-dir> -m vesicles_3d
42+
synapse_net.export_to_imod_points -i <mrc-dir> -s <seg-dir> -o <mod-dir>
43+
synapse_net.export_to_imod_objects -i <mrc-dir> -s <seg-dir> -o <mod-dir>
44+
synapse_net.run_supervised_training -h
45+
synapse_net.run_domain_adaptation -h
46+
synapse_net.visualize_vesicle_pools -h
47+
```
48+
49+
## Architecture
50+
51+
The package is organized by capability. The big picture spans several modules:
52+
53+
### Models & the model registry (`synapse_net/inference/inference.py`)
54+
Models are identified by a **`model_type` string** (`vesicles_3d`, `vesicles_2d`, `vesicles_cryo`, `active_zone`, `compartments`, `mitochondria`/`mitochondria2`, `cristae`/`cristae2`/`cristae3`, `ribbon`, plus CLI-only `vesicles_*` variants). `_get_model_registry()` maps each name to a sha256 + GWDG ownCloud download URL and fetches via `pooch`. `get_model(model_type)` downloads (if needed) and `torch.load`s the checkpoint.
55+
56+
**Voxel-size scaling is central.** Each model was trained at a specific resolution (`get_model_training_resolution`, in nm/axis). `compute_scale_from_voxel_size(voxel_size, model_type)` produces a zyx scale factor so input data is resized to match training resolution before inference and resized back after. Always pair a model with the right `scale` rather than feeding raw-resolution data.
57+
58+
### Inference dispatch
59+
`run_segmentation(image, model, model_type, tiling, scale, **kwargs)` is the top-level entry. It branches on `model_type` to a per-structure function in its own module: `inference/vesicles.py`, `active_zone.py`, `compartments.py`, `mitochondria.py`, `cristae.py`, `ribbon_synapse.py`, `actin.py`. The `ribbon` path is special — it runs multi-output prediction then optional post-processing that requires a vesicle segmentation passed via `extra_segmentation`.
60+
61+
`inference/util.py` holds the shared machinery:
62+
- `get_prediction` / `get_prediction_torch_em` — tiled prediction via `torch_em.util.prediction.predict_with_halo`. **Tiling is a dict** `{"tile": {"z","y","x"}, "halo": {"z","y","x"}}`; note `get_prediction` subtracts `2*halo` from each tile dim before calling the torch_em layer.
63+
- `_Scaler` — applies/reverts the voxel-size scale (order-0 for segmentations).
64+
- `inference_helper` — drives file-in/file-out batch segmentation over a folder; used by the CLI.
65+
- A `bioimageio` prediction path exists in `get_prediction` but currently raises `NotImplementedError` (model format migration in progress; see the `bioimage-cpp-migration` branch).
66+
67+
Per-structure **post-processing** lives in `inference/postprocessing/` (vesicles, compartments, membranes, ribbon, presynaptic_density), kept separate from raw prediction.
68+
69+
### Training (`synapse_net/training/`)
70+
Built on `torch_em` U-Nets (`UNet2d`, `AnisotropicUNet`). Two regimes:
71+
- `supervised_training.py` — needs data + manual labels. Can init weights from a pretrained `model_type`.
72+
- `domain_adaptation.py` — unsupervised student-teacher (mean-teacher) adaptation to a new condition without labels; only works if the source model already partially detects the structure.
73+
- `semisupervised_training.py`, `transform.py` — supporting pieces.
74+
75+
### Tools (`synapse_net/tools/`)
76+
- `cli.py` — argparse wrappers behind the console scripts.
77+
- Napari widgets (`segmentation_widget.py`, `distance_measure_widget.py`, `morphology_widget.py`, `vesicle_pool_widget.py`, `postprocessing_widget.py`, `size_filter_widget.py`), all subclassing `base_widget.py` and registered in `synapse_net/napari.yaml`. **When adding/renaming a widget, command, reader, or sample, update `napari.yaml` and the `setup.py` entry points to match.**
78+
- `volume_reader.py` — napari reader for `.mrc`/`.rec`/`.h5`.
79+
80+
### Analysis & I/O
81+
- `distance_measurements.py`, `morphology.py` — quantification of segmentation results.
82+
- `imod/to_imod.py`, `imod/export.py` — convert segmentations to IMOD point models (spheres per vesicle) or closed-contour object models.
83+
- `file_utils.py` — I/O. `read_mrc` reads `.mrc`/`.rec`; **voxel sizes are converted Angstrom→nm by dividing by 10**, and data axes are flipped to match Python order. Also reads OME-Zarr and streams from the CryoET Data Portal (`cryoet_data_portal`, `s3fs` — both optional imports).
84+
- `ground_truth/` — annotation/GT-curation utilities (vesicle matching, shape refinement, edge filtering).
85+
86+
### `scripts/`
87+
Project- and collaboration-specific scripts (subfolders `cooper`, `inner_ear`, `cryo`, `rizzoli`, `wichmann`, `portal`, `baselines`, …) tied to the publication and ongoing collaborations. These are **not** part of the installable package and not covered by tests; many paths are gitignored. Don't treat them as library API.
88+
89+
## Conventions
90+
91+
- Spatial dicts use explicit axis keys (`{"x":..,"y":..,"z":..}`); arrays are zyx. Be careful that tiling/scale lists are in zyx order while voxel-size dicts are keyed by axis name.
92+
- Public functions use Google-style docstrings (docs are generated with `pdoc -d google`); match that style when adding API.
93+
- Optional heavy deps (`zarr`, `s3fs`, `cryoet_data_portal`) are imported defensively and guarded — keep new optional integrations the same way.

environment.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ name:
44
synapse-net
55
dependencies:
66
- bioimageio.core
7+
- bioimage-cpp
78
- kornia
89
- magicgui
9-
- napari >=0.5.0,<0.6.0
10-
- nifty >=1.2.3
10+
- napari >=0.5.0,<0.7.0
1111
- pip
1212
- pyqt
13-
- python-elf
13+
- python-elf >=0.9.0
1414
- pytorch
1515
- tensorboard
16-
- torch_em >=0.8.1
16+
- torch_em >=0.9.0
1717
- torchvision
1818
- trimesh
1919
- zarr <3

scripts/inner_ear/processing/process_manual_segmentation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import imageio.v3 as imageio
77
import mrcfile
88
import numpy as np
9-
import nifty.tools as nt
9+
from bioimage_cpp.utils import take_dict
1010
from elf.io import open_file
1111
from skimage.transform import rescale
1212
from tqdm import tqdm
@@ -60,7 +60,7 @@ def export_vesicles(input_path, data_path, export_path):
6060
labels, label_names = process_labels(labels, label_names)
6161
imageio.imwrite(export_path, segmentation, compression="zlib")
6262

63-
segmentation = nt.takeDict(labels, segmentation)
63+
segmentation = take_dict(labels, segmentation)
6464
export_pool_path = os.path.join(os.path.split(export_path)[0], "manual_pools.tif")
6565
imageio.imwrite(export_pool_path, segmentation, compression="zlib")
6666

scripts/inner_ear/processing/run_analyis.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
import warnings
33
from pathlib import Path
44

5+
import bioimage_cpp as bic
56
import imageio.v3 as imageio
67
import mrcfile
78
import numpy as np
89
import pandas
9-
import vigra
1010

1111
from synapse_net.file_utils import get_data_path
1212
from synapse_net.distance_measurements import (
@@ -302,8 +302,8 @@ def analyze_distances(
302302
def _relabel_vesicles(path):
303303
print("Relabel vesicles at", path)
304304
seg = _load_segmentation(path, None)
305-
seg = vigra.analysis.labelVolumeWithBackground(seg.astype("uint32"))
306-
seg, _, _ = vigra.analysis.relabelConsecutive(seg, start_label=1, keep_zeros=True)
305+
seg = bic.segmentation.label(seg.astype("uint32"), connectivity=1)
306+
seg, _, _ = bic.segmentation.relabel_sequential(seg, offset=1)
307307
imageio.imwrite(path, seg, compression="zlib")
308308

309309

setup.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@
1111
author="Constantin Pape; Sarah Muth; Luca Freckmann",
1212
url="https://github.com/computational-cell-analytics/synapse-net",
1313
license="MIT",
14+
install_requires=[
15+
"bioimage-cpp",
16+
"python-elf>=0.9.0",
17+
"torch_em>=0.9.0",
18+
"numpy",
19+
"scipy",
20+
"scikit-image",
21+
"scikit-learn",
22+
"h5py",
23+
"imageio",
24+
"mrcfile",
25+
"pandas",
26+
"pooch",
27+
"tqdm",
28+
"trimesh",
29+
],
30+
extras_require={
31+
# Dependencies for the napari plugin GUI. Install with `pip install synapse_net[napari]`.
32+
"napari": [
33+
"napari>=0.5.0,<0.7.0",
34+
"magicgui",
35+
"qtpy",
36+
"superqt",
37+
"napari-skimage-regionprops",
38+
],
39+
},
1440
entry_points={
1541
"console_scripts": [
1642
"synapse_net.run_segmentation = synapse_net.tools.cli:segmentation_cli",

synapse_net/ground_truth/az_evaluation.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
import h5py
55
import pandas as pd
66
import numpy as np
7-
import vigra
87

98
from elf.evaluation.matching import _compute_scores, _compute_tps
109
from elf.evaluation import dice_score
1110
from elf.segmentation.workflows import simple_multicut_workflow
1211
from scipy.ndimage import binary_dilation, binary_closing, distance_transform_edt, binary_opening
1312
from skimage.measure import label, regionprops, regionprops_table
13+
from skimage.morphology import local_maxima
1414
from skimage.segmentation import relabel_sequential, watershed
1515
from tqdm import tqdm
1616

@@ -143,8 +143,7 @@ def _get_presynaptic_mask(boundary_map, vesicles):
143143

144144
def _compute_mask_2d(z):
145145
distances = distance_transform_edt(boundary_map[z] < 0.25).astype("float32")
146-
seeds = vigra.analysis.localMaxima(distances, marker=np.nan, allowAtBorder=True, allowPlateaus=True)
147-
seeds = label(np.isnan(seeds))
146+
seeds = label(local_maxima(distances, allow_borders=True))
148147
overseg = watershed(boundary_map[z], markers=seeds)
149148
seg = simple_multicut_workflow(
150149
boundary_map[z], use_2dws=False, watershed=overseg, n_threads=1, beta=0.6

synapse_net/ground_truth/shape_refinement.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import numpy as np
77

8+
import bioimage_cpp as bic
89
from scipy.ndimage import binary_erosion, binary_dilation
910
from skimage import img_as_ubyte
1011
from skimage.filters import gaussian, rank, sato, sobel
@@ -13,11 +14,6 @@
1314
from skimage.segmentation import watershed
1415
from tqdm import tqdm
1516

16-
try:
17-
import vigra
18-
except ImportError:
19-
vigra = None
20-
2117
FILTERS = ("sobel", "laplace", "ggm", "structure-tensor", "sato")
2218

2319

@@ -48,7 +44,7 @@ def edge_filter(
4844
- "sobel": Edges are found by smoothing the data and then applying a sobel filter.
4945
- "laplace": Edges are found with a laplacian of gaussian filter.
5046
- "ggm": Edges are found with a gaussian gradient magnitude filter.
51-
- "structure-tensor": Edges are found based on the 2nd eigenvalue of the structure tensor.
47+
- "structure-tensor": Edges are found based on the 1st eigenvalue of the structure tensor.
5248
- "sato": Edges are found with a sato-filter, followed by smoothing and leveling.
5349
per_slice: Compute the filter per slice instead of for the whole volume.
5450
n_threads: Number of threads for parallel computation over the slices.
@@ -58,8 +54,6 @@ def edge_filter(
5854
"""
5955
if method not in FILTERS:
6056
raise ValueError(f"Invalid edge filter method: {method}. Expect one of {FILTERS}.")
61-
if method in FILTERS[1:] and vigra is None:
62-
raise ValueError(f"Filter {method} requires vigra.")
6357

6458
if per_slice and data.ndim == 3:
6559
n_threads = mp.cpu_count() if n_threads is None else n_threads
@@ -73,14 +67,14 @@ def edge_filter(
7367
edge_map = gaussian(data, sigma=sigma)
7468
edge_map = sobel(edge_map)
7569
elif method == "laplace":
76-
edge_map = vigra.filters.laplacianOfGaussian(data.astype("float32"), sigma)
70+
edge_map = bic.filters.laplacian_of_gaussian(data.astype("float32"), sigma)
7771
elif method == "ggm":
78-
edge_map = vigra.filters.gaussianGradientMagnitude(data.astype("float32"), sigma)
72+
edge_map = bic.filters.gaussian_gradient_magnitude(data.astype("float32"), sigma)
7973
elif method == "structure-tensor":
8074
inner_scale, outer_scale = sigma, sigma * 0.5
81-
edge_map = vigra.filters.structureTensorEigenvalues(
82-
data.astype("float32"), innerScale=inner_scale, outerScale=outer_scale
83-
)[..., 1]
75+
edge_map = bic.filters.structure_tensor_eigenvalues(
76+
data.astype("float32"), inner_scale, outer_scale
77+
)[..., 0]
8478
elif method == "sato":
8579
edge_map = _sato_filter(data, sigma)
8680

synapse_net/ground_truth/vesicles.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414

1515
def _check_volume(raw, vesicles, labels, title=None, **extra_segmentations):
1616
import napari
17-
from nifty.tools import takeDict
17+
from bioimage_cpp.utils import take_dict
1818

1919
if labels is None:
2020
label_vol = None
2121
else:
2222
labels[0] = 0
23-
label_vol = takeDict(labels, vesicles)
23+
label_vol = take_dict(labels, vesicles)
2424

2525
v = napari.Viewer()
2626
if raw is not None:

synapse_net/inference/compartments.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22
from typing import Dict, List, Optional, Tuple, Union
33

44
import numpy as np
5-
import vigra
65
import torch
76

7+
import bioimage_cpp as bic
88
import elf.segmentation as eseg
9-
import nifty
109
from elf.tracking.tracking_utils import compute_edges_from_overlap
1110
from scipy.ndimage import distance_transform_edt, binary_closing
1211
from skimage.measure import label, regionprops
1312
from skimage.segmentation import watershed
14-
from skimage.morphology import remove_small_holes
13+
from skimage.morphology import remove_small_holes, local_maxima
1514

1615
from synapse_net.inference.util import get_prediction, _Scaler, _postprocess_seg_3d
1716

@@ -41,8 +40,7 @@ def _segment_compartments_2d(
4140
seeds[np.isin(seeds, remove_ids)] = 0
4241

4342
# Compute the small seeds = local maxima of the in-plane distance map
44-
small_seeds = vigra.analysis.localMaxima(distances_z, marker=np.nan, allowAtBorder=True, allowPlateaus=True)
45-
small_seeds = label(np.isnan(small_seeds))
43+
small_seeds = label(local_maxima(distances_z, allow_borders=True))
4644

4745
# We only keep small seeds that don't intersect with a large seed.
4846
props = regionprops(small_seeds, seeds)
@@ -87,16 +85,16 @@ def _merge_segmentation_3d(seg_2d, beta=0.5, min_z_extent=10):
8785
overlaps = np.array([edge["score"] for edge in edges])
8886

8987
n_nodes = int(seg_2d.max() + 1)
90-
graph = nifty.graph.undirectedGraph(n_nodes)
91-
graph.insertEdges(uv_ids)
88+
graph = bic.graph.UndirectedGraph(n_nodes)
89+
graph.insert_edges(uv_ids)
9290

9391
costs = eseg.multicut.compute_edge_costs(1.0 - overlaps)
9492
# set background weights to be maximally repulsive
9593
bg_edges = (uv_ids == 0).any(axis=1)
9694
costs[bg_edges] = -8.0
9795

9896
node_labels = eseg.multicut.multicut_decomposition(graph, costs, beta=beta)
99-
segmentation = nifty.tools.take(node_labels, seg_2d)
97+
segmentation = node_labels[seg_2d]
10098

10199
if min_z_extent is not None and min_z_extent > 0:
102100
props = regionprops(segmentation)

synapse_net/tools/size_filter_widget.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import napari.viewer
44

55
import numpy as np
6-
import nifty.tools as nt
6+
from bioimage_cpp.utils import take_dict
77

88
from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QPushButton, QSpinBox, QLabel
99
from skimage.measure import regionprops, label
@@ -70,7 +70,7 @@ def _filter_segmentation(self, segmentation, size_threshold, apply_label):
7070
if apply_label:
7171
mapping = {prop.label: int(prop.max_intensity) for prop in props if prop.label not in filter_ids}
7272
mapping[0] = 0
73-
segmentation = nt.takeDict(mapping, segmentation)
73+
segmentation = take_dict(mapping, segmentation)
7474
return segmentation.astype(dtype)
7575

7676
def on_size_filter(self):

0 commit comments

Comments
 (0)