Skip to content
29 changes: 19 additions & 10 deletions docs/concepts/transforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,11 @@ batch = tio.SubjectsBatch.from_subjects(subjects)
assert batch.metadata == {"site": ["A", "B"], "age": [30, 40]}
```

The first subject defines the image-name and metadata-key order of the
batch. All subjects must have the same schema, although their local
key order may differ. A custom transform should preserve that shared
schema and keep every metadata list aligned with the batch dimension.
The first subject defines the image, metadata, point, and bounding-box
key order of the batch. All subjects must have the same schema,
although their local key order may differ. A custom transform should
preserve that shared schema and keep every per-element list aligned
with the batch dimension.

## Scalar, range, or distribution: one class for both

Expand Down Expand Up @@ -198,9 +199,10 @@ MONAI transforms in TorchIO pipelines.

## Transform types

- **`SpatialTransform`**: modifies geometry. Applies to all images
(ScalarImage and LabelMap) and transforms attached Points and
BoundingBoxes.
- **`SpatialTransform`**: modifies image geometry and applies to all
images (ScalarImage and LabelMap). Spatial transforms currently raise
an error when a `Subject` or batch contains Points or BoundingBoxes,
because annotation-coordinate updates are not implemented yet.
- **`IntensityTransform`**: modifies voxel values. Applies only to
ScalarImage, leaving LabelMap and annotations untouched.

Expand Down Expand Up @@ -252,14 +254,21 @@ result = tio.Noise(std=0.1)(subject)
trace = result.applied_transforms[-1]
assert trace.name == "Noise"
assert trace.params["std"] == 0.1
replayed = tio.Noise().apply_with_params(subject, trace.params)
torch.testing.assert_close(replayed.image.data, result.image.data)
```

History parameters support inspection and inversion. TorchIO does not
currently expose a public API for applying an arbitrary saved parameter
dictionary to another input. In particular, do not use
Use `apply_with_params()` to apply an exact saved parameter dictionary
without sampling again.

This bypasses `p` and `make_params()`, but retains normal copying,
wrapping, history recording, and output-type restoration. Do not use
`apply_transform(new_subject, params)` for replay: the method requires
an already wrapped `SubjectsBatch` and omits the public-call lifecycle.

See [Write a custom transform](../how-to/custom-transform.md) for
vectorized image, batched metadata, and subject-wise examples.

## Hydra configuration

Transforms can export themselves as Hydra-compatible YAML configs
Expand Down
14 changes: 13 additions & 1 deletion docs/get-started/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,12 +327,20 @@ assert batch.metadata == {"site": ["A", "B"], "age": [30, 40]}

Treat `batch.metadata` as `dict[str, list[Any]]`. Metadata transforms
must keep each list aligned with the batch dimension. Subjects in one
batch must have equivalent image names and metadata keys. The first
batch must have equivalent image, metadata, point, and bounding-box
schemas, including image-level metadata and annotation keys. The first
subject determines the shared key order; later subjects may use a
different local order, but custom transforms should preserve the batch
schema rather than adding, removing, or renaming keys for only some
elements.

!!! warning "Spatial transforms and annotations"
Batching preserves subject- and image-level Points and
BoundingBoxes, but v2 spatial transforms do not yet update their
coordinates. They raise a clear error instead of returning stale
annotations. Remove annotations before a spatial transform or use
an annotation-aware operation.

### Choose deterministic or per-instance behavior

A fixed scalar is not sampled: transforms such as `Gamma` use that
Expand Down Expand Up @@ -400,6 +408,10 @@ assert result.identifier == "sub-01"
This pattern is more expensive than vectorized code. Uniform schema
changes are supported, but all callback results must remain compatible
enough to be re-stacked.
Use `transform.apply_with_params(data, params)` when migrating code
that replays an exact parameter dictionary. It performs normal
wrapping, copying, history recording, and output restoration without
calling `make_params()` or applying the probability gate.

## New features

Expand Down
183 changes: 183 additions & 0 deletions docs/how-to/custom-transform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Write a custom transform

Custom transforms subclass `Transform` and implement a batch-native
kernel. TorchIO wraps every supported input as a `SubjectsBatch`,
calls the kernel, and restores the original input type.

## Transform image tensors

