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..3970e2b5b --- /dev/null +++ b/docs/how-to/custom-transform.md @@ -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. diff --git a/src/torchio/data/batch.py b/src/torchio/data/batch.py index 2c51f8f29..e0eaa8868 100644 --- a/src/torchio/data/batch.py +++ b/src/torchio/data/batch.py @@ -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. @@ -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 = ( diff --git a/src/torchio/transforms/cornucopia_adapter.py b/src/torchio/transforms/cornucopia_adapter.py index 4fc64f3ad..ef88dd40d 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, copy=False) return unwrap(result) def apply_transform( @@ -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( diff --git a/src/torchio/transforms/monai_adapter.py b/src/torchio/transforms/monai_adapter.py index 298a006d0..992589408 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, copy=False) return unwrap(result) def apply_transform(self, batch: Any, params: dict[str, Any]) -> Any: @@ -171,18 +169,78 @@ 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(): - 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) + + +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 + _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 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_cornucopia_adapter.py b/tests/test_cornucopia_adapter.py index 6d54f581f..ca2902be2 100644 --- a/tests/test_cornucopia_adapter.py +++ b/tests/test_cornucopia_adapter.py @@ -81,6 +81,76 @@ 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 = tio.Subject(t1=tio.ScalarImage(torch.rand(1, 4, 4, 4))) + + with pytest.raises(TypeError, match=r"torch.Tensor"): + tio.CornucopiaAdapter(lambda tensor: "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) + + @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) + + 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) + + 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 ─────────────────────────────────────── diff --git a/tests/test_monai_adapter.py b/tests/test_monai_adapter.py index f2be5ac79..4c004c462 100644 --- a/tests/test_monai_adapter.py +++ b/tests/test_monai_adapter.py @@ -82,6 +82,56 @@ 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) + + 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: @@ -116,3 +166,46 @@ 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) + + 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) 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",