Skip to content

Models

Model discovery/loading and the segmentation/labeling model classes.

spineps.get_models

spineps.get_models

Discovery, lookup and instantiation of SPINEPS segmentation and labeling models from disk or remote URLs.

get_semantic_model

get_semantic_model(
    model_name: str, **kwargs
) -> Segmentation_Model

Finds and returns a semantic (subregion) model by name.

Parameters:

Name Type Description Default
model_name str

Id of the semantic model to load (case-insensitive).

required
**kwargs

Extra keyword arguments forwarded to the model constructor.

{}

Returns:

Name Type Description
Segmentation_Model Segmentation_Model

The instantiated semantic model.

Raises:

Type Description
KeyError

If no model with the given name is available.

Source code in spineps/get_models.py
def get_semantic_model(model_name: str, **kwargs) -> Segmentation_Model:
    """Finds and returns a semantic (subregion) model by name.

    Args:
        model_name (str): Id of the semantic model to load (case-insensitive).
        **kwargs: Extra keyword arguments forwarded to the model constructor.

    Returns:
        Segmentation_Model: The instantiated semantic model.

    Raises:
        KeyError: If no model with the given name is available.
    """
    return _get_model_by_name(model_name, modelid2folder_semantic(), SpinepsPhase.SEMANTIC, "semantic", **kwargs)

get_instance_model

get_instance_model(
    model_name: str, **kwargs
) -> Segmentation_Model

Finds and returns an instance (vertebra) model by name.

Parameters:

Name Type Description Default
model_name str

Id of the instance model to load (case-insensitive).

required
**kwargs

Extra keyword arguments forwarded to the model constructor.

{}

Returns:

Name Type Description
Segmentation_Model Segmentation_Model

The instantiated instance model.

Raises:

Type Description
KeyError

If no model with the given name is available.

Source code in spineps/get_models.py
def get_instance_model(model_name: str, **kwargs) -> Segmentation_Model:
    """Finds and returns an instance (vertebra) model by name.

    Args:
        model_name (str): Id of the instance model to load (case-insensitive).
        **kwargs: Extra keyword arguments forwarded to the model constructor.

    Returns:
        Segmentation_Model: The instantiated instance model.

    Raises:
        KeyError: If no model with the given name is available.
    """
    return _get_model_by_name(model_name, modelid2folder_instance(), SpinepsPhase.INSTANCE, "instance", **kwargs)

get_labeling_model

get_labeling_model(
    model_name: str, **kwargs
) -> VertLabelingClassifier

Finds and returns a vertebra-labeling model by name.

Parameters:

Name Type Description Default
model_name str

Id of the labeling model to load (case-insensitive).

required
**kwargs

Extra keyword arguments forwarded to the model constructor.

{}

Returns:

Name Type Description
VertLabelingClassifier VertLabelingClassifier

The instantiated labeling classifier.

Raises:

Type Description
KeyError

If no model with the given name is available.

Source code in spineps/get_models.py
def get_labeling_model(model_name: str, **kwargs) -> VertLabelingClassifier:
    """Finds and returns a vertebra-labeling model by name.

    Args:
        model_name (str): Id of the labeling model to load (case-insensitive).
        **kwargs: Extra keyword arguments forwarded to the model constructor.

    Returns:
        VertLabelingClassifier: The instantiated labeling classifier.

    Raises:
        KeyError: If no model with the given name is available.
    """
    return _get_model_by_name(model_name, modelid2folder_labeling(), SpinepsPhase.LABELING, "labeling", **kwargs)

modelid2folder_semantic

modelid2folder_semantic() -> dict[str, Path | str]

Returns the dictionary mapping semantic model ids to their corresponding path.

Uses the cached mapping if available, otherwise scans the configured models directory.

Returns:

Type Description
dict[str, Path | str]

dict[str, Path | str]: Mapping from semantic model id to its folder path or download URL.

Source code in spineps/get_models.py
def modelid2folder_semantic() -> dict[str, Path | str]:
    """Returns the dictionary mapping semantic model ids to their corresponding path.

    Uses the cached mapping if available, otherwise scans the configured models directory.

    Returns:
        dict[str, Path | str]: Mapping from semantic model id to its folder path or download URL.
    """
    if _modelid2folder_semantic is not None:
        return _modelid2folder_semantic
    else:
        return check_available_models(get_mri_segmentor_models_dir())[0]

modelid2folder_instance

modelid2folder_instance() -> dict[str, Path | str]

Returns the dictionary mapping instance model ids to their corresponding path.

Uses the cached mapping if available, otherwise scans the configured models directory.

Returns:

Type Description
dict[str, Path | str]

dict[str, Path | str]: Mapping from instance model id to its folder path or download URL.

Source code in spineps/get_models.py
def modelid2folder_instance() -> dict[str, Path | str]:
    """Returns the dictionary mapping instance model ids to their corresponding path.

    Uses the cached mapping if available, otherwise scans the configured models directory.

    Returns:
        dict[str, Path | str]: Mapping from instance model id to its folder path or download URL.
    """
    if _modelid2folder_instance is not None:
        return _modelid2folder_instance
    else:
        return check_available_models(get_mri_segmentor_models_dir())[1]

modelid2folder_labeling

modelid2folder_labeling() -> dict[str, Path | str]

Returns the dictionary mapping labeling model ids to their corresponding path.

Uses the cached mapping if available, otherwise scans the configured models directory.

Returns:

Type Description
dict[str, Path | str]

dict[str, Path | str]: Mapping from labeling model id to its folder path or download URL.

Source code in spineps/get_models.py
def modelid2folder_labeling() -> dict[str, Path | str]:
    """Returns the dictionary mapping labeling model ids to their corresponding path.

    Uses the cached mapping if available, otherwise scans the configured models directory.

    Returns:
        dict[str, Path | str]: Mapping from labeling model id to its folder path or download URL.
    """
    if _modelid2folder_labeling is not None:
        return _modelid2folder_labeling
    else:
        return check_available_models(get_mri_segmentor_models_dir())[2]

check_available_models

check_available_models(
    models_folder: str | Path, verbose: bool = False
) -> tuple[
    dict[str, Path | str],
    dict[str, Path | str],
    dict[str, Path | str],
]

Searches the given directory for models and sorts them into semantic, instance and labeling id-to-folder maps.

Recursively finds all inference_config.json files, loads each config and assigns the model to the labeling map (classifier), the instance map (segmentation input modality) or the semantic map (everything else). The results are cached in module-level globals. Models whose config fails to load are skipped.

Parameters:

Name Type Description Default
models_folder str | Path

The folder to be analyzed for models.

required
verbose bool

If true, logs models that were skipped because their config could not be loaded. Defaults to False.

False

Returns:

Type Description
tuple[dict[str, Path | str], dict[str, Path | str], dict[str, Path | str]]

tuple[dict[str, Path | str], dict[str, Path | str], dict[str, Path | str]]: The semantic, instance and labeling id-to-folder maps.

Raises:

Type Description
AssertionError

If models_folder does not exist.

Source code in spineps/get_models.py
def check_available_models(
    models_folder: str | Path, verbose: bool = False
) -> tuple[dict[str, Path | str], dict[str, Path | str], dict[str, Path | str]]:
    """Searches the given directory for models and sorts them into semantic, instance and labeling id-to-folder maps.

    Recursively finds all inference_config.json files, loads each config and assigns the model to the labeling map
    (classifier), the instance map (segmentation input modality) or the semantic map (everything else). The results are
    cached in module-level globals. Models whose config fails to load are skipped.

    Args:
        models_folder (str | Path): The folder to be analyzed for models.
        verbose (bool, optional): If true, logs models that were skipped because their config could not be loaded.
            Defaults to False.

    Returns:
        tuple[dict[str, Path | str], dict[str, Path | str], dict[str, Path | str]]: The semantic, instance and labeling
            id-to-folder maps.

    Raises:
        AssertionError: If models_folder does not exist.
    """
    logger.print("Check available models...")
    if isinstance(models_folder, str):
        models_folder = Path(models_folder)
    assert models_folder.exists(), f"models_folder {models_folder} does not exist"

    config_paths = search_path(models_folder, query="**/inference_config.json", suppress=True)
    global _modelid2folder_semantic, _modelid2folder_instance, _modelid2folder_labeling  # noqa: PLW0603
    _modelid2folder_semantic = semantic  # id to model_folder
    _modelid2folder_instance = instances  # id to model_folder
    _modelid2folder_labeling = labeling
    for cp in tqdm(config_paths, desc="Checking models"):
        model_folder = cp.parent
        model_folder_name = model_folder.parent.name.lower() if "nnUNetPlans" in model_folder.name else model_folder.name.lower()
        try:
            inference_config = load_inference_config(str(cp))
            if inference_config.modeltype == ModelType.classifier:
                _modelid2folder_labeling[model_folder_name] = model_folder
            elif Modality.SEG in inference_config.modalities:
                _modelid2folder_instance[model_folder_name] = model_folder
            else:
                _modelid2folder_semantic[model_folder_name] = model_folder
        except Exception as e:
            logger.print(f"Modelfolder '{model_folder_name}' ignored, caused by '{e}'", Log_Type.STRANGE, verbose=verbose)
            # raise e  #

    return _modelid2folder_semantic, _modelid2folder_instance, _modelid2folder_labeling

modeltype2class

modeltype2class(modeltype: ModelType) -> type

Maps a ModelType to the corresponding model class.

Parameters:

Name Type Description Default
modeltype ModelType

The model type from the inference config.

required

Raises:

Type Description
NotImplementedError

If the model type is not supported.

Returns:

Name Type Description
type type

The class to instantiate (Segmentation_Model_NNunet, Segmentation_Model_Unet3D or VertLabelingClassifier).

Source code in spineps/get_models.py
def modeltype2class(modeltype: ModelType) -> type:
    """Maps a ModelType to the corresponding model class.

    Args:
        modeltype (ModelType): The model type from the inference config.

    Raises:
        NotImplementedError: If the model type is not supported.

    Returns:
        type: The class to instantiate (Segmentation_Model_NNunet, Segmentation_Model_Unet3D or VertLabelingClassifier).
    """
    if modeltype == ModelType.nnunet:
        return Segmentation_Model_NNunet
    elif modeltype == ModelType.unet:
        return Segmentation_Model_Unet3D
    elif modeltype == ModelType.classifier:
        return VertLabelingClassifier
    else:
        raise NotImplementedError(modeltype)

get_actual_model

get_actual_model(
    in_config: str | Path, use_cpu: bool = False, **kwargs
) -> Segmentation_Model | VertLabelingClassifier

Creates and returns the appropriate model from a given inference config path.

Accepts either a path to an inference_config.json file or a folder containing exactly one such file (searched recursively). Loads the config, picks the matching model class and instantiates it.

Parameters:

Name Type Description Default
in_config str | Path

Path to the model's inference config file, or to a folder containing it.

required
use_cpu bool

If true, runs inference on CPU instead of GPU. Defaults to False.

False
**kwargs

Extra keyword arguments forwarded to the model constructor.

{}

Returns:

Type Description
Segmentation_Model | VertLabelingClassifier

Segmentation_Model | VertLabelingClassifier: The instantiated model.

Raises:

Type Description
FileNotFoundError

If no inference_config.json is found in the given folder.

AssertionError

If more than one inference_config.json is found in the given folder.

Source code in spineps/get_models.py
def get_actual_model(
    in_config: str | Path,
    use_cpu: bool = False,
    **kwargs,
) -> Segmentation_Model | VertLabelingClassifier:
    """Creates and returns the appropriate model from a given inference config path.

    Accepts either a path to an inference_config.json file or a folder containing exactly one such file (searched
    recursively). Loads the config, picks the matching model class and instantiates it.

    Args:
        in_config (str | Path): Path to the model's inference config file, or to a folder containing it.
        use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
        **kwargs: Extra keyword arguments forwarded to the model constructor.

    Returns:
        Segmentation_Model | VertLabelingClassifier: The instantiated model.

    Raises:
        FileNotFoundError: If no inference_config.json is found in the given folder.
        AssertionError: If more than one inference_config.json is found in the given folder.
    """
    # if isinstance(in_config, MODELS):
    #    in_dir = filepath_model(in_config.value, model_dir=None)
    # else:

    in_dir = in_config

    if os.path.isdir(str(in_dir)):  # noqa: PTH112
        # search for config
        path_search = search_path(in_dir, "**/*inference_config.json", suppress=True)
        if len(path_search) == 0:
            logger.print(
                f"get_actual_model: did not find a singular inference_config.json in {in_dir}/**/*inference_config.json. Is this the correct folder?",
                Log_Type.FAIL,
            )
            raise FileNotFoundError(f"{in_dir}/**/*inference_config.json")
        assert len(path_search) == 1, (
            f"get_actual_model: found more than one inference_config.json in {in_dir}/**/*inference_config.json. Ambiguous behavior, please manually correct this by removing one of these.\nFound {path_search}"
        )
        in_dir = path_search[0]
    # else:
    #    base = filepath_model(in_config, model_dir=None)
    #    in_dir = base

    inference_config = load_inference_config(str(in_dir))
    modeltype: type[Segmentation_Model] = modeltype2class(inference_config.modeltype)
    return modeltype(model_folder=in_config, inference_config=inference_config, use_cpu=use_cpu, **kwargs)

