File size: 17,088 Bytes
663494c |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 |
"""
Written by Jinhyung Park
Simple 3D visualization for 3D points & boxes. Intended as a simple, hackable
alternative to mayavi for certain point cloud tasks.
"""
import numpy as np
import cv2
import copy
from functools import partial
import matplotlib
class Canvas_3D(object):
def __init__(
self,
canvas_shape=(500, 1000),
camera_center_coords=(20, -40, 15),
camera_focus_coords=(-4 + 0.9396926, 0, 4 - 0.34202014),
focal_length=None,
canvas_bg_color=(0, 0, 0),
):
"""
Args:
canvas_shape (Tuple[Int]): Canvas image size - height & width.
camera_center_coords (Tuple[Float]): Location of camera center in
3D space. x -> right, y -> front, z -> height
camera_focus_coords (Tuple[Float]): Intuitively, what point in 3D
space is the camera pointed at? These are absolute coordinates,
*not* relative to camera center.
focal_length (None | Int):
None: Half of the max of height & width of canvas_shape. This
seems to be a decent default.
Int: Specified directly.
canvas_bg_color (Tuple[Int]): RGB (0 ~ 255) of canvas background
color.
"""
self.canvas_shape = canvas_shape
self.H, self.W = self.canvas_shape
self.canvas_bg_color = canvas_bg_color
self.camera_center_coords = camera_center_coords
self.camera_focus_coords = camera_focus_coords
if focal_length is None:
self.focal_length = max(self.H, self.W) // 2
else:
self.focal_length = focal_length
# Setup extrinsics and intrinsics of this virtual camera.
self.ext_matrix = self.get_extrinsic_matrix(
self.camera_center_coords, self.camera_focus_coords
)
self.int_matrix = np.array(
[
[self.focal_length, 0, self.W // 2, 0],
[0, self.focal_length, self.H // 2, 0],
[0, 0, 1, 0],
]
)
self.clear_canvas()
def get_canvas(self):
return self.canvas
def clear_canvas(self):
self.canvas = np.zeros((self.H, self.W, 3), dtype=np.uint8)
self.canvas[..., :] = self.canvas_bg_color
def get_canvas_coords(self, xyz, depth_min=0.1, return_depth=False):
"""
Projects XYZ points onto the canvas and returns the projected canvas
coordinates.
Args:
xyz (ndarray): (N, 3+) array of coordinates. Additional columns
beyond the first three are ignored.
depth_min (Float): Only points with a projected depth larger
than this value are "valid".
return_depth (Boolean): Whether to additionally return depth of
projected points.
Returns:
canvas_xy (ndarray): (N, 2) array of projected canvas coordinates.
"x" is dim0, "y" is dim1 of canvas.
valid_mask (ndarray): (N,) boolean mask indicating which of
canvas_xy fits into canvas (are visible from virtual camera).
depth (ndarray): Optionally returned (N,) array of depth values
"""
xyz = np.copy(xyz) # prevent in-place modifications
xyz = xyz[:, :3]
xyz_hom = np.concatenate(
[xyz, np.ones((xyz.shape[0], 1), dtype=np.float32)], axis=1
)
img_pts = (self.int_matrix @ self.ext_matrix @ xyz_hom.T).T
depth = img_pts[:, 2]
xy = img_pts[:, :2] / depth[:, None]
xy_int = xy.round().astype(np.int32)
# Flip X and Y so "x" is dim0, "y" is dim1 of canvas
xy_int = xy_int[:, ::-1]
valid_mask = (
(depth > depth_min)
& (xy_int[:, 0] >= 0)
& (xy_int[:, 0] < self.H)
& (xy_int[:, 1] >= 0)
& (xy_int[:, 1] < self.W)
)
if return_depth:
return xy_int, valid_mask, depth
else:
return xy_int, valid_mask
def draw_canvas_points(
self, canvas_xy, radius=-1, colors=None, colors_operand=None
):
"""
Draws canvas_xy onto self.canvas.
Args:
canvas_xy (ndarray): (N, 2) array of *valid* canvas coordinates.
"x" is dim0, "y" is dim1 of canvas.
radius (Int):
-1: Each point is visualized as a single pixel.
r: Each point is visualized as a circle with radius r.
colors:
None: colors all points white.
Tuple: RGB (0 ~ 255), indicating a single color for all points.
ndarray: (N, 3) array of RGB values for each point.
String: Such as "Spectral", uses a matplotlib cmap, with the
operand (the value cmap is called on for each point) being
colors_operand.
colors_operand (ndarray): (N,) array of values cooresponding to
canvas_xy, to be used only if colors is a cmap. Unlike
Canvas_BEV, cannot be None if colors is a String.
"""
if len(canvas_xy) == 0:
return
if colors is None:
colors = np.full((len(canvas_xy), 3), fill_value=255, dtype=np.uint8)
elif isinstance(colors, tuple):
assert len(colors) == 3
colors_tmp = np.zeros((len(canvas_xy), 3), dtype=np.uint8)
colors_tmp[..., : len(colors)] = np.array(colors)
colors = colors_tmp
elif isinstance(colors, np.ndarray):
assert len(colors) == len(canvas_xy)
colors = colors.astype(np.uint8)
elif isinstance(colors, str):
assert colors_operand is not None
colors = matplotlib.cm.get_cmap(colors)
# Normalize 0 ~ 1 for cmap
colors_operand = colors_operand - colors_operand.min()
colors_operand = colors_operand / colors_operand.max()
# Get cmap colors - note that cmap returns (*input_shape, 4), with
# colors scaled 0 ~ 1
colors = (colors(colors_operand)[:, :3] * 255).astype(np.uint8)
else:
raise Exception(
"colors type {} was not an expected type".format(type(colors))
)
if radius == -1:
self.canvas[canvas_xy[:, 0], canvas_xy[:, 1], :] = colors
else:
for color, (x, y) in zip(colors.tolist(), canvas_xy.tolist()):
self.canvas = cv2.circle(
self.canvas, (y, x), radius, color, -1, lineType=cv2.LINE_AA
)
def draw_lines(self, start_xyz, end_xyz, colors=(255, 255, 255), thickness=1):
"""
Draws lines between provided 3D points.
Args:
start_xyz (ndarray): Shape (N, 3) of 3D points to start from.
end_xyz (ndarray): Shape (N, 3) of 3D points to end at. Same length
as start_xyz.
colors:
None: colors all points white.
Tuple: RGB (0 ~ 255), indicating a single color for all points.
ndarray: (N, 3) array of RGB values for each point.
thickness (Int):
Thickness of drawn cv2 line.
"""
if colors is None:
colors = np.full((len(canvas_xy), 3), fill_value=255, dtype=np.uint8)
elif isinstance(colors, tuple):
assert len(colors) == 3
colors_tmp = np.zeros((len(canvas_xy), 3), dtype=np.uint8)
colors_tmp[..., : len(colors)] = np.array(colors)
colors = colors_tmp
elif isinstance(colors, np.ndarray):
assert len(colors) == len(canvas_xy)
colors = colors.astype(np.uint8)
else:
raise Exception(
"colors type {} was not an expected type".format(type(colors))
)
start_pts_xy, start_pts_valid_mask, start_pts_d = self.get_canvas_coords(
start_xyz, True
)
end_pts_xy, end_pts_valid_mask, end_pts_d = self.get_canvas_coords(
end_xyz, True
)
for idx, (color, start_pt_xy, end_pt_xy) in enumerate(
zip(colors.tolist(), start_pts_xy.tolist(), end_pts_xy.tolist())
):
if start_pts_valid_mask[idx] and end_pts_valid_mask[idx]:
self.canvas = cv2.line(
self.canvas,
tuple(start_pt_xy[::-1]),
tuple(end_pt_xy[::-1]),
color=color,
thickness=thickness,
lineType=cv2.LINE_AA,
)
def draw_boxes(
self,
boxes=None,
corners=None,
colors=None,
texts=None,
depth_min=0.1,
draw_incomplete_boxes=True,
box_line_thickness=2,
box_text_size=0.5,
text_corner=1,
):
"""
Draws 3D boxes.
Args:
boxes (ndarray): Shape (N, 7), each row representing a box of
format (x, y, z, x_size, y_size, z_size, yaw). This function
assumes *bottom center* - the xyz center of the provided box
is the center of the bottom face of the 3D box, not the
floating true center of the 3D box.
colors:
None: colors all points white.
Tuple: RGB (0 ~ 255), indicating a single color for all points.
ndarray: (N, 3) array of RGB values for each point.
texts (List[String]): Length N; text to write next to boxes.
depth_min (Float): Only box corners with a projected depth larger
than this value are drawn if draw_incomplete_boxes is True.
draw_incomplete_boxes (Boolean): If any boxes are incomplete,
meaning it has a corner out of view based on depth_min, decide
whether to draw them at all.
thickness (Int):
Thickness of drawn cv2 box lines.
box_line_thickness (int): cv2 line/text thickness
box_text_size (float): cv2 putText size
text_corner (int): 0 ~ 7. Which corner of 3D box to write text at.
"""
num_boxes = len(boxes) if boxes is not None else len(corners)
# Setup colors
if colors is None:
colors = np.full((num_boxes, 3), fill_value=255, dtype=np.uint8)
elif isinstance(colors, tuple):
assert len(colors) == 3
colors_tmp = np.zeros((num_boxes, 3), dtype=np.uint8)
colors_tmp[..., : len(colors)] = np.array(colors)
colors = colors_tmp
elif isinstance(colors, np.ndarray):
assert len(colors) == num_boxes
colors = colors.astype(np.uint8)
else:
raise Exception(
"colors type {} was not an expected type".format(type(colors))
)
if boxes is not None:
# boxes is N x 7
boxes = np.copy(boxes) # prevent in-place modifications
assert len(boxes.shape) == 2
dims = boxes[:, 3:6]
corners_norm = np.stack(np.unravel_index(np.arange(8), [2] * 3), axis=1)
corners_norm = corners_norm[[0, 1, 3, 2, 4, 5, 7, 6]]
# use relative origin [0.5, 0.5, 0], assuming bottom center
corners_norm = corners_norm - np.array([0.5, 0.5, 0])
corners = dims.reshape(-1, 1, 3) * corners_norm.reshape([1, 8, 3])
# rotate around z axis
angles = boxes[:, 6]
rot_sin = np.sin(angles)
rot_cos = np.cos(angles)
ones = np.ones_like(rot_cos)
zeros = np.zeros_like(rot_cos)
rot_mat_T = np.stack(
[
np.stack([rot_cos, -rot_sin, zeros]),
np.stack([rot_sin, rot_cos, zeros]),
np.stack([zeros, zeros, ones]),
]
)
corners = np.einsum("aij,jka->aik", corners, rot_mat_T)
corners += boxes[:, :3].reshape(-1, 1, 3) # N x 8 x 3
elif corners is not None:
corners = corners
# Now we have corners. Need them on the canvas 2D space.
corners_xy, valid_mask = self.get_canvas_coords(
corners.reshape(-1, 3), depth_min=depth_min
)
corners_xy = corners_xy.reshape(-1, 8, 2)
valid_mask = valid_mask.reshape(-1, 8)
# Now draw them with lines in correct places
for i, (color, curr_corners_xy, curr_valid_mask) in enumerate(
zip(colors.tolist(), corners_xy.tolist(), valid_mask.tolist())
):
if not draw_incomplete_boxes and sum(curr_valid_mask) != 8:
# Some corner is invalid, don't draw the box at all.
continue
for start, end in [
(0, 1),
(1, 2),
(2, 3),
(3, 0),
(0, 4),
(1, 5),
(2, 6),
(3, 7),
(4, 5),
(5, 6),
(6, 7),
(7, 4),
]:
if not (curr_valid_mask[start] and curr_valid_mask[end]):
continue # start or end is not valid
self.canvas = cv2.line(
self.canvas,
(curr_corners_xy[start][1], curr_corners_xy[start][0]),
(curr_corners_xy[end][1], curr_corners_xy[end][0]),
color=color,
thickness=box_line_thickness,
lineType=cv2.LINE_AA,
)
# If even a single line was drawn, add text as well.
if sum(curr_valid_mask) > 0:
if texts is not None:
self.canvas = cv2.putText(
self.canvas,
str(texts[i]),
(
curr_corners_xy[text_corner][1],
curr_corners_xy[text_corner][0],
),
cv2.FONT_HERSHEY_SIMPLEX,
box_text_size,
color,
thickness=box_line_thickness,
)
@staticmethod
def cart2sph(xyz):
x, y, z = xyz[:, 0], xyz[:, 1], xyz[:, 2]
depth = np.linalg.norm(xyz, 2, axis=1)
az = -np.arctan2(y, x)
el = np.arcsin(z / depth)
return az, el, depth
@staticmethod
def get_extrinsic_matrix(
camera_center_coords,
camera_focus_coords,
):
"""
Args:
camera_center_coords: (x, y, z) of where camera should be located
in 3D space.
camera_focus_coords: (x, y, z) of where camera should look at from
camera_center_coords
Thoughts:
Remember that in camera coordiantes, pos x is right, pos y is up,
pos z is forward.
"""
center_x, center_y, center_z = camera_center_coords
focus_x, focus_y, focus_z = camera_focus_coords
az, el, depth = Canvas_3D.cart2sph(
np.array([[focus_x - center_x, focus_y - center_y, focus_z - center_z]])
)
az = float(az)
el = float(el)
depth = float(depth)
### First, construct extrinsics
## Rotation matrix
z_rot = np.array(
[[np.cos(az), -np.sin(az), 0], [np.sin(az), np.cos(az), 0], [0, 0, 1]]
)
# el is rotation around y axis.
y_rot = np.array(
[
[np.cos(-el), 0, -np.sin(-el)],
[0, 1, 0],
[np.sin(-el), 0, np.cos(-el)],
]
)
## Now, how the z_rot and y_rot work (spherical coordiantes), is it
## computes rotations starting from the positive x axis and rotates
## positive x axis to the desired direction. The desired direction is
## the "looking direction" of the camera, which should actually be the
## z-axis. So should convert the points so that the x axis is the new z
## axis, and after the transformations.
## Why x -> z for points? If we think about rotating the camera, z
## should become x, so reverse when moving points.
last_rot = np.array([[0, -1, 0], [0, 0, -1], [1, 0, 0]]) # x -> z
# Put them together. Order matters. Make it hom.
rot_matrix = np.eye(4, dtype=np.float32)
rot_matrix[:3, :3] = last_rot @ y_rot @ z_rot
## Translation matrix
trans_matrix = np.array(
[
[1, 0, 0, -center_x],
[0, 1, 0, -center_y],
[0, 0, 1, -center_z],
[0, 0, 0, 1],
]
)
## Finally, extrinsics matrix. Order matters - do trans then rot
ext_matrix = rot_matrix @ trans_matrix
return ext_matrix
|