Image tensors inside a transform have shape `(B, C, I, J, K)`. Operate
on the leading batch dimension directly and use negative indices for
spatial dimensions when practical.

```python
from typing import Any

import torch
import torchio as tio


class AddValue(tio.Transform):
"""Add a fixed value to every image."""

def __init__(self, value: float) -> None:
super().__init__()
self.value = value

def make_params(self, batch: tio.SubjectsBatch) -> dict[str, Any]:
"""Return the value to add."""
return {"value": self.value}

def apply_transform(
self,
batch: tio.SubjectsBatch,
params: dict[str, Any],
) -> tio.SubjectsBatch:
"""Add the value to all 5D image tensors."""
for image_batch in batch.images.values():
assert image_batch.data.ndim == 5
image_batch.data = image_batch.data + params["value"]
return batch


subject = tio.Subject(image=tio.ScalarImage(torch.zeros(1, 2, 3, 4)))
result = AddValue(2)(subject)
assert isinstance(result, tio.Subject)
assert result.image.data.shape == (1, 2, 3, 4)
assert torch.all(result.image.data == 2)
```

Call `transform(data)`, not `apply_transform` directly. The public call
handles copying, probability, wrapping, history, and output-type
restoration.

## Transform batched metadata

`batch.metadata` is a `dict[str, list[Any]]`. Each list must remain
aligned with `batch.batch_size`.

```python
from typing import Any

import torchio as tio


class NormalizeAge(tio.Transform):
"""Convert age in years to a fraction of a fixed maximum."""

def __init__(self, maximum: float) -> None:
super().__init__()
self.maximum = maximum

def make_params(self, batch: tio.SubjectsBatch) -> dict[str, Any]:
"""Return the normalization denominator."""
return {"maximum": self.maximum}

def apply_transform(
self,
batch: tio.SubjectsBatch,
params: dict[str, Any],
) -> tio.SubjectsBatch:
"""Normalize every age in the batch."""
batch.metadata["age"] = [
age / params["maximum"] for age in batch.metadata["age"]
]
return batch


batch = tio.SubjectsBatch.from_subjects([
tio.Subject(age=20),
tio.Subject(age=40),
])
result = NormalizeAge(100)(batch)
assert result.metadata["age"] == [0.2, 0.4]
```

Subjects in one batch must have compatible image, metadata, point, and
bounding-box schemas. Reordered equivalent keys are accepted, but no
field is silently discarded.

## Map a subject-oriented operation

Use `SubjectsBatch.map_subjects()` for logic that cannot be vectorized,
such as text processing or an external library that accepts one subject
at a time.

```python
from typing import Any

import torchio as tio


class NormalizeReport(tio.Transform):
"""Normalize report whitespace one subject at a time."""

def make_params(self, batch: tio.SubjectsBatch) -> dict[str, Any]:
"""Return no parameters."""
return {}

def apply_transform(
self,
batch: tio.SubjectsBatch,
params: dict[str, Any],
) -> tio.SubjectsBatch:
"""Normalize each report."""
return batch.map_subjects(self._normalize_subject)

@staticmethod
def _normalize_subject(subject: tio.Subject) -> tio.Subject:
subject.metadata["report"] = " ".join(subject.report.split())
return subject


batch = tio.SubjectsBatch.from_subjects([
tio.Subject(report="No acute finding."),
tio.Subject(report="Stable\nappearance."),
])
result = NormalizeReport()(batch)
assert result.metadata["report"] == [
"No acute finding.",
"Stable appearance.",
]
```

The callback must return a `Subject`. Uniform schema changes are
allowed; changes that make batch elements incompatible raise a
`ValueError`. History added by callbacks is retained, using
per-element history when callback results differ.

## Apply exact parameters

Use `apply_with_params()` to apply a saved parameter dictionary without
sampling again:

```python
import torch
import torchio as tio

subject = tio.Subject(image=tio.ScalarImage(torch.zeros(1, 4, 4, 4)))
transform = tio.Noise(mean=(-1, 1), std=(0.1, 0.5))
transformed = transform(subject)
params = transformed.applied_transforms[-1].params

replayed = transform.apply_with_params(subject, params)
torch.testing.assert_close(replayed.image.data, transformed.image.data)
```

