Repository: Project-MONAI/MONAI

Issue:
Box transforms
**Is your feature request related to a problem? Please describe.**
Box pre- and post-processing transform functions are not in MONAI

**Describe the solution you'd like**
Add box transform functions

**Describe alternatives you've considered**
N/A

**Additional context**
1. convert input box with original mode to the box with standard mode
2. convert output box with standard mode to the box with original mode
3. Flip boxes with image as a transform



Discussion:

Resolving patch:
diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py
--- a/monai/apps/detection/transforms/array.py
+++ b/monai/apps/detection/transforms/array.py
@@ -13,20 +13,58 @@
 https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design
 """
 
-from typing import Type, Union
+from typing import Sequence, Type, Union
+
+import numpy as np
 
 from monai.config.type_definitions import NdarrayOrTensor
-from monai.data.box_utils import BoxMode, convert_box_mode, convert_box_to_standard_mode
+from monai.data.box_utils import BoxMode, convert_box_mode, convert_box_to_standard_mode, get_spatial_dims
 from monai.transforms.transform import Transform
+from monai.utils import ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option
 from monai.utils.enums import TransformBackends
 
-__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode"]
+from .box_ops import apply_affine_to_boxes, resize_boxes, zoom_boxes
+
+__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode", "AffineBox", "ZoomBox", "ResizeBox"]
 
 
 class ConvertBoxMode(Transform):
     """
     This transform converts the boxes in src_mode to the dst_mode.
 
+    Args:
+        src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``.
+        dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``.
+
+    Note:
+        ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`,
+        also represented as "xyxy" for 2D and "xyzxyz" for 3D.
+
+        src_mode and dst_mode can be:
+            #. str: choose from :class:`~monai.utils.enums.BoxModeName`, for example,
+                - "xyxy": boxes has format [xmin, ymin, xmax, ymax]
+                - "xyzxyz": boxes has format [xmin, ymin, zmin, xmax, ymax, zmax]
+                - "xxyy": boxes has format [xmin, xmax, ymin, ymax]
+                - "xxyyzz": boxes has format [xmin, xmax, ymin, ymax, zmin, zmax]
+                - "xyxyzz": boxes has format [xmin, ymin, xmax, ymax, zmin, zmax]
+                - "xywh": boxes has format [xmin, ymin, xsize, ysize]
+                - "xyzwhd": boxes has format [xmin, ymin, zmin, xsize, ysize, zsize]
+                - "ccwh": boxes has format [xcenter, ycenter, xsize, ysize]
+                - "cccwhd": boxes has format [xcenter, ycenter, zcenter, xsize, ysize, zsize]
+            #. BoxMode class: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example,
+                - CornerCornerModeTypeA: equivalent to "xyxy" or "xyzxyz"
+                - CornerCornerModeTypeB: equivalent to "xxyy" or "xxyyzz"
+                - CornerCornerModeTypeC: equivalent to "xyxy" or "xyxyzz"
+                - CornerSizeMode: equivalent to "xywh" or "xyzwhd"
+                - CenterSizeMode: equivalent to "ccwh" or "cccwhd"
+            #. BoxMode object: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example,
+                - CornerCornerModeTypeA(): equivalent to "xyxy" or "xyzxyz"
+                - CornerCornerModeTypeB(): equivalent to "xxyy" or "xxyyzz"
+                - CornerCornerModeTypeC(): equivalent to "xyxy" or "xyxyzz"
+                - CornerSizeMode(): equivalent to "xywh" or "xyzwhd"
+                - CenterSizeMode(): equivalent to "ccwh" or "cccwhd"
+            #. None: will assume mode is ``StandardMode()``
+
     Example:
         .. code-block:: python
 
@@ -43,41 +81,6 @@ def __init__(
         src_mode: Union[str, BoxMode, Type[BoxMode], None] = None,
         dst_mode: Union[str, BoxMode, Type[BoxMode], None] = None,
     ) -> None:
-        """
-
-        Args:
-            src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``.
-            dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``.
-
-        Note:
-            ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`,
-            also represented as "xyxy" for 2D and "xyzxyz" for 3D.
-
-            src_mode and dst_mode can be:
-                #. str: choose from :class:`~monai.utils.enums.BoxModeName`, for example,
-                    - "xyxy": boxes has format [xmin, ymin, xmax, ymax]
-                    - "xyzxyz": boxes has format [xmin, ymin, zmin, xmax, ymax, zmax]
-                    - "xxyy": boxes has format [xmin, xmax, ymin, ymax]
-                    - "xxyyzz": boxes has format [xmin, xmax, ymin, ymax, zmin, zmax]
-                    - "xyxyzz": boxes has format [xmin, ymin, xmax, ymax, zmin, zmax]
-                    - "xywh": boxes has format [xmin, ymin, xsize, ysize]
-                    - "xyzwhd": boxes has format [xmin, ymin, zmin, xsize, ysize, zsize]
-                    - "ccwh": boxes has format [xcenter, ycenter, xsize, ysize]
-                    - "cccwhd": boxes has format [xcenter, ycenter, zcenter, xsize, ysize, zsize]
-                #. BoxMode class: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example,
-                    - CornerCornerModeTypeA: equivalent to "xyxy" or "xyzxyz"
-                    - CornerCornerModeTypeB: equivalent to "xxyy" or "xxyyzz"
-                    - CornerCornerModeTypeC: equivalent to "xyxy" or "xyxyzz"
-                    - CornerSizeMode: equivalent to "xywh" or "xyzwhd"
-                    - CenterSizeMode: equivalent to "ccwh" or "cccwhd"
-                #. BoxMode object: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example,
-                    - CornerCornerModeTypeA(): equivalent to "xyxy" or "xyzxyz"
-                    - CornerCornerModeTypeB(): equivalent to "xxyy" or "xxyyzz"
-                    - CornerCornerModeTypeC(): equivalent to "xyxy" or "xyxyzz"
-                    - CornerSizeMode(): equivalent to "xywh" or "xyzwhd"
-                    - CenterSizeMode(): equivalent to "ccwh" or "cccwhd"
-                #. None: will assume mode is ``StandardMode()``
-        """
         self.src_mode = src_mode
         self.dst_mode = dst_mode
 
@@ -85,6 +88,9 @@ def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor:
         """
         Converts the boxes in src_mode to the dst_mode.
 
