diff --git a/batching-redesign.md b/batching-redesign.md new file mode 100644 index 000000000..1adfa6841 --- /dev/null +++ b/batching-redesign.md @@ -0,0 +1,280 @@ +# TorchIO batching redesign + +## Executive summary + +The current batching system is too complicated, but the core idea is sound. +TorchIO should remain a **struct of arrays**: + +- image fields are stacked into 5D tensors `(B, C, I, J, K)`; +- metadata and ragged annotations remain Python lists; +- transforms operate once on the batch and return the same input type. + +The accidental complexity comes from representing the same concepts in multiple +ways: + +- `ImagesBatch` may or may not have image templates; +- batch history may be shared or per-element; +- per-element history is reconstructed later from private keys embedded in + transform parameters; +- `OneOf`, `SomeOf`, adapters, inversion, and unbatching each need special + history handling; +- raw constructors expose internal assembly details such as + `image_templates`. + +The redesign keeps the useful features but enforces one representation per +concept: + +1. Every image batch always has private per-element prototypes. +2. Every batch element always has its own exact transform history. +3. Public batches are created through factories, not internal constructors. +4. Images remain stacked; metadata, points, and boxes remain explicit typed + stores. + +This avoids replacing simple dictionaries with a generic proxy framework. The +explicit stores are not the main problem; the optional state and history +duality are. + +## Features that remain + +| Feature | Redesign | +| --- | --- | +| Vectorized GPU transforms | Image fields remain 5D `ImagesBatch` tensors. | +| Exact `Subject -> batch -> Subject` round-trip | Private image prototypes and explicit object stores preserve payload. | +| Custom `Image` subclasses | Prototypes rebuild images through `Image.new_like()`. | +| Image metadata and image-level annotations | Stored in each private image prototype. | +| Subject metadata, points, and boxes | Remain explicit dictionaries of per-element lists. | +| Metadata-only and annotation-only batches | Batch size comes from stored elements, not the first image. | +| Mutable `batch.metadata` | Remains a real dictionary of live lists; no proxy mapping is introduced. | +| `map_subjects()` | Unbatches exact rows, invokes the callback, and re-batches through the same schema path. | +| Per-instance transforms and probability | Parameters are still sampled per element where supported. | +| `OneOf` / `SomeOf` per-element branches | Exact per-element histories make branching ordinary rather than special. | +| Inversion | Uniform histories use the vectorized batch inverse; divergent histories use per-element inversion. | +| Exact replay | `apply_with_params()` remains, but internal batch bookkeeping is not persisted in history. | + +## New public model + +### Construction + +The lossless factories become the primary public API: + +```python +images = tio.ImagesBatch.from_images(image_list) +images = tio.ImagesBatch.from_tensor( + data, + affines, + image_class=tio.ScalarImage, +) + +subjects = tio.SubjectsBatch.from_subjects(subject_list) +``` + +Raw constructors that expose internal assembly details are removed from the +public API. In particular: + +- `ImagesBatch(..., image_templates=...)` is removed; +- `SubjectsBatch(images, points=..., bounding_boxes=..., metadata=...)` becomes + a private `_from_parts()` constructor for TorchIO internals. + +### Image batches + +`ImagesBatch` has one invariant: + +```text +data + affines + one private prototype per element +``` + +Prototypes are always present. `from_tensor()` synthesizes minimal prototypes; +`from_images()` derives them from the input images. This removes: + +- the `templates is None` branches; +- the duplicated `_image_class` state; +- the public `image_templates` argument. + +`image_class` and `is_label` become public derived properties. + +### Subject batches + +`SubjectsBatch` keeps the explicit stores: + +```text +images: dict[str, ImagesBatch] +points: dict[str, list[Points]] +bounding_boxes: dict[str, list[BoundingBoxes]] +metadata: dict[str, list[Any]] +``` + +These dictionaries already provide the simplest correct mutation semantics. +A generic `EntryStore` plus filtered mutable proxies would add indirection and +new aliasing rules without removing meaningful complexity. + +Construction is driven by one private `SubjectSchema`: + +```text +SubjectSchema + ordered image specifications + metadata keys + point specifications + bounding-box specifications +``` + +The schema is derived from the first subject, validates every later subject, and +drives stacking and unbatching. This replaces the collection of loosely coupled +validation helpers with one explicit invariant. + +## Simpler history model + +### Current model + +Today a batch has: + +```text +applied_transforms +optional _per_element_history +``` + +Per-instance parameters are stored once with private fields: + +```text +_batch_size +_batched_keys +_keep +``` + +Unbatching later interprets those fields to reconstruct each element's history. +This makes history handling leak into transforms, inversion, composition, +adapters, and replay validation. + +### New model + +Every batch stores exact histories directly: + +```python +batch.histories: list[list[AppliedTransform]] +batch.history(index) +``` + +When a transform finishes: + +1. Its transient parameter dictionary is split into one clean parameter + dictionary per element. +2. Gated-out elements receive no trace. +3. Each element history receives its exact `AppliedTransform`. + +Private batching keys may still exist transiently while built-in transforms are +migrated, but they are never persisted in public history. + +Every element receives an independent trace record, including uniform +applications. This avoids cross-element aliasing through mutable parameter +dictionaries and keeps the exact-history model simple. + +This removes: + +- `_per_element_history`; +- `set_per_element_history()`; +- `adopt_history()`; +- read-time `_slice_history()`; +- special `OneOf` / `SomeOf` history freezing; +- batch-level history parameters containing `_batch_size`, `_batched_keys`, or + `_keep`. + +### Inversion + +Vectorization is retained: + +- if every element history is equal, build one inverse `Compose` and apply it to + the whole batch; +- if histories differ, invert each element and re-batch when schemas and shapes + remain compatible. + +`get_inverse_transform()` is available only for uniform histories. +`apply_inverse_transform()` handles both cases. + +## What is deliberately not introduced + +### No list-of-subjects with lazy tensor stacking + +That model looks elegant, but in-place tensor mutation makes cache invalidation +and scatter-back semantics difficult. It moves complexity into invisible proxy +behavior and risks losing vectorized performance. + +### No generic mutable column proxy framework + +Metadata must support operations such as: + +```python +batch.metadata["age"][0] = 42 +batch.metadata["site"] = ["A", "B"] +``` + +Plain dictionaries of live lists already implement this correctly. A filtered +`MutableMapping` proxy would need custom assignment, deletion, ordering, +namespace, and aliasing rules. + +### No always-per-element inversion + +Uniform histories are common and can be inverted efficiently on a 5D batch. +The redesign keeps that fast path. + +## Expected simplification + +The redesign removes or consolidates: + +- duplicated history methods across `ImagesBatch` and `SubjectsBatch`; +- optional-template branches; +- public internal-construction arguments; +- shared/per-element history duality; +- read-time history slicing; +- special composition/adaptor history handling; +- private batch metadata persisted in `AppliedTransform.params`. + +The target is a 25–35% reduction in the batching/history implementation, but +the more important improvement is conceptual: + +```text +one image representation +one schema representation +one history representation +``` + +## Replacement PR stack + +The current open PRs #1494–#1496 should be superseded rather than force-rewritten +again. Keep them open until replacement PRs exist, then close them with links to +the new stack. + +0. [#1500 Fix padding mode type narrowing](https://github.com/TorchIO-project/torchio/pull/1500) + - independent prerequisite restoring a clean `prek` baseline. +1. [#1501 Redesign batch construction and schema](https://github.com/TorchIO-project/torchio/pull/1501) + - add `ImagesBatch.from_tensor()`; + - make image prototypes private and mandatory; + - add public `image_class` / `is_label`; + - make `SubjectsBatch.from_subjects()` the public construction path; + - centralize schema validation. +2. [#1502 Store exact history per batch element](https://github.com/TorchIO-project/torchio/pull/1502) + - add the new history container/API; + - split transform params eagerly when recording; + - retain vectorized inversion for uniform histories; + - remove shared/per-element history duality and persisted private keys. +3. [#1503 Integrate subject mapping and exact replay](https://github.com/TorchIO-project/torchio/pull/1503) + - simplify wrapping/unwrapping; + - remove special `OneOf` / `SomeOf` history handling; + - simplify `map_subjects()`; + - migrate MONAI and Cornucopia adapters; + - keep exact replay with transient validation only. +4. **Documentation, migration, and performance validation** + - update the data-model, transform, migration, loader, and custom-transform + documentation; + - document all public arguments; + - add CPU/GPU batch and unbatch benchmarks; + - close #1494–#1496 as superseded with links. + +## Success criteria + +- All existing user-visible features in the table above remain. +- No public history contains private batch bookkeeping fields. +- `ImagesBatch` has no optional prototype mode. +- Batch constructors no longer expose internal payload arguments. +- Uniform inverse transforms remain vectorized. +- Metadata mutation remains direct and unsurprising. +- Full tests, type checking, Ruff, docs tests/build, `prek`, and Xenon pass. +- Benchmarks show no material regression in vectorized transform throughput. diff --git a/benchmarks/batching.py b/benchmarks/batching.py new file mode 100644 index 000000000..e7d135a45 --- /dev/null +++ b/benchmarks/batching.py @@ -0,0 +1,114 @@ +"""Benchmark core TorchIO batching operations.""" + +from __future__ import annotations + +import time +from collections.abc import Callable + +import torch +from rich.console import Console +from rich.table import Table + +import torchio as tio + +_BATCH_SIZE = 8 +_SPATIAL_SHAPE = 64, 64, 64 +_ITERATIONS = 10 + + +def _make_subjects( + batch_size: int, + spatial_shape: tuple[int, int, int], + device: torch.device, +) -> list[tio.Subject]: + """Build representative image and metadata subjects.""" + data = torch.rand(1, *spatial_shape, device=device) + return [ + tio.Subject( + image=tio.ScalarImage(data.clone()), + age=40 + index, + ) + for index in range(batch_size) + ] + + +def _measure( + operation: Callable[[], object], + iterations: int, + device: torch.device, +) -> float: + """Return average operation time in milliseconds.""" + for _ in range(2): + operation() + if device.type == "cuda": + torch.cuda.synchronize(device) + start = time.perf_counter() + for _ in range(iterations): + operation() + if device.type == "cuda": + torch.cuda.synchronize(device) + return (time.perf_counter() - start) * 1000 / iterations + + +def _benchmark_device(device: torch.device) -> dict[str, float]: + """Benchmark batching operations on one device.""" + subjects = _make_subjects(_BATCH_SIZE, _SPATIAL_SHAPE, device) + batch = tio.SubjectsBatch.from_subjects(subjects) + transform = tio.Gamma(log_gamma=(0.2, 0.8)) + uniform_transformed = tio.Gamma(log_gamma=0.3, per_instance=False)(batch) + metadata_subjects = [tio.Subject(age=index) for index in range(_BATCH_SIZE)] + + results = { + "construct": _measure( + lambda: tio.SubjectsBatch.from_subjects(subjects), + _ITERATIONS, + device, + ), + "unbatch": _measure(batch.unbatch, _ITERATIONS, device), + "vectorized transform": _measure( + lambda: transform(batch), + _ITERATIONS, + device, + ), + "uniform inverse": _measure( + uniform_transformed.apply_inverse_transform, + _ITERATIONS, + device, + ), + } + if device.type == "cpu": + results["metadata-only"] = _measure( + lambda: tio.SubjectsBatch.from_subjects(metadata_subjects), + _ITERATIONS, + torch.device("cpu"), + ) + return results + + +def main() -> None: + """Print CPU and optional CUDA batching benchmarks.""" + devices = [torch.device("cpu")] + if torch.cuda.is_available(): + devices.append(torch.device("cuda")) + + table = Table(title="TorchIO batching benchmarks") + table.add_column("Operation") + for device in devices: + table.add_column(f"{device.type} (ms)", justify="right") + + results = {device.type: _benchmark_device(device) for device in devices} + for operation in results["cpu"]: + table.add_row( + operation, + *( + f"{results[device.type][operation]:.2f}" + if operation in results[device.type] + else "—" + for device in devices + ), + ) + Console().print(table) + + +if __name__ == "__main__": + main() diff --git a/docs/concepts/data-model.md b/docs/concepts/data-model.md index 91a5e77d3..ed3a19c92 100644 --- a/docs/concepts/data-model.md +++ b/docs/concepts/data-model.md @@ -300,7 +300,28 @@ for batch in loader: ``` Each `ImagesBatch` stores per-sample affine matrices, so subjects -with different spatial properties batch correctly. +with different spatial properties batch correctly. It also stores one +private image prototype per element so unbatching preserves image +subclasses, metadata, and annotations. + +Create batches through the lossless factories: + +```python +import torch +import torchio as tio + +images = [tio.ScalarImage(torch.zeros(1, 2, 3, 4)) for _ in range(2)] +tensor_5d = torch.zeros(2, 1, 2, 3, 4) +subjects = [tio.Subject(image=image) for image in images] + +image_batch = tio.ImagesBatch.from_images(images) +tensor_batch = tio.ImagesBatch.from_tensor(tensor_5d) +subject_batch = tio.SubjectsBatch.from_subjects(subjects) +``` + +Metadata-only and annotation-only subjects are supported. Every subject +in one batch must share the same field schema, but point and bounding-box +counts may differ because annotations are stored as per-element objects. Transforms work directly on batches. By default, transforms that support it sample independent parameters per batch element (see @@ -310,3 +331,22 @@ support it sample independent parameters per batch element (see ```python augmented = tio.Flip(axes=(0,))(batch) ``` + +Each batch element carries its own exact transform history: + +```python +import torch +import torchio as tio + +batch = tio.SubjectsBatch.from_subjects([ + tio.Subject(image=tio.ScalarImage(torch.zeros(1, 2, 3, 4))), + tio.Subject(image=tio.ScalarImage(torch.ones(1, 2, 3, 4))), +]) +augmented = tio.Flip(axes=(0,))(batch) + +first_history = augmented.history(0) +all_histories = augmented.histories +``` + +Uniform histories can be inverted as one vectorized batch. Divergent +histories are inverted element by element. diff --git a/docs/concepts/transforms.md b/docs/concepts/transforms.md index 7dd618c0b..b6b38a600 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,49 @@ 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. + +### Batch histories + +A batch stores one exact history per element: + +```python +import torch +import torchio as tio + +batch = tio.SubjectsBatch.from_subjects([ + tio.Subject(image=tio.ScalarImage(torch.zeros(1, 2, 3, 4))), + tio.Subject(image=tio.ScalarImage(torch.ones(1, 2, 3, 4))), +]) +result = tio.Gamma(log_gamma=(0.2, 0.8))(batch) + +assert len(result.histories) == 2 +assert isinstance(result.history(0)[-1].params["log_gamma"], float) +assert "_batched_keys" not in result.history(0)[-1].params +``` + +The batch history contains no private batching fields. When all element +histories match, inversion remains vectorized over the 5D batch. When +histories differ (for example after per-element `OneOf`), inversion is +performed per element and the results are re-batched. + +For compatibility, a batch with uniform histories exposes an immutable +`applied_transforms` tuple. Use `histories` or `history(index)` for +batch code; `Subject.applied_transforms` remains a mutable list. + ## 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 c4b61cf48..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 @@ -358,9 +366,8 @@ for the capability contract and stochastic-realisation caveats. ### Migrate inherently per-subject logic Prefer vectorized operations on 5D tensors or metadata lists. If logic -must call a subject-oriented external API, the current low-level escape -hatch is to unbatch, process every subject without changing its schema, -and restack. Exact per-element histories are preserved automatically: +must call a subject-oriented external API, use +`SubjectsBatch.map_subjects()`: ```python from typing import Any @@ -382,11 +389,12 @@ class StripIdentifier(tio.Transform): params: dict[str, Any], ) -> tio.SubjectsBatch: """Process metadata one subject at a time.""" - subjects = batch.unbatch() - for subject in subjects: - identifier = subject.metadata["identifier"] - subject.metadata["identifier"] = identifier.strip() - return tio.SubjectsBatch.from_subjects(subjects) + return batch.map_subjects(self._strip_identifier) + + @staticmethod + def _strip_identifier(subject: tio.Subject) -> tio.Subject: + subject.metadata["identifier"] = subject.identifier.strip() + return subject subject = tio.Subject( @@ -397,10 +405,13 @@ result = StripIdentifier()(subject) assert result.identifier == "sub-01" ``` -This pattern is more expensive than vectorized code and requires every -resulting subject to retain a compatible image and metadata schema. A -supported mapping utility is planned, but it is not part of the current -API. +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..a3f37c92e --- /dev/null +++ b/docs/how-to/custom-transform.md @@ -0,0 +1,188 @@ +# 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.IntensityTransform): + """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 self._get_images(batch).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 +exact per-element histories 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. + +For a transformed batch, `batch.history(index)` returns that element's +trace tuple. Read `batch.history(index)[-1].params` for the latest clean +parameter dictionary. Internal batching keys are never persisted in +public history. + +## 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/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",