`apply_with_params()` bypasses `p` and `make_params()`, honors `copy`,
restores the input type, validates per-instance parameter dimensions,
and records the supplied parameters in history. `Compose`, `OneOf`,
`SomeOf`, `CropOrPad`, `EnsureShapeMultiple`, `MonaiAdapter`, and
`CornucopiaAdapter` do not expose a compatible exact-parameter kernel
and therefore reject this method.

## Handle annotations safely

Batching preserves subject- and image-level `Points` and
`BoundingBoxes`. Spatial transforms do not yet update annotation
coordinates, so they raise an error when annotations are present.
Remove annotations first or use an annotation-aware spatial operation.

See [Transform design](../concepts/transforms.md) for the execution
model and [Migrating from v1 to v2](../get-started/migration.md) for
the old and new subclass hooks.
20 changes: 14 additions & 6 deletions src/torchio/data/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,16 +485,23 @@ def unbatch(self) -> list[Any]:
def map_subjects(
self,
callback: Callable[[Subject], Subject],
*,
copy: bool = True,
) -> Self:
"""Apply a callback to every subject and rebuild the batch.

Each callback receives an independent `Subject` carrying its
complete transform history. All returned subjects must have a
compatible schema and image shapes so they can be re-stacked.
Callback-added histories are retained.
Each callback receives an unbatched `Subject` carrying its complete
transform history. All returned subjects must have a compatible
schema and image shapes so they can be re-stacked. Callback-added
histories are retained. By default, image tensors are cloned before
the callback so the input batch is unchanged. With `copy=False`,
callbacks may mutate the input batch's image tensors.

Args:
callback: Callable taking and returning one `Subject`.
copy: Clone each image tensor before invoking the callback.
Set to `False` when the caller has already handled copy
semantics.

Returns:
A new batch containing the callback results.
Expand All @@ -513,8 +520,9 @@ def map_subjects(

mapped = []
for index, subject in enumerate(self.unbatch()):
for image in subject.images.values():
image.set_data(image.data.clone())
if copy:
for image in subject.images.values():
image.set_data(image.data.clone())
result = callback(subject)
if not isinstance(result, Subject):
msg = (
Expand Down
49 changes: 37 additions & 12 deletions src/torchio/transforms/cornucopia_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,14 @@ def forward(self, data: Any) -> Any:
batch, unwrap = self._wrap(data)
if self.copy:
batch = _copy.deepcopy(batch)
if torch.rand(1).item() > self.p:
if torch.rand(1).item() >= self.p:
return unwrap(batch)
subjects = batch.unbatch()
for subject in subjects:

def apply_to_subject(subject: Subject) -> Subject:
_apply_cornucopia(subject, self.cornucopia_transform, self)
from ..data.batch import SubjectsBatch
return subject

result = SubjectsBatch.from_subjects(subjects)
result.adopt_history(batch, subjects)
result = batch.map_subjects(apply_to_subject, copy=False)
return unwrap(result)

def apply_transform(
Expand Down Expand Up @@ -124,14 +123,40 @@ def _apply_cornucopia(
# Cornucopia transforms accept multiple tensors as *args
# and return the same number of tensors.
results = cornucopia_transform(*tensors)

# If only one image, result is a single tensor (not a tuple).
if len(names) == 1:
results = (results,)
results = _normalize_results(results, len(names))

for name, result_tensor in zip(names, results, strict=True):
if isinstance(result_tensor, torch.Tensor):
images[name].set_data(result_tensor)
if not isinstance(result_tensor, torch.Tensor):
msg = (
f"Expected torch.Tensor for image field {name!r},"
f" got {type(result_tensor).__name__}"
)
raise TypeError(msg)
images[name].set_data(result_tensor)


def _normalize_results(
results: Any,
num_images: int,
) -> tuple[Any, ...] | list[Any]:
"""Normalize Cornucopia outputs and validate their arity."""
if num_images == 1:
if not isinstance(results, (tuple, list)):
return (results,)
if len(results) != 1:
msg = f"Expected 1 image result, got {len(results)}"
raise ValueError(msg)
return results
if not isinstance(results, (tuple, list)):
msg = (
f"Expected a tuple or list with {num_images} image results,"
f" got {type(results).__name__}"
)
raise TypeError(msg)
if len(results) != num_images:
msg = f"Expected {num_images} image results, got {len(results)}"
raise ValueError(msg)
return results


def _filter_images(
Expand Down
Loading
Loading