spineps.seg_model

spineps.seg_model

Segmentation model abstractions: the abstract Segmentation_Model and its nnU-Net and Unet3D subclasses.

Segmentation_Model

Bases: ABC

Abstract base class wrapping a segmentation network together with its inference configuration.

Subclasses implement load() and run() for a concrete backend (e.g. nnU-Net or Unet3D). The class handles input preparation (reorientation, rescaling to the recommended zoom, padding), running the model and mapping the output back into the input space.

Attributes:

Name Type Description
name str

Optional human-readable model name.

logger No_Logger

Logger used for all model output.

use_cpu bool

If true, runs inference on CPU instead of GPU.

inference_config Segmentation_Inference_Config

Configuration describing expected inputs, resolution range and labels.

default_verbose bool

Default verbosity for printing.

default_allow_tqdm bool

Whether a progress bar is shown during segmentation by default.

model_folder str

Path to the model's folder on disk.

predictor

The loaded backend predictor, or None until load() is called.

Source code in spineps/seg_model.py
class Segmentation_Model(ABC):
    """Abstract base class wrapping a segmentation network together with its inference configuration.

    Subclasses implement load() and run() for a concrete backend (e.g. nnU-Net or Unet3D). The class handles input
    preparation (reorientation, rescaling to the recommended zoom, padding), running the model and mapping the output back
    into the input space.

    Attributes:
        name (str): Optional human-readable model name.
        logger (No_Logger): Logger used for all model output.
        use_cpu (bool): If true, runs inference on CPU instead of GPU.
        inference_config (Segmentation_Inference_Config): Configuration describing expected inputs, resolution range and labels.
        default_verbose (bool): Default verbosity for printing.
        default_allow_tqdm (bool): Whether a progress bar is shown during segmentation by default.
        model_folder (str): Path to the model's folder on disk.
        predictor: The loaded backend predictor, or None until load() is called.
    """

    def __init__(
        self,
        model_folder: str | Path,
        inference_config: Segmentation_Inference_Config | None = None,  # type:ignore
        use_cpu: bool = False,
        default_verbose: bool = False,
        default_allow_tqdm: bool = True,
    ):
        """Initializes the segmentation model, finding and loading the corresponding inference config for that model.

        Args:
            model_folder (str | Path): Path to that model's folder.
            inference_config (Segmentation_Inference_Config | None, optional): Inference config to use; if None, loads
                "inference_config.json" from the model folder. Defaults to None.
            use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
            default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
            default_allow_tqdm (bool, optional): If true, shows a progress bar while segmenting. Defaults to True.

        Raises:
            AssertionError: If model_folder does not exist.
        """
        self.name: str = ""
        assert Path(model_folder).exists(), f"model_folder does not exist, got {model_folder}"

        self.logger = No_Logger()
        self.use_cpu = use_cpu

        if inference_config is None:
            json_dir = Path(model_folder).joinpath("inference_config.json")
            self.inference_config = load_inference_config(json_dir, self.logger)
        else:
            self.inference_config = inference_config

        self.default_verbose = default_verbose
        self.logger.prefix = self.inference_config.log_name
        self.logger.default_verbose = self.default_verbose
        self.model_folder = str(model_folder)
        self.default_allow_tqdm = default_allow_tqdm
        self.predictor = None

        self.print("initialized with inference config", self.inference_config)

    @abstractmethod
    def load(self, folds: tuple[str, ...] | None = None) -> Self:
        """Loads the model weights from disk.

        Args:
            folds (tuple[str, ...] | None, optional): Which folds to load; if None, uses the folds from the inference config.
                Defaults to None.

        Returns:
            Self: This model with its predictor loaded.
        """
        return self

    def calc_recommended_resampling_zoom(self, input_zoom: ZOOMS) -> ZOOMS:
        """Calculates the resolution a corresponding input should be resampled to for this model.

        If the inference config defines a (min, max) resolution range, each axis of the input zoom is clamped into that
        range; otherwise the fixed configured resolution is returned.

        Args:
            input_zoom (ZOOMS): Voxel spacing (mm) of the input image, per axis.

        Returns:
            ZOOMS: Recommended voxel spacing (mm) to resample the input to before inference.
        """
        if len(self.inference_config.resolution_range) != 2:
            return self.inference_config.resolution_range
        output_zoom = tuple(
            max(
                min(input_zoom[idx], self.inference_config.resolution_range[1][idx]),  # type:ignore
                self.inference_config.resolution_range[0][idx],  # type:ignore
            )
            for idx in range(len(input_zoom))
        )
        return output_zoom

    def same_modelzoom_as_model(self, model: Self, input_zoom: ZOOMS) -> bool:
        """Checks whether another model would resample a given input to the same resolution as this model.

        Args:
            model (Self): The other segmentation model to compare against.
            input_zoom (ZOOMS): Voxel spacing (mm) of the input image, per axis.

        Returns:
            bool: True if both models' recommended resampling zooms agree on every axis within ZOOM_MATCH_TOLERANCE.
        """
        self_zms = self.calc_recommended_resampling_zoom(input_zoom=input_zoom)
        model_zms = model.calc_recommended_resampling_zoom(input_zoom=self_zms)
        match: bool = bool(np.all([abs(self_zms[i] - model_zms[i]) < ZOOM_MATCH_TOLERANCE for i in range(3)]))
        return match

    @citation_reminder
    def segment_scan(
        self,
        input_image: Image_Reference | dict[InputType, Image_Reference],
        pad_size: int = 0,
        step_size: float | None = None,
        resample_to_recommended: bool = True,
        resample_output_to_input_space: bool = True,
        verbose: bool = False,
    ) -> dict[OutputType, NII | None]:
        """Segments a given input with this model.

        Prepares each expected input (optional padding, reorientation to the model orientation and rescaling to the
        recommended zoom), runs the model and maps the outputs back into the input space.

        Args:
            input_image (Image_Reference | dict[InputType, Image_Reference]): A single image, or a mapping from InputType to
                image for multi-input models.
            pad_size (int, optional): Padding added in each dimension (this many extra voxels on each side per axis), removed
                again from the output. Defaults to 0.
            step_size (float | None, optional): Sliding-window tile step size; if None, uses the config default. Defaults to None.
            resample_to_recommended (bool, optional): If true, rescales each input to the model's recommended zoom. Defaults to True.
            resample_output_to_input_space (bool, optional): If true, resamples and pads the outputs back to the original input
                space. Defaults to True.
            verbose (bool, optional): If true, prints verbose information. Defaults to False.

        Returns:
            dict[OutputType, NII | None]: Mapping of output type to result NII (e.g. the segmentation mask, optionally softmax
                logits).
        """
        if self.predictor is None:
            self.load()
            assert self.predictor is not None, "self.predictor == None after load(). Error!"

        # Check if input matches expectation
        if not isinstance(input_image, dict):
            if len(self.inference_config.expected_inputs) >= 2:
                self.print(
                    "input is one Image_Reference but model expected more, if not already stacked correctly, this will fail!",
                    Log_Type.WARNING,
                )
            inputdict = {self.inference_config.expected_inputs[0]: input_image}
        else:
            inputdict: dict[InputType, Image_Reference] = input_image
        # Check if all required inputs are there
        if not set(inputdict.keys()).issuperset(self.inference_config.expected_inputs):
            self.print(f"expected {self.inference_config.expected_inputs}, but only got {list(inputdict.keys())}")
        orig_shape = None
        orientation = None
        zms = None
        input_niftys_in_order = []
        zms_pir: ZOOMS = None  # type: ignore
        for id in self.inference_config.expected_inputs:  # noqa: A001
            # Make nifty
            nii = to_nii(inputdict[id], seg=id == InputType.seg)
            # Padding
            if pad_size > 0:
                arr = nii.get_array()
                arr = np.pad(arr, pad_size, mode="edge")
                nii.set_array_(arr)
            input_niftys_in_order.append(nii)
            # Save first values for comparison
            if orig_shape is None:
                orig_shape = nii.shape
                orientation = nii.orientation
                zms = nii.zoom
            # Consistency check
            nii.assert_affine(shape=orig_shape, orientation=orientation, zoom=zms)
            # ), "All inputs need to be of same shape, orientation and zoom, got at least two different."
            # Reorient and rescale
            nii.reorient_(self.inference_config.model_expected_orientation, verbose=self.logger)
            zms_pir = nii.zoom
            if resample_to_recommended:
                nii.rescale_(self.calc_recommended_resampling_zoom(zms_pir), verbose=self.logger)

        assert orig_shape is not None
        if not resample_to_recommended:
            self.print("resample_to_recommended set to False, segmentation might not work. Proceed at own risk", Log_Type.WARNING)

        # set step_size
        if hasattr(self.predictor, "tile_step_size"):
            self.predictor.tile_step_size = step_size if step_size is not None else self.inference_config.default_step_size

        self.print("input", input_niftys_in_order[0], verbose=verbose)
        self.print("Run Segmentation")
        result = self.run(input_nii=input_niftys_in_order, verbose=verbose)
        assert OutputType.seg in result and isinstance(result[OutputType.seg], NII), "No seg output in segmentation result"
        for k, v in result.items():
            if isinstance(v, NII):  # and k != OutputType.seg_modelres:
                if resample_output_to_input_space:
                    v.resample_from_to_(inputdict[self.inference_config.expected_inputs[0]])
                    # v.rescale_(zms_pir, verbose=self.logger).reorient_(orientation, verbose=self.logger)
                    v.pad_to(orig_shape, inplace=True)
                if k == OutputType.seg:
                    v.map_labels_(self.inference_config.segmentation_labels, verbose=self.logger)
                if pad_size > 0:
                    arr = v.get_array()
                    arr = arr[pad_size:-pad_size, pad_size:-pad_size, pad_size:-pad_size]
                    v.set_array_(arr)

                self.print(f"out_seg {k}", v.zoom, v.orientation, v.shape, verbose=verbose)
        self.print("Segmenting done!")
        return result

    def modalities(self) -> list[Modality]:
        """Returns the modalities this model supports.

        Returns:
            list[Modality]: Modalities the model was trained for, as listed in its inference config.
        """
        return self.inference_config.modalities

    def acquisition(self) -> Acquisition:
        """Returns the acquisition this model supports.

        Returns:
            Acquisition: Acquisition plane/type the model expects, as listed in its inference config.
        """
        return self.inference_config.acquisition

    @abstractmethod
    def run(self, input_nii: list[NII], verbose: bool = False) -> dict[OutputType, NII | None]:
        """Runs the backend predictor on the prepared inputs.

        Args:
            input_nii (list[NII]): Inputs already reoriented and rescaled to the model's expectation, in the configured order.
            verbose (bool, optional): If true, prints verbose information. Defaults to False.

        Returns:
            dict[OutputType, NII | None]: Mapping of output type to result NII produced by the model.
        """

    def print(self, *text: object, verbose: bool | None = None):
        """Logs text via the model's logger.

        Args:
            *text: Items to print.
            verbose (bool | None, optional): Overrides the default verbosity; if None, uses default_verbose. Defaults to None.
        """
        if verbose is None:
            verbose = self.default_verbose
        self.logger.print(*text, verbose=verbose)

    def print_self(self):
        """Prints the model id and its inference config."""
        self.print(self.modelid(include_log_name=False), verbose=True)
        self.print("Config:", self.inference_config, verbose=True)

    def modelid(self, include_log_name: bool = False) -> str:
        """Returns an identifier string for this model.

        Args:
            include_log_name (bool, optional): If true and a name is set, appends the config log name. Defaults to False.

        Returns:
            str: The model name, or the inference config's log name if no name is set.
        """
        name: str = str(self.name)
        if name != "":
            if include_log_name:
                return name + " -- " + self.inference_config.log_name
            return name
        return self.inference_config.log_name

    def dict_representation(self) -> dict[str, str]:
        """Builds a summary dictionary describing this model.

        Returns:
            dict[str, str]: Model id, model path, modalities, acquisition and resolution range as strings.
        """
        info = {
            "name": self.modelid(),  # self.inference_config.__repr__()
            "model_path": str(self.model_folder),
            "modality": str(self.modalities()),
            "aquisition": str(self.acquisition()),
            "resolution_range": str(self.inference_config.resolution_range),
        }
        # if input_zms is not None:
        #    proc_zms = self.calc_recommended_resampling_zoom(input_zms)
        #    info["resolution_processed"] = str(proc_zms)
        return info

    def __str__(self) -> str:
        """Returns the model id together with its inference config representation.

        Returns:
            str: Human-readable description of the model.
        """
        return self.modelid(include_log_name=True) + "\nConfig: " + self.inference_config.__repr__()

    def __repr__(self) -> str:
        """Returns the same representation as __str__.

        Returns:
            str: Human-readable description of the model.
        """
        return str(self)

