Files
hand-motion-pipeline/third_party/Dyn-HaMR/dyn-hamr/vis/viewer.py
T
liyang ae28d55f81 Update to 2026-09-17 pipeline snapshot; add weights, L20 assets and recording via Git LFS
Source: RGB-D -> Dyn-HaMR -> L20 retargeting -> FoundationPose -> reference repair -> SPIDER,
documented in docs/PIPELINE_LATEST.md and docs/SETUP_AND_WEIGHTS.md. Adds FoundationPose and
nvdiffrast upstream snapshots, requirements/pipeline_venv.txt and the FoundationPose weight
manifest/downloader.

Assets (Git LFS): weights/ (WiLoR detector, HandFlow denoiser, UniDepth-L), FoundationPose
checkpoints, HaMeR checkpoint, Dyn-HaMR HMP model and BMC constraints, L20 URDF/meshes, the
20260915_171525 D405 recording and the two box CADs. MANO models are not redistributed
(third_party/hamer/_DATA/data/mano/README.txt). Environments, caches and run outputs excluded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:43:37 +08:00

675 lines
22 KiB
Python

import os
import imageio
import numpy as np
import time
import torch
import trimesh
os.environ["PYOPENGL_PLATFORM"] = "egl"
import pyrender
from pyrender.constants import RenderFlags
from pyrender.light import DirectionalLight
from pyrender.node import Node
from geometry.camera import make_rotation, make_translation
from .tools import (
read_image,
checkerboard_geometry,
camera_marker_geometry,
transform_pyrender,
)
def init_viewer(
img_size, intrins, vis_scale=1.0, bg_paths=None, fps=30,
):
img_size = int(vis_scale * img_size[0]), int(vis_scale * img_size[1])
intrins = vis_scale * intrins
platform = os.environ.get("PYOPENGL_PLATFORM", "pyglet")
if platform == "pyglet":
vis = AnimationViewer(img_size, intrins=intrins, fps=fps)
print("VIS", vis)
return vis
if platform != "egl":
raise NotImplementedError
vis = OffscreenAnimation(img_size, intrins=intrins, fps=fps)
if bg_paths is not None:
bg_imgs = [
read_image(p, scale=vis_scale).astype(np.float32) / 255 for p in bg_paths
]
vis.set_bg_seq(bg_imgs)
print("VIS", vis)
return vis
class AnimationBase(object):
"""
Render an animation
IMPORTANT: pyrender uses coordinates with Y UP (i.e. xyz = right up back),
we must keep this in mind because the human prior frame has Z UP (i.e.
xyz = right forward up)
"""
def __init__(self, img_size, intrins=None):
self.scene = pyrender.Scene(
ambient_light=[0.3, 0.3, 0.3], bg_color=[1.0, 1.0, 1.0, 0.0]
)
self.scene.bg_color = np.array([1.0, 1.0, 1.0, 0.0])
self.viewport_size = img_size
self.camera = make_pyrender_camera(img_size, intrins)
# set the ground with Z up
self.ground_pose = np.eye(4)
ground = pyrender.Mesh.from_trimesh(
make_checkerboard(color0=[0.9, 0.95, 1.0], color1=[0.7, 0.8, 0.85], up="y", alpha=1.0), smooth=False
)
self.ground_node = self.scene.add(ground, name="ground", pose=self.ground_pose)
self.cam_node = self.scene.add(self.camera, name="camera")
self.cam_marker_node = None
self.cam_marker_poses = []
self.light_nodes = []
self.light_poses = []
self.bg_seq = []
# pyrender nodes, reused between frames
self.anim_nodes = []
# list of meshes per frame of animation
self.anim_meshes = []
# list of camera poses per frame of animation
self.anim_cameras = None
# current timestep of animation
self.anim_idx = 0
def acquire_lock(self):
pass
def release_lock(self):
pass
def close(self):
pass
def delete(self):
pass
def clear_meshes(self):
self.anim_meshes = []
self.anim_idx = 0
@property
def anim_len(self):
return len(self.anim_meshes)
def add_lighting(self, color=np.ones(3), intensity=1.0):
self.light_poses = get_light_poses()
self.light_poses.append(np.eye(4))
cam_pose = self.scene.get_pose(self.cam_node)
for i, pose in enumerate(self.light_poses):
matrix = cam_pose @ pose
node = Node(
name=f"light-{i:02d}",
light=DirectionalLight(color=color, intensity=intensity),
matrix=matrix,
)
if self.scene.has_node(node):
continue
self.scene.add_node(node)
self.light_nodes.append(node)
def add_mesh_frame(self, meshes, debug=False):
"""
:param meshes (list) trimesh meshes for this frame timestep
"""
meshes = [pyrender.Mesh.from_trimesh(m) for m in meshes]
num_nodes = len(meshes)
self.anim_meshes.append(meshes)
if num_nodes > len(self.anim_nodes):
# need to add a node in the pyrender scene
self.acquire_lock()
for i in range(len(self.anim_nodes), num_nodes):
self.anim_nodes.append(self.scene.add(meshes[i], name=f"mesh_{i:03d}"))
self.release_lock()
def add_static_meshes(self, meshes, smooth=True):
"""
add all meshes to a single frame (timestep)
"""
meshes = [pyrender.Mesh.from_trimesh(m, smooth=smooth) for m in meshes]
if len(self.anim_meshes) < 1:
self.anim_meshes.append([])
self.anim_meshes[0].extend(meshes)
num_meshes = len(self.anim_meshes[0])
num_nodes = len(self.anim_nodes)
if num_meshes > num_nodes:
# need to add a node in the pyrender scene
self.acquire_lock()
self.anim_nodes.extend(
[
self.scene.add(self.anim_meshes[0][i])
for i in range(num_nodes, num_meshes)
]
)
self.release_lock()
def set_camera_seq(self, poses):
"""
:param poses (*, 4, 4)
"""
poses = transform_pyrender(poses)
poses = np.asarray(poses)
assert poses.ndim >= 2 and poses.shape[-2:] == (4, 4)
if poses.ndim < 3:
poses = poses[None]
print(f"Adding camera sequence length {len(poses)}")
self.anim_cameras = poses
def add_camera_markers(self, poses):
"""
Add a single camera marker node that we move around when we animate
"""
if os.environ.get("DYNHAMR_HIDE_CAMERA_MARKERS") == "1":
return
print("ADDING CAMERA MARKERS")
if self.cam_marker_node is None:
cam_marker = make_camera_marker(up="y")
self.cam_marker_node = self.scene.add(
pyrender.Mesh.from_trimesh(cam_marker, smooth=False)
)
poses = transform_pyrender(poses)
poses = np.asarray(poses)
self.cam_marker_poses = poses
def add_camera_markers_static(self, poses):
"""
Add all camera markers as separate mesh nodes to render in one pass
"""
print("ADDING STATIC CAMERA MARKERS")
poses = transform_pyrender(poses)
poses = np.asarray(poses)
cam_meshes = [make_camera_marker(up="y", transform=pose) for pose in poses]
self.add_static_meshes(cam_meshes, smooth=False)
def update_camera(self, cam_pose):
self.acquire_lock()
self.scene.set_pose(self.cam_node, pose=cam_pose)
self.release_lock()
def set_ground(self, pose):
pose = transform_pyrender(pose)
print("Setting ground pose to be", pose)
self.ground_pose = np.asarray(pose)
self.scene.set_pose(self.ground_node, pose=self.ground_pose)
def set_bg_seq(self, bg_imgs):
pass
def set_mesh_visibility(self, vis):
self.acquire_lock()
for node in self.anim_nodes:
node.mesh.is_visible = vis
self.release_lock()
def check_mesh_visibility(self):
for node in self.anim_nodes:
if node.mesh.is_visible:
return True
if self.ground_node.mesh.is_visible:
return True
if self.cam_marker_node is not None and self.cam_marker_node.mesh.is_visible:
return True
return False
def update_frame(self):
t = self.anim_idx % self.anim_len
# update camera
if self.anim_cameras is not None:
cam_t = min(t, len(self.anim_cameras) - 1)
cam_pose = self.anim_cameras[cam_t]
self.update_camera(cam_pose)
if self.cam_marker_node is not None and t < len(self.cam_marker_poses):
self.acquire_lock()
self.scene.set_pose(self.cam_marker_node, self.cam_marker_poses[t])
self.release_lock()
# update meshes
meshes = self.anim_meshes[t]
self.acquire_lock()
for i, node in enumerate(self.anim_nodes):
if i < len(meshes):
node.mesh = meshes[i]
node.mesh.is_visible = True
else:
node.mesh.is_visible = False
self.release_lock()
class OffscreenAnimation(AnimationBase):
def __init__(self, img_size, intrins=None, fps=30, ext="mp4"):
super().__init__(img_size, intrins=intrins)
self.add_lighting(0.9)
self.fps = fps
self.ext = ext
self.viewer = pyrender.OffscreenRenderer(*self.viewport_size)
self.bg_seq = []
self.keypoints_seq = None # Store GT 2D keypoints for overlay
self.pred_keypoints_seq = None # Store predicted 2D keypoints
def set_bg_seq(self, bg_imgs):
assert isinstance(bg_imgs, list)
print(f"setting length {len(bg_imgs)} bg sequence")
self.bg_seq = bg_imgs
def set_keypoints_seq(self, keypoints_seq):
"""
Set GT 2D keypoints sequence for overlay visualization
Args:
keypoints_seq: List of (J, 3) arrays with [x, y, confidence] for each frame
"""
self.keypoints_seq = keypoints_seq
print(f"setting length {len(keypoints_seq)} GT keypoints sequence")
def set_pred_keypoints_seq(self, pred_keypoints_seq):
"""
Set predicted 2D keypoints sequence
Args:
pred_keypoints_seq: List of (J, 2) arrays with [x, y] for each frame
"""
self.pred_keypoints_seq = pred_keypoints_seq
print(f"setting length {len(pred_keypoints_seq)} predicted keypoints sequence")
def delete(self):
self.viewer.delete()
def close(self):
self.delete()
def draw_keypoints_on_image(self, img, keypoints, radius=4, color=(0, 255, 0), thickness=-1, conf_threshold=0.3):
"""
Draw 2D keypoints on an image
Args:
img: numpy array (H, W, 3)
keypoints: (J, 3) array with [x, y, confidence]
radius: circle radius for keypoints
color: BGR color tuple
thickness: -1 for filled circles
conf_threshold: minimum confidence to draw keypoint
Returns:
img with keypoints drawn
"""
import cv2
img = img.copy()
for j in range(keypoints.shape[0]):
x, y, conf = keypoints[j]
if conf > conf_threshold:
# Draw circle at keypoint location
cv2.circle(img, (int(x), int(y)), radius, color, thickness)
return img
def render(
self, render_bg=True, render_ground=True, render_cam=True, fac=1.0, render_keypoints=False, **kwargs
):
flags = RenderFlags.RGBA | RenderFlags.SHADOWS_DIRECTIONAL
self.ground_node.mesh.is_visible = render_ground and (not render_bg)
if self.cam_marker_node is not None:
self.cam_marker_node.mesh.is_visible = render_cam
rgba = np.zeros((*self.viewport_size[::-1], 4))
if self.check_mesh_visibility(): # if any meshes are visible
rgba, _ = self.viewer.render(self.scene, flags=flags)
img = rgba
if render_bg and len(self.bg_seq) > 0:
t = min(self.anim_idx, len(self.bg_seq) - 1)
bg = self.bg_seq[t]
rgba = rgba.astype(np.float32) / 255
alpha = fac * rgba[..., 3:]
img = alpha * rgba[..., :3] + (1 - alpha) * bg
img = (255 * img).astype(np.uint8)
# Overlay 2D keypoints if available and requested
if render_keypoints and render_bg:
# Draw GT keypoints in GREEN (larger)
if self.keypoints_seq is not None:
t = min(self.anim_idx, len(self.keypoints_seq) - 1)
keypoints = self.keypoints_seq[t]
if keypoints is not None and len(keypoints) > 0:
img = self.draw_keypoints_on_image(img, keypoints, color=(0, 255, 0), radius=5)
# Draw predicted keypoints in RED (smaller)
if self.pred_keypoints_seq is not None:
t = min(self.anim_idx, len(self.pred_keypoints_seq) - 1)
pred_kp = self.pred_keypoints_seq[t]
if pred_kp is not None and len(pred_kp) > 0:
# Add confidence=1.0 if only (J, 2)
if pred_kp.ndim == 2 and pred_kp.shape[1] == 2:
pred_kp = np.concatenate([pred_kp, np.ones((len(pred_kp), 1))], axis=1)
img = self.draw_keypoints_on_image(img, pred_kp, color=(0, 0, 255), radius=3, conf_threshold=0.0)
return img
def render_mesh_layers(self, render_ground=True, render_cam=True, **kwargs):
# render each mesh in its own layer
flags = RenderFlags.RGBA | RenderFlags.SHADOWS_DIRECTIONAL
render_cam = render_cam and self.cam_marker_node is not None
# current timestep
t = self.anim_idx % self.anim_len
meshes = self.anim_meshes[t]
layers = []
# toggle ground and camera
self.ground_node.mesh.is_visible = False
if self.cam_marker_node is not None:
self.cam_marker_node.mesh.is_visible = False
# render animation meshes for current timestep
for i in range(len(meshes)):
for j, node in enumerate(self.anim_nodes):
if i == j:
node.mesh.is_visible = True
else:
node.mesh.is_visible = False
rgba, _ = self.viewer.render(self.scene, flags=flags)
layers.append(rgba)
if not render_ground and not render_cam:
return layers
# render the ground and camera
for node in self.anim_nodes:
node.mesh.is_visible = False
if render_cam:
self.cam_marker_node.mesh.is_visible = render_cam
rgba, _ = self.viewer.render(self.scene, flags=flags)
layers.append(rgba)
self.cam_marker_node.mesh.is_visible = False
if render_ground:
self.ground_node.mesh.is_visible = render_ground
rgba, _ = self.viewer.render(self.scene, flags=flags)
layers.append(rgba)
return layers
def render_frames_layers(self):
frames_layers = []
for t in range(self.anim_len):
self.anim_idx = t
self.update_frame()
layers = self.render_mesh_layers()
frames_layers.append(layers)
return frames_layers
def animate(self, save_name, save_frames=False, render_layers=False, **kwargs):
if render_layers:
frames_layers = self.render_frames_layers()
os.makedirs(save_name, exist_ok=True)
for t, layers in enumerate(frames_layers):
for l, layer in enumerate(layers):
path = f"{save_name}/{t:06d}_{l:03d}.png"
imageio.imwrite(path, layer)
print(f"saved frame layers to {save_name}")
return save_name
frames = self.render_frames(**kwargs)
if len(frames) > 1:
if save_frames:
os.makedirs(save_name, exist_ok=True)
for i, frame in enumerate(frames):
path = f"{save_name}/{i:06d}.png"
imageio.imwrite(path, frame)
return save_name
save_path = f"{save_name}.{self.ext}"
os.makedirs(save_name, exist_ok=True)
for idx, i in enumerate(frames):
imageio.imwrite(f"{save_name}/" + f'{str(idx).zfill(6)}.jpg', i[:, :, :3])
imageio.mimwrite(save_path, frames, fps=self.fps)
print("wrote video to", save_path)
return save_path
save_path = f"{save_name}.png"
imageio.imwrite(save_path, frames[0])
print("wrote image to", save_path)
return save_path
def render_frames(self, **kwargs):
print("ANIMATION LENGTH", self.anim_len)
frames = []
for t in range(self.anim_len):
self.anim_idx = t
self.update_frame()
img = self.render(**kwargs)
frames.append(img)
return frames
def render_layers(self, save_dir, composite=False, fac=0.4):
os.makedirs(save_dir, exist_ok=True)
# render all frames
frames = self.render_frames(render_bg=False, render_ground=False)
# render background
self.set_mesh_visibility(False)
bg_img = self.render(render_bg=False, render_ground=True) / 255
# save frames
for t, frame in enumerate(frames):
imageio.imwrite(f"{save_dir}/{t:06d}.png", frame)
imageio.imwrite(f"{save_dir}/background.png", bg_img)
if composite:
rgba = composite_layers(frames, bg_img, fac=fac)
imageio.imwrite(f"{save_dir}/composite.png", (255 * rgba).astype(np.uint8))
def composite_layers(frames, bg_img, fac=0.4):
# composite front to back
H, W = frames[0].shape[:2]
comp = np.zeros((H, W, 3))
vis = np.ones((H, W, 1))
for frame in frames:
frame = frame / 255
alpha = fac * frame[..., 3:]
comp += vis * alpha * frame[..., :3]
vis *= 1 - alpha
comp += vis * bg_img[..., 3:] * bg_img[..., :3]
vis *= 1 - bg_img[..., 3:]
return comp + vis
rgba = np.concatenate([comp, 1 - vis], axis=-1)
return rgba
class AnimationViewer(AnimationBase):
def __init__(self, img_size, intrins=None, fps=30, num_repeats=1):
super().__init__(img_size, intrins=intrins)
self.fps = fps
self.ext = "gif"
def pause_play_callback(_, viewer):
viewer.is_paused = not viewer.is_paused
def step_callback(_, viewer, step):
viewer.anim_idx = (viewer.anim_idx + step) % viewer.anim_len
def stop_callback(_, viewer):
viewer.do_animate = False
def loop_callback(_, viewer):
viewer.num_repeats *= -1
registered_keys = {
"p": (pause_play_callback, [self]),
".": (step_callback, [self, 1]),
",": (step_callback, [self, -1]),
"l": (loop_callback, [self]),
"s": (stop_callback, [self]),
}
self.viewer = pyrender.Viewer(
self.scene,
run_in_thread=True,
viewport_size=self.viewport_size,
use_raymond_lighting=True,
registered_keys=registered_keys,
)
self.num_repeats = num_repeats
self.do_animate = False
self.is_paused = False
def acquire_lock(self):
self.viewer.render_lock.acquire()
def release_lock(self):
self.viewer.render_lock.release()
def close(self):
self.viewer.close_external()
def animate(self, save_name=None, **kwargs):
print("===================")
print(f"PLAYING ANIMATION {self.num_repeats} TIMES")
print("VIEWER CONTROLS:")
print("p\tpause/play")
print("l\tloop/unloop")
print(".\tstep forward")
print(",\tstep backward")
print("s\tstop")
print("===================")
self.anim_idx = 0
self.do_animate = True
frame_dur = 1 / self.fps
if save_name is not None:
self.viewer.viewer_flags["record"] = True
# set up initial frame
self.update_frame()
while self.do_animate:
if self.is_paused:
self.update_frame()
continue
if (
self.num_repeats > 0
and self.anim_idx >= self.num_repeats * self.anim_len
):
break
if save_name is not None and self.anim_idx >= self.anim_len:
self.viewer.viewer_flags["record"] = False
start_time = time.time()
self.update_frame()
render_time = time.time() - start_time
self.anim_idx += 1
sleep_time = frame_dur - render_time
if sleep_time > 0:
time.sleep(sleep_time)
if save_name is not None:
print(f"SAVING RECORDING TO {save_name}")
self.viewer.save_gif(f"{save_name}.{self.ext}")
def make_checkerboard(
length=25.0,
color0=[0.8, 0.9, 0.9],
color1=[0.6, 0.7, 0.7],
tile_width=0.5,
alpha=1.0,
up="y",
):
v, f, _, fc = checkerboard_geometry(length, color0, color1, tile_width, alpha, up)
return trimesh.Trimesh(v, f, face_colors=fc, process=False)
def make_pyrender_camera(img_size, intrins=None):
if intrins is not None:
print("USING INTRINSICS CAMERA", intrins)
fx, fy, cx, cy = intrins
return pyrender.IntrinsicsCamera(fx, fy, cx, cy)
W, H = img_size
focal = 0.5 * (H + W)
yfov = 2 * np.arctan(0.5 * H / focal)
print("USING PERSPECTIVE CAMERA", H, W, focal)
return pyrender.PerspectiveCamera(yfov=yfov, aspectRatio=W / H)
def make_camera_marker(radius=0.1, height=0.2, up="y", transform=None):
"""
:param radius (default 0.1) radius of pyramid base, diagonal of image plane
:param height (default 0.2) height of pyramid, focal length
:param up (default y) camera up vector
:param transform (default None) (4, 4) cam to world transform
"""
verts, faces, face_colors = camera_marker_geometry(radius, height, up)
if transform is not None:
assert transform.shape == (4, 4)
verts = (
np.einsum("ij,nj->ni", transform[:3, :3], verts) + transform[None, :3, 3]
)
return trimesh.Trimesh(verts, faces, face_colors=face_colors, process=False)
def get_light_poses(n_lights=5, elevation=np.pi / 3, dist=12):
# get lights in a circle around origin at elevation
thetas = elevation * np.ones(n_lights)
phis = 2 * np.pi * np.arange(n_lights) / n_lights
poses = []
trans = make_translation(torch.tensor([0, 0, dist]))
for phi, theta in zip(phis, thetas):
rot = make_rotation(rx=-theta, ry=phi, order="xyz")
poses.append((rot @ trans).numpy())
return poses
def get_raymond_light_poses(up="z"):
thetas = np.pi * np.ones(3) / 6
phis = 2 * np.pi * np.arange(3) / 3
poses = []
for phi, theta in zip(phis, thetas):
xp = np.sin(theta) * np.cos(phi)
yp = np.sin(theta) * np.sin(phi)
zp = np.cos(theta)
if up == "y":
z = np.array([xp, zp, -yp])
else:
z = np.array([xp, yp, zp])
z = z / np.linalg.norm(z)
x = np.array([-z[1], z[0], 0.0])
if np.linalg.norm(x) == 0:
x = np.array([1.0, 0.0, 0.0])
x = x / np.linalg.norm(x)
y = np.cross(z, x)
matrix = np.eye(4)
matrix[:3, :3] = np.c_[x, y, z]
poses.append(matrix)
return poses