|
3 | 3 | import inspect |
4 | 4 | import logging |
5 | 5 | import os |
| 6 | +from functools import partial |
6 | 7 | from typing import Callable, Literal, Sequence |
7 | 8 |
|
8 | 9 | import numpy as np |
@@ -70,6 +71,36 @@ def _center_crop_to_shape(tensor: Tensor, spatial_shape: tuple[int, ...]) -> Ten |
70 | 71 | return tensor[tuple(slices)] |
71 | 72 |
|
72 | 73 |
|
| 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 | + |
73 | 104 | class MaskedMSELoss(nn.Module): |
74 | 105 | """Masked MSE loss for FCMAE pre-training.""" |
75 | 106 |
|
@@ -603,6 +634,40 @@ def __init__( |
603 | 634 | self._inverse_transforms = inverse_transforms or [_identity] |
604 | 635 | self._reduction = reduction |
605 | 636 |
|
| 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 | + |
606 | 671 | def forward(self, x: Tensor) -> Tensor: |
607 | 672 | """Run forward pass through the model. |
608 | 673 |
|
@@ -659,9 +724,15 @@ def _predict_with_tta(self, source: Tensor) -> Tensor: |
659 | 724 | preds = [] |
660 | 725 | for fwd_t, inv_t in zip(self._forward_transforms, self._inverse_transforms): |
661 | 726 | 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:] |
662 | 733 | aug_source = self._predict_pad(aug_source) |
663 | 734 | pred = self.forward(aug_source) |
664 | | - pred = _center_crop_to_shape(pred, source.shape[2:]) |
| 735 | + pred = _center_crop_to_shape(pred, aug_shape) |
665 | 736 | preds.append(inv_t(pred)) |
666 | 737 | if len(preds) == 1: |
667 | 738 | return preds[0] |
|
0 commit comments