Skip to content

Normalize

Bases: IntensityTransform

Linearly rescale voxel intensities to a target range.

The transform clips values to an input range, then applies the affine map:

\[v_{\text{out}} = \frac{v - m_{\min}}{m_{\max} - m_{\min}} \cdot (n_{\max} - n_{\min}) + n_{\min}\]

All six numeric parameters are independently randomizable via scalar, (low, high) range, or torch.distributions.Distribution.

Parameters:

Name Type Description Default
out_min TypeParameterValue

Lower bound of the output range.

-1.0
out_max TypeParameterValue

Upper bound of the output range.

1.0
in_min TypeParameterValue | None

Lower bound of the input range. If None, determined from percentile_low of the (masked) input data.

None
in_max TypeParameterValue | None

Upper bound of the input range. If None, determined from percentile_high of the (masked) input data.

None
percentile_low TypeParameterValue

Lower percentile for auto input range.

0.0
percentile_high TypeParameterValue

Upper percentile for auto input range. Use (0.5, 99.5) for the nn-UNet convention.

100.0
masking_method str | Callable[[Tensor], Tensor] | None

Which voxels to include when computing percentiles. None uses all voxels. A str is interpreted as a key to a LabelMap in the subject. A callable receives the image tensor and returns a boolean mask.

None
**kwargs Any

See Transform.

{}

Examples:

