Skip to content

Commit 56877ff

Browse files
authored
Merge branch 'dev' into 4980-get-wsi-at-mpp
2 parents ecece0d + eb5e8e1 commit 56877ff

14 files changed

Lines changed: 357 additions & 41 deletions

File tree

monai/networks/layers/spatial_transforms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ def forward(
566566
affine=theta,
567567
src_size=src_size[2:],
568568
dst_size=dst_size[2:],
569-
align_corners=False,
569+
align_corners=self.align_corners,
570570
zero_centered=self.zero_centered,
571571
)
572572
if self.reverse_indexing:

monai/transforms/compose.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@
3737
__all__ = ["Compose", "OneOf", "RandomOrder", "SomeOf", "execute_compose"]
3838

3939

40+
def _inverse_one(
41+
t: InvertibleTransform, data: Any, map_items: bool | int, unpack_items: bool, log_stats: bool | str
42+
) -> Any:
43+
"""Invert a single transform, delegating directly to nested ``Compose`` objects.
44+
45+
When ``t`` is a ``Compose`` instance its own ``inverse()`` is called so that
46+
the child's ``map_items`` setting is respected. For all other invertible
47+
transforms, ``apply_transform`` is used with ``lazy=False``.
48+
49+
Args:
50+
t: The invertible transform to invert.
51+
data: Data to be inverted.
52+
map_items: Whether to map over list/tuple items (forwarded to
53+
``apply_transform`` for non-``Compose`` transforms).
54+
unpack_items: Whether to unpack data as parameters.
55+
log_stats: Logger name or boolean for logging.
56+
57+
Returns:
58+
The inverted data.
59+
"""
60+
if isinstance(t, Compose):
61+
return t.inverse(data)
62+
return apply_transform(t.inverse, data, map_items, unpack_items, lazy=False, log_stats=log_stats)
63+
64+
4065
def execute_compose(
4166
data: NdarrayOrTensor | Sequence[NdarrayOrTensor] | Mapping[Any, NdarrayOrTensor],
4267
transforms: Sequence[Any],
@@ -315,20 +340,32 @@ def get_index_of_first(self, predicate):
315340
return None
316341

317342
def flatten(self):
318-
"""Return a Composition with a simple list of transforms, as opposed to any nested Compositions.
343+
"""Return a Composition with a flattened list of transforms.
344+
345+
Nested ``Compose`` objects that share the same ``map_items`` setting as
346+
the parent are inlined. Nested ``Compose`` objects with a *different*
347+
``map_items`` value are kept as-is so their item-mapping behaviour is
348+
preserved at runtime and during inversion.
319349
320350
e.g., `t1 = Compose([x, x, x, x, Compose([Compose([x, x]), x, x])]).flatten()`
321351
will result in the equivalent of `t1 = Compose([x, x, x, x, x, x, x, x])`.
322352
323353
"""
324354
new_transforms = []
325355
for t in self.transforms:
326-
if type(t) is Compose: # nopep8
356+
if type(t) is Compose and t.map_items == self.map_items:
327357
new_transforms += t.flatten().transforms
328358
else:
329359
new_transforms.append(t)
330360

331-
return Compose(new_transforms)
361+
return Compose(
362+
new_transforms,
363+
map_items=self.map_items,
364+
unpack_items=self.unpack_items,
365+
log_stats=self.log_stats,
366+
lazy=self._lazy,
367+
overrides=self.overrides,
368+
)
332369

333370
def __len__(self):
334371
"""Return number of transformations."""
@@ -365,9 +402,7 @@ def inverse(self, data):
365402
)
366403
# loop backwards over transforms
367404
for t in reversed(invertible_transforms):
368-
data = apply_transform(
369-
t.inverse, data, self.map_items, self.unpack_items, lazy=False, log_stats=self.log_stats
370-
)
405+
data = _inverse_one(t, data, self.map_items, self.unpack_items, self.log_stats)
371406
return data
372407

373408
@staticmethod
@@ -622,9 +657,7 @@ def inverse(self, data):
622657
# loop backwards over transforms
623658
for o in reversed(applied_order):
624659
if isinstance(self.transforms[o], InvertibleTransform):
625-
data = apply_transform(
626-
self.transforms[o].inverse, data, self.map_items, self.unpack_items, log_stats=self.log_stats
627-
)
660+
data = _inverse_one(self.transforms[o], data, self.map_items, self.unpack_items, self.log_stats)
628661
return data
629662

630663

@@ -789,8 +822,6 @@ def inverse(self, data):
789822
# loop backwards over transforms
790823
for o in reversed(applied_order):
791824
if isinstance(self.transforms[o], InvertibleTransform):
792-
data = apply_transform(
793-
self.transforms[o].inverse, data, self.map_items, self.unpack_items, log_stats=self.log_stats
794-
)
825+
data = _inverse_one(self.transforms[o], data, self.map_items, self.unpack_items, self.log_stats)
795826

796827
return data

monai/transforms/io/array.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ def __call__(self, filename: Sequence[PathLike] | PathLike, reader: ImageReader
299299
img_array, meta_data, self.simple_keys, pattern=self.pattern, sep=self.sep
300300
)
301301
if self.ensure_channel_first:
302-
img = EnsureChannelFirst()(img)
302+
img = EnsureChannelFirst()(img, meta_dict=meta_data)
303303
if self.image_only:
304304
return img
305305
return img, img.meta if isinstance(img, MetaTensor) else meta_data

monai/transforms/lazy/functional.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import torch
1818

19+
import monai
1920
from monai.apps.utils import get_logger
2021
from monai.config import NdarrayOrTensor
2122
from monai.data.meta_tensor import MetaTensor
@@ -29,7 +30,7 @@
2930
)
3031
from monai.transforms.traits import LazyTrait
3132
from monai.transforms.transform import MapTransform
32-
from monai.utils import LazyAttr, look_up_option
33+
from monai.utils import LazyAttr, TraceKeys, look_up_option
3334

3435
__all__ = ["apply_pending_transforms", "apply_pending_transforms_in_order", "apply_pending"]
3536

@@ -289,6 +290,25 @@ def apply_pending(data: torch.Tensor | MetaTensor, pending: list | None = None,
289290
cumulative_xform = combine_transforms(cumulative_xform, next_matrix)
290291
cur_kwargs.update(new_kwargs)
291292
cur_kwargs.update(override_kwargs)
293+
if len(pending) == 1 and isinstance(pending[0], dict):
294+
p0 = pending[0]
295+
extra_info = p0.get(TraceKeys.EXTRA_INFO)
296+
align_corners = cur_kwargs.get(LazyAttr.ALIGN_CORNERS, False)
297+
if (
298+
isinstance(extra_info, dict)
299+
and "affine" in extra_info
300+
and TraceKeys.ORIG_SIZE in p0
301+
and align_corners not in (False, TraceKeys.NONE)
302+
and not isinstance(cur_kwargs.get(LazyAttr.INTERP_MODE), int)
303+
):
304+
out_size = cur_kwargs.get(LazyAttr.SHAPE, p0.get(LazyAttr.SHAPE, p0[TraceKeys.ORIG_SIZE]))
305+
cumulative_xform = monai.transforms.Affine.compute_w_affine(
306+
len(tuple(p0[TraceKeys.ORIG_SIZE])),
307+
extra_info["affine"],
308+
p0[TraceKeys.ORIG_SIZE],
309+
out_size,
310+
align_corners=True,
311+
)
292312
data = resample(data.to(device), cumulative_xform, cur_kwargs)
293313
if isinstance(data, MetaTensor):
294314
for p in pending:

monai/transforms/lazy/utils.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from monai.config import NdarrayOrTensor
2121
from monai.data.utils import AFFINE_TOL
2222
from monai.transforms.utils_pytorch_numpy_unification import allclose
23-
from monai.utils import LazyAttr, convert_to_numpy, convert_to_tensor, look_up_option
23+
from monai.utils import LazyAttr, TraceKeys, convert_to_numpy, convert_to_tensor, look_up_option
2424

2525
__all__ = ["resample", "combine_transforms"]
2626

@@ -90,7 +90,11 @@ def affine_from_pending(pending_item):
9090

9191

9292
def kwargs_from_pending(pending_item):
93-
"""Extract kwargs from a pending transform item."""
93+
"""Extract kwargs from a pending transform item.
94+
95+
When ``pending_item`` is a dict, ``align_corners`` is also extracted from its ``extra_info`` entry
96+
(if present and boolean) so the lazy pipeline preserves the original transform's alignment.
97+
"""
9498
if not isinstance(pending_item, dict):
9599
return {}
96100
ret = {
@@ -101,7 +105,13 @@ def kwargs_from_pending(pending_item):
101105
ret[LazyAttr.SHAPE] = pending_item[LazyAttr.SHAPE]
102106
if LazyAttr.DTYPE in pending_item:
103107
ret[LazyAttr.DTYPE] = pending_item[LazyAttr.DTYPE]
104-
return ret # adding support of pending_item['extra_info']??
108+
# Extract align_corners from extra_info if available
109+
extra_info = pending_item.get(TraceKeys.EXTRA_INFO)
110+
if isinstance(extra_info, dict) and "align_corners" in extra_info:
111+
align_corners_val = extra_info["align_corners"]
112+
if isinstance(align_corners_val, bool):
113+
ret[LazyAttr.ALIGN_CORNERS] = align_corners_val
114+
return ret
105115

106116

107117
def is_compatible_apply_kwargs(kwargs_1, kwargs_2):

monai/transforms/spatial/array.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,8 @@ def __call__(
540540
if self.recompute_affine and isinstance(data_array, MetaTensor):
541541
if lazy_:
542542
raise NotImplementedError("recompute_affine is not supported with lazy evaluation.")
543-
a = scale_affine(original_spatial_shape, actual_shape)
543+
ac = align_corners if align_corners is not None else self.sp_resample.align_corners
544+
a = scale_affine(original_spatial_shape, actual_shape, align_corners=ac)
544545
data_array.affine = convert_to_dst_type(a, affine_)[0] # type: ignore
545546
return data_array
546547

@@ -2322,12 +2323,22 @@ def __call__(
23222323
)
23232324

23242325
@classmethod
2325-
def compute_w_affine(cls, spatial_rank, mat, img_size, sp_size):
2326+
def compute_w_affine(cls, spatial_rank, mat, img_size, sp_size, align_corners: bool = False):
23262327
r = int(spatial_rank)
23272328
mat = to_affine_nd(r, mat)
23282329
shift_1 = create_translate(r, [float(d - 1) / 2 for d in img_size[:r]])
23292330
shift_2 = create_translate(r, [-float(d - 1) / 2 for d in sp_size[:r]])
2330-
mat = shift_1 @ convert_data_type(mat, np.ndarray)[0] @ shift_2
2331+
mat = convert_data_type(mat, np.ndarray)[0]
2332+
if align_corners:
2333+
# Keep lazy world-affine consistent with eager sampling:
2334+
# x_in = T_in @ S_in^-1 @ A_centered @ S_out @ T_out^-1 @ x_out
2335+
src_scale = create_scale(r, [(max(float(d), 2.0) - 1.0) / max(float(d), 2.0) for d in img_size[:r]])
2336+
dst_scale = create_scale(r, [max(float(d), 2.0) / (max(float(d), 2.0) - 1.0) for d in sp_size[:r]])
2337+
src_scale = convert_data_type(src_scale, np.ndarray)[0]
2338+
dst_scale = convert_data_type(dst_scale, np.ndarray)[0]
2339+
mat = shift_1 @ src_scale @ mat @ dst_scale @ shift_2
2340+
else:
2341+
mat = shift_1 @ mat @ shift_2
23312342
return mat
23322343

23332344
def inverse(self, data: torch.Tensor) -> torch.Tensor:

monai/transforms/spatial/functional.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def resize(
304304
meta_info = TraceableTransform.track_transform_meta(
305305
img,
306306
sp_size=out_size,
307-
affine=scale_affine(orig_size, out_size),
307+
affine=scale_affine(orig_size, out_size, align_corners=align_corners if align_corners is not None else False),
308308
extra_info=extra_info,
309309
orig_size=orig_size,
310310
transform_info=transform_info,
@@ -439,7 +439,7 @@ def zoom(img, scale_factor, keep_size, mode, padding_mode, align_corners, dtype,
439439
"""
440440
im_shape = img.peek_pending_shape() if isinstance(img, MetaTensor) else img.shape[1:]
441441
output_size = [int(math.floor(float(i) * z)) for i, z in zip(im_shape, scale_factor)]
442-
xform = scale_affine(im_shape, output_size)
442+
xform = scale_affine(im_shape, output_size, align_corners=align_corners if align_corners is not None else False)
443443
extra_info = {
444444
"mode": mode,
445445
"align_corners": align_corners if align_corners is not None else TraceKeys.NONE,

monai/transforms/transform.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,13 @@ def apply_transform(
143143
try:
144144
map_items_ = int(map_items) if isinstance(map_items, bool) else map_items
145145
if isinstance(data, (list, tuple)) and map_items_ > 0 and not isinstance(transform, ReduceTrait):
146-
return [
147-
apply_transform(transform, item, map_items_ - 1, unpack_items, log_stats, lazy, overrides)
148-
for item in data
149-
]
146+
# If the transform is a Compose with its own map_items, let it handle list/tuple
147+
# expansion internally so that nested Compose map_items settings are respected.
148+
if not isinstance(transform, transforms.compose.Compose):
149+
return [
150+
apply_transform(transform, item, map_items_ - 1, unpack_items, log_stats, lazy, overrides)
151+
for item in data
152+
]
150153
return _apply_transform(transform, data, unpack_items, lazy, overrides, log_stats)
151154
except Exception as e:
152155
# if in debug mode, don't swallow exception so that the breakpoint

monai/transforms/utils.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2097,14 +2097,16 @@ def convert_to_contiguous(
20972097
return data
20982098

20992099

2100-
def scale_affine(spatial_size, new_spatial_size, centered: bool = True):
2100+
def scale_affine(spatial_size, new_spatial_size, centered: bool = True, align_corners: bool = False):
21012101
"""
21022102
Compute the scaling matrix according to the new spatial size
21032103
21042104
Args:
21052105
spatial_size: original spatial size.
21062106
new_spatial_size: new spatial size.
21072107
centered: whether the scaling is with respect to the image center (True, default) or corner (False).
2108+
Ignored when ``align_corners=True``, since corner-aligned scaling is inherently centered.
2109+
align_corners: if True, use (size-1) based scaling to match torch.nn.functional.interpolate behavior.
21082110
21092111
Returns:
21102112
the scaling matrix.
@@ -2113,9 +2115,19 @@ def scale_affine(spatial_size, new_spatial_size, centered: bool = True):
21132115
r = max(len(new_spatial_size), len(spatial_size))
21142116
if spatial_size == new_spatial_size:
21152117
return np.eye(r + 1)
2116-
s = np.array([float(o) / float(max(n, 1)) for o, n in zip(spatial_size, new_spatial_size)], dtype=float)
2118+
if align_corners:
2119+
# Match interpolate behavior: (src-1)/(dst-1); when dst == 1 the scale collapses to 0
2120+
s = np.array(
2121+
[0.0 if float(n) == 1 else (float(o) - 1) / (float(n) - 1) for o, n in zip(spatial_size, new_spatial_size)],
2122+
dtype=float,
2123+
)
2124+
else:
2125+
# Standard scaling: src/dst
2126+
s = np.array([float(o) / float(max(n, 1)) for o, n in zip(spatial_size, new_spatial_size)], dtype=float)
21172127
scale = create_scale(r, s.tolist())
2118-
if centered:
2128+
if centered and not align_corners:
2129+
# For align_corners=False, add offset to center the scaling
2130+
# For align_corners=True, the scaling is inherently centered (corners map to corners)
21192131
scale[:r, -1] = (np.diag(scale)[:r] - 1) / 2.0 # type: ignore
21202132
return scale
21212133

tests/networks/layers/test_affine_transform.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,21 @@ def test_zoom_1(self):
154154
affine = torch.as_tensor([[2.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
155155
image = torch.arange(1.0, 13.0).view(1, 1, 3, 4).to(device=torch.device("cpu:0"))
156156
out = AffineTransform()(image, affine, (1, 4))
157-
expected = [[[[2.333333, 3.333333, 4.333333, 5.333333]]]]
157+
expected = [[[[5.0, 6.0, 7.0, 8.0]]]]
158158
np.testing.assert_allclose(out, expected, atol=_rtol)
159159

160160
def test_zoom_2(self):
161161
affine = torch.as_tensor([[2.0, 0.0, 0.0], [0.0, 2.0, 0.0]], dtype=torch.float32)
162162
image = torch.arange(1.0, 13.0).view(1, 1, 3, 4).to(device=torch.device("cpu:0"))
163163
out = AffineTransform((1, 2))(image, affine)
164-
expected = [[[[1.458333, 4.958333]]]]
164+
expected = [[[[5.0, 7.0]]]]
165165
np.testing.assert_allclose(out, expected, atol=1e-5, rtol=_rtol)
166166

167167
def test_zoom_zero_center(self):
168168
affine = torch.as_tensor([[2.0, 0.0, 0.0], [0.0, 2.0, 0.0]], dtype=torch.float32)
169169
image = torch.arange(1.0, 13.0).view(1, 1, 3, 4).to(device=torch.device("cpu:0"))
170170
out = AffineTransform((1, 2), zero_centered=True)(image, affine)
171-
expected = [[[[5.5, 7.5]]]]
171+
expected = [[[[5.0, 8.0]]]]
172172
np.testing.assert_allclose(out, expected, atol=1e-5, rtol=_rtol)
173173

174174
def test_affine_transform_minimum(self):
@@ -380,6 +380,55 @@ def test_forward_3d(self):
380380
np.testing.assert_allclose(actual, expected)
381381
np.testing.assert_allclose(list(theta.shape), [1, 3, 4])
382382

383+
def test_align_corners_consistency(self):
384+
"""
385+
Test that align_corners is consistently used between to_norm_affine and grid_sample.
386+
387+
With an identity affine transform, the output should match the input regardless of
388+
the align_corners setting. This test verifies that the coordinate normalization
389+
in to_norm_affine uses the same align_corners value as affine_grid/grid_sample.
390+
"""
391+
# Create a simple test image
392+
image = torch.arange(1.0, 13.0).view(1, 1, 3, 4)
393+
394+
# Identity affine in pixel space (i, j, k convention with reverse_indexing=True)
395+
identity_affine = torch.eye(3, dtype=torch.float32).unsqueeze(0)
396+
397+
# Test with align_corners=True (the default)
398+
xform_true = AffineTransform(align_corners=True)
399+
out_true = xform_true(image, identity_affine)
400+
np.testing.assert_allclose(out_true.detach().cpu().numpy(), image.detach().cpu().numpy(), atol=1e-5, rtol=_rtol)
401+
402+
# Test with align_corners=False
403+
xform_false = AffineTransform(align_corners=False)
404+
out_false = xform_false(image, identity_affine)
405+
np.testing.assert_allclose(
406+
out_false.detach().cpu().numpy(), image.detach().cpu().numpy(), atol=1e-5, rtol=_rtol
407+
)
408+
409+
def test_align_corners_true_translation(self):
410+
"""
411+
Test that translation works correctly with align_corners=True.
412+
413+
This ensures to_norm_affine correctly converts pixel-space translations
414+
to normalized coordinates when align_corners=True.
415+
"""
416+
# 4x4 image
417+
image = torch.arange(1.0, 17.0).view(1, 1, 4, 4)
418+
419+
# Translate by +1 pixel in the j direction (column direction)
420+
# With reverse_indexing=True (default), this is the last spatial dimension
421+
# Positive translation in the affine shifts the sampling grid, resulting in
422+
# the output appearing shifted in the opposite direction
423+
affine = torch.tensor([[[1.0, 0.0, 0.0], [0.0, 1.0, 1.0], [0.0, 0.0, 1.0]]])
424+
425+
xform = AffineTransform(align_corners=True, padding_mode="zeros")
426+
out = xform(image, affine)
427+
428+
# Expected: shift columns left by 1, rightmost column becomes 0
429+
expected = torch.tensor([[[[2, 3, 4, 0], [6, 7, 8, 0], [10, 11, 12, 0], [14, 15, 16, 0]]]], dtype=torch.float32)
430+
np.testing.assert_allclose(out.detach().cpu().numpy(), expected.detach().cpu().numpy(), atol=1e-4, rtol=_rtol)
431+
383432

384433
if __name__ == "__main__":
385434
unittest.main()

0 commit comments

Comments
 (0)