From d65f37502d05f9b5a70651ece242f80fbf7f3e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Mon, 13 Jul 2026 16:51:00 +0100 Subject: [PATCH 01/11] Add subject mapping adapter examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- docs/concepts/transforms.md | 29 ++- docs/get-started/migration.md | 14 +- docs/how-to/custom-transform.md | 182 +++++++++++++++++++ src/torchio/transforms/cornucopia_adapter.py | 20 +- src/torchio/transforms/monai_adapter.py | 44 ++++- tests/test_cornucopia_adapter.py | 31 ++++ tests/test_monai_adapter.py | 63 +++++++ zensical.toml | 1 + 8 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 docs/how-to/custom-transform.md diff --git a/docs/concepts/transforms.md b/docs/concepts/transforms.md index 7dd618c0b..8190daed2 100644 --- a/docs/concepts/transforms.md +++ b/docs/concepts/transforms.md @@ -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 @@ -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. @@ -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 diff --git a/docs/get-started/migration.md b/docs/get-started/migration.md index 54412fb6e..7470eec83 100644 --- a/docs/get-started/migration.md +++ b/docs/get-started/migration.md @@ -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 @@ -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 diff --git a/docs/how-to/custom-transform.md b/docs/how-to/custom-transform.md new file mode 100644 index 000000000..21dc0096f --- /dev/null +++ b/docs/how-to/custom-transform.md @@ -0,0 +1,182 @@ +# 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`, `MonaiAdapter`, and `CornucopiaAdapter` do not expose one +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. diff --git a/src/torchio/transforms/cornucopia_adapter.py b/src/torchio/transforms/cornucopia_adapter.py index 4fc64f3ad..ad170e001 100644 --- a/src/torchio/transforms/cornucopia_adapter.py +++ b/src/torchio/transforms/cornucopia_adapter.py @@ -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) return unwrap(result) def apply_transform( @@ -130,8 +129,13 @@ def _apply_cornucopia( results = (results,) 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 _filter_images( diff --git a/src/torchio/transforms/monai_adapter.py b/src/torchio/transforms/monai_adapter.py index 298a006d0..488452dd8 100644 --- a/src/torchio/transforms/monai_adapter.py +++ b/src/torchio/transforms/monai_adapter.py @@ -74,12 +74,11 @@ def forward(self, data): 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) - # MONAI transforms operate per-subject monai = get_monai() - subjects = batch.unbatch() - for subject in subjects: + + def apply_to_subject(subject: Subject) -> Subject: is_dict = isinstance( self.monai_transform, monai.transforms.MapTransform, @@ -89,10 +88,9 @@ def forward(self, data): else: images = self._get_subject_images(subject) _apply_array_transform(images, self.monai_transform, monai) - from ..data.batch import SubjectsBatch + return subject - result = SubjectsBatch.from_subjects(subjects) - result.adopt_history(batch, subjects) + result = batch.map_subjects(apply_to_subject) return unwrap(result) def apply_transform(self, batch: Any, params: dict[str, Any]) -> Any: @@ -183,6 +181,34 @@ def _apply_dict_transform( msg = f"Expected mapping from MONAI dict transform, got {type(result).__name__}" raise TypeError(msg) + missing = set(monai_dict) - set(result) + if missing: + msg = f"MONAI dictionary transform removed fields: {sorted(missing)}" + raise ValueError(msg) + for name, image in subject.images.items(): - if name in result and isinstance(result[name], torch.Tensor): - _update_image_from_result(image, result[name], monai) + value = result[name] + if not isinstance(value, torch.Tensor): + msg = ( + f"Expected torch.Tensor for image field {name!r}," + f" got {type(value).__name__}" + ) + raise TypeError(msg) + _update_image_from_result(image, value, monai) + + for key in subject.metadata: + subject._metadata[key] = result[key] + + for key in set(result) - set(monai_dict): + if not isinstance(key, str): + msg = f"Expected MONAI output keys to be strings, got {key!r}" + raise TypeError(msg) + value = result[key] + if isinstance(value, torch.Tensor): + msg = ( + f"MONAI dictionary transform added new tensor field {key!r}." + " TorchIO cannot infer its image type; add it to the Subject" + " before applying the adapter." + ) + raise ValueError(msg) + subject._metadata[key] = value diff --git a/tests/test_cornucopia_adapter.py b/tests/test_cornucopia_adapter.py index 6d54f581f..cbfc892a5 100644 --- a/tests/test_cornucopia_adapter.py +++ b/tests/test_cornucopia_adapter.py @@ -81,6 +81,37 @@ def test_in_compose(self) -> None: result = pipeline(subject) assert result.t1.data.shape == subject.t1.data.shape + def test_preserves_prior_history_and_annotations(self) -> None: + subject = tio.Gamma(log_gamma=0.2)( + tio.Subject( + t1=tio.ScalarImage(torch.rand(1, 8, 8, 8) + 1), + landmarks=tio.Points(torch.rand(2, 3)), + ) + ) + + result = tio.CornucopiaAdapter(lambda tensor: tensor)(subject) + + assert [trace.name for trace in result.applied_transforms] == ["Gamma"] + assert set(result.points) == {"landmarks"} + + def test_probability_zero_when_random_draw_is_zero( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + subject = _make_subject() + original = subject.t1.data.clone() + monkeypatch.setattr(torch, "rand", lambda *args, **kwargs: torch.zeros(1)) + + result = tio.CornucopiaAdapter(lambda tensor: tensor + 1, p=0)(subject) + + torch.testing.assert_close(result.t1.data, original) + + def test_rejects_non_tensor_result(self) -> None: + subject = _make_subject() + + with pytest.raises(TypeError, match=r"torch.Tensor"): + tio.CornucopiaAdapter(lambda *tensors: "not a tensor")(subject) + # ── Real Cornucopia transforms ─────────────────────────────────────── diff --git a/tests/test_monai_adapter.py b/tests/test_monai_adapter.py index f2be5ac79..8578fcc12 100644 --- a/tests/test_monai_adapter.py +++ b/tests/test_monai_adapter.py @@ -82,6 +82,40 @@ def test_dict_only_modifies_specified_keys(self) -> None: result = adapter(subject) torch.testing.assert_close(result.t2.data, original_t2) + def test_dict_updates_metadata(self) -> None: + from monai.transforms import MapTransform + + class UpdateMetadata(MapTransform): + def __init__(self) -> None: + super().__init__(keys=["t1"]) + + def __call__(self, data): + return {**data, "site": data["site"].lower()} + + subject = tio.Subject( + t1=tio.ScalarImage(torch.rand(1, 8, 8, 8)), + site="ABC", + ) + + result = tio.MonaiAdapter(UpdateMetadata())(subject) + + assert result.site == "abc" + + def test_dict_rejects_added_tensor_key(self) -> None: + from monai.transforms import MapTransform + + class AddTensor(MapTransform): + def __init__(self) -> None: + super().__init__(keys=["t1"]) + + def __call__(self, data): + return {**data, "new_image": torch.zeros(1, 8, 8, 8)} + + subject = tio.Subject(t1=tio.ScalarImage(torch.rand(1, 8, 8, 8))) + + with pytest.raises(ValueError, match=r"new tensor field.*new_image"): + tio.MonaiAdapter(AddTensor())(subject) + @pytest.mark.skipif(not HAS_MONAI, reason="MONAI not installed") class TestMonaiAdapterGeneral: @@ -116,3 +150,32 @@ def test_in_compose(self) -> None: pipeline = tio.Compose([tio.MonaiAdapter(NormalizeIntensity())]) result = pipeline(subject) assert isinstance(result, tio.Subject) + + def test_preserves_prior_history_and_annotations(self) -> None: + from monai.transforms import NormalizeIntensity + + subject = tio.Gamma(log_gamma=0.2)( + tio.Subject( + t1=tio.ScalarImage(torch.rand(1, 8, 8, 8) + 1), + landmarks=tio.Points(torch.rand(2, 3)), + ) + ) + + result = tio.MonaiAdapter(NormalizeIntensity())(subject) + + assert [trace.name for trace in result.applied_transforms] == ["Gamma"] + assert set(result.points) == {"landmarks"} + + def test_probability_zero_when_random_draw_is_zero( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from monai.transforms import NormalizeIntensity + + subject = tio.Subject(t1=tio.ScalarImage(torch.rand(1, 8, 8, 8) + 1)) + original = subject.t1.data.clone() + monkeypatch.setattr(torch, "rand", lambda *args, **kwargs: torch.zeros(1)) + + result = tio.MonaiAdapter(NormalizeIntensity(), p=0)(subject) + + torch.testing.assert_close(result.t1.data, original) diff --git a/zensical.toml b/zensical.toml index facc9d1d1..e504fbbbc 100644 --- a/zensical.toml +++ b/zensical.toml @@ -36,6 +36,7 @@ nav = [ { "How-to guides" = [ "how-to/dataloader.md", "how-to/monai.md", + "how-to/custom-transform.md", "how-to/custom-reader.md", "how-to/save-nii-zarr.md", "how-to/remote-nii-zarr.md", From dc8541bb5c7d384d6e362647368b21dffa0dfedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Mon, 13 Jul 2026 22:57:22 +0100 Subject: [PATCH 02/11] Honor MONAI adapter copy semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/data/batch.py | 13 ++++++++++--- src/torchio/transforms/monai_adapter.py | 2 +- tests/test_batch.py | 12 ++++++++++++ tests/test_monai_adapter.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/torchio/data/batch.py b/src/torchio/data/batch.py index 2c51f8f29..b0779aa08 100644 --- a/src/torchio/data/batch.py +++ b/src/torchio/data/batch.py @@ -485,16 +485,22 @@ 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. + Callback-added histories are retained. By default, image tensors + are cloned before the callback so the input batch is unchanged. 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. @@ -513,8 +519,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 = ( diff --git a/src/torchio/transforms/monai_adapter.py b/src/torchio/transforms/monai_adapter.py index 488452dd8..dea7ac0a6 100644 --- a/src/torchio/transforms/monai_adapter.py +++ b/src/torchio/transforms/monai_adapter.py @@ -90,7 +90,7 @@ def apply_to_subject(subject: Subject) -> Subject: _apply_array_transform(images, self.monai_transform, monai) return subject - result = batch.map_subjects(apply_to_subject) + result = batch.map_subjects(apply_to_subject, copy=False) return unwrap(result) def apply_transform(self, batch: Any, params: dict[str, Any]) -> Any: diff --git a/tests/test_batch.py b/tests/test_batch.py index 9067a7bc3..5c61c3c94 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -790,3 +790,15 @@ def add_in_place(subject: tio.Subject) -> tio.Subject: assert torch.count_nonzero(batch.t1.data) == 0 assert torch.all(result.t1.data == 1) + + def test_copy_false_allows_in_place_mutation(self) -> None: + batch = self._batch() + + def add_in_place(subject: tio.Subject) -> tio.Subject: + subject.t1.data.add_(1) + return subject + + result = batch.map_subjects(add_in_place, copy=False) + + assert torch.all(batch.t1.data == 1) + assert torch.all(result.t1.data == 1) diff --git a/tests/test_monai_adapter.py b/tests/test_monai_adapter.py index 8578fcc12..5d5662aef 100644 --- a/tests/test_monai_adapter.py +++ b/tests/test_monai_adapter.py @@ -179,3 +179,17 @@ def test_probability_zero_when_random_draw_is_zero( result = tio.MonaiAdapter(NormalizeIntensity(), p=0)(subject) torch.testing.assert_close(result.t1.data, original) + + def test_copy_false_allows_in_place_transform(self) -> None: + class AddInPlace: + def __call__(self, tensor): + return tensor.add_(1) + + batch = tio.SubjectsBatch.from_subjects( + [tio.Subject(t1=tio.ScalarImage(torch.zeros(1, 4, 4, 4)))] + ) + + result = tio.MonaiAdapter(AddInPlace(), copy=False)(batch) + + assert torch.all(batch.t1.data == 1) + assert torch.all(result.t1.data == 1) From 35d8e951d040a1814c4a00ce004b101264c432b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Mon, 13 Jul 2026 22:58:01 +0100 Subject: [PATCH 03/11] Honor Cornucopia adapter copy semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/transforms/cornucopia_adapter.py | 2 +- tests/test_cornucopia_adapter.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/torchio/transforms/cornucopia_adapter.py b/src/torchio/transforms/cornucopia_adapter.py index ad170e001..8b94e670c 100644 --- a/src/torchio/transforms/cornucopia_adapter.py +++ b/src/torchio/transforms/cornucopia_adapter.py @@ -77,7 +77,7 @@ def apply_to_subject(subject: Subject) -> Subject: _apply_cornucopia(subject, self.cornucopia_transform, self) return subject - result = batch.map_subjects(apply_to_subject) + result = batch.map_subjects(apply_to_subject, copy=False) return unwrap(result) def apply_transform( diff --git a/tests/test_cornucopia_adapter.py b/tests/test_cornucopia_adapter.py index cbfc892a5..5f60836f9 100644 --- a/tests/test_cornucopia_adapter.py +++ b/tests/test_cornucopia_adapter.py @@ -112,6 +112,19 @@ def test_rejects_non_tensor_result(self) -> None: with pytest.raises(TypeError, match=r"torch.Tensor"): tio.CornucopiaAdapter(lambda *tensors: "not a tensor")(subject) + def test_copy_false_allows_in_place_transform(self) -> None: + batch = tio.SubjectsBatch.from_subjects( + [tio.Subject(t1=tio.ScalarImage(torch.zeros(1, 4, 4, 4)))] + ) + + result = tio.CornucopiaAdapter( + lambda tensor: tensor.add_(1), + copy=False, + )(batch) + + assert torch.all(batch.t1.data == 1) + assert torch.all(result.t1.data == 1) + # ── Real Cornucopia transforms ─────────────────────────────────────── From 1ada2b895e727051d077839bba13f6338f547ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Mon, 13 Jul 2026 23:04:16 +0100 Subject: [PATCH 04/11] Use the public subject metadata mapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/transforms/monai_adapter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/torchio/transforms/monai_adapter.py b/src/torchio/transforms/monai_adapter.py index dea7ac0a6..abd3a9fb1 100644 --- a/src/torchio/transforms/monai_adapter.py +++ b/src/torchio/transforms/monai_adapter.py @@ -197,7 +197,7 @@ def _apply_dict_transform( _update_image_from_result(image, value, monai) for key in subject.metadata: - subject._metadata[key] = result[key] + subject.metadata[key] = result[key] for key in set(result) - set(monai_dict): if not isinstance(key, str): @@ -211,4 +211,4 @@ def _apply_dict_transform( " before applying the adapter." ) raise ValueError(msg) - subject._metadata[key] = value + subject.metadata[key] = value From e33915284a05f23af69775bef66a739997b1518b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Mon, 13 Jul 2026 23:08:59 +0100 Subject: [PATCH 05/11] Preserve MONAI metadata insertion order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/transforms/monai_adapter.py | 4 +++- tests/test_monai_adapter.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/torchio/transforms/monai_adapter.py b/src/torchio/transforms/monai_adapter.py index abd3a9fb1..9516444a5 100644 --- a/src/torchio/transforms/monai_adapter.py +++ b/src/torchio/transforms/monai_adapter.py @@ -199,7 +199,9 @@ def _apply_dict_transform( for key in subject.metadata: subject.metadata[key] = result[key] - for key in set(result) - set(monai_dict): + for key in result: + if key in monai_dict: + continue if not isinstance(key, str): msg = f"Expected MONAI output keys to be strings, got {key!r}" raise TypeError(msg) diff --git a/tests/test_monai_adapter.py b/tests/test_monai_adapter.py index 5d5662aef..4c004c462 100644 --- a/tests/test_monai_adapter.py +++ b/tests/test_monai_adapter.py @@ -116,6 +116,22 @@ def __call__(self, data): with pytest.raises(ValueError, match=r"new tensor field.*new_image"): tio.MonaiAdapter(AddTensor())(subject) + def test_dict_preserves_added_metadata_order(self) -> None: + from monai.transforms import MapTransform + + class AddMetadata(MapTransform): + def __init__(self) -> None: + super().__init__(keys=["t1"]) + + def __call__(self, data): + return {**data, "zeta": 1, "alpha": 2} + + subject = tio.Subject(t1=tio.ScalarImage(torch.rand(1, 8, 8, 8))) + + result = tio.MonaiAdapter(AddMetadata())(subject) + + assert list(result.metadata) == ["zeta", "alpha"] + @pytest.mark.skipif(not HAS_MONAI, reason="MONAI not installed") class TestMonaiAdapterGeneral: From eecfe8317608645c909c81bf3c81c6333d02884a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Tue, 14 Jul 2026 00:01:29 +0100 Subject: [PATCH 06/11] Document lazy transform replay limits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- docs/how-to/custom-transform.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/how-to/custom-transform.md b/docs/how-to/custom-transform.md index 21dc0096f..3970e2b5b 100644 --- a/docs/how-to/custom-transform.md +++ b/docs/how-to/custom-transform.md @@ -167,8 +167,9 @@ 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`, `MonaiAdapter`, and `CornucopiaAdapter` do not expose one -parameter kernel and therefore reject this method. +`SomeOf`, `CropOrPad`, `EnsureShapeMultiple`, `MonaiAdapter`, and +`CornucopiaAdapter` do not expose a compatible exact-parameter kernel +and therefore reject this method. ## Handle annotations safely From be8b90f92b015a15066b94ee8c4272e69581f0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Tue, 14 Jul 2026 00:22:15 +0100 Subject: [PATCH 07/11] Reduce MONAI adapter complexity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/transforms/monai_adapter.py | 60 ++++++++++++++++++------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/src/torchio/transforms/monai_adapter.py b/src/torchio/transforms/monai_adapter.py index 9516444a5..992589408 100644 --- a/src/torchio/transforms/monai_adapter.py +++ b/src/torchio/transforms/monai_adapter.py @@ -169,23 +169,43 @@ def _apply_dict_transform( monai_transform: Callable, monai: ModuleType, ) -> None: + monai_dict = _build_monai_dict(subject, monai) + result = _validate_dict_result(monai_transform(monai_dict), monai_dict) + _update_subject_images(subject, result, monai) + _update_subject_metadata(subject, result, monai_dict) + + +def _build_monai_dict(subject: Subject, monai: ModuleType) -> dict[str, Any]: + """Build a MONAI mapping from subject images and metadata.""" monai_dict: dict[str, Any] = {} for name, image in subject.images.items(): monai_dict[name] = _image_to_meta_tensor(image, monai) for key, value in subject.metadata.items(): monai_dict[key] = value + return monai_dict - result = monai_transform(monai_dict) +def _validate_dict_result( + result: Any, + monai_dict: dict[str, Any], +) -> Mapping: + """Validate the mapping returned by a MONAI dictionary transform.""" if not isinstance(result, Mapping): msg = f"Expected mapping from MONAI dict transform, got {type(result).__name__}" raise TypeError(msg) - missing = set(monai_dict) - set(result) if missing: msg = f"MONAI dictionary transform removed fields: {sorted(missing)}" raise ValueError(msg) + return result + +def _update_subject_images( + subject: Subject, + result: Mapping, + monai: ModuleType, +) -> None: + """Update existing subject images from a MONAI result.""" for name, image in subject.images.items(): value = result[name] if not isinstance(value, torch.Tensor): @@ -196,21 +216,31 @@ def _apply_dict_transform( raise TypeError(msg) _update_image_from_result(image, value, monai) + +def _update_subject_metadata( + subject: Subject, + result: Mapping, + monai_dict: dict[str, Any], +) -> None: + """Update existing metadata and append new scalar fields.""" for key in subject.metadata: subject.metadata[key] = result[key] - for key in result: if key in monai_dict: continue - if not isinstance(key, str): - msg = f"Expected MONAI output keys to be strings, got {key!r}" - raise TypeError(msg) - value = result[key] - if isinstance(value, torch.Tensor): - msg = ( - f"MONAI dictionary transform added new tensor field {key!r}." - " TorchIO cannot infer its image type; add it to the Subject" - " before applying the adapter." - ) - raise ValueError(msg) - subject.metadata[key] = value + _add_subject_metadata(subject, key, result[key]) + + +def _add_subject_metadata(subject: Subject, key: Any, value: Any) -> None: + """Add one new metadata field returned by MONAI.""" + if not isinstance(key, str): + msg = f"Expected MONAI output keys to be strings, got {key!r}" + raise TypeError(msg) + if isinstance(value, torch.Tensor): + msg = ( + f"MONAI dictionary transform added new tensor field {key!r}." + " TorchIO cannot infer its image type; add it to the Subject" + " before applying the adapter." + ) + raise ValueError(msg) + subject.metadata[key] = value From 12f109cadeb6e69c8906e6c40aca9a83967dfcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Tue, 14 Jul 2026 00:28:33 +0100 Subject: [PATCH 08/11] Accept single Cornucopia sequence results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/transforms/cornucopia_adapter.py | 4 ++-- tests/test_cornucopia_adapter.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/torchio/transforms/cornucopia_adapter.py b/src/torchio/transforms/cornucopia_adapter.py index 8b94e670c..d4664c6ea 100644 --- a/src/torchio/transforms/cornucopia_adapter.py +++ b/src/torchio/transforms/cornucopia_adapter.py @@ -124,8 +124,8 @@ def _apply_cornucopia( # 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: + # A single input may return either one tensor or a one-item sequence. + if len(names) == 1 and not isinstance(results, (tuple, list)): results = (results,) for name, result_tensor in zip(names, results, strict=True): diff --git a/tests/test_cornucopia_adapter.py b/tests/test_cornucopia_adapter.py index 5f60836f9..b768aecf8 100644 --- a/tests/test_cornucopia_adapter.py +++ b/tests/test_cornucopia_adapter.py @@ -125,6 +125,20 @@ def test_copy_false_allows_in_place_transform(self) -> None: assert torch.all(batch.t1.data == 1) assert torch.all(result.t1.data == 1) + @pytest.mark.parametrize( + "transform", + [ + lambda tensor: (tensor,), + lambda tensor: [tensor], + ], + ) + def test_single_image_accepts_sequence_result(self, transform) -> None: + subject = tio.Subject(t1=tio.ScalarImage(torch.rand(1, 4, 4, 4))) + + result = tio.CornucopiaAdapter(transform)(subject) + + torch.testing.assert_close(result.t1.data, subject.t1.data) + # ── Real Cornucopia transforms ─────────────────────────────────────── From 9db2f2c4b24da5c135428a2a63e53ae15c7f62bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Tue, 14 Jul 2026 00:35:13 +0100 Subject: [PATCH 09/11] Validate multi-image Cornucopia results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/transforms/cornucopia_adapter.py | 24 ++++++++++++++++---- tests/test_cornucopia_adapter.py | 10 ++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/torchio/transforms/cornucopia_adapter.py b/src/torchio/transforms/cornucopia_adapter.py index d4664c6ea..8299362fd 100644 --- a/src/torchio/transforms/cornucopia_adapter.py +++ b/src/torchio/transforms/cornucopia_adapter.py @@ -123,10 +123,7 @@ def _apply_cornucopia( # Cornucopia transforms accept multiple tensors as *args # and return the same number of tensors. results = cornucopia_transform(*tensors) - - # A single input may return either one tensor or a one-item sequence. - if len(names) == 1 and not isinstance(results, (tuple, list)): - results = (results,) + results = _normalize_results(results, len(names)) for name, result_tensor in zip(names, results, strict=True): if not isinstance(result_tensor, torch.Tensor): @@ -138,6 +135,25 @@ def _apply_cornucopia( 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: + return results if isinstance(results, (tuple, list)) else (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( images: dict[str, Image], include: list[str] | None, diff --git a/tests/test_cornucopia_adapter.py b/tests/test_cornucopia_adapter.py index b768aecf8..af0151453 100644 --- a/tests/test_cornucopia_adapter.py +++ b/tests/test_cornucopia_adapter.py @@ -107,10 +107,10 @@ def test_probability_zero_when_random_draw_is_zero( torch.testing.assert_close(result.t1.data, original) def test_rejects_non_tensor_result(self) -> None: - subject = _make_subject() + subject = tio.Subject(t1=tio.ScalarImage(torch.rand(1, 4, 4, 4))) with pytest.raises(TypeError, match=r"torch.Tensor"): - tio.CornucopiaAdapter(lambda *tensors: "not a tensor")(subject) + tio.CornucopiaAdapter(lambda tensor: "not a tensor")(subject) def test_copy_false_allows_in_place_transform(self) -> None: batch = tio.SubjectsBatch.from_subjects( @@ -139,6 +139,12 @@ def test_single_image_accepts_sequence_result(self, transform) -> None: torch.testing.assert_close(result.t1.data, subject.t1.data) + def test_multiple_images_reject_single_tensor_result(self) -> None: + subject = _make_subject() + + with pytest.raises(TypeError, match="tuple or list with 2 image results"): + tio.CornucopiaAdapter(lambda *tensors: tensors[0])(subject) + # ── Real Cornucopia transforms ─────────────────────────────────────── From 27ce57c56e0e022aedb1016b54ce4f894e56c060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Tue, 14 Jul 2026 00:38:36 +0100 Subject: [PATCH 10/11] Validate single Cornucopia result arity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/transforms/cornucopia_adapter.py | 7 ++++++- tests/test_cornucopia_adapter.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/torchio/transforms/cornucopia_adapter.py b/src/torchio/transforms/cornucopia_adapter.py index 8299362fd..ef88dd40d 100644 --- a/src/torchio/transforms/cornucopia_adapter.py +++ b/src/torchio/transforms/cornucopia_adapter.py @@ -141,7 +141,12 @@ def _normalize_results( ) -> tuple[Any, ...] | list[Any]: """Normalize Cornucopia outputs and validate their arity.""" if num_images == 1: - return results if isinstance(results, (tuple, list)) else (results,) + 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," diff --git a/tests/test_cornucopia_adapter.py b/tests/test_cornucopia_adapter.py index af0151453..ca2902be2 100644 --- a/tests/test_cornucopia_adapter.py +++ b/tests/test_cornucopia_adapter.py @@ -145,6 +145,12 @@ def test_multiple_images_reject_single_tensor_result(self) -> None: with pytest.raises(TypeError, match="tuple or list with 2 image results"): tio.CornucopiaAdapter(lambda *tensors: tensors[0])(subject) + def test_single_image_rejects_wrong_result_count(self) -> None: + subject = tio.Subject(t1=tio.ScalarImage(torch.rand(1, 4, 4, 4))) + + with pytest.raises(ValueError, match="Expected 1 image result, got 2"): + tio.CornucopiaAdapter(lambda tensor: (tensor, tensor))(subject) + # ── Real Cornucopia transforms ─────────────────────────────────────── From 8e30d6ef0a44f394984befed86b955bf37f71841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Tue, 14 Jul 2026 00:38:57 +0100 Subject: [PATCH 11/11] Clarify subject mapping copy semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: baf5f11a-f67c-44dc-804e-6be849fa9160 --- src/torchio/data/batch.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/torchio/data/batch.py b/src/torchio/data/batch.py index b0779aa08..e0eaa8868 100644 --- a/src/torchio/data/batch.py +++ b/src/torchio/data/batch.py @@ -490,11 +490,12 @@ def map_subjects( ) -> 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. By default, image tensors - are cloned before the callback so the input batch is unchanged. + 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`.