Source code for zrad.preprocessing.roi

from dataclasses import dataclass

import numpy as np

from ..image import Image


[docs] @dataclass class RoiData: """Current image and ROI masks used for feature calculation. ``RoiData`` groups the image and ROI representations that move through a preprocessing pipeline. The morphological mask defines the anatomical ROI; the intensity mask stores feature-image values inside that ROI and ``NaN`` outside it. Derived discretization fields are cleared by preprocessing steps that change the image, mask, or valid intensity population. Parameters ---------- image : Image Original image used as the spatial reference for ROI processing. filtered_image : Image, optional Filtered image used for intensity-based feature calculation. If this is ``None``, ``image`` is used as the feature image. morphological_mask : Image, optional Binary ROI mask used for morphology-based feature calculation and as the base mask for intensity ROI construction. intensity_mask : Image, optional Image containing feature-image intensities inside the ROI and ``NaN`` outside it. This mask is used by intensity-based feature families. intensity_range : tuple[float, float], optional Absolute re-segmentation range applied to ``intensity_mask``. The lower bound is reused as the fixed-bin-size discretization anchor. texture_discretized_image : Image, optional Discretized intensity image used by intensity histogram and texture feature families. ivh_intensity_image : Image, optional Intensity image prepared for intensity-volume histogram features. ivh_discretization_method : str, optional IVH preparation method used for ``ivh_intensity_image``. ivh_discretization_step : float, optional Intensity interval used to sample IVH features. """ image: Image filtered_image: Image | None = None morphological_mask: Image | None = None intensity_mask: Image | None = None intensity_range: tuple[float, float] | None = None texture_discretized_image: Image | None = None ivh_intensity_image: Image | None = None ivh_discretization_method: str | None = None ivh_discretization_step: float | None = None @property def feature_image(self): """Return the image used for intensity-based feature calculation. Returns ------- image : Image ``filtered_image`` when present; otherwise the original ``image``. """ return self.filtered_image if self.filtered_image is not None else self.image
[docs] class IntensityMaskBuilder: """Build the intensity ROI image used by intensity-based feature families. The builder keeps the morphological mask binary, selects the current feature image, writes an ``intensity_mask`` whose voxels outside the ROI are set to ``NaN``, and clears any prepared texture or IVH images. This mirrors the IBSI distinction between morphology and intensity masks. This class has no constructor parameters. """
[docs] def get_params(self): """Return intensity-mask-building parameters. Returns ------- params : dict Empty dictionary because this class has no constructor parameters. """ return {}
[docs] def apply(self, roi_data): """Return ROI data with ``intensity_mask`` built from the feature image. Parameters ---------- roi_data : RoiData ROI data containing ``image`` and ``morphological_mask``. If ``filtered_image`` is present, its voxel values are used inside the intensity ROI. Returns ------- roi_data : RoiData New ROI data with a binary ``morphological_mask`` and an ``intensity_mask`` image containing feature-image values inside the ROI and ``NaN`` outside it. """ if roi_data.morphological_mask is None: raise ValueError("IntensityMaskBuilder requires RoiData.morphological_mask.") morphological_mask = roi_data.morphological_mask.copy() morphological_mask.array = morphological_mask.array.astype(np.int8) feature_image = roi_data.feature_image intensity_mask = morphological_mask.copy() intensity_mask.array = np.where(morphological_mask.array > 0, feature_image.array, np.nan) return RoiData( image=roi_data.image, filtered_image=roi_data.filtered_image, morphological_mask=morphological_mask, intensity_mask=intensity_mask, )
[docs] class RoiCropper: """Crop aligned images and masks to the ROI bounding box. Cropping reduces the working image domain to the non-zero morphological ROI plus optional padding. Image geometry metadata is updated so the cropped image remains located correctly in physical space. Prepared texture and IVH images are cropped with the same bounding box, and re-segmentation and IVH metadata are preserved. Parameters ---------- padding : int or sequence of int, optional Number of voxels to keep around the ROI bounding box. A single integer applies the same padding to all array axes. A sequence must contain one value per array axis in ``(z, y, x)`` order. The default is ``0``. """ def __init__(self, padding=0): self.padding = padding
[docs] def get_params(self): """Return ROI cropping parameters mapped to their configured values. Returns ------- params : dict Dictionary containing ``padding``. """ return { 'padding': self.padding, }
[docs] def apply(self, roi_data): """Return ROI data cropped to the morphological ROI bounding box. Parameters ---------- roi_data : RoiData ROI data with ``morphological_mask`` and ``intensity_mask``. Returns ------- roi_data : RoiData New ROI data with all available images cropped to the same bounding box. """ if roi_data.morphological_mask is None or roi_data.intensity_mask is None: raise ValueError("RoiCropper requires RoiData with morphological and intensity masks.") bbox_slices = self._bounding_box_slices(roi_data.morphological_mask.array) return RoiData( image=self._crop_image(roi_data.image, bbox_slices), filtered_image=( None if roi_data.filtered_image is None else self._crop_image(roi_data.filtered_image, bbox_slices) ), morphological_mask=self._crop_image(roi_data.morphological_mask, bbox_slices), intensity_mask=self._crop_image(roi_data.intensity_mask, bbox_slices), intensity_range=roi_data.intensity_range, texture_discretized_image=( None if roi_data.texture_discretized_image is None else self._crop_image(roi_data.texture_discretized_image, bbox_slices) ), ivh_intensity_image=( None if roi_data.ivh_intensity_image is None else self._crop_image(roi_data.ivh_intensity_image, bbox_slices) ), ivh_discretization_method=roi_data.ivh_discretization_method, ivh_discretization_step=roi_data.ivh_discretization_step, )
def _bounding_box_slices(self, mask_array): coords = np.argwhere(mask_array > 0) if coords.size == 0: raise ValueError("Cannot crop an empty ROI mask.") padding = self._normalize_padding(mask_array.ndim) starts = np.maximum(coords.min(axis=0) - padding, 0) stops = np.minimum(coords.max(axis=0) + padding + 1, mask_array.shape) return tuple(slice(int(start), int(stop)) for start, stop in zip(starts, stops)) def _normalize_padding(self, ndim): if isinstance(self.padding, int): return np.repeat(self.padding, ndim) padding = np.asarray(self.padding, dtype=int) if padding.size != ndim: raise ValueError(f"Padding must be an int or contain {ndim} values.") return padding def _crop_image(self, image, bbox_slices): cropped_array = image.array[bbox_slices] return Image( array=cropped_array, origin=self._cropped_origin(image, bbox_slices), spacing=image.spacing, direction=image.direction, shape=tuple(cropped_array.shape[::-1]), ) @staticmethod def _cropped_origin(image, bbox_slices): if image.origin is None or image.spacing is None or image.direction is None: return image.origin array_starts = np.array([bbox_slice.start for bbox_slice in bbox_slices], dtype=float) physical_starts = np.array( [ array_starts[2] * image.spacing[0], array_starts[1] * image.spacing[1], array_starts[0] * image.spacing[2], ] ) direction = np.asarray(image.direction, dtype=float).reshape(3, 3) return tuple(np.asarray(image.origin, dtype=float) + direction @ physical_starts)