SPECTRE-Large / spectre /windowing.py
cclaess's picture
Improved overall functionality and added functions for batch inference
80df7e4 verified
Raw
History Blame Contribute Delete
9.67 kB
"""Pure-torch CT preprocessing: HU scaling, centre cropping and grid patching.
This module is vendored into the Hugging Face Hub repository by `scripts/export_hf.py`,
so it must import nothing beyond `torch` and the standard library.
The functions here reproduce the MONAI pipeline used for pretraining and evaluation
(`ScaleIntensityRanged` -> `LargestMultipleCenterCropd` -> `GridPatchd`) exactly. Two details
are load-bearing and are covered by `tests/test_windowing_parity.py`:
* Patch order is C-order with depth varying fastest, i.e. crop `n` is at grid position
`n = (h * n_w + w) * n_d + d`. This matches both MONAI's `GridPatch` and the coordinates
`RotaryPositionEmbedding` builds, so getting it wrong shifts every embedding silently.
* The centre-crop offset is `start = (S // 2) - (R // 2)`, which is not the same as
`(S - R) // 2` when the crop is odd and the volume even.
"""
from __future__ import annotations
import warnings
from typing import Optional, Sequence, Tuple
import torch
import torch.nn.functional as F
DEFAULT_CROP_SIZE: Tuple[int, int, int] = (128, 128, 64) # (H, W, D)
DEFAULT_HU_RANGE: Tuple[float, float] = (-1000.0, 1000.0)
__all__ = [
'DEFAULT_CROP_SIZE',
'DEFAULT_HU_RANGE',
'scale_intensity_range',
'largest_multiple_crop_size',
'largest_multiple_center_crop',
'grid_patch',
'window_scan',
]
def scale_intensity_range(
x: torch.Tensor,
a_min: float = DEFAULT_HU_RANGE[0],
a_max: float = DEFAULT_HU_RANGE[1],
b_min: float = 0.0,
b_max: float = 1.0,
clip: bool = True,
) -> torch.Tensor:
"""Map intensities from `[a_min, a_max]` onto `[b_min, b_max]`.
Mirrors `monai.transforms.ScaleIntensityRange`. SPECTRE was pretrained with the default
HU window of [-1000, 1000] -> [0, 1] with clipping.
"""
if a_max == a_min:
raise ValueError(f"a_min and a_max must differ, got a_min == a_max == {a_min}.")
x = (x - a_min) / (a_max - a_min)
x = x * (b_max - b_min) + b_min
if clip:
x = torch.clamp(x, min(b_min, b_max), max(b_min, b_max))
return x
def largest_multiple_crop_size(
spatial_shape: Sequence[int],
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
) -> Tuple[int, ...]:
"""Largest per-axis size that is a whole multiple of `crop_size`.
An axis shorter than its crop keeps its original size (there is no non-zero multiple to
take), matching `spectre.transforms.LargestMultipleCenterCropd`.
"""
if len(spatial_shape) != len(crop_size):
raise ValueError(
f"spatial_shape {tuple(spatial_shape)} and crop_size {tuple(crop_size)} must have "
f"the same number of dimensions."
)
return tuple(
(s // c) * c if s >= c else s
for s, c in zip(spatial_shape, crop_size)
)
def largest_multiple_center_crop(
x: torch.Tensor,
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
) -> torch.Tensor:
"""Centre-crop a channel-first `(C, H, W, D)` volume to a whole multiple of `crop_size`."""
if x.ndim != 4:
raise ValueError(f"Expected a channel-first (C, H, W, D) volume, got shape {tuple(x.shape)}.")
spatial_shape = tuple(x.shape[1:])
roi_size = largest_multiple_crop_size(spatial_shape, crop_size)
# MONAI's CenterSpatialCrop offset: roi_center - (roi_size // 2), clamped at 0. This is not
# the same as (S - R) // 2 for odd R and even S, so keep the formula verbatim.
slices = []
for size, roi in zip(spatial_shape, roi_size):
start = max(size // 2 - roi // 2, 0)
slices.append(slice(start, start + roi))
return x[(slice(None), *slices)]
def grid_patch(
x: torch.Tensor,
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
) -> Tuple[torch.Tensor, Tuple[int, int, int]]:
"""Tile a `(C, H, W, D)` volume into non-overlapping crops.
Every spatial axis must already be a whole multiple of `crop_size` (see
`largest_multiple_center_crop`).
Returns:
The crops `(N, C, cH, cW, cD)` and the grid `(n_h, n_w, n_d)` with `N == n_h*n_w*n_d`.
Crop `n` sits at grid position `n = (h * n_w + w) * n_d + d`.
"""
if x.ndim != 4:
raise ValueError(f"Expected a channel-first (C, H, W, D) volume, got shape {tuple(x.shape)}.")
channels = x.shape[0]
spatial_shape = tuple(x.shape[1:])
c_h, c_w, c_d = (int(c) for c in crop_size)
for axis, (size, crop) in enumerate(zip(spatial_shape, (c_h, c_w, c_d))):
if crop <= 0:
raise ValueError(f"crop_size must be positive, got {tuple(crop_size)}.")
if size % crop != 0:
raise ValueError(
f"Axis {'HWD'[axis]} has size {size}, which is not a whole multiple of the crop "
f"size {crop}. Call largest_multiple_center_crop() first, or use window_scan() "
f"which does this for you."
)
n_h, n_w, n_d = (s // c for s, c in zip(spatial_shape, (c_h, c_w, c_d)))
# Depth varies fastest, matching MONAI's GridPatch and the RoPE coordinate order.
crops = x.contiguous().view(channels, n_h, c_h, n_w, c_w, n_d, c_d)
crops = crops.permute(1, 3, 5, 0, 2, 4, 6)
crops = crops.reshape(n_h * n_w * n_d, channels, c_h, c_w, c_d)
return crops, (n_h, n_w, n_d)
def _pad_short_axes(
x: torch.Tensor,
crop_size: Sequence[int],
pad_value: float,
) -> torch.Tensor:
"""Symmetrically pad any spatial axis shorter than its crop up to exactly one crop."""
spatial_shape = tuple(x.shape[1:])
short = [
(axis, size, int(crop))
for axis, (size, crop) in enumerate(zip(spatial_shape, crop_size))
if size < crop
]
if not short:
return x
# F.pad takes the last dimension first, so build the spec back to front: D, W, H.
pad_spec: list = []
for axis in reversed(range(3)):
size, crop = spatial_shape[axis], int(crop_size[axis])
if size < crop:
total = crop - size
pad_spec.extend([total // 2, total - total // 2])
else:
pad_spec.extend([0, 0])
detail = ', '.join(
f"{'HWD'[axis]}={size} < {crop}" for axis, size, crop in short
)
warnings.warn(
f"Scan is smaller than one crop along {detail}. Padding with {pad_value} to reach the "
f"crop size {tuple(int(c) for c in crop_size)}. SPECTRE was not pretrained on padded "
f"volumes, so the resulting embedding is out of distribution. Pass pad_short_axes=False "
f"to raise instead.",
RuntimeWarning,
stacklevel=3,
)
return F.pad(x, pad_spec, mode='constant', value=pad_value)
def window_scan(
x: torch.Tensor,
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
*,
scale_intensity: bool = True,
hu_range: Tuple[float, float] = DEFAULT_HU_RANGE,
pad_short_axes: bool = True,
pad_value: Optional[float] = None,
) -> Tuple[torch.Tensor, Tuple[int, int, int]]:
"""Turn a whole CT volume into the crops SPECTRE's backbone consumes.
Applies, in the order used for pretraining: HU scaling -> centre crop to a whole multiple of
`crop_size` -> grid patching.
Args:
x: A `(H, W, D)` or `(C, H, W, D)` volume. In raw Hounsfield Units unless
`scale_intensity=False`.
crop_size: Spatial size `(H, W, D)` of one crop.
scale_intensity: Map `hu_range` onto [0, 1] with clipping. Turn off if `x` is already
normalised.
hu_range: The `(a_min, a_max)` HU window. SPECTRE was pretrained with (-1000, 1000).
pad_short_axes: Pad axes shorter than one crop instead of raising.
pad_value: Value to pad with. Defaults to the scaled air value (0.0) when
`scale_intensity` is on, and to `x.min()` otherwise.
Returns:
The crops `(N, C, cH, cW, cD)` and the grid `(n_h, n_w, n_d)`.
"""
if x.ndim == 3:
x = x.unsqueeze(0)
elif x.ndim != 4:
raise ValueError(
f"Expected a (H, W, D) or (C, H, W, D) volume, got shape {tuple(x.shape)}."
)
if len(crop_size) != 3:
raise ValueError(f"crop_size must have 3 elements (H, W, D), got {tuple(crop_size)}.")
if scale_intensity:
if float(x.min()) > -100.0:
warnings.warn(
f"Input does not look like Hounsfield Units (min={float(x.min()):.1f}). It will "
f"be rescaled from {hu_range} to [0, 1], which double-scales already-normalised "
f"data. Pass scale_intensity=False if the volume is already normalised.",
RuntimeWarning,
stacklevel=2,
)
x = scale_intensity_range(x, a_min=hu_range[0], a_max=hu_range[1])
if pad_value is None:
pad_value = 0.0 if scale_intensity else float(x.min())
too_short = [
f"{'HWD'[axis]}={int(size)} < {int(crop)}"
for axis, (size, crop) in enumerate(zip(x.shape[1:], crop_size))
if size < crop
]
if too_short:
if not pad_short_axes:
raise ValueError(
f"Scan is smaller than one crop along {', '.join(too_short)}. Pass "
f"pad_short_axes=True to pad with air, or supply a scan covering at least "
f"{tuple(int(c) for c in crop_size)} voxels."
)
x = _pad_short_axes(x, crop_size, pad_value)
x = largest_multiple_center_crop(x, crop_size)
return grid_patch(x, crop_size)