__init__

__init__(
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config
    | None = None,
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
)

Initializes the segmentation model, finding and loading the corresponding inference config for that model.

Parameters:

Name Type Description Default
model_folder str | Path

Path to that model's folder.

required
inference_config Segmentation_Inference_Config | None

Inference config to use; if None, loads "inference_config.json" from the model folder. Defaults to None.

None
use_cpu bool

If true, runs inference on CPU instead of GPU. Defaults to False.

False
default_verbose bool

If true, prints more information when used. Defaults to False.

False
default_allow_tqdm bool

If true, shows a progress bar while segmenting. Defaults to True.

True

Raises:

Type Description
AssertionError

If model_folder does not exist.

Source code in spineps/seg_model.py
def __init__(
    self,
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config | None = None,  # type:ignore
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
):
    """Initializes the segmentation model, finding and loading the corresponding inference config for that model.

    Args:
        model_folder (str | Path): Path to that model's folder.
        inference_config (Segmentation_Inference_Config | None, optional): Inference config to use; if None, loads
            "inference_config.json" from the model folder. Defaults to None.
        use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
        default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
        default_allow_tqdm (bool, optional): If true, shows a progress bar while segmenting. Defaults to True.

    Raises:
        AssertionError: If model_folder does not exist.
    """
    self.name: str = ""
    assert Path(model_folder).exists(), f"model_folder does not exist, got {model_folder}"

    self.logger = No_Logger()
    self.use_cpu = use_cpu

    if inference_config is None:
        json_dir = Path(model_folder).joinpath("inference_config.json")
        self.inference_config = load_inference_config(json_dir, self.logger)
    else:
        self.inference_config = inference_config

    self.default_verbose = default_verbose
    self.logger.prefix = self.inference_config.log_name
    self.logger.default_verbose = self.default_verbose
    self.model_folder = str(model_folder)
    self.default_allow_tqdm = default_allow_tqdm
    self.predictor = None

    self.print("initialized with inference config", self.inference_config)

load abstractmethod

load(folds: tuple[str, ...] | None = None) -> Self

Loads the model weights from disk.

Parameters:

Name Type Description Default
folds tuple[str, ...] | None

Which folds to load; if None, uses the folds from the inference config. Defaults to None.

None

Returns:

Name Type Description
Self Self

This model with its predictor loaded.

Source code in spineps/seg_model.py
@abstractmethod
def load(self, folds: tuple[str, ...] | None = None) -> Self:
    """Loads the model weights from disk.

    Args:
        folds (tuple[str, ...] | None, optional): Which folds to load; if None, uses the folds from the inference config.
            Defaults to None.

    Returns:
        Self: This model with its predictor loaded.
    """
    return self
calc_recommended_resampling_zoom(
    input_zoom: ZOOMS,
) -> ZOOMS

Calculates the resolution a corresponding input should be resampled to for this model.

If the inference config defines a (min, max) resolution range, each axis of the input zoom is clamped into that range; otherwise the fixed configured resolution is returned.

Parameters:

Name Type Description Default
input_zoom ZOOMS

Voxel spacing (mm) of the input image, per axis.

required

Returns:

Name Type Description
ZOOMS ZOOMS

Recommended voxel spacing (mm) to resample the input to before inference.

Source code in spineps/seg_model.py
def calc_recommended_resampling_zoom(self, input_zoom: ZOOMS) -> ZOOMS:
    """Calculates the resolution a corresponding input should be resampled to for this model.

    If the inference config defines a (min, max) resolution range, each axis of the input zoom is clamped into that
    range; otherwise the fixed configured resolution is returned.

    Args:
        input_zoom (ZOOMS): Voxel spacing (mm) of the input image, per axis.

    Returns:
        ZOOMS: Recommended voxel spacing (mm) to resample the input to before inference.
    """
    if len(self.inference_config.resolution_range) != 2:
        return self.inference_config.resolution_range
    output_zoom = tuple(
        max(
            min(input_zoom[idx], self.inference_config.resolution_range[1][idx]),  # type:ignore
            self.inference_config.resolution_range[0][idx],  # type:ignore
        )
        for idx in range(len(input_zoom))
    )
    return output_zoom

same_modelzoom_as_model

same_modelzoom_as_model(
    model: Self, input_zoom: ZOOMS
) -> bool

Checks whether another model would resample a given input to the same resolution as this model.

Parameters:

Name Type Description Default
model Self

The other segmentation model to compare against.

required
input_zoom ZOOMS

Voxel spacing (mm) of the input image, per axis.

required

Returns:

Name Type Description
bool bool

True if both models' recommended resampling zooms agree on every axis within ZOOM_MATCH_TOLERANCE.

Source code in spineps/seg_model.py
def same_modelzoom_as_model(self, model: Self, input_zoom: ZOOMS) -> bool:
    """Checks whether another model would resample a given input to the same resolution as this model.

    Args:
        model (Self): The other segmentation model to compare against.
        input_zoom (ZOOMS): Voxel spacing (mm) of the input image, per axis.

    Returns:
        bool: True if both models' recommended resampling zooms agree on every axis within ZOOM_MATCH_TOLERANCE.
    """
    self_zms = self.calc_recommended_resampling_zoom(input_zoom=input_zoom)
    model_zms = model.calc_recommended_resampling_zoom(input_zoom=self_zms)
    match: bool = bool(np.all([abs(self_zms[i] - model_zms[i]) < ZOOM_MATCH_TOLERANCE for i in range(3)]))
    return match

segment_scan

segment_scan(
    input_image: Image_Reference
    | dict[InputType, Image_Reference],
    pad_size: int = 0,
    step_size: float | None = None,
    resample_to_recommended: bool = True,
    resample_output_to_input_space: bool = True,
    verbose: bool = False,
) -> dict[OutputType, NII | None]

Segments a given input with this model.

Prepares each expected input (optional padding, reorientation to the model orientation and rescaling to the recommended zoom), runs the model and maps the outputs back into the input space.

Parameters:

Name Type Description Default
input_image Image_Reference | dict[InputType, Image_Reference]

A single image, or a mapping from InputType to image for multi-input models.

required
pad_size int

Padding added in each dimension (this many extra voxels on each side per axis), removed again from the output. Defaults to 0.

0
step_size float | None

Sliding-window tile step size; if None, uses the config default. Defaults to None.

None
resample_to_recommended bool

If true, rescales each input to the model's recommended zoom. Defaults to True.

True
resample_output_to_input_space bool

If true, resamples and pads the outputs back to the original input space. Defaults to True.

True
verbose bool

If true, prints verbose information. Defaults to False.

False

Returns:

Type Description
dict[OutputType, NII | None]

dict[OutputType, NII | None]: Mapping of output type to result NII (e.g. the segmentation mask, optionally softmax logits).

Source code in spineps/seg_model.py
@citation_reminder
def segment_scan(
    self,
    input_image: Image_Reference | dict[InputType, Image_Reference],
    pad_size: int = 0,
    step_size: float | None = None,
    resample_to_recommended: bool = True,
    resample_output_to_input_space: bool = True,
    verbose: bool = False,
) -> dict[OutputType, NII | None]:
    """Segments a given input with this model.

    Prepares each expected input (optional padding, reorientation to the model orientation and rescaling to the
    recommended zoom), runs the model and maps the outputs back into the input space.

    Args:
        input_image (Image_Reference | dict[InputType, Image_Reference]): A single image, or a mapping from InputType to
            image for multi-input models.
        pad_size (int, optional): Padding added in each dimension (this many extra voxels on each side per axis), removed
            again from the output. Defaults to 0.
        step_size (float | None, optional): Sliding-window tile step size; if None, uses the config default. Defaults to None.
        resample_to_recommended (bool, optional): If true, rescales each input to the model's recommended zoom. Defaults to True.
        resample_output_to_input_space (bool, optional): If true, resamples and pads the outputs back to the original input
            space. Defaults to True.
        verbose (bool, optional): If true, prints verbose information. Defaults to False.

    Returns:
        dict[OutputType, NII | None]: Mapping of output type to result NII (e.g. the segmentation mask, optionally softmax
            logits).
    """
    if self.predictor is None:
        self.load()
        assert self.predictor is not None, "self.predictor == None after load(). Error!"

    # Check if input matches expectation
    if not isinstance(input_image, dict):
        if len(self.inference_config.expected_inputs) >= 2:
            self.print(
                "input is one Image_Reference but model expected more, if not already stacked correctly, this will fail!",
                Log_Type.WARNING,
            )
        inputdict = {self.inference_config.expected_inputs[0]: input_image}
    else:
        inputdict: dict[InputType, Image_Reference] = input_image
    # Check if all required inputs are there
    if not set(inputdict.keys()).issuperset(self.inference_config.expected_inputs):
        self.print(f"expected {self.inference_config.expected_inputs}, but only got {list(inputdict.keys())}")
    orig_shape = None
    orientation = None
    zms = None
    input_niftys_in_order = []
    zms_pir: ZOOMS = None  # type: ignore
    for id in self.inference_config.expected_inputs:  # noqa: A001
        # Make nifty
        nii = to_nii(inputdict[id], seg=id == InputType.seg)
        # Padding
        if pad_size > 0:
            arr = nii.get_array()
            arr = np.pad(arr, pad_size, mode="edge")
            nii.set_array_(arr)
        input_niftys_in_order.append(nii)
        # Save first values for comparison
        if orig_shape is None:
            orig_shape = nii.shape
            orientation = nii.orientation
            zms = nii.zoom
        # Consistency check
        nii.assert_affine(shape=orig_shape, orientation=orientation, zoom=zms)
        # ), "All inputs need to be of same shape, orientation and zoom, got at least two different."
        # Reorient and rescale
        nii.reorient_(self.inference_config.model_expected_orientation, verbose=self.logger)
        zms_pir = nii.zoom
        if resample_to_recommended:
            nii.rescale_(self.calc_recommended_resampling_zoom(zms_pir), verbose=self.logger)

    assert orig_shape is not None
    if not resample_to_recommended:
        self.print("resample_to_recommended set to False, segmentation might not work. Proceed at own risk", Log_Type.WARNING)

    # set step_size
    if hasattr(self.predictor, "tile_step_size"):
        self.predictor.tile_step_size = step_size if step_size is not None else self.inference_config.default_step_size

    self.print("input", input_niftys_in_order[0], verbose=verbose)
    self.print("Run Segmentation")
    result = self.run(input_nii=input_niftys_in_order, verbose=verbose)
    assert OutputType.seg in result and isinstance(result[OutputType.seg], NII), "No seg output in segmentation result"
    for k, v in result.items():
        if isinstance(v, NII):  # and k != OutputType.seg_modelres:
            if resample_output_to_input_space:
                v.resample_from_to_(inputdict[self.inference_config.expected_inputs[0]])
                # v.rescale_(zms_pir, verbose=self.logger).reorient_(orientation, verbose=self.logger)
                v.pad_to(orig_shape, inplace=True)
            if k == OutputType.seg:
                v.map_labels_(self.inference_config.segmentation_labels, verbose=self.logger)
            if pad_size > 0:
                arr = v.get_array()
                arr = arr[pad_size:-pad_size, pad_size:-pad_size, pad_size:-pad_size]
                v.set_array_(arr)

            self.print(f"out_seg {k}", v.zoom, v.orientation, v.shape, verbose=verbose)
    self.print("Segmenting done!")
    return result

