from __future__ import annotations
from typing import Dict, Optional, Tuple, Union
import numpy as np
[docs]class BoundingBox:
"""Structure to store a bounding box.
Attributes:
xmin (float): Minimum relative x coordinate.
ymin (float): Minimum relative y coordinate.
xmax (float): Maximum relative x coordinate.
ymax (float): Maximum relative y coordinate.
Overloaded operators:
- __repr__
- __eq__
- __bool__
- __iter__
"""
def __init__(self, xmin: float, ymin: float, xmax: float, ymax: float):
"""Create a bounding box object with the relative coordinates to the
image size of the top-left and bottom-right corners.
Args:
xmin (float): Minimum relative x coordinate.
ymin (float): Minimum relative y coordinate.
xmax (float): Maximum relative x coordinate.
ymax (float): Maximum relative y coordinate.
"""
self.xmin = max(min(float(xmin), 1), 0)
self.ymin = max(min(float(ymin), 1), 0)
self.xmax = max(min(float(xmax), 1), 0)
self.ymax = max(min(float(ymax), 1), 0)
def _to_absolute(self, rel_value: float, dim: int) -> int:
"""Convert a relative value to absolute.
Args:
rel_value (float): Relative value to convert.
dim (int): Absolute dimension.
Returns:
int: The absolute value.
"""
return min(round(rel_value * dim), dim)
[docs] def get_width(self, absolute: bool = False,
image_width: Optional[int] = None) -> Union[float, int]:
"""Get the width of the bounding box.
Args:
absolute (bool, optional): Return the absolute value,
otherwise relative to the image. Defaults to False.
image_width (Optional[int], optional): Image width.
Defaults to None.
Returns:
Union[float, int]: The width of the bounding box.
"""
assert not absolute or image_width is not None, (absolute, image_width)
width = self.xmax - self.xmin
if absolute:
width = self._to_absolute(width, image_width)
return width
[docs] def get_height(self, absolute: bool = False,
image_height: Optional[int] = None) -> Union[float, int]:
"""Get the height of the bounding box.
Args:
absolute (bool, optional): Return the absolute value,
otherwise relative to the image. Defaults to False.
image_height (Optional[int], optional): Image height.
Defaults to None.
Returns:
Union[float, int]: The height of the bounding box.
"""
assert not absolute or image_height is not None, (
absolute, image_height)
height = self.ymax - self.ymin
if absolute:
height = self._to_absolute(height, image_height)
return height
[docs] def get_center_x(self, absolute: bool = False,
image_width: Optional[int] = None) -> Union[float, int]:
"""Get the center x coordinate of the bounding box.
Args:
absolute (bool, optional): Return the absolute coordinate,
otherwise relative to the image. Defaults to False.
image_width (Optional[int], optional): Image width.
Defaults to None.
Returns:
Union[float, int]: The center x coordinate.
"""
cx = (self.xmax + self.xmin) / 2
if absolute:
cx = self._to_absolute(cx, image_width)
return cx
[docs] def get_center_y(self, absolute: bool = False,
image_height: Optional[int] = None) -> Union[float, int]:
"""Get the center y coordinate of the bounding box.
Args:
absolute (bool, optional): Return the absolute coordinate,
otherwise relative to the image. Defaults to False.
image_height (Optional[int], optional): Image height.
Defaults to None.
Returns:
Union[float, int]: The center y coordinate.
"""
cy = (self.ymax + self.ymin) / 2
if absolute:
cy = self._to_absolute(cy, image_height)
return cy
[docs] def get_area(self, absolute: bool = False,
image_width: Optional[int] = None,
image_height: Optional[int] = None) -> Union[float, int]:
"""Get the area of the bounding box.
Args:
absolute (bool, optional): Return the absolute value,
otherwise relative to the image. Defaults to False.
image_width (Optional[int], optional): Image width.
Defaults to None.
image_height (Optional[int], optional): Image height.
Defaults to None.
Returns:
float: The area of the bounding box.
"""
assert (not absolute) or \
(image_height is not None and image_width is not None), \
(absolute, image_height, image_width)
return (
self.get_width(absolute, image_width) *
self.get_height(absolute, image_height)
)
[docs] def get_xyxy(self, absolute: bool = False,
image_width: Optional[int] = None,
image_height: Optional[int] = None) -> np.ndarray:
"""Get the maximum and minimum bounding box coordinates as a numpy
array.
Args:
absolute (bool, optional): Return the absolute value,
otherwise relative to the image. Defaults to False.
image_width (Optional[int], optional): Image width.
Defaults to None.
image_height (Optional[int], optional): Image height.
Defaults to None.
Returns:
np.ndarray: [minimum x, minimum y, maximum x, maximum y].
"""
assert (not absolute) or \
(image_height is not None and image_width is not None), \
(absolute, image_height, image_width)
if absolute:
return np.array([
self._to_absolute(self.xmin, image_width),
self._to_absolute(self.ymin, image_height),
self._to_absolute(self.xmax, image_width),
self._to_absolute(self.ymax, image_height)
], int)
return np.array([self.xmin, self.ymin, self.xmax, self.ymax])
[docs] def get_xywh(self, absolute: bool = False,
image_width: Optional[int] = None,
image_height: Optional[int] = None) -> np.ndarray:
"""Get the center coordinates and the box width and height as a
numpy array.
Args:
absolute (bool, optional): Return the absolute value,
otherwise relative to the image. Defaults to False.
image_width (Optional[int], optional): Image width.
Defaults to None.
image_height (Optional[int], optional): Image height.
Defaults to None.
Returns:
np.ndarray: [center x, center y, box width, box height].
"""
return np.array([
self.get_center_x(absolute, image_width),
self.get_center_y(absolute, image_height),
self.get_width(absolute, image_width),
self.get_height(absolute, image_height)
])
[docs] @classmethod
def from_absolute(cls, xmin: int, ymin: int, xmax: int, ymax: int,
image_width: int, image_height: int) -> BoundingBox:
"""Create a bounding box object from the absolute image coordinates of
the top-left and bottom-right corners.
Args:
xmin (int): Minimum x coordinate.
ymin (int): Minimum y coordinate.
xmax (int): Maximum x coordinate.
ymax (int): Maximum y coordinate.
image_width (int): Image width.
image_height (int): Image height.
Returns:
BoundingBox: A BoundingBox object.
"""
return cls(
xmin/image_width,
ymin/image_height,
xmax/image_width,
ymax/image_height
)
[docs] @ staticmethod
def from_xywh(x: Union[float, int], y: Union[float, int],
w: Union[float, int], h: Union[float, int],
absolute: bool = False, image_width: Optional[int] = None,
image_height: Optional[int] = None) -> BoundingBox:
"""Create a bounding box object from the center coordinates and the
width and height of a box.
Args:
x (float): Center x coordinate.
y (float): Center y coordinate.
w (float): Width of the box.
h (float): Height of the box.
absolute (bool, optional): If the coordinates passed are relative.
Defaults to False.
image_width (Optional[int], optional): Image width used when
``absolute`` is True. Defaults to None.
image_height (Optional[int], optional): Image height used when
``absolute`` is True. Defaults to None.
Returns:
BoundingBox: A BoundingBox object.
"""
assert (not absolute) or \
(image_height is not None and image_width is not None), \
(absolute, image_height, image_width)
if absolute:
x /= image_width
y /= image_height
w /= image_width
h /= image_height
w2 = w/2
h2 = h/2
xmin = max(x - w2, 0)
ymin = max(y - h2, 0)
xmax = min(x + w2, 1)
ymax = min(y + h2, 1)
return BoundingBox(xmin, ymin, xmax, ymax)
[docs] def crop_image(self, image: np.ndarray) -> np.ndarray:
"""Crop an image with the bounding box.
Args:
image (np.ndarray): Numpy image of shape (h, w, c) or (h, w).
Returns:
np.ndarray: A cropped copy of the image with the region inside the
bounding box.
"""
xmin, ymin, xmax, ymax = self.get_xyxy(
True, image.shape[1], image.shape[0])
return image.copy()[ymin:ymax, xmin:xmax]
[docs] def scale(self, factor: Union[Tuple[float, float], float]) -> BoundingBox:
"""Scale the bounding box from the center by a factor.
Args:
factor (Union[Tuple[float, float], float]): A value by which both
width and height will be scaled or a tuple with the
width-factor and the height-factor.
Returns:
BoundingBox: A new scaled BoundingBox object.
"""
if isinstance(factor, (float, int)):
factor = [factor, factor]
fx, fy = factor
_mfx2 = (1-fx)/2
_pfx2 = (1+fx)/2
_mfy2 = (1-fy)/2
_pfy2 = (1+fy)/2
xmin = (self.xmax * _mfx2) + (self.xmin * _pfx2)
xmax = (self.xmax * _pfx2) + (self.xmin * _mfx2)
ymin = (self.ymax * _mfy2) + (self.ymin * _pfy2)
ymax = (self.ymax * _pfy2) + (self.ymin * _mfy2)
return BoundingBox(xmin, ymin, xmax, ymax)
def __str__(self) -> str:
return f"{self.__class__.__name__}({self.xmin:.3f},{self.ymin:.3f},{self.xmax:.3f},{self.ymax:.3f})"
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.xmin},{self.ymin},{self.xmax},{self.ymax})"
[docs] def is_empty(self) -> bool:
"""Return True if height or width is 0.
"""
if self.get_width() <= 0:
return True
if self.get_height() <= 0:
return True
return False
def __bool__(self):
return not self.is_empty()
def __eq__(self, other: BoundingBox) -> bool:
if not isinstance(other, BoundingBox):
return False
return self.xmin == other.xmin and \
self.ymin == other.ymin and \
self.xmax == other.xmax and \
self.ymax == other.ymax
[docs] def serialize(self) -> Dict[str, float]:
"""Serialize to a basic Python datatype.
Returns:
Dict[str, float]
"""
return {
"xmin": self.xmin,
"ymin": self.ymin,
"xmax": self.xmax,
"ymax": self.ymax,
}
[docs] @staticmethod
def deserialize(value: Dict[str, float]) -> BoundingBox:
"""Deserialize value.
Args:
value (Dict[str, float])
Returns:
BoundingBox
"""
return BoundingBox(
xmin=value["xmin"],
ymin=value["ymin"],
xmax=value["xmax"],
ymax=value["ymax"],
)
# Pydantic methods
def __iter__(self):
d = self.serialize()
yield from d.items()
@classmethod
def __get_validators__(cls):
yield cls.validate
[docs] @classmethod
def validate(cls, v):
if isinstance(v, BoundingBox):
return v
try:
return BoundingBox.deserialize(v)
except:
raise TypeError(f"Error parsing {v} ({type(v)}) to {cls}")
@classmethod
def __modify_schema__(cls, field_schema):
field_schema.update(example=BoundingBox(0, 0, 0, 0).serialize())