"""Bounding box class. Shape and Batch objects have a bounding box.
Bounding box is axis-aligned. Provides reference edges and points.
"""
import numpy as np
from .common import Point, common_properties, _set_Nones, defaults
from .all_enums import Side, Types, Anchor
from ..geometry.geometry import (
distance,
mid_point,
offset_line,
line_angle,
intersect,
positive_angle,
)
[docs]
class BoundingBox:
"""Rectangular bounding box.
If the object is a Shape, it contains all points.
If the object is a Batch, it contains all points of all Shapes.
Provides reference edges and points as shown in the Book page ???.
"""
def __init__(self, southwest: Point, northeast: Point):
"""
Initialize a BoundingBox object.
Args:
southwest (Point): The southwest corner of the bounding box.
northeast (Point): The northeast corner of the bounding box.
"""
# define the four corners
self.__dict__["southwest"] = southwest
self.__dict__["northeast"] = northeast
self.__dict__["northwest"] = (southwest[0], northeast[1])
self.__dict__["southeast"] = (northeast[0], southwest[1])
self._aliases = {
"s": "south",
"n": "north",
"w": "west",
"e": "east",
"sw": "southwest",
"se": "southeast",
"nw": "northwest",
"ne": "northeast",
"d1": "diagonal1",
"d2": "diagonal2",
"c": "center",
"vcl": "vert_centerline",
"hcl": "horiz_centerline",
}
common_properties(self)
self.type = Types.BOUNDING_BOX
self.subtype = Types.BOUNDING_BOX
def __getattr__(self, name):
"""
Get the attribute with the given name.
Args:
name (str): The name of the attribute.
Returns:
Any: The attribute with the given name.
"""
if name in self._aliases:
res = getattr(self, self._aliases[name])
else:
res = self.__dict__[name]
return res
[docs]
def angle_point(self, angle: float) -> float:
"""
Return the intersection point of the angled line starting
from the center and the bounding box. angle is in radians.
Args:
angle (float): The angle in radians.
Returns:
float: The intersection point.
"""
angle = positive_angle(angle)
line = ((0, 0), (np.cos(angle), np.sin(angle)))
angle1 = line_angle(self.center, self.northeast)
angle2 = -angle1 # center, southeast
angle3 = np.pi - angle1 # center, northwest
angle4 = -angle3 # center, southwest
if angle3 >= angle >= angle1:
res = intersect(line, self.top)
elif angle4 <= angle <= angle2:
res = intersect(line, self.bottom)
elif angle1 <= angle <= angle2:
res = intersect(line, self.right)
else:
res = intersect(line, self.left)
return res
@property
def left(self):
"""
Return the left edge.
Returns:
tuple: The left edge.
"""
return (self.northwest, self.southwest)
@property
def right(self):
"""
Return the right edge.
Returns:
tuple: The right edge.
"""
return (self.northeast, self.southeast)
@property
def top(self):
"""
Return the top edge.
Returns:
tuple: The top edge.
"""
return (self.northwest, self.northeast)
@property
def bottom(self):
"""
Return the bottom edge.
Returns:
tuple: The bottom edge.
"""
return (self.southwest, self.southeast)
@property
def vert_centerline(self):
"""
Return the vertical centerline.
Returns:
tuple: The vertical centerline.
"""
return (self.north, self.south)
@property
def horiz_centerline(self):
"""
Return the horizontal centerline.
Returns:
tuple: The horizontal centerline.
"""
return (self.west, self.east)
@property
def center(self):
"""
Return the center of the bounding box.
Returns:
tuple: The center of the bounding box.
"""
x1, y1 = self.southwest
x2, y2 = self.northeast
xc = (x1 + x2) / 2
yc = (y1 + y2) / 2
return (xc, yc)
@property
def corners(self):
"""
Return the four corners of the bounding box.
Returns:
tuple: The four corners of the bounding box.
"""
return (self.northwest, self.southwest, self.southeast, self.northeast)
@property
def diamond(self):
"""
Return the four center points of the bounding box in a diamond shape.
Returns:
tuple: The four center points of the bounding box in a diamond shape.
"""
return (self.north, self.west, self.south, self.east)
@property
def all_anchors(self):
"""
Return all anchors of the bounding box.
Returns:
tuple: All anchors of the bounding box.
"""
return (
self.northwest,
self.west,
self.southwest,
self.south,
self.northeast,
self.east,
self.northeast,
self.north,
self.center,
)
@property
def width(self):
"""
Return the width of the bounding box.
Returns:
float: The width of the bounding box.
"""
return distance(self.northwest, self.northeast)
@property
def height(self):
"""
Return the height of the bounding box.
Returns:
float: The height of the bounding box.
"""
return distance(self.northwest, self.southwest)
@property
def size(self):
"""
Return the size of the bounding box.
Returns:
tuple: The size of the bounding box.
"""
return (self.width, self.height)
@property
def west(self):
"""
Return the left edge midpoint.
Returns:
tuple: The left edge midpoint.
"""
return mid_point(*self.left)
@property
def south(self):
"""
Return the bottom edge midpoint.
Returns:
tuple: The bottom edge midpoint.
"""
return mid_point(*self.bottom)
@property
def east(self):
"""
Return the right edge midpoint.
Returns:
tuple: The right edge midpoint.
"""
return mid_point(*self.right)
@property
def north(self):
"""
Return the top edge midpoint.
Returns:
tuple: The top edge midpoint.
"""
return mid_point(*self.top)
@property
def northwest(self):
"""
Return the top left corner.
Returns:
tuple: The top left corner.
"""
return self.__dict__["northwest"]
@property
def northeast(self):
"""
Return the top right corner.
Returns:
tuple: The top right corner.
"""
return self.__dict__["northeast"]
@property
def southwest(self):
"""
Return the bottom left corner.
Returns:
tuple: The bottom left corner.
"""
return self.__dict__["southwest"]
@property
def southeast(self):
"""
Return the bottom right corner.
Returns:
tuple: The bottom right corner.
"""
return self.__dict__["southeast"]
@property
def diagonal1(self):
"""
Return the first diagonal. From the top left to the bottom right.
Returns:
tuple: The first diagonal.
"""
return (self.southwest, self.northeast)
@property
def diagonal2(self):
"""
Return the second diagonal. From the top right to the bottom left.
Returns:
tuple: The second diagonal.
"""
return (self.southeast, self.northwest)
[docs]
def get_inflated_b_box(
self, left_margin=None, bottom_margin=None, right_margin=None, top_margin=None
):
"""
Return a bounding box with offset edges.
Args:
left_margin (float, optional): The left margin.
bottom_margin (float, optional): The bottom margin.
right_margin (float, optional): The right margin.
top_margin (float, optional): The top margin.
Returns:
BoundingBox: The inflated bounding box.
"""
_set_Nones(
self,
["left_margin", "bottom_margin", "right_margin", "top_margin"],
[left_margin, bottom_margin, right_margin, top_margin],
)
x, y = self.southwest[:2]
southwest = (x - left_margin, y - bottom_margin)
x, y = self.northeast[:2]
northeast = (x + right_margin, y + top_margin)
return BoundingBox(southwest, northeast)
[docs]
def offset_line(self, side, offset):
"""
Offset is applied outwards. Use negative values for inward offset.
Args:
side (Side): The side to offset.
offset (float): The offset distance.
Returns:
tuple: The offset line.
"""
if isinstance(side, str):
side = Side[side.upper()]
if side == Side.RIGHT:
x1, y1 = self.southeast
x2, y2 = self.northeast
res = ((x1 + offset, y1), (x2 + offset, y2))
elif side == Side.LEFT:
x1, y1 = self.southwest
x2, y2 = self.northwest
res = ((x1 - offset, y1), (x2 - offset, y2))
elif side == Side.TOP:
x1, y1 = self.northwest
x2, y2 = self.northeast
res = ((x1, y1 + offset), (x2, y2 + offset))
elif side == Side.BOTTOM:
x1, y1 = self.southwest
x2, y2 = self.southeast
res = ((x1, y1 - offset), (x2, y2 - offset))
elif side == Side.DIAGONAL1:
res = offset_line(self.diagonal1, offset)
elif side == Side.DIAGONAL2:
res = offset_line(self.diagonal2, offset)
elif side == Side.H_CENTERLINE:
res = offset_line(self.horiz_center_line, offset)
elif side == Side.V_CENTERLINE:
res = offset_line(self.vert_center_line, offset)
else:
raise ValueError(f"Unknown side: {side}")
res = None
return res
[docs]
def offset_point(self, anchor, dx, dy):
"""
Return an offset point from the given corner.
Args:
anchor (Anchor): The anchor point.
dx (float): The x offset.
dy (float): The y offset.
Returns:
list: The offset point.
"""
if isinstance(anchor, str):
anchor = Anchor[anchor.upper()]
x, y = getattr(self, anchor.value)[:2]
elif isinstance(anchor, Anchor):
x, y = anchor.value[:2]
else:
raise ValueError(f"Unknown anchor: {anchor}")
return [x + dx, y + dy]
[docs]
def bounding_box(points):
"""
Given a list of (x, y) points return the corresponding BoundingBox object.
Args:
points (list): The list of points.
Returns:
BoundingBox: The corresponding BoundingBox object.
Raises:
ValueError: If the list of points is empty.
"""
if isinstance(points, np.ndarray):
points = points[:, :2]
else:
points = np.array(points) # numpy array of points
n_points = len(points)
BB_EPSILON = defaults["BB_EPSILON"]
if n_points == 0: # empty list of points
raise ValueError("Empty list of points")
if len(points.shape) == 1:
# single point
min_x, min_y = points
max_x = min_x + BB_EPSILON
max_y = min_y + BB_EPSILON
else:
# find minimum and maximum coordinates
min_x, min_y = points.min(axis=0)
max_x, max_y = points.max(axis=0)
if min_x == max_x: # this could be a vertical line or degenerate points
max_x += BB_EPSILON
if min_y == max_y: # this could be a horizontal line or degenerate points
max_y += BB_EPSILON
# bounding box corners
bottom_left = (min_x, min_y)
top_right = (max_x, max_y)
return BoundingBox(southwest=bottom_left, northeast=top_right)