modalities

modalities() -> list[Modality]

Returns the modalities this model supports.

Returns:

Type Description
list[Modality]

list[Modality]: Modalities the model was trained for, as listed in its inference config.

Source code in spineps/seg_model.py
def modalities(self) -> list[Modality]:
    """Returns the modalities this model supports.

    Returns:
        list[Modality]: Modalities the model was trained for, as listed in its inference config.
    """
    return self.inference_config.modalities

acquisition

acquisition() -> Acquisition

Returns the acquisition this model supports.

Returns:

Name Type Description
Acquisition Acquisition

Acquisition plane/type the model expects, as listed in its inference config.

Source code in spineps/seg_model.py
def acquisition(self) -> Acquisition:
    """Returns the acquisition this model supports.

    Returns:
        Acquisition: Acquisition plane/type the model expects, as listed in its inference config.
    """
    return self.inference_config.acquisition

run abstractmethod

run(
    input_nii: list[NII], verbose: bool = False
) -> dict[OutputType, NII | None]

Runs the backend predictor on the prepared inputs.

Parameters:

Name Type Description Default
input_nii list[NII]

Inputs already reoriented and rescaled to the model's expectation, in the configured order.

required
verbose bool

If true, prints verbose information. Defaults to False.

False

Returns:

Type Description
dict[OutputType, NII | None]

dict[OutputType, NII | None]: Mapping of output type to result NII produced by the model.

Source code in spineps/seg_model.py
@abstractmethod
def run(self, input_nii: list[NII], verbose: bool = False) -> dict[OutputType, NII | None]:
    """Runs the backend predictor on the prepared inputs.

    Args:
        input_nii (list[NII]): Inputs already reoriented and rescaled to the model's expectation, in the configured order.
        verbose (bool, optional): If true, prints verbose information. Defaults to False.

    Returns:
        dict[OutputType, NII | None]: Mapping of output type to result NII produced by the model.
    """

print

print(*text: object, verbose: bool | None = None)

Logs text via the model's logger.

Parameters:

Name Type Description Default
*text object

Items to print.

()
verbose bool | None

Overrides the default verbosity; if None, uses default_verbose. Defaults to None.

None
Source code in spineps/seg_model.py
def print(self, *text: object, verbose: bool | None = None):
    """Logs text via the model's logger.

    Args:
        *text: Items to print.
        verbose (bool | None, optional): Overrides the default verbosity; if None, uses default_verbose. Defaults to None.
    """
    if verbose is None:
        verbose = self.default_verbose
    self.logger.print(*text, verbose=verbose)

print_self

print_self()

Prints the model id and its inference config.

Source code in spineps/seg_model.py
def print_self(self):
    """Prints the model id and its inference config."""
    self.print(self.modelid(include_log_name=False), verbose=True)
    self.print("Config:", self.inference_config, verbose=True)

modelid

modelid(include_log_name: bool = False) -> str

Returns an identifier string for this model.

Parameters:

Name Type Description Default
include_log_name bool

If true and a name is set, appends the config log name. Defaults to False.

False

Returns:

Name Type Description
str str

The model name, or the inference config's log name if no name is set.

Source code in spineps/seg_model.py
def modelid(self, include_log_name: bool = False) -> str:
    """Returns an identifier string for this model.

    Args:
        include_log_name (bool, optional): If true and a name is set, appends the config log name. Defaults to False.

    Returns:
        str: The model name, or the inference config's log name if no name is set.
    """
    name: str = str(self.name)
    if name != "":
        if include_log_name:
            return name + " -- " + self.inference_config.log_name
        return name
    return self.inference_config.log_name

dict_representation

dict_representation() -> dict[str, str]

Builds a summary dictionary describing this model.

Returns:

Type Description
dict[str, str]

dict[str, str]: Model id, model path, modalities, acquisition and resolution range as strings.

Source code in spineps/seg_model.py
def dict_representation(self) -> dict[str, str]:
    """Builds a summary dictionary describing this model.

    Returns:
        dict[str, str]: Model id, model path, modalities, acquisition and resolution range as strings.
    """
    info = {
        "name": self.modelid(),  # self.inference_config.__repr__()
        "model_path": str(self.model_folder),
        "modality": str(self.modalities()),
        "aquisition": str(self.acquisition()),
        "resolution_range": str(self.inference_config.resolution_range),
    }
    # if input_zms is not None:
    #    proc_zms = self.calc_recommended_resampling_zoom(input_zms)
    #    info["resolution_processed"] = str(proc_zms)
    return info

__str__

__str__() -> str

Returns the model id together with its inference config representation.

Returns:

Name Type Description
str str

Human-readable description of the model.

Source code in spineps/seg_model.py
def __str__(self) -> str:
    """Returns the model id together with its inference config representation.

    Returns:
        str: Human-readable description of the model.
    """
    return self.modelid(include_log_name=True) + "\nConfig: " + self.inference_config.__repr__()

__repr__

__repr__() -> str

Returns the same representation as str.

Returns:

Name Type Description
str str

Human-readable description of the model.

Source code in spineps/seg_model.py
def __repr__(self) -> str:
    """Returns the same representation as __str__.

    Returns:
        str: Human-readable description of the model.
    """
    return str(self)

Segmentation_Model_NNunet

Bases: Segmentation_Model

Segmentation_Model backed by an nnU-Net predictor.

Source code in spineps/seg_model.py
class Segmentation_Model_NNunet(Segmentation_Model):
    """Segmentation_Model backed by an nnU-Net predictor."""

    def __init__(
        self,
        model_folder: str | Path,
        inference_config: Segmentation_Inference_Config | None = None,
        use_cpu: bool = False,
        default_verbose: bool = False,
        default_allow_tqdm: bool = True,
    ):
        """Initializes an nnU-Net-backed segmentation model.

        Args:
            model_folder (str | Path): Path to the nnU-Net model folder.
            inference_config (Segmentation_Inference_Config | None, optional): Inference config; if None, loads it from the
                model folder. Defaults to None.
            use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
            default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
            default_allow_tqdm (bool, optional): If true, shows a progress bar while segmenting. Defaults to True.
        """
        super().__init__(model_folder, inference_config, use_cpu, default_verbose, default_allow_tqdm)

    def load(self, folds: tuple[str, ...] | None = None) -> Self:
        """Loads the nnU-Net predictor and its ensemble folds from the model folder.

        Args:
            folds (tuple[str, ...] | None, optional): Folds to load; if None, uses the folds from the inference config.
                Defaults to None.

        Returns:
            Self: This model with its nnU-Net predictor loaded.
        """
        global threads_started  # noqa: PLW0603
        if not os.path.exists(self.model_folder):  # noqa: PTH110
            self.print(f"Model weights not found in {self.model_folder}", Log_Type.FAIL)
        conf_folds = self.inference_config.available_folds
        if isinstance(conf_folds, int):
            conf_folds = tuple(str(i) for i in range(conf_folds))
        elif isinstance(conf_folds, str):
            conf_folds = (conf_folds,)
        else:
            conf_folds = tuple(str(i) for i in conf_folds)
        self.predictor = load_inf_model(
            model_folder=self.model_folder,
            step_size=self.inference_config.default_step_size,
            use_folds=folds if folds is not None else conf_folds,
            inference_augmentation=self.inference_config.inference_augmentation,
            init_threads=not threads_started,
            allow_non_final=True,
            verbose=False,
            ddevice="cuda" if not self.use_cpu else "cpu",
        )
        threads_started = True
        self.predictor.allow_tqdm = self.default_allow_tqdm
        self.predictor.verbose = False
        self.print("Model loaded from", self.model_folder, Log_Type.OK, verbose=True)
        return self

    def run(
        self,
        input_nii: list[NII],
        verbose: bool = False,
    ) -> dict[OutputType, NII | None]:
        """Runs nnU-Net inference on the prepared inputs.

        Args:
            input_nii (list[NII]): Inputs in the model's expected orientation and resolution, in the configured order.
            verbose (bool, optional): If true, prints verbose information. Defaults to False.

        Returns:
            dict[OutputType, NII | None]: The segmentation mask under OutputType.seg and the softmax logits under
                OutputType.softmax_logits.
        """
        self.print("Segmenting...")
        seg_nii, softmax_logits = run_inference(input_nii, self.predictor)
        self.print("Segmentation done!")
        self.print("out_inf", seg_nii.zoom, seg_nii.orientation, seg_nii.shape, verbose=verbose)
        return {OutputType.seg: seg_nii, OutputType.softmax_logits: softmax_logits}

__init__

__init__(
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config
    | None = None,
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
)

Initializes an nnU-Net-backed segmentation model.

Parameters:

Name Type Description Default
model_folder str | Path

Path to the nnU-Net model folder.

required
inference_config Segmentation_Inference_Config | None

Inference config; if None, loads it from the model folder. Defaults to None.

None
use_cpu bool

If true, runs inference on CPU instead of GPU. Defaults to False.

False
default_verbose bool

If true, prints more information when used. Defaults to False.

False
default_allow_tqdm bool

If true, shows a progress bar while segmenting. Defaults to True.

True
Source code in spineps/seg_model.py
def __init__(
    self,
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config | None = None,
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
):
    """Initializes an nnU-Net-backed segmentation model.

    Args:
        model_folder (str | Path): Path to the nnU-Net model folder.
        inference_config (Segmentation_Inference_Config | None, optional): Inference config; if None, loads it from the
            model folder. Defaults to None.
        use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
        default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
        default_allow_tqdm (bool, optional): If true, shows a progress bar while segmenting. Defaults to True.
    """
    super().__init__(model_folder, inference_config, use_cpu, default_verbose, default_allow_tqdm)

load

load(folds: tuple[str, ...] | None = None) -> Self

Loads the nnU-Net predictor and its ensemble folds from the model folder.

Parameters:

Name Type Description Default
folds tuple[str, ...] | None

Folds to load; if None, uses the folds from the inference config. Defaults to None.

None

Returns:

Name Type Description
Self Self

This model with its nnU-Net predictor loaded.

Source code in spineps/seg_model.py
def load(self, folds: tuple[str, ...] | None = None) -> Self:
    """Loads the nnU-Net predictor and its ensemble folds from the model folder.

    Args:
        folds (tuple[str, ...] | None, optional): Folds to load; if None, uses the folds from the inference config.
            Defaults to None.

    Returns:
        Self: This model with its nnU-Net predictor loaded.
    """
    global threads_started  # noqa: PLW0603
    if not os.path.exists(self.model_folder):  # noqa: PTH110
        self.print(f"Model weights not found in {self.model_folder}", Log_Type.FAIL)
    conf_folds = self.inference_config.available_folds
    if isinstance(conf_folds, int):
        conf_folds = tuple(str(i) for i in range(conf_folds))
    elif isinstance(conf_folds, str):
        conf_folds = (conf_folds,)
    else:
        conf_folds = tuple(str(i) for i in conf_folds)
    self.predictor = load_inf_model(
        model_folder=self.model_folder,
        step_size=self.inference_config.default_step_size,
        use_folds=folds if folds is not None else conf_folds,
        inference_augmentation=self.inference_config.inference_augmentation,
        init_threads=not threads_started,
        allow_non_final=True,
        verbose=False,
        ddevice="cuda" if not self.use_cpu else "cpu",
    )
    threads_started = True
    self.predictor.allow_tqdm = self.default_allow_tqdm
    self.predictor.verbose = False
    self.print("Model loaded from", self.model_folder, Log_Type.OK, verbose=True)
    return self

run

run(
    input_nii: list[NII], verbose: bool = False
) -> dict[OutputType, NII | None]

Runs nnU-Net inference on the prepared inputs.

Parameters:

Name Type Description Default
input_nii list[NII]

Inputs in the model's expected orientation and resolution, in the configured order.

required
verbose bool

If true, prints verbose information. Defaults to False.

False

Returns:

Type Description
dict[OutputType, NII | None]

dict[OutputType, NII | None]: The segmentation mask under OutputType.seg and the softmax logits under OutputType.softmax_logits.

