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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ scripts and job arrays.
## SPINEPS Capabilities

The pipeline can process either:
- Single Nifty (.nii.gz) files
- Single Nifty files, `.nii.gz` or uncompressed `.nii` (the outputs are always written as `.nii.gz`)
- Whole Datasets

### Single nifty
Expand All @@ -212,7 +212,7 @@ Processes a single nifty file, will create a derivatves folder next to the nifty

| argument | explanation |
| :--- | --------- |
| --input, -i | Absolute path to the single nifty file (.nii.gz) to be processed (required) |
| --input, -i | Path to the single nifty file (`.nii.gz` or `.nii`) to be processed (required) |
| --model-semantic, -ms | The model used for the semantic segmentation (required) |
| --model-instance, -mv, -mi | The model used for the vertebra instance segmentation (default: instance) |
| --model-labeling, -ml | The (optional) VERIDAH model used for vertebra labeling (default: t2w_labeling) |
Expand Down
4 changes: 2 additions & 2 deletions spineps/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ def _as_bids_file(image: ImageInput):
yield BIDS_FILE(str(path), dataset=tmp, verbose=False)
else:
path = Path(image).absolute()
if not str(path).endswith(".nii.gz"):
raise ValueError(f"image must be a .nii.gz file, got {path}")
if not path.name.endswith((".nii.gz", ".nii")):
raise ValueError(f"image must be a .nii.gz or .nii file, got {path}")
if not path.is_file():
raise FileNotFoundError(f"image does not exist or is not a file, got {path}")
yield BIDS_FILE(str(path), dataset=str(path.parent), verbose=False)
Expand Down
14 changes: 8 additions & 6 deletions spineps/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)
from spineps.seg_enums import ErrCode
from spineps.seg_run import process_dataset, segment_image
from spineps.seg_utils import NIFTI_FILE_TYPES
from spineps.utils.citation_reminder import citation_reminder

logger = No_Logger(prefix="Init")
Expand Down Expand Up @@ -259,6 +260,10 @@ def entry_point():
raise NotImplementedError("cmd", opt.cmd)


# Input extensions accepted by --input; the outputs are always written as .nii.gz.
NIFTI_SUFFIXES = tuple(f".{t}" for t in NIFTI_FILE_TYPES)


@citation_reminder
def run_sample(opt: Namespace):
"""Run the full segmentation pipeline on a single input NIfTI file.
Expand All @@ -279,17 +284,14 @@ def run_sample(opt: Namespace):
FileNotFoundError: If the input path's parent directory is missing, or the input file does not exist.
"""
input_path = Path(opt.input).absolute()
if not input_path.exists() and not input_path.name.endswith(".nii.gz"):
if not input_path.exists() and not input_path.name.endswith(NIFTI_SUFFIXES):
input_path = input_path.with_name(input_path.name + ".nii.gz") # allow --input without the extension
if not input_path.parent.exists():
raise FileNotFoundError(f"--input/-i: the folder {input_path.parent} does not exist")
if not input_path.is_file():
raise FileNotFoundError(f"--input/-i: {input_path} does not exist or is not a file")
if not input_path.name.endswith(".nii.gz"):
raise ValueError(
f"--input/-i: SPINEPS reads gzipped NIfTI images (.nii.gz), got {input_path.name}. "
f"Compress it first, e.g. 'gzip {input_path.name}'."
)
if not input_path.name.endswith(NIFTI_SUFFIXES):
raise ValueError(f"--input/-i: SPINEPS reads NIfTI images ({' or '.join(NIFTI_SUFFIXES)}), got {input_path.name}.")
dataset = str(input_path.parent)
input_path = str(input_path)
# model semantic
Expand Down
14 changes: 10 additions & 4 deletions spineps/seg_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
from spineps.seg_enums import Acquisition, ErrCode, Modality
from spineps.seg_model import SegmentationModel
from spineps.seg_pipeline import NoOpDebugSink, logger, predict_centroids_from_both
from spineps.seg_utils import Modality_Pair, check_input_model_compatibility, check_model_modality_acquisition
from spineps.seg_utils import (
NIFTI_FILE_TYPES,
Modality_Pair,
check_input_model_compatibility,
check_model_modality_acquisition,
input_image_path,
)
from spineps.utils.citation_reminder import citation_reminder