>>> import torchio as tio
>>> # Rescale to [-1, 1] (default)
>>> transform = tio.Normalize()
>>> # CT windowing
>>> transform = tio.Normalize(
...     out_min=0.0, out_max=1.0,
...     in_min=-1000.0, in_max=1000.0,
... )
>>> # nn-UNet percentile clipping
>>> transform = tio.Normalize(
...     percentile_low=0.5, percentile_high=99.5,
... )
>>> # Random output range
>>> transform = tio.Normalize(
...     out_min=(-1.0, 0.0), out_max=(0.5, 1.0),
... )
Source code in src/torchio/transforms/intensity/normalize.py
class Normalize(IntensityTransform):
    r"""Linearly rescale voxel intensities to a target range.

    The transform clips values to an input range, then applies the
    affine map:

    $$v_{\text{out}} = \frac{v - m_{\min}}{m_{\max} - m_{\min}}
    \cdot (n_{\max} - n_{\min}) + n_{\min}$$

    All six numeric parameters are independently randomizable via
    scalar, `(low, high)` range, or `torch.distributions.Distribution`.

    Args:
        out_min: Lower bound of the output range.
        out_max: Upper bound of the output range.
        in_min: Lower bound of the input range. If `None`, determined
            from *percentile_low* of the (masked) input data.
        in_max: Upper bound of the input range. If `None`, determined
            from *percentile_high* of the (masked) input data.
        percentile_low: Lower percentile for auto input range.
        percentile_high: Upper percentile for auto input range.
            Use `(0.5, 99.5)` for the nn-UNet convention.
        masking_method: Which voxels to include when computing
            percentiles. `None` uses all voxels. A `str` is
            interpreted as a key to a
            [`LabelMap`][torchio.LabelMap] in the subject. A callable
            receives the image tensor and returns a boolean mask.
        **kwargs: See [`Transform`][torchio.Transform].

    Examples:
        >>> import torchio as tio
        >>> # Rescale to [-1, 1] (default)
        >>> transform = tio.Normalize()
        >>> # CT windowing
        >>> transform = tio.Normalize(
        ...     out_min=0.0, out_max=1.0,
        ...     in_min=-1000.0, in_max=1000.0,
        ... )
        >>> # nn-UNet percentile clipping
        >>> transform = tio.Normalize(
        ...     percentile_low=0.5, percentile_high=99.5,
        ... )
        >>> # Random output range
        >>> transform = tio.Normalize(
        ...     out_min=(-1.0, 0.0), out_max=(0.5, 1.0),
        ... )
    """

    def __init__(
        self,
        *,
        out_min: TypeParameterValue = -1.0,
        out_max: TypeParameterValue = 1.0,
        in_min: TypeParameterValue | None = None,
        in_max: TypeParameterValue | None = None,
        percentile_low: TypeParameterValue = 0.0,
        percentile_high: TypeParameterValue = 100.0,
        masking_method: str | Callable[[Tensor], Tensor] | None = None,
        **kwargs: Any,
    ) -> None:
        super().__init__(**kwargs)
        self.out_min = _to_range(out_min)
        self.out_max = _to_range(out_max)
        self.in_min = _to_range(in_min) if in_min is not None else None
        self.in_max = _to_range(in_max) if in_max is not None else None
        self.percentile_low = _to_range(percentile_low)
        self.percentile_high = _to_range(percentile_high)
        self.masking_method = masking_method

    def make_params(self, batch: SubjectsBatch) -> dict[str, Any]:
        """Sample random parameters and compute the input range.

        When per-instance augmentation is active, the output range is
        sampled independently per batch element; the data-driven input
        range stays batch-shared.

        Returns:
            Dict with `out_min`, `out_max`, and either `in_min`/`in_max`
            or `in_ranges` (per image name).
        """
        n = self._resolve_n(batch)
        out_min = self.out_min.sample_1d(n)
        out_max = self.out_max.sample_1d(n)
        pct_low = self.percentile_low.sample_1d()
        pct_high = self.percentile_high.sample_1d()

        params: dict[str, Any] = {
            "out_min": self._serialize_param(out_min),
            "out_max": self._serialize_param(out_max),
        }
        # If explicit in_min/in_max are given, sample them directly.
        if self.in_min is not None and self.in_max is not None:
            params["in_min"] = self.in_min.sample_1d()
            params["in_max"] = self.in_max.sample_1d()
        else:
            # Otherwise, compute per-image input range from percentiles.
            in_ranges: dict[str, tuple[float, float]] = {}
            for name, img_batch in self._get_images(batch).items():
                mask = self._get_mask(img_batch, batch)
                in_ranges[name] = _percentile_range(
                    img_batch.data[0],
                    mask,
                    pct_low,
                    pct_high,
                    name,
                )
            params["in_ranges"] = in_ranges

        if n is not None:
            self._tag_batched(params, batch, n, None, ["out_min", "out_max"])
        return params

    @property
    def supports_per_instance_params(self) -> bool:
        return True

    def apply_transform(
        self,
        batch: SubjectsBatch,
        params: dict[str, Any],
    ) -> SubjectsBatch:
        """Clip and linearly rescale each selected image."""
        for name, img_batch in self._get_images(batch).items():
            if "in_min" in params:
                in_min = params["in_min"]
                in_max = params["in_max"]
            else:
                in_ranges = params.get("in_ranges", {})
                if name not in in_ranges:
                    continue
                in_min, in_max = in_ranges[name]

            in_range = in_max - in_min
            if in_range == 0:
                warnings.warn(
                    f'Cannot rescale "{name}": input range is zero.',
                    RuntimeWarning,
                    stacklevel=2,
                )
                continue

            data = img_batch.data.float()
            out_min, out_range = _out_min_and_range(
                params["out_min"],
                params["out_max"],
                data,
            )
            data = data.clamp(in_min, in_max)
            data = (data - in_min) / in_range * out_range + out_min
            img_batch.data = data

        return batch

    @property
    def invertible(self) -> bool:
        """Whether this transform can be inverted."""
        return True

    def inverse(self, params: dict[str, Any]) -> _RescaleInverse:
        """Build the inverse transform from recorded parameters."""
        return _RescaleInverse(
            out_min=params["out_min"],
            out_max=params["out_max"],
            in_min=params.get("in_min"),
            in_max=params.get("in_max"),
            in_ranges=params.get("in_ranges"),
            copy=False,
        )

    def _get_mask(
        self,
        img_batch: ImagesBatch,
        batch: SubjectsBatch,
    ) -> Tensor | None:
        """Resolve masking_method to a boolean tensor or None."""
        if self.masking_method is None:
            return None
        if callable(self.masking_method) and not isinstance(self.masking_method, str):
            return self.masking_method(img_batch.data[0]).bool()
        # String key: look up a LabelMap in the batch.
        if isinstance(self.masking_method, str):
            key = self.masking_method
            if key not in batch.images:
                msg = (
                    f'Masking method "{key}" not found in batch images.'
                    f" Available: {list(batch.images.keys())}"
                )
                raise KeyError(msg)
            mask_batch = batch.images[key]
            if not issubclass(mask_batch._image_class, LabelMap):
                msg = f'Masking method "{key}" must refer to a LabelMap.'
                raise TypeError(msg)
            return mask_batch.data[0].bool()
        msg = (
            "masking_method must be None, str, or callable, got"
            f" {type(self.masking_method)}"
        )
        raise TypeError(msg)