Source code in spineps/seg_model.py
def run(
    self,
    input_nii: list[NII],
    verbose: bool = False,
) -> dict[OutputType, NII | None]:
    """Runs nnU-Net inference on the prepared inputs.

    Args:
        input_nii (list[NII]): Inputs in the model's expected orientation and resolution, in the configured order.
        verbose (bool, optional): If true, prints verbose information. Defaults to False.

    Returns:
        dict[OutputType, NII | None]: The segmentation mask under OutputType.seg and the softmax logits under
            OutputType.softmax_logits.
    """
    self.print("Segmenting...")
    seg_nii, softmax_logits = run_inference(input_nii, self.predictor)
    self.print("Segmentation done!")
    self.print("out_inf", seg_nii.zoom, seg_nii.orientation, seg_nii.shape, verbose=verbose)
    return {OutputType.seg: seg_nii, OutputType.softmax_logits: softmax_logits}

Segmentation_Model_Unet3D

Bases: Segmentation_Model

Segmentation_Model backed by a single-input 3D U-Net (PyTorch Lightning PLNet).

Used as the instance (vertebra) model: it takes a segmentation mask as input and refines it into the vertebra instance output. Supports both the current multi-channel network and a legacy single-channel network.

Source code in spineps/seg_model.py
class Segmentation_Model_Unet3D(Segmentation_Model):
    """Segmentation_Model backed by a single-input 3D U-Net (PyTorch Lightning PLNet).

    Used as the instance (vertebra) model: it takes a segmentation mask as input and refines it into the vertebra instance
    output. Supports both the current multi-channel network and a legacy single-channel network.
    """

    def __init__(
        self,
        model_folder: str | Path,
        inference_config: Segmentation_Inference_Config | None = None,
        use_cpu: bool = False,
        default_verbose: bool = False,
        default_allow_tqdm: bool = True,
    ):
        """Initializes a 3D U-Net-backed segmentation model.

        Args:
            model_folder (str | Path): Path to the model folder containing the checkpoint.
            inference_config (Segmentation_Inference_Config | None, optional): Inference config; if None, loads it from the
                model folder. Defaults to None.
            use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
            default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
            default_allow_tqdm (bool, optional): If true, shows a progress bar while segmenting. Defaults to True.

        Raises:
            AssertionError: If the inference config expects more than one input.
        """
        super().__init__(model_folder, inference_config, use_cpu, default_verbose, default_allow_tqdm)
        assert len(self.inference_config.expected_inputs) == 1, "Unet3D cannot expect more than one input"

    def load(self, folds: tuple[str, ...] | None = None) -> Self:  # noqa: ARG002
        """Loads the 3D U-Net checkpoint, trying the current then the legacy PLNet implementation.

        Args:
            folds (tuple[str, ...] | None, optional): Unused; present for interface compatibility. Defaults to None.

        Returns:
            Self: This model with its 3D U-Net predictor loaded and moved to the selected device.

        Raises:
            AssertionError: If exactly one checkpoint file is not found in the model folder.
        """
        assert os.path.exists(self.model_folder)  # noqa: PTH110

        chktpath = search_path(self.model_folder, "**/*weights*.ckpt")
        assert len(chktpath) == 1, chktpath
        try:
            model = PLNet.load_from_checkpoint(checkpoint_path=chktpath[0], weights_only=False)
        except RuntimeError:
            model = PLNet_new.load_from_checkpoint(checkpoint_path=chktpath[0], weights_only=False)

        model.eval()
        self.device = torch.device("cuda:0" if torch.cuda.is_available() and not self.use_cpu else "cpu")
        model.to(self.device)
        self.predictor = model
        self.print("Model loaded from", self.model_folder, Log_Type.OK, verbose=True)
        return self

    def run(self, input_nii: list[NII], verbose: bool = False) -> dict[OutputType, NII | None]:
        """Runs the 3D U-Net on a single input segmentation mask.

        Converts the input mask to a network tensor (one-hot encoded for the multi-channel network, or intensity-normalized
        for the legacy single-channel network), runs the forward pass and returns the per-voxel argmax class as a mask.

        Args:
            input_nii (list[NII]): A single-element list containing the input segmentation mask.
            verbose (bool, optional): If true, prints verbose information. Defaults to False.

        Returns:
            dict[OutputType, NII | None]: The predicted segmentation mask under OutputType.seg.

        Raises:
            AssertionError: If more than one input is provided.
        """
        assert len(input_nii) == 1, "Unet3D does not support more than one input"
        input_nii_ = input_nii[0]

        arr = input_nii_.get_seg_array().astype(np.int16)

        target = from_numpy(arr)
        n_classes = self.predictor.network.channels

        target[target >= n_classes] = 0

        # channel-wise
        if n_classes != 1:
            targetc = target.to(torch.int64)
            targetc = F.one_hot(targetc, num_classes=n_classes)
            targetc = targetc.permute(3, 0, 1, 2)
            targetc = targetc.unsqueeze(0)
            targetc = targetc.to(torch.float32)
            logits = self.predictor.forward(targetc.to(self.device))
        else:
            # legacy version
            target = target.to(torch.float32)
            target /= LEGACY_LABEL_NORMALIZATION
            target = target.unsqueeze(0)
            target = target.unsqueeze(0)
            logits = self.predictor.forward(target.to(self.device))
        soft_max = torch.nn.Softmax(dim=1)
        pred_x = soft_max(logits)
        _, pred_cls = torch.max(pred_x, 1)
        del logits
        del pred_x
        pred_cls = pred_cls.detach().cpu().numpy()[0]
        seg_nii: NII = input_nii_.set_array(pred_cls)
        self.print("out", seg_nii.zoom, seg_nii.orientation, seg_nii.shape) if verbose else None
        return {OutputType.seg: seg_nii}

__init__

__init__(
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config
    | None = None,
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
)

Initializes a 3D U-Net-backed segmentation model.

Parameters:

Name Type Description Default
model_folder str | Path

Path to the model folder containing the checkpoint.

required
inference_config Segmentation_Inference_Config | None

Inference config; if None, loads it from the model folder. Defaults to None.

None
use_cpu bool

If true, runs inference on CPU instead of GPU. Defaults to False.

False
default_verbose bool

If true, prints more information when used. Defaults to False.

False
default_allow_tqdm bool

If true, shows a progress bar while segmenting. Defaults to True.

True

Raises:

Type Description
AssertionError

If the inference config expects more than one input.

Source code in spineps/seg_model.py
def __init__(
    self,
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config | None = None,
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
):
    """Initializes a 3D U-Net-backed segmentation model.

    Args:
        model_folder (str | Path): Path to the model folder containing the checkpoint.
        inference_config (Segmentation_Inference_Config | None, optional): Inference config; if None, loads it from the
            model folder. Defaults to None.
        use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
        default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
        default_allow_tqdm (bool, optional): If true, shows a progress bar while segmenting. Defaults to True.

    Raises:
        AssertionError: If the inference config expects more than one input.
    """
    super().__init__(model_folder, inference_config, use_cpu, default_verbose, default_allow_tqdm)
    assert len(self.inference_config.expected_inputs) == 1, "Unet3D cannot expect more than one input"

load

load(folds: tuple[str, ...] | None = None) -> Self

Loads the 3D U-Net checkpoint, trying the current then the legacy PLNet implementation.

Parameters:

Name Type Description Default
folds tuple[str, ...] | None

Unused; present for interface compatibility. Defaults to None.

None

Returns:

Name Type Description
Self Self

This model with its 3D U-Net predictor loaded and moved to the selected device.

Raises:

Type Description
AssertionError

If exactly one checkpoint file is not found in the model folder.

Source code in spineps/seg_model.py
def load(self, folds: tuple[str, ...] | None = None) -> Self:  # noqa: ARG002
    """Loads the 3D U-Net checkpoint, trying the current then the legacy PLNet implementation.

    Args:
        folds (tuple[str, ...] | None, optional): Unused; present for interface compatibility. Defaults to None.

    Returns:
        Self: This model with its 3D U-Net predictor loaded and moved to the selected device.

    Raises:
        AssertionError: If exactly one checkpoint file is not found in the model folder.
    """
    assert os.path.exists(self.model_folder)  # noqa: PTH110

    chktpath = search_path(self.model_folder, "**/*weights*.ckpt")
    assert len(chktpath) == 1, chktpath
    try:
        model = PLNet.load_from_checkpoint(checkpoint_path=chktpath[0], weights_only=False)
    except RuntimeError:
        model = PLNet_new.load_from_checkpoint(checkpoint_path=chktpath[0], weights_only=False)

    model.eval()
    self.device = torch.device("cuda:0" if torch.cuda.is_available() and not self.use_cpu else "cpu")
    model.to(self.device)
    self.predictor = model
    self.print("Model loaded from", self.model_folder, Log_Type.OK, verbose=True)
    return self

run

run(
    input_nii: list[NII], verbose: bool = False
) -> dict[OutputType, NII | None]

Runs the 3D U-Net on a single input segmentation mask.

Converts the input mask to a network tensor (one-hot encoded for the multi-channel network, or intensity-normalized for the legacy single-channel network), runs the forward pass and returns the per-voxel argmax class as a mask.

Parameters:

Name Type Description Default
input_nii list[NII]

A single-element list containing the input segmentation mask.

required
verbose bool

If true, prints verbose information. Defaults to False.

False

Returns:

Type Description
dict[OutputType, NII | None]

dict[OutputType, NII | None]: The predicted segmentation mask under OutputType.seg.

Raises:

Type Description
AssertionError

If more than one input is provided.

Source code in spineps/seg_model.py
def run(self, input_nii: list[NII], verbose: bool = False) -> dict[OutputType, NII | None]:
    """Runs the 3D U-Net on a single input segmentation mask.

    Converts the input mask to a network tensor (one-hot encoded for the multi-channel network, or intensity-normalized
    for the legacy single-channel network), runs the forward pass and returns the per-voxel argmax class as a mask.

    Args:
        input_nii (list[NII]): A single-element list containing the input segmentation mask.
        verbose (bool, optional): If true, prints verbose information. Defaults to False.

    Returns:
        dict[OutputType, NII | None]: The predicted segmentation mask under OutputType.seg.

    Raises:
        AssertionError: If more than one input is provided.
    """
    assert len(input_nii) == 1, "Unet3D does not support more than one input"
    input_nii_ = input_nii[0]

    arr = input_nii_.get_seg_array().astype(np.int16)

    target = from_numpy(arr)
    n_classes = self.predictor.network.channels

    target[target >= n_classes] = 0

    # channel-wise
    if n_classes != 1:
        targetc = target.to(torch.int64)
        targetc = F.one_hot(targetc, num_classes=n_classes)
        targetc = targetc.permute(3, 0, 1, 2)
        targetc = targetc.unsqueeze(0)
        targetc = targetc.to(torch.float32)
        logits = self.predictor.forward(targetc.to(self.device))
    else:
        # legacy version
        target = target.to(torch.float32)
        target /= LEGACY_LABEL_NORMALIZATION
        target = target.unsqueeze(0)
        target = target.unsqueeze(0)
        logits = self.predictor.forward(target.to(self.device))
    soft_max = torch.nn.Softmax(dim=1)
    pred_x = soft_max(logits)
    _, pred_cls = torch.max(pred_x, 1)
    del logits
    del pred_x
    pred_cls = pred_cls.detach().cpu().numpy()[0]
    seg_nii: NII = input_nii_.set_array(pred_cls)
    self.print("out", seg_nii.zoom, seg_nii.orientation, seg_nii.shape) if verbose else None
    return {OutputType.seg: seg_nii}

spineps.lab_model

spineps.lab_model

Vertebra-labeling classifier: crops vertebra patches and predicts their anatomical labels.

VertLabelingClassifier

Bases: Segmentation_Model

Classifier that assigns anatomical labels to individual vertebrae.

For each vertebra a patch is cropped around its center of mass, optionally rotated to align with the spine axis, normalized and center-cropped to a fixed size, then passed through a DenseNet (PLClassifier) that outputs per-head softmax predictions. Although it subclasses Segmentation_Model to reuse config loading, it does not perform voxel segmentation (run/segment_scan are not implemented).

Attributes:

Name Type Description
device device

Device the classifier runs on.

final_size tuple[int, int, int]

Spatial size (voxels) the cropped patch is reduced to before inference.

cutout_size tuple[int, int, int]