Expand Down Expand Up @@ -218,7 +224,7 @@ def process_dataset( # noqa: C901
allowed_acq = Acquisition.format_keys(mod_pair[1])
q = subject.new_query(flatten=True)
# optional give subject list
q.filter_filetype("nii.gz")
q.filter_filetype(list(NIFTI_FILE_TYPES))
q.filter_non_existence("seg", required=True)
if not ignore_bids_filter:
q.filter_format(allowed_format)
Expand Down Expand Up @@ -274,7 +280,7 @@ def process_dataset( # noqa: C901
elif errcode == ErrCode.ALL_DONE:
processed_alldone_counter += 1
else:
not_properly_processed.append((errcode, str(s.file["nii.gz"])))
not_properly_processed.append((errcode, str(input_image_path(s))))
if subject_scan_processed == 0:
logger.print(f"Subject {s_idx + 1}: {name} had no scans to be processed")

Expand Down Expand Up @@ -507,7 +513,7 @@ def segment_image( # noqa: C901
logger.print("Issues are ignored, might not have expected outcome", Log_Type.WARNING)

start_time = start_time2 = perf_counter()
file_dir = img_ref.file["nii.gz"]
file_dir = input_image_path(img_ref)

logger.print("Processing", file_dir.name)
with logger:
Expand Down
26 changes: 25 additions & 1 deletion spineps/seg_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@
# `X | Y` on bare types needs Python 3.10+, so this must stay `Union[...]` for the 3.9 floor.
Modality_Pair = tuple[Union[list[Modality], Modality], Acquisition]

# NIfTI file types SPINEPS reads, in the order they are preferred when a BIDS_FILE holds several.
NIFTI_FILE_TYPES = ("nii.gz", "nii")


def input_image_path(img_ref: BIDS_FILE):
"""Returns the NIfTI file behind a ``BIDS_FILE``, gzipped or not.

``BIDS_FILE.file`` is keyed by extension, so ``file["nii.gz"]`` raises ``KeyError`` for an
uncompressed ``.nii`` input -- which is what most DICOM converters produce by default.

Args:
img_ref (BIDS_FILE): The input reference.

Returns:
Path: Path to the image file.

Raises:
KeyError: If the reference holds no NIfTI file at all.
"""
for file_type in NIFTI_FILE_TYPES:
if file_type in img_ref.file:
return img_ref.file[file_type]
raise KeyError(f"no NIfTI file in {img_ref.file}, expected one of {NIFTI_FILE_TYPES}")


def check_model_modality_acquisition(
model: SegmentationModel,
Expand Down Expand Up @@ -110,7 +134,7 @@ def check_input_model_compatibility(
allowed_format = Modality.format_keys(model_modalities)
allowed_acq = [*Acquisition.format_keys(model_acquisition), "iso"]

file_dir = img_ref.file["nii.gz"]
file_dir = input_image_path(img_ref)
filename = file_dir.name

compatible = True
Expand Down
14 changes: 7 additions & 7 deletions unit_tests/test_cli_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

The hints told users to set flags that do not exist (`-override_subreg`, `-override_vert`,
`-override_ctd`, `-model_instance`; the real ones are `--override-semantic`, `--override-instance`,
`--override-ctd`, `--model-instance`), and `--input scan.nii` -- an uncompressed NIfTI, which SPINEPS
cannot read -- silently became `scan.nii.nii.gz` and then reported *"-input does not exist or is not a
file"* about a path the user never typed.
`--override-ctd`, `--model-instance`), and `--input scan.mha` -- a file SPINEPS cannot read --
silently became `scan.mha.nii.gz` and then reported *"-input does not exist or is not a file"* about a
path the user never typed.
"""

from __future__ import annotations
Expand Down Expand Up @@ -83,17 +83,17 @@ def _opt(input_path: str) -> argparse.Namespace:
opt.model_labeling = "none"
return opt

def test_uncompressed_nifti_says_so(self):
def test_unsupported_extension_says_so(self):
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "scan.nii"
path = Path(td) / "scan.mha"
path.write_bytes(b"")
with self.assertRaises(ValueError) as cm:
entrypoint.run_sample(self._opt(str(path)))
message = str(cm.exception)
self.assertIn("scan.nii", message)
self.assertIn("scan.mha", message)
self.assertIn(".nii.gz", message)
# and it must not invent a path the user never typed
self.assertNotIn("scan.nii.nii.gz", message)
self.assertNotIn("scan.mha.nii.gz", message)

def test_missing_file_names_the_path_given(self):
with tempfile.TemporaryDirectory() as td:
Expand Down
161 changes: 161 additions & 0 deletions unit_tests/test_plain_nii_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Call 'python -m unittest' on this folder
"""An uncompressed ``.nii`` input must be segmented like a ``.nii.gz`` one.

Plain ``.nii`` is what `dcm2niix` writes without `-z y`, and what a good share of public datasets ship.
SPINEPS accepted only ``.nii.gz``: the CLI appended the suffix to whatever it was given, `BIDS_FILE.file`
is keyed by extension so the three `file["nii.gz"]` lookups raised `KeyError`, and the dataset query
filtered on that one extension. Outputs are written as ``.nii.gz`` either way.
"""

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path

import numpy as np
from TPTBox import BIDS_FILE, NII, No_Logger
from TPTBox.tests.test_utils import get_test_mri
from typing_extensions import Self

from spineps.seg_enums import ErrCode, OutputType
from spineps.seg_model import Segmentation_Inference_Config, SegmentationModel
from spineps.seg_run import segment_image
from spineps.seg_utils import NIFTI_FILE_TYPES, check_input_model_compatibility, input_image_path

logger = No_Logger()


def _config(modality: list[str]) -> Segmentation_Inference_Config:
return Segmentation_Inference_Config(
logger=No_Logger(),
modality=modality,
acquisition="sag",
log_name="DummyModel",
modeltype="unet",
model_expected_orientation=("P", "I", "R"),
available_folds=1,
inference_augmentation=False,
resolution_range=[1.5, 1.5, 1.5], # equals the test fixture zoom -> no rescaling
default_step_size=0.5,
labels={1: 1},
cutout_size=(48, 48, 32),
)


class _SemanticDummy(SegmentationModel):
"""Echoes the fixture's subregion mask, resampled onto whatever grid it is handed."""

def __init__(self, subreg: NII) -> None:
self.logger = No_Logger()
self._subreg = subreg
super().__init__(__file__, _config(["T2w", "T1w"]), default_verbose=False, default_allow_tqdm=False)

def load(self, folds: tuple[str, ...] | None = None) -> Self: # noqa: ARG002
self.predictor = object()
return self

def run(self, input_nii: list[NII], verbose: bool = False) -> dict[OutputType, NII | None]: # noqa: ARG002
return {OutputType.seg: self._subreg.copy().resample_from_to(input_nii[0]), OutputType.softmax_logits: None}


class _InstanceDummy(SegmentationModel):
"""Splits each corpus cutout into a 1/2/3 three-vertebra hierarchy, like the real instance model."""

def __init__(self) -> None:
self.logger = No_Logger()
super().__init__(__file__, _config(["SEG"]), default_verbose=False, default_allow_tqdm=False)

def load(self, folds: tuple[str, ...] | None = None) -> Self: # noqa: ARG002
self.predictor = object()
return self

def run(self, input_nii: list[NII], verbose: bool = False) -> dict[OutputType, NII | None]: # noqa: ARG002
return {OutputType.seg: input_nii[0], OutputType.softmax_logits: None}

def segment_scan(self, input_image, **kwargs): # noqa: ARG002
cut_nii: NII = input_image
arr = cut_nii.get_seg_array()
out = np.zeros_like(arr)
corpus = np.argwhere(arr != 0)
if len(corpus) > 0:
axis = int(np.argmax(corpus.max(axis=0) - corpus.min(axis=0)))
lo, hi = corpus[:, axis].min(), corpus[:, axis].max() + 1
third = max((hi - lo) / 3.0, 1.0)
for c in corpus:
out[c[0], c[1], c[2]] = min(int((c[axis] - lo) / third), 2) + 1
return {OutputType.seg: cut_nii.set_array(out), OutputType.softmax_logits: None}


class Test_Input_Image_Path(unittest.TestCase):
def test_both_extensions_are_accepted(self):
self.assertEqual(NIFTI_FILE_TYPES, ("nii.gz", "nii"))
mri, *_ = get_test_mri()
for name in ("sub-01_T2w.nii.gz", "sub-01_T2w.nii"):
with self.subTest(name=name), tempfile.TemporaryDirectory() as td:
path = Path(td) / name
mri.save(path, verbose=False)
img_ref = BIDS_FILE(str(path), dataset=td, verbose=False)
self.assertEqual(input_image_path(img_ref), path)

def test_compatibility_check_works_on_plain_nii(self):
mri, *_ = get_test_mri()
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "sub-01_T2w.nii"
mri.save(path, verbose=False)
img_ref = BIDS_FILE(str(path), dataset=td, verbose=False)
model = _SemanticDummy(mri).load()
self.assertTrue(check_input_model_compatibility(img_ref, model=model, img_nii=mri, verbose=False))


class Test_Segment_Plain_Nii(unittest.TestCase):
def test_full_pipeline_on_a_plain_nii(self):
mri, subreg, *_ = get_test_mri()
with tempfile.TemporaryDirectory() as td:
directory = Path(td)
path = directory / "sub-01_T2w.nii"
mri.save(path, verbose=False)
img_ref = BIDS_FILE(str(path), dataset=str(directory), verbose=False)

output_paths, errcode = segment_image(
img_ref,
model_semantic=_SemanticDummy(subreg).load(),
model_instance=_InstanceDummy().load(),
model_labeling=None,
proc_sem_n4_bias_correction=False,
verbose=False,
)
self.assertEqual(errcode, ErrCode.OK)
for key in ("out_spine", "out_vert", "out_ctd", "out_snap"):
self.assertTrue(output_paths[key].is_file(), f"{key} was not written to {output_paths[key]}")
# the masks are written compressed regardless of how the input was stored
self.assertTrue(output_paths["out_spine"].name.endswith(".nii.gz"), output_paths["out_spine"].name)


class Test_Cli_Accepts_Plain_Nii(unittest.TestCase):
def test_run_sample_does_not_reject_nii(self):
import argparse
from unittest import mock

from spineps import entrypoint

self.assertEqual(entrypoint.NIFTI_SUFFIXES, (".nii.gz", ".nii"))
mri, *_ = get_test_mri()
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "sub-01_T2w.nii"
mri.save(path, verbose=False)
parser = argparse.ArgumentParser()
entrypoint.parser_arguments(parser)
opt = parser.parse_args([])
opt.input = str(path)
opt.model_semantic, opt.model_instance, opt.model_labeling = "t2w", "instance", "none"
with (
mock.patch.object(entrypoint, "get_semantic_model"),
mock.patch.object(entrypoint, "get_instance_model"),
mock.patch.object(entrypoint, "segment_image", return_value=({"out_spine": path}, ErrCode.OK)),
):
entrypoint.run_sample(opt) # must not raise


if __name__ == "__main__":
unittest.main()
Loading