lidarverse / utils /camera /base.py
multimodalart's picture
multimodalart HF Staff
LidarVerse Stage-3 world generation demo
af8bc9f verified
Raw History Blame Contribute Delete
26.4 kB
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
import numpy as np
import cv2
from utils.misc import make_sure_numpy, make_sure_torch
from typing import List, Tuple, Union
from abc import ABC, abstractmethod
DEPTH_MAX = 122.5
def interpolate_polyline_to_points(polyline, voxel_size=0.025):
"""
polyline:
numpy.ndarray, shape (N, 3) or list of points
Returns:
points: numpy array, shape (interpolate_num*N, 3)
"""
def interpolate_points(previous_vertex, vertex):
"""
Args:
previous_vertex: (x, y, z)
vertex: (x, y, z)
Returns:
points: numpy array, shape (interpolate_num, 3)
"""
interpolate_num = int(np.linalg.norm(np.array(vertex) - np.array(previous_vertex)) / voxel_size)
interpolate_num = max(interpolate_num, 2)
# interpolate between previous_vertex and vertex
x = np.linspace(previous_vertex[0], vertex[0], num=interpolate_num)
y = np.linspace(previous_vertex[1], vertex[1], num=interpolate_num)
z = np.linspace(previous_vertex[2], vertex[2], num=interpolate_num)
return np.stack([x, y, z], axis=1)
points = []
previous_vertex = None
for idx, vertex in enumerate(polyline):
if idx == 0:
previous_vertex = vertex
continue
else:
points.extend(interpolate_points(previous_vertex, vertex))
previous_vertex = vertex
return np.array(points)
class CameraBase:
def __init__(self):
pass
@abstractmethod
def ray2pixel_torch(self, rays: torch.Tensor) -> torch.Tensor:
raise NotImplementedError
@abstractmethod
def ray2pixel_np(self, rays: np.ndarray) -> np.ndarray:
raise NotImplementedError
@abstractmethod
def _get_rays(self) -> torch.Tensor:
raise NotImplementedError
def transform_points_torch(self, points: torch.Tensor, tfm: torch.Tensor) -> torch.Tensor:
"""
Args:
points: (M, 3)
tfm: (4, 4)
Returns:
points_transformed: (M, 3)
"""
transformed_points = tfm[:3, :3] @ points.T + tfm[:3, 3].unsqueeze(-1)
return transformed_points.T
def transform_points_np(self, points: np.ndarray, tfm: np.ndarray) -> np.ndarray:
"""
Args:
points: (M, 3)
tfm: (4, 4)
Returns:
points_transformed: (M, 3)
"""
transformed_points = tfm[:3, :3] @ points.T + tfm[:3, 3].reshape(-1, 1)
return transformed_points.T
def _get_rays_posed(self, camera_poses: torch.Tensor):
"""
Args:
camera_poses: (N, 4, 4)
Returns:
ray_o: (N, H, W, 3), camera origin
ray_d: (N, H, W, 3), camera rays
"""
rays_in_cam = self._get_rays() # shape (H, W, 3)
rays_d_in_world = torch.einsum('bij,hwj->bhwi', camera_poses[:, :3, :3], rays_in_cam) # shape (N, H, W, 3)
rays_o_in_world = camera_poses[:, :3, 3].unsqueeze(-2).unsqueeze(-2).expand_as(rays_d_in_world) # shape (N, H, W, 3)
return rays_o_in_world, rays_d_in_world
def _clip_polyline_to_image_plane(self, points_in_cam: np.ndarray) -> np.ndarray:
"""
Args:
points_in_cam: np.ndarray
shape: (M, 3), a polyline, they are connected.
Returns:
points: np.ndarray
shape: (M', 3), a polyline, but we clip the points to positive z if the points are behind the camera.
"""
depth = points_in_cam[:, 2]
# go through all the edges of the polyline.
eps = 1e-1
cam_coords_cliped = []
for i in range(len(points_in_cam) - 1):
pt1 = points_in_cam[i]
pt2 = points_in_cam[i+1]
if depth[i] >= 0 and depth[i+1] >= 0:
cam_coords_cliped.append(pt1)
elif depth[i] < 0 and depth[i+1] < 0:
continue
else:
# clip the line to the image boundary
if depth[i] >= 0:
# add the first point
cam_coords_cliped.append(pt1)
# calculate the intersection point and add it
t = (- pt2[2]) / (pt1[2] - pt2[2]) + eps
inter_pt = pt2 + t * (pt1 - pt2)
cam_coords_cliped.append(inter_pt)
else:
# calculate the intersection point and add it
t = (- pt1[2]) / (pt2[2] - pt1[2]) + eps
inter_pt = pt1 + t * (pt2 - pt1)
cam_coords_cliped.append(inter_pt)
# handle the last point, if its depth > 0 and not already added
if depth[-1] >= 0:
cam_coords_cliped.append(points_in_cam[-1])
cam_coords_cliped = np.stack(cam_coords_cliped, axis=0) # shape (M', 3)
return cam_coords_cliped
"""
Projection related functions
"""
def distance_to_zbuffer(self, distance_map: Union[torch.Tensor, np.ndarray]) -> Union[torch.Tensor, np.ndarray]:
"""
Args:
distance_map: (N, H, W) or (H, W), distance to the camera
Returns:
zbuffer_map: (N, H, W) or (H, W), 0 means no depth value
"""
rays = self._get_rays() # normalized camera rays, shape (H, W, 3)
return distance_map * rays[..., 2].expand_as(distance_map)
def zbuffer_to_distance(self, zbuffer_map: Union[torch.Tensor, np.ndarray]) -> Union[torch.Tensor, np.ndarray]:
"""
Args:
zbuffer_map: (N, H, W) or (H, W), depth value
Returns:
distance_map: (N, H, W) or (H, W), distance to the camera
"""
rays = self._get_rays()
return zbuffer_map / rays[..., 2].expand_as(zbuffer_map)
def get_zbuffer_map_from_points(self, camera_poses: Union[torch.Tensor, np.ndarray], points: Union[torch.Tensor, np.ndarray]) -> Union[torch.Tensor, np.ndarray]:
if isinstance(camera_poses, np.ndarray):
return self.get_zbuffer_map_from_points_np(camera_poses, points)
else:
return self.get_zbuffer_map_from_points_torch(camera_poses, points)
def get_zbuffer_map_from_points_torch(self, camera_poses: torch.Tensor, points: torch.Tensor) -> torch.Tensor:
"""
Args:
camera_pose: (N, 4, 4) or (4, 4)
points: (M, 3), in world coordinate
Returns:
zbuffer_map: (N, H, W) or (H, W), 0 means no depth value
"""
depth_images = []
camera_poses = make_sure_torch(camera_poses).to(self.device).to(self.dtype)
points = make_sure_torch(points).to(self.device).to(self.dtype)
if len(camera_poses.shape) == 2:
camera_poses = camera_poses.unsqueeze(0)
for camera_to_world in camera_poses:
points_in_cam = self.transform_points_torch(points, torch.inverse(camera_to_world))
uv_coords = self.ray2pixel_torch(points_in_cam)
depth = points_in_cam[:, 2]
valid_depth_mask = depth > 0
u_round = torch.round(uv_coords[:, 0]).long()
v_round = torch.round(uv_coords[:, 1]).long()
valid_uv_mask = (u_round >= 0) & (u_round < self.width) & (v_round >= 0) & (v_round < self.height)
valid_mask = valid_depth_mask & valid_uv_mask
u_valid = u_round[valid_mask]
v_valid = v_round[valid_mask]
z_valid = depth[valid_mask]
indices = v_valid * self.width + u_valid
depth_image = torch.full((self.height, self.width), float('inf')).to(depth).flatten()
depth_image = depth_image.scatter_reduce_(0, indices, z_valid, "amin")
depth_image = depth_image.view(self.height, self.width)
depth_mask = torch.isfinite(depth_image)
# change inf to 0
depth_image[~depth_mask] = 0
depth_images.append(depth_image)
depth_images = torch.stack(depth_images, dim=0)
if depth_images.shape[0] == 1:
depth_images = depth_images.squeeze(0)
return depth_images
def get_zbuffer_map_from_points_np(self, camera_poses: np.ndarray, points: np.ndarray) -> np.ndarray:
"""
Args:
camera_pose: (N, 4, 4) or (4, 4)
points: (M, 3), in world coordinate
Returns:
zbuffer_map: (N, H, W) or (H, W), 0 means no depth value
"""
depth_images = []
camera_poses = make_sure_numpy(camera_poses)
points = make_sure_numpy(points)
if len(camera_poses.shape) == 2:
camera_poses = camera_poses[np.newaxis, ...]
for camera_to_world in camera_poses:
points_in_cam = self.transform_points_np(points, np.linalg.inv(camera_to_world))
uv_coords = self.ray2pixel_np(points_in_cam)
depth = points_in_cam[:, 2]
valid_depth_mask = depth > 0
u_round = np.round(uv_coords[:, 0]).astype(np.int32)
v_round = np.round(uv_coords[:, 1]).astype(np.int32)
valid_uv_mask = (u_round >= 0) & (u_round < self.width) & (v_round >= 0) & (v_round < self.height)
valid_mask = valid_depth_mask & valid_uv_mask
u_valid = u_round[valid_mask]
v_valid = v_round[valid_mask]
z_valid = depth[valid_mask]
depth_image = np.zeros((self.height, self.width))
# use np.minimum.at to update the depth image
np.minimum.at(depth_image, (v_valid, u_valid), z_valid)
depth_images.append(depth_image)
depth_images = np.stack(depth_images, axis=0)
if depth_images.shape[0] == 1:
depth_images = depth_images[0]
return depth_images
def get_distance_map_from_points(self, camera_poses: torch.Tensor, points: torch.Tensor) -> torch.Tensor:
"""
Args:
camera_pose: (N, 4, 4) or (4, 4)
points: (M, 3), in world coordinate
Returns:
distance_map: (N, H, W) or (H, W), 0 means no depth value
"""
depth_map = self.get_zbuffer_map_from_points(camera_poses, points) # shape (N, H, W) or (H, W)
return self.zbuffer_to_distance(depth_map)
def get_distance_map_from_voxel(self, camera_poses: torch.Tensor, voxel_grid) -> torch.Tensor:
"""
Args:
camera_pose: (N, 4, 4) or (4, 4)
voxel_grid: fvdb.GridBatch
Returns:
distance_map: (N, H, W) or (H, W), 0 means no depth value
"""
camera_poses = make_sure_torch(camera_poses).to(self.device).to(self.dtype)
voxel_grid = voxel_grid.to(self.device).to(self.dtype)
if len(camera_poses.shape) == 2:
camera_poses = camera_poses.unsqueeze(0)
rays_o, rays_d = self._get_rays_posed(camera_poses)
N, H, W = rays_o.shape[:3]
segment = voxel_grid.segments_along_rays(rays_o.reshape(-1, 3), rays_d.reshape(-1, 3), 1, eps=1e-1)
pixel_hit = segment.joffsets[1:] - segment.joffsets[:-1]
pixel_hit = pixel_hit.view(N, H, W).float()
distance = segment.jdata[:, 0] # [N_hit,]
distance_map = torch.zeros((N, H, W)).to(distance.device)
distance_map[pixel_hit > 0] = distance
if N == 1:
distance_map = distance_map.squeeze(0)
return distance_map
def get_zbuffer_map_from_voxel(self, camera_poses: torch.Tensor, voxel_grid) -> torch.Tensor:
"""
Args:
camera_pose: (N, 4, 4) or (4, 4)
voxel_grid: fvdb.GridBatch
Returns:
zbuffer_map: (N, H, W) or (H, W), 0 means no depth value
"""
distance_map = self.get_distance_map_from_voxel(camera_poses, voxel_grid)
return self.distance_to_zbuffer(distance_map)
def get_semantic_map_from_voxel(self, camera_poses: torch.Tensor, voxel_grid, voxel_semantic: torch.Tensor, background_semantic: int = 0) -> torch.Tensor:
"""
Args:
camera_pose: (N, 4, 4) or (4, 4)
voxel_grid: fvdb.GridBatch
voxel_semantic: torch.Tensor, (#voxel, )
Returns:
semantic_map: (N, H, W) or (H, W)
"""
camera_poses = make_sure_torch(camera_poses).to(self.device).to(self.dtype)
voxel_grid = voxel_grid.to(self.device)
voxel_semantic = voxel_semantic.to(self.device)
if len(camera_poses.shape) == 2:
camera_poses = camera_poses.unsqueeze(0)
rays_o, rays_d = self._get_rays_posed(camera_poses)
N, H, W = rays_o.shape[:3]
vox, times = voxel_grid.voxels_along_rays(rays_o.reshape(-1, 3), rays_d.reshape(-1, 3), 1, eps=1e-1, return_ijk=False)
pixel_hit = times.joffsets[1:] - times.joffsets[:-1]
pixel_hit = pixel_hit.view(N, H, W).bool() # (N, H, W) 0,1 mask
# semantic
hit_voxel_semantic = voxel_semantic[vox.jdata] # [N_hit, C]
# fill the semantic to the image plane. 0 refers to UNDEFINED, which is the sky / background.
semantic_rasterize = torch.full((N, H, W), background_semantic).to(hit_voxel_semantic)
semantic_rasterize[pixel_hit] = hit_voxel_semantic
if N == 1:
semantic_rasterize = semantic_rasterize.squeeze(0) # (H, W)
return semantic_rasterize
"""
Drawing related functions
"""
def draw_points(
self,
camera_poses: Union[torch.Tensor, np.ndarray],
points: Union[torch.Tensor, np.ndarray],
colors: Union[torch.Tensor, np.ndarray, None] = None,
radius: int = 1,
fast_impl_when_radius_gt_1: bool = True
) -> np.ndarray:
"""
Args:
camera_poses: torch.Tensor or np.ndarray
shape: (N, 4, 4) or (4, 4)
points: torch.Tensor or np.ndarray
shape: (M, 3)
colors: torch.Tensor or np.ndarray or None
shape: (M, 3) in uint8
radius: int,
radius of the point
fast_impl_when_radius_gt_1: bool,
if True, use cv2.circle to draw the point when radius > 1
Returns:
canvas: np.ndarray
shape: (N, H, W, 3) or (H, W, 3)
dtype: np.uint8
"""
draw_images = []
camera_poses = make_sure_numpy(camera_poses)
points = make_sure_numpy(points)
if len(camera_poses.shape) == 2:
camera_poses = camera_poses[np.newaxis, ...]
if colors is not None:
colors = make_sure_numpy(colors)
else:
colors = np.tile([[255, 0, 0]], (points.shape[0], 1))
for camera_to_world in camera_poses:
points_in_cam = self.transform_points_np(points, np.linalg.inv(camera_to_world))
uv_coords = self.ray2pixel_np(points_in_cam)
depth = points_in_cam[:, 2]
valid_depth_mask = depth > 0
u_round = np.round(uv_coords[:, 0]).astype(np.int32)
v_round = np.round(uv_coords[:, 1]).astype(np.int32)
valid_uv_mask = (u_round >= 0) & (u_round < self.width) & (v_round >= 0) & (v_round < self.height)
valid_mask = valid_depth_mask & valid_uv_mask
u_valid = u_round[valid_mask]
v_valid = v_round[valid_mask]
z_valid = depth[valid_mask]
colors_valid = colors[valid_mask]
sorted_indices = np.argsort(z_valid, axis=0)[::-1]
u_valid = u_valid[sorted_indices]
v_valid = v_valid[sorted_indices]
colors_valid = colors_valid[sorted_indices]
if radius > 1 and fast_impl_when_radius_gt_1 is False:
canvas = np.zeros((self.height, self.width, 3), dtype=np.uint8)
for u, v, color in zip(u_valid, v_valid, colors_valid):
cv2.circle(canvas, (u.item(), v.item()), radius, color.tolist(), -1)
canvas = np.array(canvas, dtype=np.uint8)
# radius = 1 or we want fast impl
else:
canvas = np.zeros((self.height, self.width, 3), dtype=np.uint8)
canvas[v_valid, u_valid] = colors_valid # fill from the farthest point to the nearest point
# use fast impl when radius > 1
if radius > 1:
canvas_accum = np.zeros_like(canvas)
i_shifts = np.arange(-radius//2, radius//2+1)
j_shifts = np.arange(-radius//2, radius//2+1)
for i in i_shifts:
for j in j_shifts:
# use torch.roll to shift the canvas
canvas_shifted = np.roll(canvas, shift=(i, j), axis=(0, 1))
canvas_accum = np.maximum(canvas_accum, canvas_shifted)
canvas = canvas_accum
draw_images.append(canvas)
draw_images = np.stack(draw_images, axis=0)
if draw_images.shape[0] == 1:
draw_images = draw_images[0]
return draw_images
def draw_line_depth(
self,
camera_poses: Union[torch.Tensor, np.ndarray],
polylines: List,
radius: int = 8,
colors: np.ndarray = None,
segment_interval: float = 0,
) -> np.ndarray:
"""
draw lines on the image, and the drawed pixel value is related to the depth of the points.
The polyline can be out of boundary, use cv2.clipLine to clip the line to the image boundary, or abandon the line.
Then use cv2.line to draw the line.
Args:
camera_poses: torch.Tensor or np.ndarray
shape: (N, 4, 4) or (4, 4)
polylines: list of list of points,
each point is in 3D (x, y, z)
radius: int,
radius of the drawn circle
colors: np.ndarray,
shape: (3, ), dtype: np.uint8
segment_interval: float,
if > 0, the polyline is segmented into segments with the interval
Returns:
draw_images: np.ndarray
shape: (N, H, W, 3) or (H, W, 3)
dtype: np.uint8
"""
draw_images = []
camera_poses = make_sure_numpy(camera_poses)
if colors is None:
colors = np.array([255, 255, 255])
if len(camera_poses.shape) == 2:
camera_poses = camera_poses[np.newaxis, ...]
for camera_to_world in camera_poses:
canvas = np.zeros((self.height, self.width, 3), dtype=np.uint8)
H, W = canvas.shape[:2]
for polyline in polylines:
if len(polyline) < 2:
continue
if isinstance(polyline, list):
polyline = np.array(polyline)
if segment_interval > 0:
polyline = interpolate_polyline_to_points(polyline, segment_interval)
points_in_cam = self.transform_points_np(polyline, np.linalg.inv(camera_to_world))
uv_coords = self.ray2pixel_np(points_in_cam)
depth = points_in_cam[:, 2]
u_round = np.round(uv_coords[:, 0]).astype(np.int32)
v_round = np.round(uv_coords[:, 1]).astype(np.int32)
valid_uv_mask = (u_round >= 0) & (u_round < W) & (v_round >= 0) & (v_round < H)
# filter out the polyline if all points are out of the image boundary
if (~valid_uv_mask).all():
continue
# if depth all greater than DEPTH_MAX, skip
if depth.min() > DEPTH_MAX:
continue
for i in range(len(u_round) - 1):
if depth[i] < 0 and depth[i+1] < 0:
continue
if depth[i] * depth[i+1] < 0:
# if the two points are on different sides of the camera, we first clip the 3d point in the back to the camera plane + epsilon
# and then reproject it to the image plane, calculate the uv coordinate
pt1 = points_in_cam[i]
pt2 = points_in_cam[i+1]
# make sure pt1 is in front of the camera, pt2 is behind the camera
if depth[i] < 0:
pt1, pt2 = pt2, pt1
# clip the line to the image boundary
eps = 2e-1
t = (- pt2[2]) / (pt1[2] - pt2[2]) + eps
pt2 = t * pt1 + (1 - t) * pt2
# project the point to the image plane
pt1_norm = pt1[:3] / pt1[2]
pt2_norm = pt2[:3] / pt2[2]
pixel1 = self.ray2pixel_np(pt1_norm)[0]
pixel2 = self.ray2pixel_np(pt2_norm)[0]
else:
pixel1 = np.array([u_round[i], v_round[i]])
pixel2 = np.array([u_round[i+1], v_round[i+1]])
try:
clipped, pixel1, pixel2 = \
cv2.clipLine((0, 0, W, H), pixel1.astype(np.int32), pixel2.astype(np.int32))
except:
breakpoint()
depth_mean = (depth[i] + depth[i+1]) / 2
depth_mean = np.clip(depth_mean, 0, DEPTH_MAX)
fill_weight = (2 * (DEPTH_MAX - depth_mean)) / 255
fill_value = (fill_weight * colors).astype(np.uint8).tolist()
cv2.line(canvas, tuple(pixel1), tuple(pixel2), fill_value, radius)
draw_images.append(canvas)
draw_images = np.stack(draw_images, axis=0)
if draw_images.shape[0] == 1 and len(draw_images.shape) == 3:
draw_images = draw_images[0]
return draw_images
def draw_hull_depth(
self,
camera_poses: Union[torch.Tensor, np.ndarray],
hulls: List,
colors: np.ndarray = None
) -> torch.Tensor:
"""
draw hulls on the image, and the drawed pixel value is related to the depth of the points.
The hull can be out of boundary, use cv2.clipLine to clip the line to the image boundary, or abandon the line.
Then use cv2.line to draw the line.
Args:
camera_poses: torch.Tensor or np.ndarray
shape: (N, 4, 4) or (4, 4)
hulls: list of list of points,
each point is in 3D (x, y, z)
colors: np.ndarray,
shape: (3, ), dtype: np.uint8
Returns:
draw_images: (N, H, W, 3) or (H, W, 3), image with hulls drawn
"""
draw_images = []
camera_poses = make_sure_numpy(camera_poses)
if len(camera_poses.shape) == 2:
camera_poses = camera_poses[np.newaxis, ...]
for camera_to_world in camera_poses:
canvas = np.zeros((self.height, self.width, 3), dtype=np.uint8)
H, W = canvas.shape[:2]
for hull in hulls:
if len(hull) < 3:
continue
points_in_cam = self.transform_points_np(hull, np.linalg.inv(camera_to_world))
uv_coords = self.ray2pixel_np(points_in_cam).astype(np.int32)
depth = points_in_cam[:, 2]
valid_depth_mask = depth > 0
u_round = uv_coords[:, 0]
v_round = uv_coords[:, 1]
valid_uv_mask = (u_round >= 0) & (u_round < W) & (v_round >= 0) & (v_round < H)
valid_mask = valid_depth_mask & valid_uv_mask
# filter out the polyline if all points are out of the image boundary
if not valid_mask.any():
continue
# if depth all greater than DEPTH_MAX, skip
if depth.min() > DEPTH_MAX:
continue
# project again with clipped points
points_in_cam_clipped = self._clip_polyline_to_image_plane(points_in_cam)
uv_coords = self.ray2pixel_np(points_in_cam_clipped).astype(np.int32)
depth_mean = points_in_cam_clipped[:, 2].mean()
# cv2.clipLine to limit the points to the image boundary
uv_coords_line_clipped = []
for i in range(len(uv_coords) - 1):
pixel1 = uv_coords[i]
pixel2 = uv_coords[i+1]
clipped, pixel1, pixel2 = cv2.clipLine((0, 0, W, H), pixel1, pixel2)
uv_coords_line_clipped.append(pixel1)
uv_coords_line_clipped.append(pixel2)
hull_in_uv = np.array(uv_coords_line_clipped).astype(np.int32)
# create convex hull and draw on the image
convex_hull_in_uv = cv2.convexHull(hull_in_uv.astype(np.int32)) # shape (N, 1, 2)
fill_weight = (2 * (DEPTH_MAX - depth_mean)) / 255
fill_value = (fill_weight * colors).astype(np.uint8).tolist()
cv2.fillPoly(canvas, [convex_hull_in_uv], fill_value)
draw_images.append(canvas)
draw_images = np.stack(draw_images, axis=0)
if draw_images.shape[0] == 1 and len(draw_images.shape) == 3:
draw_images = draw_images[0]
return draw_images