Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ jobs:
- name: Test
run: uv run pytest

wheel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v10.2.0
- name: Build and install the wheel outside the repository
run: |
uv build --wheel -o /tmp/dist
uv venv /tmp/venv
VIRTUAL_ENV=/tmp/venv uv pip install /tmp/dist/*.whl --extra-index-url https://download.pytorch.org/whl/cpu --index-strategy unsafe-best-match
- name: Run the commands from another folder
working-directory: /tmp
run: |
mkdir -p data/P0001 && echo '{}' > data/P0001/config.json
/tmp/venv/bin/deepaneseg-train --help
/tmp/venv/bin/deepaneseg-preprocess data_dir=data
test -f data/0Work/split_pats.json

docker:
runs-on: ubuntu-latest
steps:
Expand All @@ -35,4 +53,4 @@ jobs:
- name: Smoke test
run: |
docker run --rm deepaneseg:cpu python -c "import torch, deepaneseg.utils; print(torch.__version__)"
docker run --rm deepaneseg:cpu python scripts/train.py --help
docker run --rm deepaneseg:cpu deepaneseg-train --help
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ RUN uv sync --locked --no-install-project --no-dev --extra ${TORCH} && rm -rf /r
COPY . .
RUN uv sync --locked --no-dev --extra ${TORCH} && rm -rf /root/.cache/uv

CMD ["python", "scripts/train.py", "--help"]
CMD ["deepaneseg-train", "--help"]
261 changes: 167 additions & 94 deletions README.md

Large diffs are not rendered by default.

Empty file added deepaneseg/cli/__init__.py
Empty file.
5 changes: 2 additions & 3 deletions scripts/evaluate.py → deepaneseg/cli/evaluate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
"""Score a training's predictions on its test patients; configuration in configs/evaluate.yaml.
"""Score a training's predictions on its test patients; configuration in deepaneseg/configs/evaluate.yaml.

Example: python scripts/evaluate.py train_dir=/data/0Work/exp1
Example: deepaneseg-evaluate train_dir=/data/0Work/exp1
A detection (connected component above the threshold) is a true positive when its center lies within an
aneurysm's radius (ADAM challenge criteria). Writes per_patient.csv and summary.json to <train_dir>/evaluation.
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
"""Select negative patch centers (on vessels and in the parenchyma) for every patient.

Configuration in configs/extract_points.yaml. Example: python scripts/extract_points.py data_dir=/data
Configuration in deepaneseg/configs/extract_points.yaml. Example: deepaneseg-extract-points data_dir=/data
Writes <output> (CSV) and its 3D Slicer .fcsv counterpart in each patient folder.
Requires the 'noskull volume' produced by remove_skull.py.
"""
Expand Down
7 changes: 3 additions & 4 deletions scripts/predict.py → deepaneseg/cli/predict.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
#!/usr/bin/env python3
"""Predict with a trained model; configuration in configs/predict.yaml.
"""Predict with a trained model; configuration in deepaneseg/configs/predict.yaml.

Examples:
python scripts/predict.py train_dir=/data/0Work/exp1 # test patients of the split
python scripts/predict.py train_dir=/data/0Work/exp1 input=scan.nii.gz # any scan, or a folder of scans
deepaneseg-predict train_dir=/data/0Work/exp1 # test patients of the split
deepaneseg-predict train_dir=/data/0Work/exp1 input=scan.nii.gz # any scan, or a folder of scans
Data and model settings come from the training's resolved configuration (<train_dir>/.hydra/config.yaml).
For each case, writes the probability map <name>.nii.gz and the detections <name>_detections.csv (and .fcsv,
for 3D Slicer).
Expand Down
57 changes: 57 additions & 0 deletions deepaneseg/cli/prepare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Convert a dataset of scans and aneurysm masks to the pipeline's layout; configuration in deepaneseg/configs/prepare.yaml.

Example (ADAM challenge data): deepaneseg-prepare source_dir=/data/ADAM data_dir=/data/converted labels=[1]
Writes one P???? folder per case (volume, F.csv with the aneurysms, config.json) and cases.csv, which maps the
patients to the original case names.
"""

import json
import os
import shutil
import sys

import hydra
import pandas as pd

import deepaneseg.data.io as dio
from deepaneseg.data.prepare import aneurysm_points, find_cases
from deepaneseg.utils import get_logger


@hydra.main(version_base="1.3", config_path="../configs", config_name="prepare")
def main(cfg):
logger = get_logger("Prepare")
cases = find_cases(cfg.source_dir, cfg.scan)
if not cases:
sys.exit(f"No scan matches '{cfg.scan}' in {cfg.source_dir}")
masks = {case: os.path.join(cfg.source_dir, cfg.mask.replace("{case}", case)) for case in cases} if cfg.mask else {}
missing = sorted(case for case, path in masks.items() if not os.path.isfile(path))
if missing:
raise FileNotFoundError(f"No aneurysm mask '{cfg.mask}' for {', '.join(missing)}")
if dio.fetch_patient_dirs(cfg.data_dir) and not cfg.overwrite:
sys.exit(f"{cfg.data_dir} already contains patients; pass overwrite=true to replace them")

rows = []
for i, (case, scan) in enumerate(cases.items(), start=1):
patient = os.path.join(cfg.data_dir, f"P{i:04d}")
os.makedirs(patient, exist_ok=True)
volume = "volume.nii.gz" if scan.endswith(".gz") else "volume.nii"
shutil.copyfile(scan, os.path.join(patient, volume))
config = {"init volume": volume}
points = []
if case in masks:
mask, affine = dio.read_nii_from_file(masks[case])
points = aneurysm_points(mask, affine, labels=cfg.labels)
if len(points):
pd.DataFrame(points, columns=["x", "y", "z"]).to_csv(os.path.join(patient, "F.csv"), index=False)
config["pts aneurysm"] = "F.csv"
with open(os.path.join(patient, "config.json"), "w") as f:
json.dump(config, f, indent=2)
rows.append({"patient": f"P{i:04d}", "case": case, "scan": scan, "aneurysms": len(points) // 2})
logger.info(f"{case} -> P{i:04d}: {len(points) // 2} aneurysms")

pd.DataFrame(rows).to_csv(os.path.join(cfg.data_dir, "cases.csv"), index=False)


if __name__ == "__main__":
main()
5 changes: 2 additions & 3 deletions scripts/preprocess_data.py → deepaneseg/cli/preprocess.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
"""Split the patients into training/validation/testing sets; configuration in configs/preprocess.yaml.
"""Split the patients into training/validation/testing sets; configuration in deepaneseg/configs/preprocess.yaml.

Example: python scripts/preprocess_data.py data_dir=/data split=[0.7,0.2,0.1]
Example: deepaneseg-preprocess data_dir=/data split=[0.7,0.2,0.1]
Writes the split to data.split_file (<data_dir>/0Work/split_pats.json by default).
"""

Expand Down
5 changes: 2 additions & 3 deletions scripts/remove_skull.py → deepaneseg/cli/remove_skull.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
"""Skull-strip every patient volume; configuration in configs/remove_skull.yaml.
"""Skull-strip every patient volume; configuration in deepaneseg/configs/remove_skull.yaml.

Example: python scripts/remove_skull.py data_dir=/data
Example: deepaneseg-remove-skull data_dir=/data
The result is saved next to each volume and registered as 'noskull volume' in the patient's config.json.
"""

Expand Down
5 changes: 2 additions & 3 deletions scripts/train.py → deepaneseg/cli/train.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
"""Train a model; configuration in configs/train.yaml.
"""Train a model; configuration in deepaneseg/configs/train.yaml.

Example: python scripts/train.py data_dir=/data name=exp1 batch_size=8
Example: deepaneseg-train data_dir=/data name=exp1 batch_size=8
Checkpoints, TensorBoard logs and the resolved configuration (.hydra/) are written to <data_dir>/0Work/<name>.
Running the same command again resumes from the last checkpoint.
"""
Expand Down
1 change: 1 addition & 0 deletions deepaneseg/configs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Hydra configuration of the deepaneseg commands (a package so that installed commands find it)."""
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Patient data and patch geometry
data_dir: ${data_dir} # directory containing the patient folders (P????)
work_dir: ${.data_dir}/0Work # split file and trainings are written here
split_file: ${.work_dir}/split_pats.json # written by preprocess_data.py
split_file: ${.work_dir}/split_pats.json # written by deepaneseg-preprocess
volume: init volume # key, in each patient's config.json, of the volume to train on
normalize: Linear # Linear (rescale to [0, 1]), Normal (zero mean, unit variance) or null
negative_patch_centers: points.csv # written by extract_points.py in each patient folder
negative_patch_centers: points.csv # written by deepaneseg-extract-points in each patient folder
patch_shape: [48, 48, 48] # patch dimension, in voxels
patch_size: [38, 38, 38] # patch size, in mm (resolution = patch_size / patch_shape)
4 changes: 2 additions & 2 deletions configs/evaluate.yaml → deepaneseg/configs/evaluate.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Detection metrics (ADAM challenge criteria) of a training's predictions on its test patients
train_dir: ??? # training directory written by train.py
predictions: ${train_dir}/prediction/test # <patient>.nii.gz probability maps written by predict.py
train_dir: ??? # training directory written by deepaneseg-train
predictions: ${train_dir}/prediction/test # <patient>.nii.gz probability maps written by deepaneseg-predict
threshold: 0.5 # probability above which a voxel belongs to a detection
min_size: null # detections smaller than this, in voxels, are ignored; null keeps all

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
4 changes: 2 additions & 2 deletions configs/predict.yaml → deepaneseg/configs/predict.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Predicts with a trained model; data and model settings are read from the training's config
train_dir: ??? # training directory written by train.py
train_dir: ??? # training directory written by deepaneseg-train
input: null # NIfTI scan or folder of scans; null: the test patients of the training's split
output_dir: null # null: <train_dir>/prediction/test, or <train_dir>/prediction/scans for input
checkpoint: last_checkpoint.pytorch
volume: noskull volume # key, in each patient's config.json, of the volume to predict on
skull_strip: true # skull-strip input scans as remove_skull.py does, to match volume above
skull_strip: true # skull-strip input scans as deepaneseg-remove-skull does, to match volume above
threshold: 0.5 # detections: connected components above this probability
min_size: null # detections smaller than this, in voxels, are ignored; null keeps all
margin: 8 # voxels discarded at each patch border when stitching predictions
Expand Down
11 changes: 11 additions & 0 deletions deepaneseg/configs/prepare.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Converts a dataset of scans and aneurysm label masks (defaults: ADAM challenge layout) to the pipeline's layout
source_dir: ??? # dataset to convert
data_dir: ??? # output folder, one P???? folder per case
scan: "{case}/orig/TOF.nii.gz" # scan of each case, relative to source_dir; {case} matches one name
mask: "{case}/aneurysms.nii.gz" # aneurysm label mask of a case, relative to source_dir; null if there are none
labels: null # mask values that are aneurysms, e.g. [1] for ADAM (2: treated); null: any non-zero
overwrite: false # replace existing patients in data_dir

hydra:
run:
dir: ${data_dir}/0Work/logs/${hydra.job.name}/${now:%Y-%m-%d_%H-%M-%S}
File renamed without changes.
File renamed without changes.
File renamed without changes.
42 changes: 42 additions & 0 deletions deepaneseg/data/prepare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Conversion of a dataset of scans and aneurysm masks to the layout used by the pipeline."""

import glob
import os
import re

import numpy as np

from deepaneseg.volume.selection import connected_components_to_spheres


def find_cases(source_dir, scan_pattern):
"""
Returns {case name: scan path}, sorted by case, for the files matching scan_pattern in source_dir.
scan_pattern is a path relative to source_dir where {case} stands for one folder or file name part,
e.g. "{case}/orig/TOF.nii.gz" (ADAM challenge) or "scans/{case}.nii.gz".
"""
if scan_pattern.count("{case}") != 1:
raise ValueError(f"scan pattern must contain {{case}} exactly once, got '{scan_pattern}'")
before, after = scan_pattern.split("{case}")
regex = re.compile(re.escape(before) + r"([^/]+)" + re.escape(after) + "$")
cases = {}
for path in glob.glob(os.path.join(source_dir, scan_pattern.replace("{case}", "*"))):
match = regex.search(os.path.relpath(path, source_dir))
if match:
cases[match.group(1)] = path
return dict(sorted(cases.items()))


def aneurysm_points(mask, affine, labels=None):
"""
Annotation points of the aneurysms of a label mask: each connected component of the selected labels (any
non-zero value if labels is None) is approximated by a sphere, described by the two ends of a diameter along x,
as in the F.csv files. Returns a 2Nx3 array of points in mm.
"""
selected = mask > 0 if labels is None else np.isin(mask, list(labels))
spheres = connected_components_to_spheres(selected, affine)
offset = np.zeros_like(spheres[:, :3])
offset[:, 0] = spheres[:, 3]
points = np.empty((2 * len(spheres), 3))
points[0::2], points[1::2] = spheres[:, :3] - offset, spheres[:, :3] + offset
return points
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ dependencies = [
"hydra-core>=1.3",
]

[project.scripts]
deepaneseg-prepare = "deepaneseg.cli.prepare:main"
deepaneseg-remove-skull = "deepaneseg.cli.remove_skull:main"
deepaneseg-extract-points = "deepaneseg.cli.extract_points:main"
deepaneseg-preprocess = "deepaneseg.cli.preprocess:main"
deepaneseg-train = "deepaneseg.cli.train:main"
deepaneseg-predict = "deepaneseg.cli.predict:main"
deepaneseg-evaluate = "deepaneseg.cli.evaluate:main"

[project.optional-dependencies]
# PyTorch build, for uv: `uv sync --extra cpu` or `uv sync --extra cu126` (see [tool.uv.sources])
cpu = ["torch"]
Expand Down Expand Up @@ -54,6 +63,9 @@ explicit = true
[tool.setuptools.packages.find]
include = ["deepaneseg*"]

[tool.setuptools.package-data]
deepaneseg = ["configs/*.yaml", "configs/*/*.yaml"]