+        Args:
+            boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``
+
         Returns:
             bounding boxes with target mode, with same data type as ``boxes``, does not share memory with ``boxes``
         """
@@ -97,6 +103,10 @@ class ConvertBoxToStandardMode(Transform):
     Standard mode is "xyxy" or "xyzxyz",
     representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax].
 
+    Args:
+        mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``.
+            It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` .
+
     Example:
         .. code-block:: python
 
@@ -109,11 +119,6 @@ class ConvertBoxToStandardMode(Transform):
     backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
 
     def __init__(self, mode: Union[str, BoxMode, Type[BoxMode], None] = None) -> None:
-        """
-        Args:
-            mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``.
-                It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` .
-        """
         self.mode = mode
 
     def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor:
@@ -122,7 +127,140 @@ def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor:
         Standard mode is "xyxy" or "xyzxyz",
         representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax].
 
+        Args:
+            boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``
+
         Returns:
             bounding boxes with standard mode, with same data type as ``boxes``, does not share memory with ``boxes``
         """
         return convert_box_to_standard_mode(boxes, mode=self.mode)
+
+
+class AffineBox(Transform):
+    """
+    Applys affine matrix to the boxes
+    """
+
+    backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
+
+    def __call__(self, boxes: NdarrayOrTensor, affine: Union[NdarrayOrTensor, None]) -> NdarrayOrTensor:  # type: ignore
+        """
+        Args:
+            boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``
+            affine: affine matric to be applied to the box coordinate
+        """
+        if affine is None:
+            return boxes
+
+        return apply_affine_to_boxes(boxes, affine=affine)
+
+
+class ZoomBox(Transform):
+    """
+    Zooms an ND Box with same padding or slicing setting with Zoom().
+
+    Args:
+        zoom: The zoom factor along the spatial axes.
+            If a float, zoom is the same for each spatial axis.
+            If a sequence, zoom should contain one value for each spatial axis.
+        keep_size: Should keep original size (padding/slicing if needed), default is True.
+        kwargs: other arguments for the `np.pad` or `torch.pad` function.
+            note that `np.pad` treats channel dimension as the first dimension.
+    """
+
+    backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
+
+    def __init__(self, zoom: Union[Sequence[float], float], keep_size: bool = False, **kwargs) -> None:
+        self.zoom = zoom
+        self.keep_size = keep_size
+        self.kwargs = kwargs
+
+    def __call__(
+        self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int, None] = None
+    ) -> NdarrayOrTensor:  # type: ignore
+        """
+        Args:
+            boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``
+            src_spatial_size: original image spatial size before zooming, used only when keep_size=True.
+        """
+        spatial_dims: int = get_spatial_dims(boxes=boxes)
+        self._zoom = ensure_tuple_rep(self.zoom, spatial_dims)  # match the spatial image dim
+
+        if not self.keep_size:
+            return zoom_boxes(boxes, self._zoom)
+
+        if src_spatial_size is None:
+            raise ValueError("keep_size=True, src_spatial_size must be provided.")
+
+        src_spatial_size = ensure_tuple_rep(src_spatial_size, spatial_dims)
+        dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self._zoom, src_spatial_size)]
+        self._zoom = tuple(ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size))
+        zoomed_boxes = zoom_boxes(boxes, self._zoom)
+
+        # See also keep_size in monai.transforms.spatial.array.Zoom()
+        if not np.allclose(np.array(src_spatial_size), np.array(dst_spatial_size)):
+            for axis, (od, zd) in enumerate(zip(src_spatial_size, dst_spatial_size)):
+                diff = od - zd
+                half = abs(diff) // 2
+                if diff > 0:  # need padding (half, diff - half)
+                    zoomed_boxes[:, axis] = zoomed_boxes[:, axis] + half
+                    zoomed_boxes[:, axis + spatial_dims] = zoomed_boxes[:, axis + spatial_dims] + half
+                elif diff < 0:  # need slicing (half, half + od)
+                    zoomed_boxes[:, axis] = zoomed_boxes[:, axis] - half
+                    zoomed_boxes[:, axis + spatial_dims] = zoomed_boxes[:, axis + spatial_dims] - half
+        return zoomed_boxes
+
+
+class ResizeBox(Transform):
+    """
+    Resize the input boxes when the corresponding image is
+    resized to given spatial size (with scaling, not cropping/padding).
+
+    Args:
+        spatial_size: expected shape of spatial dimensions after resize operation.
+            if some components of the `spatial_size` are non-positive values, the transform will use the
+            corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted
+            to `(32, 64)` if the second spatial dimension size of img is `64`.
+        size_mode: should be "all" or "longest", if "all", will use `spatial_size` for all the spatial dims,
+            if "longest", rescale the image so that only the longest side is equal to specified `spatial_size`,
+            which must be an int number in this case, keeping the aspect ratio of the initial image, refer to:
+            https://albumentations.ai/docs/api_reference/augmentations/geometric/resize/
+            #albumentations.augmentations.geometric.resize.LongestMaxSize.
+        kwargs: other arguments for the `np.pad` or `torch.pad` function.
+            note that `np.pad` treats channel dimension as the first dimension.
+    """
+
+    backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
+
+    def __init__(self, spatial_size: Union[Sequence[int], int], size_mode: str = "all", **kwargs) -> None:
+        self.size_mode = look_up_option(size_mode, ["all", "longest"])
+        self.spatial_size = spatial_size
+
+    def __call__(self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int]) -> NdarrayOrTensor:  # type: ignore
+        """
+        Args:
+            boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``
+            src_spatial_size: original image spatial size before resizing.
+
+        Raises:
+            ValueError: When ``self.spatial_size`` length is less than ``boxes`` spatial dimensions.
+        """
+        input_ndim = get_spatial_dims(boxes=boxes)  # spatial ndim
+        src_spatial_size_ = ensure_tuple_rep(src_spatial_size, input_ndim)
+
+        if self.size_mode == "all":
+            # spatial_size must be a Sequence if size_mode is 'all'
+            output_ndim = len(ensure_tuple(self.spatial_size))
+            if output_ndim != input_ndim:
+                raise ValueError(
+                    "len(spatial_size) must be greater or equal to img spatial dimensions, "
+                    f"got spatial_size={output_ndim} img={input_ndim}."
+                )
+            spatial_size_ = fall_back_tuple(self.spatial_size, src_spatial_size_)
+        else:  # for the "longest" mode
+            if not isinstance(self.spatial_size, int):
+                raise ValueError("spatial_size must be an int number if size_mode is 'longest'.")
+            scale = self.spatial_size / max(src_spatial_size_)
+            spatial_size_ = tuple(int(round(s * scale)) for s in src_spatial_size_)
+
+        return resize_boxes(boxes, src_spatial_size_, spatial_size_)
diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py
--- a/monai/apps/detection/transforms/dictionary.py
+++ b/monai/apps/detection/transforms/dictionary.py
@@ -16,14 +16,23 @@
 """
 
 from copy import deepcopy
-from typing import Dict, Hashable, Mapping, Type, Union
+from enum import Enum
+from typing import Dict, Hashable, List, Mapping, Optional, Sequence, Type, Union
 
-from monai.apps.detection.transforms.array import ConvertBoxMode, ConvertBoxToStandardMode
+import numpy as np
+import torch
+
+from monai.apps.detection.transforms.array import AffineBox, ConvertBoxMode, ConvertBoxToStandardMode, ZoomBox
 from monai.config import KeysCollection
 from monai.config.type_definitions import NdarrayOrTensor
 from monai.data.box_utils import BoxMode
+from monai.data.utils import orientation_ras_lps
+from monai.transforms import RandZoom, SpatialPad, Zoom
 from monai.transforms.inverse import InvertibleTransform
-from monai.transforms.transform import MapTransform
+from monai.transforms.transform import MapTransform, RandomizableTransform
+from monai.utils import InterpolateMode, NumpyPadMode, PytorchPadMode, ensure_tuple, ensure_tuple_rep
+from monai.utils.enums import PostFix, TraceKeys
+from monai.utils.type_conversion import convert_data_type
 
 __all__ = [
     "ConvertBoxModed",
@@ -32,8 +41,21 @@
     "ConvertBoxToStandardModed",
     "ConvertBoxToStandardModeD",
     "ConvertBoxToStandardModeDict",
+    "AffineBoxToImageCoordinated",
+    "AffineBoxToImageCoordinateD",
+    "AffineBoxToImageCoordinateDict",
+    "ZoomBoxd",
+    "ZoomBoxD",
+    "ZoomBoxDict",
+    "RandZoomBoxd",
+    "RandZoomBoxD",
+    "RandZoomBoxDict",
 ]
 
+DEFAULT_POST_FIX = PostFix.meta()
+InterpolateModeSequence = Union[Sequence[Union[InterpolateMode, str]], InterpolateMode, str]
+PadModeSequence = Union[Sequence[Union[NumpyPadMode, PytorchPadMode, str]], NumpyPadMode, PytorchPadMode, str]
+
 
 class ConvertBoxModed(MapTransform, InvertibleTransform):
     """
@@ -70,21 +92,22 @@ def __init__(
         """
         super().__init__(box_keys, allow_missing_keys)
         self.converter = ConvertBoxMode(src_mode=src_mode, dst_mode=dst_mode)
-        self.inverse_converter = ConvertBoxMode(src_mode=dst_mode, dst_mode=src_mode)
 
     def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
         d = dict(data)
         for key in self.key_iterator(d):
-            self.push_transform(d, key)
+            self.push_transform(d, key, extra_info={"src": self.converter.src_mode, "dst": self.converter.dst_mode})
             d[key] = self.converter(d[key])
         return d
 
     def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
         d = deepcopy(dict(data))
         for key in self.key_iterator(d):
-            _ = self.get_most_recent_transform(d, key)
+            tr = self.get_most_recent_transform(d, key)
+            src_mode, dst_mode = tr[TraceKeys.EXTRA_INFO]["src"], tr[TraceKeys.EXTRA_INFO]["dst"]
+            inverse_converter = ConvertBoxMode(src_mode=dst_mode, dst_mode=src_mode)
             # Inverse is same as forward
-            d[key] = self.inverse_converter(d[key])
+            d[key] = inverse_converter(d[key])
             # Remove the applied transform
             self.pop_transform(d, key)
         return d
@@ -124,25 +147,407 @@ def __init__(
         """
         super().__init__(box_keys, allow_missing_keys)
         self.converter = ConvertBoxToStandardMode(mode=mode)
-        self.inverse_converter = ConvertBoxMode(src_mode=None, dst_mode=mode)
 
     def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
         d = dict(data)
         for key in self.key_iterator(d):
-            self.push_transform(d, key)
+            self.push_transform(d, key, extra_info={"mode": self.converter.mode})
             d[key] = self.converter(d[key])
         return d
 
     def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
         d = deepcopy(dict(data))
         for key in self.key_iterator(d):
-            _ = self.get_most_recent_transform(d, key)
+            tr = self.get_most_recent_transform(d, key)
+            original_mode = tr[TraceKeys.EXTRA_INFO]["mode"]
+            inverse_converter = ConvertBoxMode(src_mode=None, dst_mode=original_mode)
             # Inverse is same as forward
-            d[key] = self.inverse_converter(d[key])
+            d[key] = inverse_converter(d[key])
             # Remove the applied transform
             self.pop_transform(d, key)
         return d
 
 
+class AffineBoxToImageCoordinated(MapTransform, InvertibleTransform):
+    """
+    Dictionary-based transfrom that converts box in world coordinate to image coordinate.
+
+    Args:
+        box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``.
+        box_ref_image_keys: The single key that represents the reference image to which ``box_keys`` are attached.
+        remove_empty: whether to remove the boxes that are actually empty
+        allow_missing_keys: don't raise exception if key is missing.
+        image_meta_key: explicitly indicate the key of the corresponding meta data dictionary.
+            for example, for data with key `image`, the metadata by default is in `image_meta_dict`.
+            the meta data is a dictionary object which contains: filename, affine, original_shape, etc.
+            it is a string, map to the `box_ref_image_key`.
+            if None, will try to construct meta_keys by `box_ref_image_key_{meta_key_postfix}`.
+        image_meta_key_postfix: if image_meta_keys=None, use `box_ref_image_key_{postfix}` to fetch the meta data according
+            to the key data, default is `meta_dict`, the meta data is a dictionary object.
+            For example, to handle key `image`,  read/write affine matrices from the
+            metadata `image_meta_dict` dictionary's `affine` field.
+        affine_lps_to_ras: default ``False``. Yet if 1) the image is read by ITKReader,
+            and 2) the ITKReader has affine_lps_to_ras=True, and 3) the box is in world coordinate,
+            then set ``affine_lps_to_ras=True``.
+    """
+
+    def __init__(
+        self,
+        box_keys: KeysCollection,
+        box_ref_image_keys: str,
+        allow_missing_keys: bool = False,
+        image_meta_key: Union[str, None] = None,
+        image_meta_key_postfix: Union[str, None] = DEFAULT_POST_FIX,
+        affine_lps_to_ras=False,
+    ) -> None:
+        super().__init__(box_keys, allow_missing_keys)
+        box_ref_image_keys_tuple = ensure_tuple(box_ref_image_keys)
+        if len(box_ref_image_keys_tuple) > 1:
+            raise ValueError(
+                "Please provide a single key for box_ref_image_keys.\
+                All boxes of box_keys are attached to box_ref_image_keys."
+            )
+        self.image_meta_key = image_meta_key or f"{box_ref_image_keys}_{image_meta_key_postfix}"
+        self.converter_to_image_coordinate = AffineBox()
+        self.affine_lps_to_ras = affine_lps_to_ras
+
+    def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+        d = dict(data)
+
+        meta_key = self.image_meta_key
+        # extract affine matrix from meta_data
+        if meta_key not in d:
+            raise ValueError(f"{meta_key} is not found. Please check whether it is the correct the image meta key.")
+        if "affine" not in d[meta_key]:
+            raise ValueError(
+                f"'affine' is not found in {meta_key}. \
+                Please check whether it is the correct the image meta key."
+            )
+        affine: NdarrayOrTensor = d[meta_key]["affine"]  # type: ignore
+        if self.affine_lps_to_ras:  # RAS affine
+            affine = orientation_ras_lps(affine)
+
+        # when convert boxes from world coordinate to image coordinate,
+        # we apply inverse affine transform
+        affine_t, *_ = convert_data_type(affine, torch.Tensor)
+        inv_affine_t = torch.inverse(affine_t)
+
+        for key in self.key_iterator(d):
+            self.push_transform(d, key, extra_info={"affine": affine})
+            d[key] = self.converter_to_image_coordinate(d[key], affine=inv_affine_t)
+        return d
+
+    def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+        d = deepcopy(dict(data))
+        for key in self.key_iterator(d):
+            transform = self.get_most_recent_transform(d, key)
+            affine = transform["extra_info"]["affine"]
+            d[key] = AffineBox()(d[key], affine=affine)
+            self.pop_transform(d, key)
+        return d
+
+
+class ZoomBoxd(MapTransform, InvertibleTransform):
+    """
+    Zooms input arrays with given probability within given zoom range.
+
+    Args:
+        image_keys: Keys to pick image data for transformation.
+        box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``.
+        box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached.
+        zoom: The zoom factor along the spatial axes.
+            If a float, zoom is the same for each spatial axis.
+            If a sequence, zoom should contain one value for each spatial axis.
+        mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``}
+            The interpolation mode. Defaults to ``"area"``.
+            See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html
+            It also can be a sequence of string, each element corresponds to a key in ``keys``.
+        padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
+            ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
+            available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
+            One of the listed string values or a user supplied function. Defaults to ``"constant"``.
+            The mode to pad data after zooming.
+            See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
+            https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
+        align_corners: This only has an effect when mode is
+            'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None.
+            See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html
+            It also can be a sequence of bool or None, each element corresponds to a key in ``keys``.
+        keep_size: Should keep original size (pad if needed), default is True.
+        allow_missing_keys: don't raise exception if key is missing.
+        kwargs: other arguments for the `np.pad` or `torch.pad` function.
+            note that `np.pad` treats channel dimension as the first dimension.
+    """
+
+    def __init__(
+        self,
+        image_keys: KeysCollection,
+        box_keys: KeysCollection,
+        box_ref_image_keys: KeysCollection,
+        zoom: Union[Sequence[float], float],
+        mode: InterpolateModeSequence = InterpolateMode.AREA,
+        padding_mode: PadModeSequence = NumpyPadMode.EDGE,
+        align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None,
+        keep_size: bool = True,
+        allow_missing_keys: bool = False,
+        **kwargs,
+    ) -> None:
+        self.image_keys = ensure_tuple(image_keys)
+        self.box_keys = ensure_tuple(box_keys)
+        super().__init__(self.image_keys + self.box_keys, allow_missing_keys)
+
+        self.mode = ensure_tuple_rep(mode, len(self.image_keys))
+        self.padding_mode = ensure_tuple_rep(padding_mode, len(self.image_keys))
+        self.align_corners = ensure_tuple_rep(align_corners, len(self.image_keys))
+        self.zoomer = Zoom(zoom=zoom, keep_size=keep_size, **kwargs)
+
+        self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys))
+        self.keep_size = keep_size
+
+    def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+        d = dict(data)
+
+        # zoom box
+        for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys):
+            src_spatial_size = d[box_ref_image_key].shape[1:]
+            dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self.zoomer.zoom, src_spatial_size)]  # type: ignore
+            self.zoomer.zoom = [ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size)]
+            self.push_transform(
+                d,
+                box_key,
+                extra_info={"zoom": self.zoomer.zoom, "src_spatial_size": src_spatial_size, "type": "box_key"},
+            )
+            d[box_key] = ZoomBox(zoom=self.zoomer.zoom, keep_size=self.keep_size)(
+                d[box_key], src_spatial_size=src_spatial_size
+            )
+
+        # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd
+        for key, mode, padding_mode, align_corners in zip(
+            self.image_keys, self.mode, self.padding_mode, self.align_corners
+        ):
+            self.push_transform(
+                d,
+                key,
+                extra_info={
+                    "mode": mode.value if isinstance(mode, Enum) else mode,
+                    "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode,
+                    "align_corners": align_corners if align_corners is not None else TraceKeys.NONE,
+                    "original_shape": d[key].shape[1:],
+                    "type": "image_key",
+                },
+            )
+            d[key] = self.zoomer(d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners)
+
+        return d
+
+    def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+        d = deepcopy(dict(data))
+
+        for key in self.key_iterator(d):
+            transform = self.get_most_recent_transform(d, key)
+            key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+            # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd
+            if key_type == "image_key":
+                # Create inverse transform
+                zoom = np.array(self.zoomer.zoom)
+                inverse_transform = Zoom(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size)
+                mode = transform[TraceKeys.EXTRA_INFO]["mode"]
+                padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"]
+                align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"]
+                # Apply inverse
+                d[key] = inverse_transform(
+                    d[key],
+                    mode=mode,
+                    padding_mode=padding_mode,
+                    align_corners=None if align_corners == TraceKeys.NONE else align_corners,
+                )
+                # Size might be out by 1 voxel so pad
+                d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key])
+                # Remove the applied transform
+                self.pop_transform(d, key)
+
+            # zoom boxes
+            if key_type == "box_key":
+                zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"])
+                src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"]
+                box_inverse_transform = ZoomBox(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size)
+                d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size)
+                # Remove the applied transform
+                self.pop_transform(d, key)
+
+        return d
+
+
+class RandZoomBoxd(RandomizableTransform, MapTransform, InvertibleTransform):
+    """
+    Randomly zooms input arrays with given probability within given zoom range.
+
+    Args:
+        image_keys: Keys to pick image data for transformation.
+        box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``.
+        box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached.
+        prob: Probability of zooming.
+        min_zoom: Min zoom factor. Can be float or sequence same size as image.
+            If a float, select a random factor from `[min_zoom, max_zoom]` then apply to all spatial dims
+            to keep the original spatial shape ratio.
+            If a sequence, min_zoom should contain one value for each spatial axis.
+            If 2 values provided for 3D data, use the first value for both H & W dims to keep the same zoom ratio.
+        max_zoom: Max zoom factor. Can be float or sequence same size as image.
+            If a float, select a random factor from `[min_zoom, max_zoom]` then apply to all spatial dims
+            to keep the original spatial shape ratio.
+            If a sequence, max_zoom should contain one value for each spatial axis.
+            If 2 values provided for 3D data, use the first value for both H & W dims to keep the same zoom ratio.
+        mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``}
+            The interpolation mode. Defaults to ``"area"``.
+            See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html
+            It also can be a sequence of string, each element corresponds to a key in ``keys``.
+        padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
+            ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
+            available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
+            One of the listed string values or a user supplied function. Defaults to ``"constant"``.
+            The mode to pad data after zooming.
+            See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
+            https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
+        align_corners: This only has an effect when mode is
+            'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None.
+            See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html
+            It also can be a sequence of bool or None, each element corresponds to a key in ``keys``.
+        keep_size: Should keep original size (pad if needed), default is True.
+        allow_missing_keys: don't raise exception if key is missing.
+        kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension.
+            more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
+    """
+
+    backend = RandZoom.backend
+
+    def __init__(
+        self,
+        image_keys: KeysCollection,
+        box_keys: KeysCollection,
+        box_ref_image_keys: KeysCollection,
+        prob: float = 0.1,
+        min_zoom: Union[Sequence[float], float] = 0.9,
+        max_zoom: Union[Sequence[float], float] = 1.1,
+        mode: InterpolateModeSequence = InterpolateMode.AREA,
+        padding_mode: PadModeSequence = NumpyPadMode.EDGE,
+        align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None,
+        keep_size: bool = True,
+        allow_missing_keys: bool = False,
+        **kwargs,
+    ) -> None:
+        self.image_keys = ensure_tuple(image_keys)
+        self.box_keys = ensure_tuple(box_keys)
+        MapTransform.__init__(self, self.image_keys + self.box_keys, allow_missing_keys)
+        RandomizableTransform.__init__(self, prob)
+
+        self.rand_zoom = RandZoom(prob=1.0, min_zoom=min_zoom, max_zoom=max_zoom, keep_size=keep_size, **kwargs)
+        self.mode = ensure_tuple_rep(mode, len(self.image_keys))
+        self.padding_mode = ensure_tuple_rep(padding_mode, len(self.image_keys))
+        self.align_corners = ensure_tuple_rep(align_corners, len(self.image_keys))
+
+        self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys))
+        self.keep_size = keep_size
+
+    def set_random_state(
+        self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None
+    ) -> "RandZoomBoxd":
+        super().set_random_state(seed, state)
+        self.rand_zoom.set_random_state(seed, state)
+        return self
+
+    def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+        d = dict(data)
+        first_key: Union[Hashable, List] = self.first_key(d)
+        if first_key == []:
+            return d
+
+        self.randomize(None)
+
+        # all the keys share the same random zoom factor
+        self.rand_zoom.randomize(d[first_key])  # type: ignore
+
+        # zoom box
+        for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys):
+            if self._do_transform:
+                src_spatial_size = d[box_ref_image_key].shape[1:]
+                dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self.rand_zoom._zoom, src_spatial_size)]
+                self.rand_zoom._zoom = [ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size)]
+
+                self.push_transform(
+                    d,
+                    box_key,
+                    extra_info={"zoom": self.rand_zoom._zoom, "src_spatial_size": src_spatial_size, "type": "box_key"},
+                )
+                d[box_key] = ZoomBox(zoom=self.rand_zoom._zoom, keep_size=self.keep_size)(
+                    d[box_key], src_spatial_size=src_spatial_size
+                )
+
+        # zoom image, copied from monai.transforms.spatial.dictionary.RandZoomd
+        for key, mode, padding_mode, align_corners in zip(
+            self.image_keys, self.mode, self.padding_mode, self.align_corners
+        ):
+            if self._do_transform:
+                self.push_transform(
+                    d,
+                    key,
+                    extra_info={
+                        "zoom": self.rand_zoom._zoom,
+                        "mode": mode.value if isinstance(mode, Enum) else mode,
+                        "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode,
+                        "align_corners": align_corners if align_corners is not None else TraceKeys.NONE,
+                        "original_shape": d[key].shape[1:],
+                        "type": "image_key",
+                    },
+                )
+                d[key] = self.rand_zoom(
+                    d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners, randomize=False
+                )
+
+        return d
+
+    def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+        d = deepcopy(dict(data))
+
+        # zoom image, copied from monai.transforms.spatial.dictionary.RandZoomd
+        for key in self.key_iterator(d):
+            transform = self.get_most_recent_transform(d, key)
+            key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+            # Check if random transform was actually performed (based on `prob`)
+            if transform[TraceKeys.DO_TRANSFORM]:
+                # Create inverse transform
+
+                # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd
+                if key_type == "image_key":
+                    zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"])
+                    mode = transform[TraceKeys.EXTRA_INFO]["mode"]
+                    padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"]
+                    align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"]
+                    inverse_transform = Zoom(zoom=(1.0 / zoom).tolist(), keep_size=self.rand_zoom.keep_size)
+                    d[key] = inverse_transform(
+                        d[key],
+                        mode=mode,
+                        padding_mode=padding_mode,
+                        align_corners=None if align_corners == TraceKeys.NONE else align_corners,
+                    )
+                    # Size might be out by 1 voxel so pad
+                    d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key])
+                    # Remove the applied transform
+                    self.pop_transform(d, key)
+
+                # zoom boxes
+                if key_type == "box_key":
+                    # Create inverse transform
+                    zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"])
+                    src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"]
+                    box_inverse_transform = ZoomBox(zoom=(1.0 / zoom).tolist(), keep_size=self.rand_zoom.keep_size)
+                    d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size)
+                    # Remove the applied transform
+                    self.pop_transform(d, key)
+        return d
+
+
 ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed
 ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed
+ZoomBoxD = ZoomBoxDict = ZoomBoxd
+RandZoomBoxD = RandZoomBoxDict = RandZoomBoxd
+AffineBoxToImageCoordinateD = AffineBoxToImageCoordinateDict = AffineBoxToImageCoordinated


Test patch:
diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py
--- a/tests/test_box_transform.py
+++ b/tests/test_box_transform.py
@@ -12,24 +12,32 @@
 import unittest
 
 import numpy as np
+import torch
 from parameterized import parameterized
 
-from monai.apps.detection.transforms.dictionary import ConvertBoxToStandardModed
-from monai.transforms import Invertd
+from monai.apps.detection.transforms.dictionary import (
+    AffineBoxToImageCoordinated,
+    ConvertBoxModed,
+    RandZoomBoxd,
+    ZoomBoxd,
+)
+from monai.transforms import CastToTyped, Invertd
 from tests.utils import TEST_NDARRAYS, assert_allclose
 
 TESTS = []
 
-boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]
-image_size = [1, 5, 6, 4]
+boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]
+image_size = [1, 4, 6, 4]
 image = np.zeros(image_size)
 
 for p in TEST_NDARRAYS:
     TESTS.append(
         [
-            {"box_keys": "boxes", "mode": "xyzwhd"},
+            {"box_keys": "boxes", "dst_mode": "xyzwhd"},
             {"boxes": p(boxes), "image": p(image)},
-            p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]),
+            p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]),
+            p([[0, 0, 0, 0, 0, 0], [0, 3, 0, 1, 9, 4.5], [0, 3, 1.5, 1, 9, 6]]),
+            p([[1, -6, -1, 1, -6, -1], [1, -3, -1, 2, 3, 3.5], [1, -3, 0.5, 2, 3, 5]]),
             p([[0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]),
             p([[0, 1, 1, 2, 3, 4], [0, 1, 0, 2, 3, 3]]),
         ]
@@ -38,14 +46,83 @@
 
 class TestBoxTransform(unittest.TestCase):
     @parameterized.expand(TESTS)
-    def test_value(self, keys, data, expected_standard_result, expected_clip_result, expected_flip_result):
-        transform_convert_mode = ConvertBoxToStandardModed(**keys)
-        result = transform_convert_mode(data)
-        assert_allclose(result["boxes"], expected_standard_result, type_test=True, device_test=True, atol=0.0)
-
-        invert_transform_convert_mode = Invertd(keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"])
-        data_back = invert_transform_convert_mode(result)
-        assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.0)
+    def test_value(
+        self,
+        keys,
+        data,
+        expected_convert_result,
+        expected_zoom_result,
+        expected_zoom_keepsize_result,
+        expected_clip_result,
+        expected_flip_result,
+    ):
+        test_dtype = [torch.float32]
+        for dtype in test_dtype:
+            data = CastToTyped(keys=["image", "boxes"], dtype=dtype)(data)
+            # test ConvertBoxToStandardModed
+            transform_convert_mode = ConvertBoxModed(**keys)
+            convert_result = transform_convert_mode(data)
+            assert_allclose(
+                convert_result["boxes"], expected_convert_result, type_test=True, device_test=True, atol=1e-3
+            )
+
+            invert_transform_convert_mode = Invertd(
+                keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"]
+            )
+            data_back = invert_transform_convert_mode(convert_result)
+            assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+
+            # test ZoomBoxd
+            transform_zoom = ZoomBoxd(
+                image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=False
+            )
+            zoom_result = transform_zoom(data)
+            assert_allclose(zoom_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=1e-3)
+            invert_transform_zoom = Invertd(
+                keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"]
+            )
+            data_back = invert_transform_zoom(zoom_result)
+            assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+            assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+            transform_zoom = ZoomBoxd(
+                image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=True
+            )
+            zoom_result = transform_zoom(data)
+            assert_allclose(
+                zoom_result["boxes"], expected_zoom_keepsize_result, type_test=True, device_test=True, atol=1e-3
+            )
+
+            # test ZoomBoxd
+            transform_zoom = RandZoomBoxd(
+                image_keys="image",
+                box_keys="boxes",
+                box_ref_image_keys="image",
+                prob=1.0,
+                min_zoom=(0.3,) * 3,
+                max_zoom=(3.0,) * 3,
+                keep_size=False,
+            )
+            zoom_result = transform_zoom(data)
+            invert_transform_zoom = Invertd(
+                keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"]
+            )
+            data_back = invert_transform_zoom(zoom_result)
+            assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
+            assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+            # test AffineBoxToImageCoordinated
+            transform_affine = AffineBoxToImageCoordinated(box_keys="boxes", box_ref_image_keys="image")
+            with self.assertRaises(Exception) as context:
+                transform_affine(data)
+            self.assertTrue("Please check whether it is the correct the image meta key." in str(context.exception))
+
+            data["image_meta_dict"] = {"affine": torch.diag(1.0 / torch.Tensor([0.5, 3, 1.5, 1]))}
+            affine_result = transform_affine(data)
+            assert_allclose(affine_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=0.01)
+            invert_transform_affine = Invertd(keys=["boxes"], transform=transform_affine, orig_keys=["boxes"])
+            data_back = invert_transform_affine(affine_result)
+            assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
 
 
 if __name__ == "__main__":