supports_per_instance_p property

Whether this transform can gate each batch element independently.

Defaults to False. Shape-preserving transforms that implement per-element probability override this to return True. Shape-changing transforms must leave it False because masked and unmasked elements would have incompatible shapes.

invertible property

Whether this transform can be inverted.

forward(data)

forward(data: Subject) -> Subject
forward(data: Image) -> Image
forward(data: Tensor) -> Tensor
forward(data: np.ndarray) -> np.ndarray
forward(data: sitk.Image) -> sitk.Image
forward(data: nib.Nifti1Image) -> nib.Nifti1Image
forward(data: dict) -> dict
forward(data: ImagesBatch) -> ImagesBatch
forward(data: SubjectsBatch) -> SubjectsBatch

Apply the transform.

The output type always matches the input type.

Parameters:

Name Type Description Default
data Any

Input data to transform.

required
Source code in src/torchio/transforms/transform.py
def forward(self, data: Any) -> Any:
    """Apply the transform.

    The output type always matches the input type.

    Args:
        data: Input data to transform.
    """
    if self.copy:
        data = _copy.deepcopy(data)
    batch, unwrap = self._wrap(data)
    # When per-element gating is active, the transform handles the
    # probability itself (masked-out elements get identity params),
    # so skip the batch-wide coin flip here. Apply iff rand < p, so
    # p=0 is always a no-op and p=1 always applies.
    if not self._per_instance_p_active(batch) and torch.rand(1).item() >= self.p:
        return unwrap(batch)
    params = self.make_params(batch)
    batch = self.apply_transform(batch, params)
    # Record history on the batch, unless every element was gated out by
    # per-element probability: that is an exact no-op, and recording it
    # would let history replay (e.g. an invertible spatial transform)
    # trigger an unnecessary identity resample.
    if not _all_elements_gated_out(params):
        trace = AppliedTransform(name=type(self).__name__, params=params)
        if not hasattr(batch, "applied_transforms"):
            batch.applied_transforms = []
        batch.applied_transforms.append(trace)
    result = unwrap(batch)
    # Propagate history to outputs that can carry it
    if (
        hasattr(batch, "applied_transforms")
        and not isinstance(result, (SubjectsBatch, Tensor, np.ndarray))
        and not isinstance(result, dict)
    ):
        with contextlib.suppress(AttributeError):
            result.applied_transforms = list(batch.applied_transforms)
    return result

to_hydra()

Export as a Hydra-compatible config dict.

Returns a dict with _target_ set to the fully qualified class name and only non-default field values included.

Returns:

Type Description
dict[str, Any]

Dict suitable for hydra.utils.instantiate().

