From 4b318024fdc603571aaab0af7e903c597dbda000 Mon Sep 17 00:00:00 2001 From: iback Date: Mon, 5 Oct 2026 08:50:02 +0000 Subject: [PATCH 1/3] feat: read uncompressed .nii inputs, not only .nii.gz Plain `.nii` is what dcm2niix writes without `-z y` and what a good share of public datasets ship, and SPINEPS could not read it: the CLI appended `.nii.gz` 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 single extension. Users had to gzip their whole dataset first. A shared `input_image_path()` resolves either extension, the dataset query accepts both, and `--input` / `spineps.segment()` take both. Outputs are still always written as `.nii.gz`; a genuinely unreadable extension still gets the clear error. Co-Authored-By: Claude Opus 5 --- README.md | 6 +- spineps/api.py | 4 +- spineps/entrypoint.py | 12 +-- spineps/seg_run.py | 14 ++- spineps/seg_utils.py | 26 ++++- unit_tests/test_cli_messages.py | 14 +-- unit_tests/test_plain_nii_input.py | 161 +++++++++++++++++++++++++++++ 7 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 unit_tests/test_plain_nii_input.py diff --git a/README.md b/README.md index 1489bdc..c24dd44 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ spineps sample -i --model-semantic --model-instance (replacing `` with the model you want to use). You can also call SPINEPS from Python — see [Using the Code](#using-the-code). +Both `.nii.gz` and uncompressed `.nii` inputs are read; the outputs are always written as `.nii.gz`. + SPINEPS prints a short citation reminder on first use and at exit. Set `SPINEPS_NO_CITATION_REMINDER=1` (or `true`/`yes`/`on`) to silence it. @@ -189,7 +191,7 @@ SPINEPS prints a short citation reminder on first use and at exit. Set `SPINEPS_ ## SPINEPS Capabilities The pipeline can process either: -- Single Nifty (.nii.gz) files +- Single Nifty (`.nii.gz` or `.nii`) files - Whole Datasets ### Single nifty @@ -200,7 +202,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) | diff --git a/spineps/api.py b/spineps/api.py index 47e9d18..89083a5 100644 --- a/spineps/api.py +++ b/spineps/api.py @@ -85,8 +85,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) diff --git a/spineps/entrypoint.py b/spineps/entrypoint.py index e59eca6..2db6134 100755 --- a/spineps/entrypoint.py +++ b/spineps/entrypoint.py @@ -24,6 +24,9 @@ logger = No_Logger(prefix="Init") +# Input extensions accepted by --input; the outputs are always written as .nii.gz. +NIFTI_SUFFIXES = (".nii.gz", ".nii") + # TODO replace with Class_to_ArgParse and then load only from config files! def parser_arguments(parser: argparse.ArgumentParser): @@ -250,17 +253,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 diff --git a/spineps/seg_run.py b/spineps/seg_run.py index 5ee5355..d8f445b 100755 --- a/spineps/seg_run.py +++ b/spineps/seg_run.py @@ -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 @@ -214,7 +220,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) @@ -270,7 +276,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") @@ -486,7 +492,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: diff --git a/spineps/seg_utils.py b/spineps/seg_utils.py index 01f4b78..3a71fae 100755 --- a/spineps/seg_utils.py +++ b/spineps/seg_utils.py @@ -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, @@ -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 diff --git a/unit_tests/test_cli_messages.py b/unit_tests/test_cli_messages.py index f9a5cb3..78ba782 100644 --- a/unit_tests/test_cli_messages.py +++ b/unit_tests/test_cli_messages.py @@ -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 @@ -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: diff --git a/unit_tests/test_plain_nii_input.py b/unit_tests/test_plain_nii_input.py new file mode 100644 index 0000000..2c94dfb --- /dev/null +++ b/unit_tests/test_plain_nii_input.py @@ -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() From 381763f21fc166d266f2e1df84037b4aca485aee Mon Sep 17 00:00:00 2001 From: iback Date: Mon, 5 Oct 2026 08:51:05 +0000 Subject: [PATCH 2/3] docs: state the accepted input formats where they are already listed Keeps this change off the paragraph the non-BIDS-filename PR also adds to. Co-Authored-By: Claude Opus 5 --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index c24dd44..eed9b87 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,6 @@ spineps sample -i --model-semantic --model-instance (replacing `` with the model you want to use). You can also call SPINEPS from Python — see [Using the Code](#using-the-code). -Both `.nii.gz` and uncompressed `.nii` inputs are read; the outputs are always written as `.nii.gz`. - SPINEPS prints a short citation reminder on first use and at exit. Set `SPINEPS_NO_CITATION_REMINDER=1` (or `true`/`yes`/`on`) to silence it. @@ -191,7 +189,7 @@ SPINEPS prints a short citation reminder on first use and at exit. Set `SPINEPS_ ## SPINEPS Capabilities The pipeline can process either: -- Single Nifty (`.nii.gz` or `.nii`) files +- Single Nifty files, `.nii.gz` or uncompressed `.nii` (the outputs are always written as `.nii.gz`) - Whole Datasets ### Single nifty From 71e4d893840f307b5b59186c202d5a6970ee5549 Mon Sep 17 00:00:00 2001 From: iback Date: Mon, 5 Oct 2026 08:52:12 +0000 Subject: [PATCH 3/3] refactor: derive the accepted CLI suffixes from NIFTI_FILE_TYPES One source of truth, and it keeps the top of entrypoint.py free so this does not overlap the CT-default change that also adds code there. Co-Authored-By: Claude Opus 5 --- spineps/entrypoint.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/spineps/entrypoint.py b/spineps/entrypoint.py index 2db6134..0f7c5e3 100755 --- a/spineps/entrypoint.py +++ b/spineps/entrypoint.py @@ -20,13 +20,11 @@ modelid2folder_semantic, ) 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") -# Input extensions accepted by --input; the outputs are always written as .nii.gz. -NIFTI_SUFFIXES = (".nii.gz", ".nii") - # TODO replace with Class_to_ArgParse and then load only from config files! def parser_arguments(parser: argparse.ArgumentParser): @@ -234,6 +232,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.