Patch size used when cutting out a vertebra, set from the loaded model.

totensor ToTensor

Transform converting numpy arrays to tensors.

transform Compose

Intensity normalization and center-crop transform applied to each patch.

Source code in spineps/lab_model.py
class VertLabelingClassifier(Segmentation_Model):
    """Classifier that assigns anatomical labels to individual vertebrae.

    For each vertebra a patch is cropped around its center of mass, optionally rotated to align with the spine axis,
    normalized and center-cropped to a fixed size, then passed through a DenseNet (PLClassifier) that outputs per-head
    softmax predictions. Although it subclasses Segmentation_Model to reuse config loading, it does not perform voxel
    segmentation (run/segment_scan are not implemented).

    Attributes:
        device (torch.device): Device the classifier runs on.
        final_size (tuple[int, int, int]): Spatial size (voxels) the cropped patch is reduced to before inference.
        cutout_size (tuple[int, int, int]): Patch size used when cutting out a vertebra, set from the loaded model.
        totensor (ToTensor): Transform converting numpy arrays to tensors.
        transform (Compose): Intensity normalization and center-crop transform applied to each patch.
    """

    def __init__(
        self,
        model_folder: str | Path,
        inference_config: Segmentation_Inference_Config | None = None,  # type:ignore
        use_cpu: bool = False,
        default_verbose: bool = False,
        default_allow_tqdm: bool = True,
    ):
        """Initializes the vertebra-labeling classifier and its preprocessing transforms.

        Args:
            model_folder (str | Path): Path to the classifier's model folder.
            inference_config (Segmentation_Inference_Config | None, optional): Inference config; if None, loads it from the
                model folder. Defaults to None.
            use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
            default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
            default_allow_tqdm (bool, optional): If true, shows a progress bar while predicting. Defaults to True.

        Raises:
            AssertionError: If the inference config expects more than one input.
        """
        super().__init__(model_folder, inference_config, use_cpu, default_verbose, default_allow_tqdm)
        assert len(self.inference_config.expected_inputs) == 1, "Unet3D cannot expect more than one input"
        # self.model: PLClassifier = model
        self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
        self.final_size: tuple[int, int, int] = DEFAULT_CLASSIFIER_INPUT_SIZE
        self.totensor = ToTensor()
        self.transform = Compose(
            [
                NormalizeIntensityd(keys=["img"], nonzero=True, channel_wise=False),
                CenterSpatialCropd(keys=["img", "seg"], roi_size=self.final_size),
            ]
        )

    def load(self, folds: tuple[str, ...] | None = None) -> Self:  # noqa: ARG002
        """Loads the classifier checkpoint and updates the preprocessing transform to the model's input size.

        Args:
            folds (tuple[str, ...] | None, optional): Unused; present for interface compatibility. Defaults to None.

        Returns:
            Self: This classifier with its predictor loaded and moved to the selected device.

        Raises:
            AssertionError: If no matching checkpoint file is found in the model folder.
        """
        assert os.path.exists(self.model_folder)  # noqa: PTH110

        chktpath = search_path(self.model_folder, "**/*val_f1=*valf1-weights.ckpt")
        assert len(chktpath) >= 1, chktpath
        model = PLClassifier.load_from_checkpoint(checkpoint_path=chktpath[-1], weights_only=False)
        if hasattr(model.opt, "final_size"):
            self.final_size = model.opt.final_size
            self.transform = Compose(
                [
                    NormalizeIntensityd(keys=["img"], nonzero=True, channel_wise=False),
                    CenterSpatialCropd(keys=["img", "seg"], roi_size=self.final_size),
                ]
            )
        model.eval()
        model.net.eval()
        self.device = torch.device("cuda:0" if torch.cuda.is_available() and not self.use_cpu else "cpu")
        model.to(self.device)
        self.predictor = model
        self.cutout_size = model.opt.final_size
        self.print("Model loaded from", self.model_folder, Log_Type.OK, verbose=True)
        return self

    def run(
        self,
        input_nii: list[NII],
        verbose: bool = False,
    ) -> dict[OutputType, NII | None]:
        """Not implemented: the classifier does not perform voxel segmentation.

        Args:
            input_nii (list[NII]): Unused.
            verbose (bool, optional): Unused. Defaults to False.

        Raises:
            NotImplementedError: Always, since running it as a segmentation model is not meaningful.
        """
        raise NotImplementedError("Doesnt make sense")

    def segment_scan(*args, **kwargs):
        """Not implemented: the classifier does not perform voxel segmentation.

        Raises:
            NotImplementedError: Always, since segmenting with this model is not meaningful.
        """
        raise NotImplementedError("Doesnt make sense")

    @classmethod
    def from_modelfolder(cls, model_folder: str | Path):
        """Not implemented: construction directly from a model folder.

        Args:
            model_folder (str | Path): Path to the model folder.

        Raises:
            NotImplementedError: Always; use from_checkpoint_path instead.
        """
        raise NotImplementedError()
        # find checkpoint yourself, then load from checkpoitn path

    @classmethod
    def from_checkpoint_path(cls, checkpoint_path: str | Path) -> VertLabelingClassifier:
        """Constructs a classifier from a checkpoint file path.

        Resolves the model folder as the grandparent of the checkpoint file and instantiates the classifier from it.

        Args:
            checkpoint_path (str | Path): Path to the checkpoint (.ckpt) file.

        Returns:
            VertLabelingClassifier: The constructed classifier.

        Raises:
            AssertionError: If the checkpoint path does not exist.
        """
        if isinstance(checkpoint_path, str):
            checkpoint_path = Path(checkpoint_path)
        assert checkpoint_path.exists(), f"Checkpoint path does not exist: {checkpoint_path}"
        # model = PLClassifier.load_from_checkpoint(
        #    str(checkpoint_path),
        # )
        d = cls(checkpoint_path.parent.parent)
        logger.print("Model loaded from", checkpoint_path, verbose=True)
        return d

    def run_all_position_instances(self, img: NII, com_list: list[tuple[int, int, int]]) -> dict[int, dict[str, np.ndarray]]:
        """Runs the classifier on patches cropped around a list of center-of-mass positions.

        Args:
            img (NII): The intensity image (reoriented in place to the default orientation).
            com_list (list[tuple[int, int, int]]): Center-of-mass voxel positions, ordered top-to-bottom, one per vertebra.

        Returns:
            dict[int, dict[str, np.ndarray]]: Mapping from list index to a dict with "soft" (softmax outputs) and
                "pred" (argmax class) per classifier head.
        """
        img.reorient_()
        # assert coms are in PIR?
        # assert coms are in order top-to-bottom
        predictions = {}
        for idx, com in enumerate(com_list):
            logits_soft, pred_cls = self.run_given_center_pos(img, com)
            predictions[idx] = {"soft": logits_soft, "pred": pred_cls}
        return predictions

    def run_all_seg_instances(self, img: NII, seg: NII) -> dict[int, dict[str, np.ndarray]]:
        """Runs the classifier on every vertebra instance present in a segmentation mask.

        For each label in the mask, computes the patch rotation angle from the neighbouring vertebra centers of mass (to
        align with the spine axis) and runs the classifier on the corresponding patch.

        Args:
            img (NII): The intensity image.
            seg (NII): The vertebra instance segmentation mask.

        Returns:
            dict[int, dict[str, np.ndarray]]: Mapping from vertebra label to a dict with "soft" (softmax outputs) and
                "pred" (argmax class) per classifier head.
        """
        img = img.reorient()
        seg = seg.reorient()
        # TODO assert order of seg labels are order from top to bottom
        predictions = {}

        coms = seg.reorient(("I", "P", "L")).center_of_masses()
        sorted_ctds = sorted([[a, *b] for a, b in coms.items()], key=lambda x: x[1])

        for v in seg.unique():
            # Find the index of the given vertebra in the sorted list
            idx = next(i for i, ct in enumerate(sorted_ctds) if ct[0] == v)

            # Get the centroids above and below
            ctd1 = sorted_ctds[idx - 1][1:] if idx > 0 else sorted_ctds[idx][1:]
            ctd2 = sorted_ctds[idx + 1][1:] if idx < len(sorted_ctds) - 1 else sorted_ctds[idx][1:]
            myradians = angle_between(np.asarray(ctd2) - np.asarray(ctd1), (1, 0, 0))  # type: ignore
            mydegrees = math.degrees(myradians)

            logits_soft, pred_cls = self.run_given_seg_pos(img, seg, vert_label=v, angle=mydegrees)
            predictions[v] = {"soft": logits_soft, "pred": pred_cls}
        return predictions

    def run_given_seg_pos(
        self, img: NII, seg: NII, vert_label: int | None = None, angle: float | None = None
    ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
        """Runs the classifier on the patch centered on a single vertebra defined by a segmentation.

        Selects the given vertebra label (or binarizes the mask if multiple labels are present), computes the center of its
        bounding box and runs the classifier there.

        Args:
            img (NII): The intensity image.
            seg (NII): The segmentation mask defining the vertebra location.
            vert_label (int | None, optional): Label of the vertebra to use; if None, the whole mask is used. Defaults to None.
            angle (float | None, optional): Rotation angle (degrees) to align the patch with the spine axis. Defaults to None.

        Returns:
            tuple[dict, dict]: The softmax outputs and argmax class predictions per classifier head.
        """
        if vert_label is not None:
            seg = seg.extract_label(vert_label)
        elif len(seg.unique()) > 1:
            logger.print("Found multiple labels in given seg for center of mass calculation, intended?", Log_Type.STRANGE)
            seg[seg != 0] = 1
        crop = seg.compute_crop()
        center_of_crop = []
        for i in range(len(crop)):
            size_t = crop[i].stop - crop[i].start
            center_of_crop.append(crop[i].start + (size_t // 2))
        return self.run_given_center_pos(img, seg, center_of_crop, angle=angle)  # type: ignore

    def run_given_center_pos(
        self, img: NII, seg: NII, center_pos: tuple[int, int, int], angle: float | None = None
    ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
        """Crops image and segmentation patches around a center point, optionally rotates them, and runs the classifier.

        Cuts out a patch larger than the final size (with extra padding for rotation), reorients to (I, P, L), optionally
        rotates sagittally by the given angle, crops back to the cutout size and runs the classifier on the patch.

        Args:
            img (NII): The intensity image (or a raw array).
            seg (NII): The segmentation mask used as the second channel.
            center_pos (tuple[int, int, int]): Voxel position to center the patch on.
            angle (float | None, optional): Rotation angle (degrees) to align the patch with the spine axis; no rotation if
                None or 0. Defaults to None.

        Returns:
            tuple[dict, dict]: The softmax outputs and argmax class predictions per classifier head.
        """
        extra_rotation_padding = 64
        extra_rotation_padding_halfed = extra_rotation_padding // 2
        #
        # cut array then runs prediction
        arr = img.get_array() if isinstance(img, NII) else img
        arr_cut, cutout_coords_slices, padding = np_utils.np_calc_crop_around_centerpoint(
            center_pos,
            arr,
            (self.cutout_size[0] + extra_rotation_padding, self.cutout_size[1] + extra_rotation_padding, self.cutout_size[2]),
        )
        sem_cut = np.pad(seg[cutout_coords_slices], padding)
        # final cutout size (200, 160, 32)

        ori = img.orientation
        img_v = img.set_array(arr_cut).reorient_(("I", "P", "L"))
        seg_v = seg.set_array(sem_cut).reorient_(("I", "P", "L"))

        # angle = 0
        if angle is not None and angle != 0:
            arr_cut = rotate_patch_sagitally(img_v.get_array(), -angle, msk=False)
            sem_cut = rotate_patch_sagitally(seg_v.get_seg_array(), -angle, msk=True)

        # crop down to final cutout size (200, 160, 32)
        arr_cut = arr_cut[
            extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
            extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
            :,
        ]
        sem_cut = sem_cut[
            extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
            extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
            :,
        ]

        img_v.set_array_(arr_cut).reorient_(ori)
        seg_v.set_array_(sem_cut).reorient_(ori)
        # img_v.save("/DATA/NAS/ongoing_projects/hendrik/img_v.nii.gz")
        # seg_v.save("/DATA/NAS/ongoing_projects/hendrik/seg_v.nii.gz")
        return self._run_array(img_v.get_array(), seg_v.get_seg_array())  # sem_cut

    def _run_nii(self, img_nii: NII):
        """Runs the classifier on the raw array of an NII patch.

        Args:
            img_nii (NII): The patch image to classify.

        Returns:
            tuple[dict, dict]: The softmax outputs and argmax class predictions per classifier head.
        """
        # TODO check resolution
        # TODO check size
        return self._run_array(img_nii.get_array())

    def run_all_arrays(self, img_arrays: dict[int, np.ndarray]) -> dict[int, dict[str, np.ndarray]]:
        """Runs the classifier on a set of pre-cut image patches.

        Args:
            img_arrays (dict[int, np.ndarray]): Mapping from vertebra id to its 3D image patch.

        Returns:
            dict[int, dict[str, np.ndarray]]: Mapping from vertebra id to a dict with "soft" (softmax outputs) and
                "pred" (argmax class) per classifier head.
        """
        # TODO assert order of seg labels are order from top to bottom
        predictions = {}
        for v, arr in img_arrays.items():
            logits_soft, pred_cls = self._run_array(arr)
            predictions[v] = {"soft": logits_soft, "pred": pred_cls}
        return predictions

    def _run_array(self, img_arr: np.ndarray, seg_arr: np.ndarray | None | torch.Tensor = None):  # , seg_arr: np.ndarray):
        """Applies preprocessing and runs the classifier forward pass on a single image patch.

        Converts the patch (and optional segmentation) to tensors, applies intensity normalization and center cropping,
        adds the channel/batch dimensions and runs the network, returning per-head softmax probabilities and argmax classes.

        Args:
            img_arr (np.ndarray): The 3D image patch.
            seg_arr (np.ndarray | None | torch.Tensor, optional): Optional segmentation patch; if None, a copy of the image
                is used. Defaults to None.

        Returns:
            tuple[dict[str, np.ndarray], dict[str, np.ndarray]]: Per-head softmax probabilities and per-head argmax classes.

        Raises:
            AssertionError: If img_arr is not 3-dimensional.
        """
        assert img_arr.ndim == 3, f"Dimension mismatch, {img_arr.shape}, expected 3 dimensions"
        #
        img_arr = self.totensor(img_arr)
        # add channel
        img_arr.unsqueeze_(0)

        if seg_arr is not None:
            seg_arr = self.totensor(seg_arr)
            seg_arr.unsqueeze_(0)
        else:
            seg_arr = img_arr.clone()

        d = self.transform({"img": img_arr, "seg": seg_arr})

        # TODO seg channelwise and stuff

        model_input = d["img"]
        # print(model_input.shape)
        model_input.unsqueeze_(0)
        # print(model_input.shape)
        model_input = model_input.to(torch.float32)
        model_input = model_input.to(self.device)

        self.predictor.eval()
        self.predictor.to(self.device)
        logits_dict = self.predictor.forward(model_input)
        logits_soft = {k: self.predictor.softmax(v)[0].detach().cpu().numpy() for k, v in logits_dict.items()}
        pred_cls = {k: np.argmax(v, 0) for k, v in logits_soft.items()}
        return logits_soft, pred_cls

__init__

__init__(
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config
    | None = None,
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
)

Initializes the vertebra-labeling classifier and its preprocessing transforms.

Parameters:

Name Type Description Default
model_folder str | Path

Path to the classifier's model folder.

required
inference_config Segmentation_Inference_Config | None

Inference config; if None, loads it from the model folder. Defaults to None.

None
use_cpu bool

If true, runs inference on CPU instead of GPU. Defaults to False.

False
default_verbose bool

If true, prints more information when used. Defaults to False.

False
default_allow_tqdm bool

If true, shows a progress bar while predicting. Defaults to True.

True

Raises:

Type Description
AssertionError

If the inference config expects more than one input.

Source code in spineps/lab_model.py
def __init__(
    self,
    model_folder: str | Path,
    inference_config: Segmentation_Inference_Config | None = None,  # type:ignore
    use_cpu: bool = False,
    default_verbose: bool = False,
    default_allow_tqdm: bool = True,
):
    """Initializes the vertebra-labeling classifier and its preprocessing transforms.

    Args:
        model_folder (str | Path): Path to the classifier's model folder.
        inference_config (Segmentation_Inference_Config | None, optional): Inference config; if None, loads it from the
            model folder. Defaults to None.
        use_cpu (bool, optional): If true, runs inference on CPU instead of GPU. Defaults to False.
        default_verbose (bool, optional): If true, prints more information when used. Defaults to False.
        default_allow_tqdm (bool, optional): If true, shows a progress bar while predicting. Defaults to True.

    Raises:
        AssertionError: If the inference config expects more than one input.
    """
    super().__init__(model_folder, inference_config, use_cpu, default_verbose, default_allow_tqdm)
    assert len(self.inference_config.expected_inputs) == 1, "Unet3D cannot expect more than one input"
    # self.model: PLClassifier = model
    self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    self.final_size: tuple[int, int, int] = DEFAULT_CLASSIFIER_INPUT_SIZE
    self.totensor = ToTensor()
    self.transform = Compose(
        [
            NormalizeIntensityd(keys=["img"], nonzero=True, channel_wise=False),
            CenterSpatialCropd(keys=["img", "seg"], roi_size=self.final_size),
        ]
    )

load

load(folds: tuple[str, ...] | None = None) -> Self

Loads the classifier checkpoint and updates the preprocessing transform to the model's input size.

Parameters:

Name Type Description Default
folds tuple[str, ...] | None

Unused; present for interface compatibility. Defaults to None.

None

Returns:

Name Type Description
Self Self

This classifier with its predictor loaded and moved to the selected device.

Raises:

Type Description
AssertionError

If no matching checkpoint file is found in the model folder.

Source code in spineps/lab_model.py
def load(self, folds: tuple[str, ...] | None = None) -> Self:  # noqa: ARG002
    """Loads the classifier checkpoint and updates the preprocessing transform to the model's input size.

    Args:
        folds (tuple[str, ...] | None, optional): Unused; present for interface compatibility. Defaults to None.

    Returns:
        Self: This classifier with its predictor loaded and moved to the selected device.

    Raises:
        AssertionError: If no matching checkpoint file is found in the model folder.
    """
    assert os.path.exists(self.model_folder)  # noqa: PTH110

    chktpath = search_path(self.model_folder, "**/*val_f1=*valf1-weights.ckpt")
    assert len(chktpath) >= 1, chktpath
    model = PLClassifier.load_from_checkpoint(checkpoint_path=chktpath[-1], weights_only=False)
    if hasattr(model.opt, "final_size"):
        self.final_size = model.opt.final_size
        self.transform = Compose(
            [
                NormalizeIntensityd(keys=["img"], nonzero=True, channel_wise=False),
                CenterSpatialCropd(keys=["img", "seg"], roi_size=self.final_size),
            ]
        )
    model.eval()
    model.net.eval()
    self.device = torch.device("cuda:0" if torch.cuda.is_available() and not self.use_cpu else "cpu")
    model.to(self.device)
    self.predictor = model
    self.cutout_size = model.opt.final_size
    self.print("Model loaded from", self.model_folder, Log_Type.OK, verbose=True)
    return self

run

run(
    input_nii: list[NII], verbose: bool = False
) -> dict[OutputType, NII | None]

Not implemented: the classifier does not perform voxel segmentation.

Parameters:

Name Type Description Default
input_nii list[NII]

Unused.

required
verbose bool

Unused. Defaults to False.

False

Raises:

Type Description
NotImplementedError

Always, since running it as a segmentation model is not meaningful.

Source code in spineps/lab_model.py
def run(
    self,
    input_nii: list[NII],
    verbose: bool = False,
) -> dict[OutputType, NII | None]:
    """Not implemented: the classifier does not perform voxel segmentation.

    Args:
        input_nii (list[NII]): Unused.
        verbose (bool, optional): Unused. Defaults to False.

    Raises:
        NotImplementedError: Always, since running it as a segmentation model is not meaningful.
    """
    raise NotImplementedError("Doesnt make sense")

segment_scan

segment_scan(*args, **kwargs)

Not implemented: the classifier does not perform voxel segmentation.

Raises:

Type Description
NotImplementedError

Always, since segmenting with this model is not meaningful.

Source code in spineps/lab_model.py
def segment_scan(*args, **kwargs):
    """Not implemented: the classifier does not perform voxel segmentation.

    Raises:
        NotImplementedError: Always, since segmenting with this model is not meaningful.
    """
    raise NotImplementedError("Doesnt make sense")

from_modelfolder classmethod

from_modelfolder(model_folder: str | Path)

Not implemented: construction directly from a model folder.

Parameters:

Name Type Description Default
model_folder str | Path

Path to the model folder.

required

Raises:

Type Description
NotImplementedError

Always; use from_checkpoint_path instead.

Source code in spineps/lab_model.py
@classmethod
def from_modelfolder(cls, model_folder: str | Path):
    """Not implemented: construction directly from a model folder.

    Args:
        model_folder (str | Path): Path to the model folder.

    Raises:
        NotImplementedError: Always; use from_checkpoint_path instead.
    """
    raise NotImplementedError()

from_checkpoint_path classmethod

from_checkpoint_path(
    checkpoint_path: str | Path,
) -> VertLabelingClassifier

Constructs a classifier from a checkpoint file path.

Resolves the model folder as the grandparent of the checkpoint file and instantiates the classifier from it.

Parameters:

Name Type Description Default
checkpoint_path str | Path

Path to the checkpoint (.ckpt) file.

required

Returns:

Name Type Description
VertLabelingClassifier VertLabelingClassifier

The constructed classifier.

Raises:

Type Description
AssertionError

If the checkpoint path does not exist.

Source code in spineps/lab_model.py
@classmethod
def from_checkpoint_path(cls, checkpoint_path: str | Path) -> VertLabelingClassifier:
    """Constructs a classifier from a checkpoint file path.

    Resolves the model folder as the grandparent of the checkpoint file and instantiates the classifier from it.

    Args:
        checkpoint_path (str | Path): Path to the checkpoint (.ckpt) file.

    Returns:
        VertLabelingClassifier: The constructed classifier.

    Raises:
        AssertionError: If the checkpoint path does not exist.
    """
    if isinstance(checkpoint_path, str):
        checkpoint_path = Path(checkpoint_path)
    assert checkpoint_path.exists(), f"Checkpoint path does not exist: {checkpoint_path}"
    # model = PLClassifier.load_from_checkpoint(
    #    str(checkpoint_path),
    # )
    d = cls(checkpoint_path.parent.parent)
    logger.print("Model loaded from", checkpoint_path, verbose=True)
    return d

run_all_position_instances

run_all_position_instances(
    img: NII, com_list: list[tuple[int, int, int]]
) -> dict[int, dict[str, np.ndarray]]

Runs the classifier on patches cropped around a list of center-of-mass positions.

Parameters:

Name Type Description Default
img NII

The intensity image (reoriented in place to the default orientation).

required
com_list list[tuple[int, int, int]]

Center-of-mass voxel positions, ordered top-to-bottom, one per vertebra.

required

Returns:

Type Description
dict[int, dict[str, ndarray]]

dict[int, dict[str, np.ndarray]]: Mapping from list index to a dict with "soft" (softmax outputs) and "pred" (argmax class) per classifier head.

Source code in spineps/lab_model.py
def run_all_position_instances(self, img: NII, com_list: list[tuple[int, int, int]]) -> dict[int, dict[str, np.ndarray]]:
    """Runs the classifier on patches cropped around a list of center-of-mass positions.

    Args:
        img (NII): The intensity image (reoriented in place to the default orientation).
        com_list (list[tuple[int, int, int]]): Center-of-mass voxel positions, ordered top-to-bottom, one per vertebra.

    Returns:
        dict[int, dict[str, np.ndarray]]: Mapping from list index to a dict with "soft" (softmax outputs) and
            "pred" (argmax class) per classifier head.
    """
    img.reorient_()
    # assert coms are in PIR?
    # assert coms are in order top-to-bottom
    predictions = {}
    for idx, com in enumerate(com_list):
        logits_soft, pred_cls = self.run_given_center_pos(img, com)
        predictions[idx] = {"soft": logits_soft, "pred": pred_cls}
    return predictions

run_all_seg_instances

run_all_seg_instances(
    img: NII, seg: NII
) -> dict[int, dict[str, np.ndarray]]

Runs the classifier on every vertebra instance present in a segmentation mask.

For each label in the mask, computes the patch rotation angle from the neighbouring vertebra centers of mass (to align with the spine axis) and runs the classifier on the corresponding patch.

Parameters:

Name Type Description Default
img NII

The intensity image.

required
seg NII

The vertebra instance segmentation mask.

required

Returns:

Type Description
dict[int, dict[str, ndarray]]

dict[int, dict[str, np.ndarray]]: Mapping from vertebra label to a dict with "soft" (softmax outputs) and "pred" (argmax class) per classifier head.

Source code in spineps/lab_model.py
def run_all_seg_instances(self, img: NII, seg: NII) -> dict[int, dict[str, np.ndarray]]:
    """Runs the classifier on every vertebra instance present in a segmentation mask.

    For each label in the mask, computes the patch rotation angle from the neighbouring vertebra centers of mass (to
    align with the spine axis) and runs the classifier on the corresponding patch.

    Args:
        img (NII): The intensity image.
        seg (NII): The vertebra instance segmentation mask.

    Returns:
        dict[int, dict[str, np.ndarray]]: Mapping from vertebra label to a dict with "soft" (softmax outputs) and
            "pred" (argmax class) per classifier head.
    """
    img = img.reorient()
    seg = seg.reorient()
    # TODO assert order of seg labels are order from top to bottom
    predictions = {}

    coms = seg.reorient(("I", "P", "L")).center_of_masses()
    sorted_ctds = sorted([[a, *b] for a, b in coms.items()], key=lambda x: x[1])

    for v in seg.unique():
        # Find the index of the given vertebra in the sorted list
        idx = next(i for i, ct in enumerate(sorted_ctds) if ct[0] == v)

        # Get the centroids above and below
        ctd1 = sorted_ctds[idx - 1][1:] if idx > 0 else sorted_ctds[idx][1:]
        ctd2 = sorted_ctds[idx + 1][1:] if idx < len(sorted_ctds) - 1 else sorted_ctds[idx][1:]
        myradians = angle_between(np.asarray(ctd2) - np.asarray(ctd1), (1, 0, 0))  # type: ignore
        mydegrees = math.degrees(myradians)

        logits_soft, pred_cls = self.run_given_seg_pos(img, seg, vert_label=v, angle=mydegrees)
        predictions[v] = {"soft": logits_soft, "pred": pred_cls}
    return predictions

run_given_seg_pos

run_given_seg_pos(
    img: NII,
    seg: NII,
    vert_label: int | None = None,
    angle: float | None = None,
) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]

Runs the classifier on the patch centered on a single vertebra defined by a segmentation.

Selects the given vertebra label (or binarizes the mask if multiple labels are present), computes the center of its bounding box and runs the classifier there.

Parameters:

Name Type Description Default
img NII

The intensity image.

required
seg NII

The segmentation mask defining the vertebra location.

required
vert_label int | None

Label of the vertebra to use; if None, the whole mask is used. Defaults to None.

None
angle float | None

Rotation angle (degrees) to align the patch with the spine axis. Defaults to None.

None

Returns:

Type Description
tuple[dict[str, ndarray], dict[str, ndarray]]

tuple[dict, dict]: The softmax outputs and argmax class predictions per classifier head.

Source code in spineps/lab_model.py
def run_given_seg_pos(
    self, img: NII, seg: NII, vert_label: int | None = None, angle: float | None = None
) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
    """Runs the classifier on the patch centered on a single vertebra defined by a segmentation.

    Selects the given vertebra label (or binarizes the mask if multiple labels are present), computes the center of its
    bounding box and runs the classifier there.

    Args:
        img (NII): The intensity image.
        seg (NII): The segmentation mask defining the vertebra location.
        vert_label (int | None, optional): Label of the vertebra to use; if None, the whole mask is used. Defaults to None.
        angle (float | None, optional): Rotation angle (degrees) to align the patch with the spine axis. Defaults to None.

    Returns:
        tuple[dict, dict]: The softmax outputs and argmax class predictions per classifier head.
    """
    if vert_label is not None:
        seg = seg.extract_label(vert_label)
    elif len(seg.unique()) > 1:
        logger.print("Found multiple labels in given seg for center of mass calculation, intended?", Log_Type.STRANGE)
        seg[seg != 0] = 1
    crop = seg.compute_crop()
    center_of_crop = []
    for i in range(len(crop)):
        size_t = crop[i].stop - crop[i].start
        center_of_crop.append(crop[i].start + (size_t // 2))
    return self.run_given_center_pos(img, seg, center_of_crop, angle=angle)  # type: ignore

run_given_center_pos

run_given_center_pos(
    img: NII,
    seg: NII,
    center_pos: tuple[int, int, int],
    angle: float | None = None,
) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]

Crops image and segmentation patches around a center point, optionally rotates them, and runs the classifier.

Cuts out a patch larger than the final size (with extra padding for rotation), reorients to (I, P, L), optionally rotates sagittally by the given angle, crops back to the cutout size and runs the classifier on the patch.

Parameters:

Name Type Description Default
img NII

The intensity image (or a raw array).

required
seg NII

The segmentation mask used as the second channel.

required
center_pos tuple[int, int, int]

Voxel position to center the patch on.

required
angle float | None

Rotation angle (degrees) to align the patch with the spine axis; no rotation if None or 0. Defaults to None.

None

Returns:

Type Description
tuple[dict[str, ndarray], dict[str, ndarray]]

tuple[dict, dict]: The softmax outputs and argmax class predictions per classifier head.

Source code in spineps/lab_model.py
def run_given_center_pos(
    self, img: NII, seg: NII, center_pos: tuple[int, int, int], angle: float | None = None
) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
    """Crops image and segmentation patches around a center point, optionally rotates them, and runs the classifier.

    Cuts out a patch larger than the final size (with extra padding for rotation), reorients to (I, P, L), optionally
    rotates sagittally by the given angle, crops back to the cutout size and runs the classifier on the patch.

    Args:
        img (NII): The intensity image (or a raw array).
        seg (NII): The segmentation mask used as the second channel.
        center_pos (tuple[int, int, int]): Voxel position to center the patch on.
        angle (float | None, optional): Rotation angle (degrees) to align the patch with the spine axis; no rotation if
            None or 0. Defaults to None.

    Returns:
        tuple[dict, dict]: The softmax outputs and argmax class predictions per classifier head.
    """
    extra_rotation_padding = 64
    extra_rotation_padding_halfed = extra_rotation_padding // 2
    #
    # cut array then runs prediction
    arr = img.get_array() if isinstance(img, NII) else img
    arr_cut, cutout_coords_slices, padding = np_utils.np_calc_crop_around_centerpoint(
        center_pos,
        arr,
        (self.cutout_size[0] + extra_rotation_padding, self.cutout_size[1] + extra_rotation_padding, self.cutout_size[2]),
    )
    sem_cut = np.pad(seg[cutout_coords_slices], padding)
    # final cutout size (200, 160, 32)

    ori = img.orientation
    img_v = img.set_array(arr_cut).reorient_(("I", "P", "L"))
    seg_v = seg.set_array(sem_cut).reorient_(("I", "P", "L"))

    # angle = 0
    if angle is not None and angle != 0:
        arr_cut = rotate_patch_sagitally(img_v.get_array(), -angle, msk=False)
        sem_cut = rotate_patch_sagitally(seg_v.get_seg_array(), -angle, msk=True)

    # crop down to final cutout size (200, 160, 32)
    arr_cut = arr_cut[
        extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
        extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
        :,
    ]
    sem_cut = sem_cut[
        extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
        extra_rotation_padding_halfed:-extra_rotation_padding_halfed,
        :,
    ]

    img_v.set_array_(arr_cut).reorient_(ori)
    seg_v.set_array_(sem_cut).reorient_(ori)
    # img_v.save("/DATA/NAS/ongoing_projects/hendrik/img_v.nii.gz")
    # seg_v.save("/DATA/NAS/ongoing_projects/hendrik/seg_v.nii.gz")
    return self._run_array(img_v.get_array(), seg_v.get_seg_array())  # sem_cut

run_all_arrays

run_all_arrays(
    img_arrays: dict[int, ndarray],
) -> dict[int, dict[str, np.ndarray]]

Runs the classifier on a set of pre-cut image patches.

Parameters:

Name Type Description Default
img_arrays dict[int, ndarray]

Mapping from vertebra id to its 3D image patch.

required

Returns:

Type Description
dict[int, dict[str, ndarray]]

dict[int, dict[str, np.ndarray]]: Mapping from vertebra id to a dict with "soft" (softmax outputs) and "pred" (argmax class) per classifier head.

Source code in spineps/lab_model.py
def run_all_arrays(self, img_arrays: dict[int, np.ndarray]) -> dict[int, dict[str, np.ndarray]]:
    """Runs the classifier on a set of pre-cut image patches.

    Args:
        img_arrays (dict[int, np.ndarray]): Mapping from vertebra id to its 3D image patch.

    Returns:
        dict[int, dict[str, np.ndarray]]: Mapping from vertebra id to a dict with "soft" (softmax outputs) and
            "pred" (argmax class) per classifier head.
    """
    # TODO assert order of seg labels are order from top to bottom
    predictions = {}
    for v, arr in img_arrays.items():
        logits_soft, pred_cls = self._run_array(arr)
        predictions[v] = {"soft": logits_soft, "pred": pred_cls}
    return predictions

unit_vector

unit_vector(vector)

Returns the unit vector of the vector.

Source code in spineps/lab_model.py
def unit_vector(vector):
    """Returns the unit vector of the vector."""
    return vector / np.linalg.norm(vector)

angle_between

angle_between(v1, v2, signed=True)

Returns the angle in radians between vectors 'v1' and 'v2'::

angle_between((1, 0, 0), (0, 1, 0)) 1.5707963267948966 angle_between((1, 0, 0), (1, 0, 0)) 0.0 angle_between((1, 0, 0), (-1, 0, 0)) 3.141592653589793

Source code in spineps/lab_model.py
def angle_between(v1, v2, signed=True):
    """Returns the angle in radians between vectors 'v1' and 'v2'::

    >>> angle_between((1, 0, 0), (0, 1, 0))
    1.5707963267948966
    >>> angle_between((1, 0, 0), (1, 0, 0))
    0.0
    >>> angle_between((1, 0, 0), (-1, 0, 0))
    3.141592653589793
    """
    v1_u = unit_vector(v1)
    v2_u = unit_vector(v2)
    angle = np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0))

    if signed:
        sign = np.array(np.sign(np.cross(v1, v2).dot((1, 1, 1))))
        # 0 means collinear: 0 or 180. Let's call that clockwise.
        sign[sign == 0] = 1
        angle = sign * angle
    return angle

rotate_patch_sagitally

rotate_patch_sagitally(
    patch: ndarray,
    angle: float,
    msk: bool = False,
    cval: int = 0,
) -> np.ndarray

Rotates a patch sagittally by a given angle (assuming the patch is in (I, P, L) orientation).

Parameters:

Name Type Description Default
patch ndarray

A numpy array in (I, P, L) orientation.

required
angle float

Angle of rotation in degrees.

required
msk bool

If true, treats the patch as a mask and uses nearest-neighbour interpolation (order 0); otherwise uses cubic interpolation (order 3). Defaults to False.

False
cval int

Constant value used to fill regions outside the rotated patch. Defaults to 0.

0

Returns:

Type Description
ndarray

np.ndarray: The rotated patch with the same shape as the input.

Source code in spineps/lab_model.py
def rotate_patch_sagitally(patch: np.ndarray, angle: float, msk: bool = False, cval: int = 0) -> np.ndarray:
    """Rotates a patch sagittally by a given angle (assuming the patch is in (I, P, L) orientation).

    Args:
        patch (np.ndarray): A numpy array in (I, P, L) orientation.
        angle (float): Angle of rotation in degrees.
        msk (bool, optional): If true, treats the patch as a mask and uses nearest-neighbour interpolation (order 0);
            otherwise uses cubic interpolation (order 3). Defaults to False.
        cval (int, optional): Constant value used to fill regions outside the rotated patch. Defaults to 0.

    Returns:
        np.ndarray: The rotated patch with the same shape as the input.
    """
    if msk:
        cval = 0
        order = 0
    else:
        order = 3
    rotated_patch = rotate(patch, angle=-angle, reshape=False, order=order, mode="constant", cval=cval)  # type: ignore
    return rotated_patch