[tool.pytest.ini_options]
testpaths = ["tests"]

Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""The installed commands and the configs they read."""

import shutil
import subprocess
from importlib.metadata import entry_points
from importlib.resources import files

import pytest

COMMANDS = {
"deepaneseg-prepare": "prepare",
"deepaneseg-remove-skull": "remove_skull",
"deepaneseg-extract-points": "extract_points",
"deepaneseg-preprocess": "preprocess",
"deepaneseg-train": "train",
"deepaneseg-predict": "predict",
"deepaneseg-evaluate": "evaluate",
}


def test_every_command_is_installed():
installed = {ep.name: ep for ep in entry_points(group="console_scripts") if ep.name.startswith("deepaneseg-")}

assert set(installed) == set(COMMANDS)
assert all(callable(ep.load()) for ep in installed.values())


@pytest.mark.parametrize("config", sorted(set(COMMANDS.values())))
def test_every_command_has_its_config_in_the_package(config):
assert (files("deepaneseg.configs") / f"{config}.yaml").is_file()


@pytest.mark.parametrize("command", ["deepaneseg-train", "deepaneseg-predict"])
def test_commands_run_from_any_folder(command, tmp_path):
result = subprocess.run([shutil.which(command), "--help"], cwd=tmp_path, capture_output=True, text=True)

assert result.returncode == 0 and "Configuration groups" in result.stdout
2 changes: 1 addition & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from deepaneseg.training.trainer import create_trainer
from deepaneseg.utils import get_model

CONFIG_DIR = str(Path(__file__).parents[1] / "configs")
CONFIG_DIR = str(Path(__file__).parents[1] / "deepaneseg" / "configs")


def compose_config(name, overrides):
Expand Down
7 changes: 2 additions & 5 deletions tests/test_evaluate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json
import subprocess
import sys
from pathlib import Path

import nibabel as ni
import numpy as np
Expand All @@ -12,8 +11,6 @@
from deepaneseg.data.io import read_aneurysm_spheres
from deepaneseg.inference.evaluation import summarize_detections

SCRIPTS = Path(__file__).parents[1] / "scripts"


def make_patient(data_dir, name, aneurysm_points=None):
patient = data_dir / name
Expand Down Expand Up @@ -66,7 +63,7 @@ def test_evaluate_script_scores_the_test_predictions(tmp_path):
ni.save(ni.Nifti1Image(np.full((20, 20, 20), 0.1), np.eye(4)), predictions / "P0002.nii.gz")

result = subprocess.run(
[sys.executable, str(SCRIPTS / "evaluate.py"), f"train_dir={train_dir}"], capture_output=True, text=True
[sys.executable, "-m", "deepaneseg.cli.evaluate", f"train_dir={train_dir}"], capture_output=True, text=True
)

assert result.returncode == 0, result.stderr[-2000:]
Expand All @@ -87,7 +84,7 @@ def test_evaluate_script_reports_missing_predictions(tmp_path):
)

result = subprocess.run(
[sys.executable, str(SCRIPTS / "evaluate.py"), f"train_dir={train_dir}"], capture_output=True, text=True
[sys.executable, "-m", "deepaneseg.cli.evaluate", f"train_dir={train_dir}"], capture_output=True, text=True
)

assert result.returncode != 0 and "P0001" in result.stderr
4 changes: 2 additions & 2 deletions tests/test_predict_scans.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_save_detections_writes_csv_and_slicer_markups(tmp_path):
@pytest.fixture
def trained(tmp_path):
"""A tiny untrained model with its saved training configuration."""
with initialize_config_dir(config_dir=str(ROOT / "configs"), version_base="1.3"):
with initialize_config_dir(config_dir=str(ROOT / "deepaneseg" / "configs"), version_base="1.3"):
cfg = compose(
"train",
[
Expand All @@ -67,7 +67,7 @@ def trained(tmp_path):

def predict(*args):
return subprocess.run(
[sys.executable, str(ROOT / "scripts" / "predict.py"), *map(str, args)], capture_output=True, text=True
[sys.executable, "-m", "deepaneseg.cli.predict", *map(str, args)], capture_output=True, text=True
)


Expand Down
Loading
Loading