Repository: Project-MONAI/MONAI

Issue:
inverse lazily applied cropforeground with k_divisible>1
**Describe the bug**
```py
import torch
import monai.transforms as t
from monai.transforms.lazy.functional import apply_transforms

img = torch.tensor([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]])

kwargs = {"select_fn": lambda x: x > 0, "channel_indices": None, "margin": 0, "k_divisible": 10}
xform = t.CropForeground(**kwargs)
xform.lazy_evaluation = True
pending_result = xform(img)
result = apply_transforms(pending_result, mode="nearest")[0]
print(xform.inverse(result))

```

error:
```
monai/transforms/inverse.py", line 258, in check_transforms_match
    raise RuntimeError(
RuntimeError: Error CropForeground getting the most recently applied invertible transform Pad 140360605105696 != 140360605208640.
```


cc @KumoLiu 


Discussion:

Resolving patch:
diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py
--- a/monai/transforms/compose.py
+++ b/monai/transforms/compose.py
@@ -374,7 +374,7 @@ def randomize(self, data: Any | None = None) -> None:
             except TypeError as type_error:
                 tfm_name: str = type(_transform).__name__
                 warnings.warn(
-                    f'Transform "{tfm_name}" in Compose not randomized\n{tfm_name}.{type_error}.', RuntimeWarning
+                    f"Transform '{tfm_name}' in Compose not randomized\n{tfm_name}.{type_error}.", RuntimeWarning
                 )
 
     def get_index_of_first(self, predicate):
@@ -470,6 +470,11 @@ def inverse(self, data):
 
         # loop backwards over transforms
         for t in reversed(invertible_transforms):
+            if isinstance(t, LazyTransform) and t.lazy_evaluation:
+                warnings.warn(
+                    f"inversing {t.__class__.__name__} lazily may not implemented"
+                    "please set `lazy_evaluation=False` before calling inverse."
+                )
             data = apply_transform(t.inverse, data, self.map_items, self.unpack_items, self.log_stats)
         return data
 
diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py
--- a/monai/transforms/croppad/array.py
+++ b/monai/transforms/croppad/array.py
@@ -816,6 +816,18 @@ def crop_pad(
         if get_track_meta() and isinstance(ret, MetaTensor):
             if not self.lazy_evaluation:
                 ret.applied_operations[-1][TraceKeys.EXTRA_INFO]["pad_info"] = ret.applied_operations.pop()
+            else:
+                pad_info = ret.pending_operations.pop()
+                crop_info = ret.pending_operations.pop()
+                extra = crop_info[TraceKeys.EXTRA_INFO]
+                extra["pad_info"] = pad_info
+                self.push_transform(
+                    ret,
+                    orig_size=crop_info.get(TraceKeys.ORIG_SIZE),
+                    sp_size=pad_info[LazyAttr.SHAPE],
+                    affine=crop_info[LazyAttr.AFFINE] @ pad_info[LazyAttr.AFFINE],
+                    extra_info=extra,
+                )
         return ret
 
     def __call__(  # type: ignore[override]
diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py
--- a/monai/transforms/inverse.py
+++ b/monai/transforms/inverse.py
@@ -123,9 +123,10 @@ def push_transform(self, data, *args, **kwargs):
                 xform = data.pending_operations.pop()
                 extra = xform.copy()
                 xform.update(transform_info)
-                meta_obj = self.push_transform(data, transform_info=xform, lazy_evaluation=lazy_eval, extra_info=extra)
-                return data.copy_meta_from(meta_obj)
-            return data
+            else:  # lazy, replace=True, do_transform=False
+                xform, extra = transform_info, {}
+            meta_obj = self.push_transform(data, transform_info=xform, lazy_evaluation=True, extra_info=extra)
+            return data.copy_meta_from(meta_obj)
         kwargs["lazy_evaluation"] = lazy_eval
         if "transform_info" in kwargs and isinstance(kwargs["transform_info"], dict):
             kwargs["transform_info"].update(transform_info)
@@ -211,14 +212,16 @@ def track_transform_meta(
         if lazy_evaluation:
             if sp_size is None:
                 if LazyAttr.SHAPE not in info:
-                    warnings.warn("spatial size is None in push transform.")
+                    info[LazyAttr.SHAPE] = info.get(TraceKeys.ORIG_SIZE, [])
             else:
-                info[LazyAttr.SHAPE] = tuple(convert_to_numpy(sp_size, wrap_sequence=True).tolist())
+                info[LazyAttr.SHAPE] = sp_size
+            info[LazyAttr.SHAPE] = tuple(convert_to_numpy(info[LazyAttr.SHAPE], wrap_sequence=True).tolist())
             if affine is None:
                 if LazyAttr.AFFINE not in info:
-                    warnings.warn("affine is None in push transform.")
+                    info[LazyAttr.AFFINE] = MetaTensor.get_default_affine()
             else:
-                info[LazyAttr.AFFINE] = convert_to_tensor(affine, device=torch.device("cpu"))
+                info[LazyAttr.AFFINE] = affine
+            info[LazyAttr.AFFINE] = convert_to_tensor(info[LazyAttr.AFFINE], device=torch.device("cpu"))
             out_obj.push_pending_operation(info)
         else:
             if out_obj.pending_operations:


Test patch:
diff --git a/tests/croppers.py b/tests/croppers.py
--- a/tests/croppers.py
+++ b/tests/croppers.py
@@ -127,6 +127,11 @@ def crop_test_pending_ops(self, input_param, input_shape, align_corners=False):
         result = apply_transforms(pending_result, mode="nearest", align_corners=align_corners)[0]
         # compare
         assert_allclose(result, expected, rtol=1e-5)
+        if isinstance(result, MetaTensor) and not isinstance(crop_fn, MapTransform):
+            crop_fn.lazy_evaluation = False
+            inverted = crop_fn.inverse(result)
+            self.assertTrue((not inverted.applied_operations) and (not inverted.pending_operations))
+            self.assertEqual(inverted.shape, im.shape)
 
     def crop_test_combine_ops(self, funcs, input_shape):
         _funcs = []
diff --git a/tests/lazy_transforms_utils.py b/tests/lazy_transforms_utils.py
--- a/tests/lazy_transforms_utils.py
+++ b/tests/lazy_transforms_utils.py
@@ -11,8 +11,10 @@
 
 from __future__ import annotations
 
-from monai.data import set_track_meta
-from monai.transforms import Randomizable
+from copy import deepcopy
+
+from monai.data import MetaTensor, set_track_meta
+from monai.transforms import InvertibleTransform, MapTransform, Randomizable
 from monai.transforms.lazy.functional import apply_transforms
 from tests.utils import assert_allclose
 
@@ -22,12 +24,10 @@
 def get_apply_param(init_param=None, call_param=None, params=apply_transforms_kwargs):
     apply_param = {}
     for key in apply_transforms_kwargs:
-        if init_param:
-            if key in init_param.keys():
-                apply_param[key] = init_param[key]
-        if call_param:
-            if key in call_param.keys():
-                apply_param[key] = call_param[key]
+        if init_param and key in init_param.keys():
+            apply_param[key] = init_param[key]
+        if call_param and key in call_param.keys():
+            apply_param[key] = call_param[key]
     return apply_param
 
 
@@ -62,7 +62,7 @@ def test_resampler_lazy(
         resampler.set_random_state(seed=seed)
     set_track_meta(True)
     resampler.lazy_evaluation = True
-    pending_output = resampler(**call_param)
+    pending_output = resampler(**deepcopy(call_param))
     if output_idx is not None:
         expected_output, pending_output = expected_output[output_idx], pending_output[output_idx]
     if output_key is not None:
@@ -75,3 +75,17 @@ def test_resampler_lazy(
     apply_param = get_apply_param(init_param, call_param)
     lazy_out = apply_transforms(lazy_out, **apply_param)[0]
     assert_allclose(lazy_out, non_lazy_out, rtol=rtol, atol=atol)
+    if (
+        isinstance(resampler, InvertibleTransform)
+        and (not isinstance(resampler, MapTransform))
+        and isinstance(lazy_out, MetaTensor)
+        and isinstance(non_lazy_out, MetaTensor)
+        and non_lazy_out.applied_operations
+    ):
+        resampler.lazy_evaluation = False
+        out = resampler.inverse(lazy_out.clone())
+        ref = resampler.inverse(non_lazy_out.clone())
+        assert_allclose(out.applied_operations, [])
+        assert_allclose(out.pending_operations, [])
+        assert_allclose(ref, out, type_test=False, rtol=1e-3, atol=1e-3)
+        resampler.lazy_evaluation = True
diff --git a/tests/padders.py b/tests/padders.py
--- a/tests/padders.py
+++ b/tests/padders.py
@@ -137,6 +137,11 @@ def pad_test_pending_ops(self, input_param, input_shape):
             result = apply_transforms(pending_result, mode="nearest", padding_mode=mode[1], align_corners=False)[0]
             # compare
             assert_allclose(result, expected, rtol=1e-5)
+            if isinstance(result, MetaTensor) and not isinstance(pad_fn, MapTransform):
+                pad_fn.lazy_evaluation = False
+                inverted = pad_fn.inverse(result)
+                self.assertTrue((not inverted.pending_operations) and (not inverted.applied_operations))
+                self.assertEqual(inverted.shape, im.shape)
 
     def pad_test_combine_ops(self, funcs, input_shape, expected_shape):
         for mode in TESTS_PENDING_MODE:
diff --git a/tests/test_crop_foreground.py b/tests/test_crop_foreground.py
--- a/tests/test_crop_foreground.py
+++ b/tests/test_crop_foreground.py
@@ -146,6 +146,17 @@ def test_lazy_error(self, input_param, image, _expected_data, align_corners):
             pending_result = crop_fn(image)
             return apply_transforms(pending_result, mode="nearest", align_corners=align_corners)[0]
 
+    @parameterized.expand(TEST_COORDS + TESTS)
+    def test_inverse_pending_ops(self, input_param, image, _expected_data, align_corners):
+        crop_fn = CropForeground(**input_param)
+        crop_fn.lazy_evaluation = True
+        pending_result = crop_fn(image)
+        self.assertIsInstance(pending_result, MetaTensor)
+        result = apply_transforms(pending_result, mode="nearest", align_corners=align_corners)[0]
+        inverted = crop_fn.inverse(result)
+        self.assertEqual(image.shape, inverted.shape)
+        self.assertTrue((not inverted.applied_operations) and (not inverted.pending_operations))
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_resample_to_match.py b/tests/test_resample_to_match.py
--- a/tests/test_resample_to_match.py
+++ b/tests/test_resample_to_match.py
@@ -72,9 +72,8 @@ def test_correct(self, reader, writer):
         im_mod = tr(data["im2"], data["im1"])
 
         # check lazy resample
-        tr_lazy = ResampleToMatch()
         call_param = {"img": data["im2"], "img_dst": data["im1"]}
-        test_resampler_lazy(tr_lazy, im_mod, init_param={}, call_param=call_param)
+        test_resampler_lazy(tr, im_mod, init_param={}, call_param=call_param)
 
         saver = SaveImaged(
             "im3", output_dir=self.tmpdir, output_postfix="", separate_folder=False, writer=writer, resample=False
@@ -87,7 +86,7 @@ def test_correct(self, reader, writer):
         assert_allclose(saved.header["dim"][:4], np.array([3, 384, 384, 19]))
 
     def test_inverse(self):
-        loader = Compose([LoadImaged(("im1", "im2")), EnsureChannelFirstd(("im1", "im2"))])
+        loader = Compose([LoadImaged(("im1", "im2"), image_only=True), EnsureChannelFirstd(("im1", "im2"))])
         data = loader({"im1": self.fnames[0], "im2": self.fnames[1]})
         tr = ResampleToMatch()
         im_mod = tr(data["im2"], data["im1"])
diff --git a/tests/test_resize_with_pad_or_crop.py b/tests/test_resize_with_pad_or_crop.py
--- a/tests/test_resize_with_pad_or_crop.py
+++ b/tests/test_resize_with_pad_or_crop.py
@@ -93,6 +93,8 @@ def test_pending_ops(self, input_param, input_shape, _expected_data, align_corne
             )[0]
             # compare
             assert_allclose(result, expected, rtol=1e-5)
+            inverted = padcropper.inverse(result)
+            self.assertEqual(inverted.shape, image.shape)
 
 
 if __name__ == "__main__":