Skip to content

Commit 674dbee

Browse files
ieivanovclaudeCopilot
authored
feat(cytoland): non-square rotation TTA + reuse helpers (#450)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 676e254 commit 674dbee

5 files changed

Lines changed: 129 additions & 3 deletions

File tree

applications/cytoland/src/cytoland/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
FcmaeUNet,
66
MaskedMSELoss,
77
VSUNet,
8+
rotation_tta_transforms,
89
)
910
from cytoland.evaluation import SegmentationMetrics2D
1011

@@ -14,4 +15,5 @@
1415
"MaskedMSELoss",
1516
"SegmentationMetrics2D",
1617
"VSUNet",
18+
"rotation_tta_transforms",
1719
]

applications/cytoland/src/cytoland/engine.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44
import logging
55
import os
6+
from functools import partial
67
from typing import Callable, Literal, Sequence
78

89
import numpy as np
@@ -70,6 +71,36 @@ def _center_crop_to_shape(tensor: Tensor, spatial_shape: tuple[int, ...]) -> Ten
7071
return tensor[tuple(slices)]
7172

7273

74+
def rotation_tta_transforms(
75+
n: int = 4,
76+
) -> tuple[list[Callable[[Tensor], Tensor]], list[Callable[[Tensor], Tensor]]]:
77+
"""Build forward/inverse 90-degree rotation transforms for test-time augmentation.
78+
79+
Returns ``(forward_transforms, inverse_transforms)`` suitable for
80+
:class:`AugmentedPredictionVSUNet`. Each forward transform rotates the YX
81+
plane by ``k * 90`` degrees (``k = 0 .. n-1``) and the matching inverse
82+
rotates back. Combined with ``reduction="median"`` this reproduces the
83+
rotation TTA used by :meth:`VSUNet.perform_test_time_augmentations`, and
84+
(unlike passing rotations through other code paths) works for non-square
85+
fields of view.
86+
87+
Parameters
88+
----------
89+
n : int, optional
90+
Number of 90-degree rotations, by default 4 (0, 90, 180, 270 degrees).
91+
92+
Returns
93+
-------
94+
tuple[list[Callable], list[Callable]]
95+
The forward and inverse transform lists.
96+
"""
97+
if n < 1:
98+
raise ValueError(f"n must be >= 1, got {n}")
99+
forward = [partial(torch.rot90, k=k, dims=(-2, -1)) for k in range(n)]
100+
inverse = [partial(torch.rot90, k=-k, dims=(-2, -1)) for k in range(n)]
101+
return forward, inverse
102+
103+
73104
class MaskedMSELoss(nn.Module):
74105
"""Masked MSE loss for FCMAE pre-training."""
75106