Source code in src/torchio/transforms/transform.py
def to_hydra(self) -> dict[str, Any]:
    """Export as a Hydra-compatible config dict.

    Returns a dict with `_target_` set to the fully qualified
    class name and only non-default field values included.

    Returns:
        Dict suitable for `hydra.utils.instantiate()`.
    """
    from .parameter_range import _ParameterRange

    cls = type(self)
    target = f"torchio.{cls.__qualname__}"
    cfg: dict[str, Any] = {"_target_": target}

    for name, default in _collect_init_params(cls).items():
        value = getattr(self, name, default)
        if isinstance(value, _ParameterRange):
            if value._original == default:
                continue
            value = _hydra_value(value._original)
        elif value == default:
            continue
        else:
            value = _hydra_value(value)
        cfg[name] = value
    return cfg

make_params(batch)

Sample random parameters and compute the input range.

When per-instance augmentation is active, the output range is sampled independently per batch element; the data-driven input range stays batch-shared.

Returns:

Type Description
dict[str, Any]

Dict with out_min, out_max, and either in_min/in_max

dict[str, Any]

or in_ranges (per image name).

Source code in src/torchio/transforms/intensity/normalize.py
def make_params(self, batch: SubjectsBatch) -> dict[str, Any]:
    """Sample random parameters and compute the input range.

    When per-instance augmentation is active, the output range is
    sampled independently per batch element; the data-driven input
    range stays batch-shared.

    Returns:
        Dict with `out_min`, `out_max`, and either `in_min`/`in_max`
        or `in_ranges` (per image name).
    """
    n = self._resolve_n(batch)
    out_min = self.out_min.sample_1d(n)
    out_max = self.out_max.sample_1d(n)
    pct_low = self.percentile_low.sample_1d()
    pct_high = self.percentile_high.sample_1d()

    params: dict[str, Any] = {
        "out_min": self._serialize_param(out_min),
        "out_max": self._serialize_param(out_max),
    }
    # If explicit in_min/in_max are given, sample them directly.
    if self.in_min is not None and self.in_max is not None:
        params["in_min"] = self.in_min.sample_1d()
        params["in_max"] = self.in_max.sample_1d()
    else:
        # Otherwise, compute per-image input range from percentiles.
        in_ranges: dict[str, tuple[float, float]] = {}
        for name, img_batch in self._get_images(batch).items():
            mask = self._get_mask(img_batch, batch)
            in_ranges[name] = _percentile_range(
                img_batch.data[0],
                mask,
                pct_low,
                pct_high,
                name,
            )
        params["in_ranges"] = in_ranges

    if n is not None:
        self._tag_batched(params, batch, n, None, ["out_min", "out_max"])
    return params

apply_transform(batch, params)

Clip and linearly rescale each selected image.

Source code in src/torchio/transforms/intensity/normalize.py
def apply_transform(
    self,
    batch: SubjectsBatch,
    params: dict[str, Any],
) -> SubjectsBatch:
    """Clip and linearly rescale each selected image."""
    for name, img_batch in self._get_images(batch).items():
        if "in_min" in params:
            in_min = params["in_min"]
            in_max = params["in_max"]
        else:
            in_ranges = params.get("in_ranges", {})
            if name not in in_ranges:
                continue
            in_min, in_max = in_ranges[name]

        in_range = in_max - in_min
        if in_range == 0:
            warnings.warn(
                f'Cannot rescale "{name}": input range is zero.',
                RuntimeWarning,
                stacklevel=2,
            )
            continue

        data = img_batch.data.float()
        out_min, out_range = _out_min_and_range(
            params["out_min"],
            params["out_max"],
            data,
        )
        data = data.clamp(in_min, in_max)
        data = (data - in_min) / in_range * out_range + out_min
        img_batch.data = data

    return batch

inverse(params)

Build the inverse transform from recorded parameters.

Source code in src/torchio/transforms/intensity/normalize.py
def inverse(self, params: dict[str, Any]) -> _RescaleInverse:
    """Build the inverse transform from recorded parameters."""
    return _RescaleInverse(
        out_min=params["out_min"],
        out_max=params["out_max"],
        in_min=params.get("in_min"),
        in_max=params.get("in_max"),
        in_ranges=params.get("in_ranges"),
        copy=False,
    )