Files
issacdataengine/workflows/simbox/core/utils/box.py
2026-03-16 11:44:10 +00:00

337 lines
11 KiB
Python

"""General 3D Bounding Box class.
Origin: https://github.com/OasisYang/Wild6D/blob/main/lib/box.py
"""
import numpy as np
from numpy.linalg import lstsq as optimizer
from scipy.spatial.transform import Rotation as rotation_util
EDGES = (
[1, 5],
[2, 6],
[3, 7],
[4, 8], # lines along x-axis
[1, 3],
[5, 7],
[2, 4],
[6, 8], # lines along y-axis
[1, 2],
[3, 4],
[5, 6],
[7, 8], # lines along z-axis
)
# The vertices are ordered according to the left-hand rule, so the normal
# vector of each face will point inward the box.
FACES = np.array(
[
[5, 6, 8, 7], # +x on yz plane
[1, 3, 4, 2], # -x on yz plane
[3, 7, 8, 4], # +y on xz plane = top
[1, 2, 6, 5], # -y on xz plane
[2, 4, 8, 6], # +z on xy plane = front
[1, 5, 7, 3], # -z on xy plane
]
)
UNIT_BOX = np.asarray(
[
[0.0, 0.0, 0.0],
[-0.5, -0.5, -0.5],
[-0.5, -0.5, 0.5],
[-0.5, 0.5, -0.5],
[-0.5, 0.5, 0.5],
[0.5, -0.5, -0.5],
[0.5, -0.5, 0.5],
[0.5, 0.5, -0.5],
[0.5, 0.5, 0.5],
]
)
NUM_KEYPOINTS = 9
FRONT_FACE_ID = 4
TOP_FACE_ID = 2
def get_bbox_center_and_corners(bbox):
x_min, y_min, z_min, x_max, y_max, z_max = (
bbox.min[0],
bbox.min[1],
bbox.min[2],
bbox.max[0],
bbox.max[1],
bbox.max[2],
)
center = np.array([(x_min + x_max) / 2, (y_min + y_max) / 2, (z_min + z_max) / 2])
corners = np.array(
[
[x_min, y_min, z_min], # 0: minimum corner
[x_min, y_min, z_max], # 1: z changes
[x_min, y_max, z_min], # 2: y changes
[x_min, y_max, z_max], # 3: y and z change
[x_max, y_min, z_min], # 4: x changes
[x_max, y_min, z_max], # 5: x and z change
[x_max, y_max, z_min], # 6: x and y change
[x_max, y_max, z_max], # 7: maximum corner
]
)
result = np.vstack([center, corners])
return result
class Box(object):
"""General 3D Oriented Bounding Box."""
def __init__(self, vertices=None):
if vertices is None:
vertices = self.scaled_axis_aligned_vertices(np.array([1.0, 1.0, 1.0]))
self._vertices = vertices
self._rotation = None
self._translation = None
self._scale = None
self._transformation = None
self._volume = None
@classmethod
def from_transformation(cls, rotation, translation, scale):
"""Constructs an oriented bounding box from transformation and scale."""
if rotation.size not in (3, 9):
raise ValueError("Unsupported rotation, only 3x1 euler angles or 3x3 rotation matrices are supported.")
if rotation.size == 3:
rotation = rotation_util.from_rotvec(rotation.tolist()).as_dcm()
scaled_identity_box = cls.scaled_axis_aligned_vertices(scale)
vertices = np.zeros((NUM_KEYPOINTS, 3))
for i in range(NUM_KEYPOINTS):
vertices[i, :] = np.matmul(rotation, scaled_identity_box[i, :]) + translation.flatten()
return cls(vertices=vertices)
def __repr__(self):
representation = "Box: "
for i in range(NUM_KEYPOINTS):
representation += f"[{i}: {self.vertices[i, 0]}, {self.vertices[i, 1]}, {self.vertices[i, 2]}]"
return representation
def __len__(self):
return NUM_KEYPOINTS
def __name__(self):
return "Box"
def apply_transformation(self, transformation):
"""Applies transformation on the box.
Group multiplication is the same as rotation concatenation. Therefore return
new box with SE3(R * R2, T + R * T2); Where R2 and T2 are existing rotation
and translation. Note we do not change the scale.
Args:
transformation: a 4x4 transformation matrix.
Returns:
transformed box.
"""
if transformation.shape != (4, 4):
raise ValueError("Transformation should be a 4x4 matrix.")
new_rotation = np.matmul(transformation[:3, :3], self.rotation)
new_translation = transformation[:3, 3] + (np.matmul(transformation[:3, :3], self.translation))
return Box.from_transformation(new_rotation, new_translation, self.scale)
@classmethod
def scaled_axis_aligned_vertices(cls, scale):
"""Returns an axis-aligned set of verticies for a box of the given scale.
Args:
scale: A 3*1 vector, specifiying the size of the box in x-y-z dimension.
"""
w = scale[0] / 2.0
h = scale[1] / 2.0
d = scale[2] / 2.0
# Define the local coordinate system, w.r.t. the center of the box
aabb = np.array(
[
[0.0, 0.0, 0.0],
[-w, -h, -d],
[-w, -h, +d],
[-w, +h, -d],
[-w, +h, +d],
[+w, -h, -d],
[+w, -h, +d],
[+w, +h, -d],
[+w, +h, +d],
]
)
return aabb
@classmethod
def fit(cls, vertices):
"""Estimates a box 9-dof parameters from the given vertices.
Directly computes the scale of the box, then solves for orientation and
translation.
Args:
vertices: A 9*3 array of points. Points are arranged as 1 + 8 (center
keypoint + 8 box vertices) matrix.
Returns:
orientation: 3*3 rotation matrix.
translation: 3*1 translation vector.
scale: 3*1 scale vector.
"""
orientation = np.identity(3)
translation = np.zeros((3, 1))
scale = np.zeros(3)
# The scale would remain invariant under rotation and translation.
# We can safely estimate the scale from the oriented box.
for axis in range(3):
for edge_id in range(4):
# The edges are stored in quadruples according to each axis
begin, end = EDGES[axis * 4 + edge_id]
scale[axis] += np.linalg.norm(vertices[begin, :] - vertices[end, :])
scale[axis] /= 4.0
x = cls.scaled_axis_aligned_vertices(scale)
system = np.concatenate((x, np.ones((NUM_KEYPOINTS, 1))), axis=1)
solution, _, _, _ = optimizer(system, vertices, rcond=None)
orientation = solution[:3, :3].T
translation = solution[3, :3]
return orientation, translation, scale
def inside(self, point):
"""Tests whether a given point is inside the box.
Brings the 3D point into the local coordinate of the box. In the local
coordinate, the looks like an axis-aligned bounding box. Next checks if
the box contains the point.
Args:
point: A 3*1 numpy vector.
Returns:
True if the point is inside the box, False otherwise.
"""
inv_trans = np.linalg.inv(self.transformation)
scale = self.scale
point_w = np.matmul(inv_trans[:3, :3], point) + inv_trans[:3, 3]
for i in range(3):
if abs(point_w[i]) > scale[i] / 2.0:
return False
return True
def sample(self):
"""Samples a 3D point uniformly inside this box."""
point = np.random.uniform(-0.5, 0.5, 3) * self.scale
point = np.matmul(self.rotation, point) + self.translation
return point
@property
def vertices(self):
return self._vertices
@property
def rotation(self):
if self._rotation is None:
self._rotation, self._translation, self._scale = self.fit(self._vertices)
return self._rotation
@property
def translation(self):
if self._translation is None:
self._rotation, self._translation, self._scale = self.fit(self._vertices)
return self._translation
@property
def scale(self):
if self._scale is None:
self._rotation, self._translation, self._scale = self.fit(self._vertices)
return self._scale
@property
def volume(self):
"""Compute the volume of the parallelpiped or the box.
For the boxes, this is equivalent to np.prod(self.scale). However for
parallelpiped, this is more involved. Viewing the box as a linear function
we can estimate the volume using a determinant. This is equivalent to
sp.ConvexHull(self._vertices).volume
Returns:
volume (float)
"""
if self._volume is None:
i = self._vertices[2, :] - self._vertices[1, :]
j = self._vertices[3, :] - self._vertices[1, :]
k = self._vertices[5, :] - self._vertices[1, :]
sys = np.array([i, j, k])
self._volume = abs(np.linalg.det(sys))
return self._volume
@property
def transformation(self):
if self._rotation is None:
self._rotation, self._translation, self._scale = self.fit(self._vertices)
if self._transformation is None:
self._transformation = np.identity(4)
self._transformation[:3, :3] = self._rotation
self._transformation[:3, 3] = self._translation
return self._transformation
def get_ground_plane(self, gravity_axis=1):
"""Get ground plane under the box."""
gravity = np.zeros(3)
gravity[gravity_axis] = 1
def get_face_normal(face, center):
"""Get a normal vector to the given face of the box."""
v1 = self.vertices[face[0], :] - center
v2 = self.vertices[face[1], :] - center
normal = np.cross(v1, v2)
return normal
def get_face_center(face):
"""Get the center point of the face of the box."""
center = np.zeros(3)
for vertex in face:
center += self.vertices[vertex, :]
center /= len(face)
return center
ground_plane_id = 0
ground_plane_error = 10.0
# The ground plane is defined as a plane aligned with gravity.
# gravity is the (0, 1, 0) vector in the world coordinate system.
for i in [0, 2, 4]:
face = FACES[i, :]
center = get_face_center(face)
normal = get_face_normal(face, center)
w = np.cross(gravity, normal)
w_sq_norm = np.linalg.norm(w)
if w_sq_norm < ground_plane_error:
ground_plane_error = w_sq_norm
ground_plane_id = i
face = FACES[ground_plane_id, :]
center = get_face_center(face)
normal = get_face_normal(face, center)
# For each face, we also have a parallel face that it's normal is also
# aligned with gravity vector. We pick the face with lower height (y-value).
# The parallel to face 0 is 1, face 2 is 3, and face 4 is 5.
parallel_face_id = ground_plane_id + 1
parallel_face = FACES[parallel_face_id]
parallel_face_center = get_face_center(parallel_face)
parallel_face_normal = get_face_normal(parallel_face, parallel_face_center)
if parallel_face_center[gravity_axis] < center[gravity_axis]:
center = parallel_face_center
normal = parallel_face_normal
return center, normal