@@ -603,6 +634,40 @@ def __init__(
603634
self._inverse_transforms = inverse_transforms or [_identity]
604635
self._reduction = reduction
605636

637+
@classmethod
638+
def with_rotation_tta(
639+
cls,
640+
model: nn.Module,
641+
n_rotations: int = 4,
642+
reduction: Literal["mean", "median"] = "median",
643+
) -> "AugmentedPredictionVSUNet":
644+
"""Build a predictor that applies 90-degree rotation test-time augmentation.
645+
646+
Convenience constructor that wires :func:`rotation_tta_transforms` into
647+
the forward/inverse transform lists, so callers do not have to build
648+
them by hand. Works for non-square fields of view.
649+
650+
Parameters
651+
----------
652+
model : nn.Module
653+
The model to wrap.
654+
n_rotations : int, optional
655+
Number of 90-degree rotations, by default 4.
656+
reduction : {"mean", "median"}, optional
657+
How to aggregate the rotated predictions, by default "median".
658+
659+
Returns
660+
-------
661+
AugmentedPredictionVSUNet
662+
"""
663+
forward_transforms, inverse_transforms = rotation_tta_transforms(n_rotations)
664+
return cls(
665+
model=model,
666+
forward_transforms=forward_transforms,
667+
inverse_transforms=inverse_transforms,
668+
reduction=reduction,
669+
)
670+
606671
def forward(self, x: Tensor) -> Tensor:
607672
"""Run forward pass through the model.
608673
@@ -659,9 +724,15 @@ def _predict_with_tta(self, source: Tensor) -> Tensor:
659724
preds = []
660725
for fwd_t, inv_t in zip(self._forward_transforms, self._inverse_transforms):
661726
aug_source = fwd_t(source)
727+
# Crop back to the augmented (post-forward-transform) spatial shape,
728+
# not the original one: a shape-changing transform such as a 90/270
729+
# degree rotation swaps Y and X, so the prediction lives in the
730+
# augmented frame until ``inv_t`` undoes the transform. Cropping to
731+
# ``source.shape[2:]`` here would be wrong for non-square inputs.
732+
aug_shape = aug_source.shape[2:]
662733
aug_source = self._predict_pad(aug_source)
663734
pred = self.forward(aug_source)
664-
pred = _center_crop_to_shape(pred, source.shape[2:])
735+
pred = _center_crop_to_shape(pred, aug_shape)
665736
preds.append(inv_t(pred))
666737
if len(preds) == 1:
667738
return preds[0]

applications/cytoland/tests/test_engine.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,46 @@ def test_predict_sliding_windows_missing_out_stack_depth():
206206
vs = AugmentedPredictionVSUNet(model=model)
207207
with pytest.raises(ValueError, match="out_stack_depth"):
208208
vs.predict_sliding_windows(torch.randn(1, 1, 10, 4, 4))
209+
210+
211+
def test_rotation_tta_transforms():
212+
"""Verify the rotation TTA factory builds matched forward/inverse rotations."""
213+
from cytoland.engine import rotation_tta_transforms
214+
215+
forward, inverse = rotation_tta_transforms()
216+
assert len(forward) == len(inverse) == 4
217+
x = torch.randn(1, 1, 5, 6, 8) # non-square YX
218+
for fwd_t, inv_t in zip(forward, inverse):
219+
# inverse(forward(x)) is the identity and restores the original shape
220+
assert torch.allclose(inv_t(fwd_t(x)), x)
221+
222+
223+
@pytest.mark.parametrize("yx", [(64, 64), (64, 48), (48, 64)])
224+
def test_predict_sliding_windows_rotation_tta_nonsquare(yx):
225+
"""Verify rotation TTA + sliding windows works for non-square FOVs.
226+
227+
Regression test: ``_predict_with_tta`` must crop to the augmented (rotated)
228+
shape, otherwise 90/270-degree rotations on non-square inputs produce
229+
mismatched shapes and fail to reduce.
230+
"""
231+
z_window, depth, out_channels = 5, 8, 2
232+
height, width = yx
233+
model = VSUNet(
234+
architecture="fcmae",
235+
model_config={
236+
"in_channels": 1,
237+
"out_channels": out_channels,
238+
"encoder_blocks": [2, 2, 2, 2],
239+
"dims": [4, 8, 16, 32],
240+
"decoder_conv_blocks": 1,
241+
"stem_kernel_size": [z_window, 4, 4],
242+
"in_stack_depth": z_window,
243+
"pretraining": False,
244+
},
245+
)
246+
vs = AugmentedPredictionVSUNet.with_rotation_tta(model.model, reduction="median").eval()
247+
x = torch.randn(1, 1, depth, height, width)
248+
with torch.inference_mode():
249+
output = vs.predict_sliding_windows(x, out_channel=out_channels, step=1)
250+
assert output.shape == (1, out_channels, depth, height, width)
251+
assert torch.isfinite(output).all()

packages/viscy-data/src/viscy_data/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
except ImportError:
7373
pass
7474

75+
# Normalization metadata reader (from _utils.py)
76+
from viscy_data._utils import read_norm_meta
77+
7578
# Channel dropout augmentation (from channel_dropout.py)
7679
from viscy_data.channel_dropout import ChannelDropout
7780

@@ -150,6 +153,7 @@
150153
"ChannelDropout",
151154
# Utilities
152155
"FlexibleBatchSampler",
156+
"read_norm_meta",
153157
"SelectWell",
154158
"ShardedDistributedSampler",
155159
# Core

packages/viscy-data/src/viscy_data/_utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
This module centralizes helper functions that are used by multiple data modules:
44
- From ``hcs.py``: ``_ensure_channel_list``, ``_search_int_in_str``,
5-
``_collate_samples``, ``_read_norm_meta``
5+
``_collate_samples``, ``read_norm_meta``
66
- From ``triplet.py``: ``_scatter_channels``, ``_gather_channels``,
77
``_transform_channel_wise``
88
"""
@@ -24,7 +24,7 @@
2424
"_collate_samples",
2525
"_ensure_channel_list",
2626
"_gather_channels",
27-
"_read_norm_meta",
27+
"read_norm_meta",
2828
"_scatter_channels",
2929
"_search_int_in_str",
3030
"_transform_channel_wise",
@@ -165,6 +165,12 @@ def _read_norm_meta(fov: Position) -> NormMeta | None:
165165
return norm_meta
166166

167167

168+
# Public alias re-exported as ``viscy_data.read_norm_meta``; the canonical
169+
# definition keeps its private name since this is a private ``_utils`` module
170+
# and all internal callers import ``_read_norm_meta``.
171+
read_norm_meta = _read_norm_meta
172+
173+
168174
def _collate_norm_meta(norm_metas: list[NormMeta]) -> NormMeta:
169175
"""Stack per-sample norm_meta dicts into batched tensors.
170176

0 commit comments

Comments
 (0)