7e4ef6f98b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
360 lines
17 KiB
Python
360 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""demo.py — HandFlow unified demo: cam overlay + orthographic view in one run.
|
|
|
|
Two camera modes (selected by --fix_camera):
|
|
--fix_camera Fixed camera (e.g. DexYCB on a tripod). ViPE is skipped, c2w is
|
|
identity, and the orthographic view assumes a level, fixed camera:
|
|
a side orthographic view of the camera-space hand motion (the
|
|
camera marker stays at the origin; only the hand moves).
|
|
(default) Moving camera (e.g. HOT3D head-mounted). ViPE SLAM estimates c2w
|
|
and the orthographic view is a world-space trajectory view: camera
|
|
trajectory line + per-frame hand mesh.
|
|
|
|
Both modes write two mp4s into --output_dir:
|
|
overlay.mp4 Clean camera-space MANO mesh overlaid onto the RGB frames.
|
|
ortho.mp4 Orthographic (side / topdown) view of the hand (+ camera motion).
|
|
|
|
Intrinsics (--intrinsics):
|
|
fx,fy,cx,cy Explicit pinhole intrinsics (recommended; e.g. the demo GT values).
|
|
auto Let ViPE estimate intrinsics (only valid without --fix_camera).
|
|
(unset) Generic default [600, 600, W/2, H/2].
|
|
|
|
Usage:
|
|
# Fixed camera (no ViPE needed)
|
|
python scripts/demo.py --input video.mp4 --fm_ckpt <ckpt> \
|
|
--intrinsics fx,fy,cx,cy --fix_camera --output_dir output/demo
|
|
|
|
# Moving camera (ViPE estimates c2w; requires the vipe env)
|
|
python scripts/demo.py --input video.mp4 --fm_ckpt <ckpt> \
|
|
--intrinsics fx,fy,cx,cy --output_dir output/demo
|
|
|
|
# Moving camera, unknown intrinsics -> ViPE estimates both intr + c2w
|
|
python scripts/demo.py --input video.mp4 --fm_ckpt <ckpt> \
|
|
--intrinsics auto --output_dir output/demo
|
|
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import torch
|
|
from omegaconf import OmegaConf
|
|
from pytorch3d.transforms import axis_angle_to_quaternion, quaternion_to_axis_angle
|
|
|
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
|
|
from utils.checkpoint_utils import load_denoiser_from_ckpt
|
|
from utils.inference_utils import run_fm_inference_with_overlap, split_sequence_into_windows
|
|
from utils.mano_utils import MANOForwardKinematics
|
|
from utils.online_hamer import OnlineHaMeRPipeline
|
|
from utils.vipe_worker import run_vipe_slam
|
|
from visualization.renderer_p3d import PhongRenderer
|
|
from visualization.video_io import read_video_frames, write_video_ffmpeg
|
|
|
|
|
|
def resolve_intrinsics(arg_str, W: int, H: int):
|
|
"""Resolve --intrinsics into (intr(4,) | None, vipe_estimate: bool).
|
|
|
|
None / "" / "default" -> generic [600, 600, W/2, H/2], vipe_estimate=False
|
|
"auto" -> (None, True) (ViPE estimates; only without --fix_camera)
|
|
"fx,fy,cx,cy" -> explicit (4,), vipe_estimate=False
|
|
"""
|
|
if arg_str in (None, "", "default"):
|
|
return np.array([600.0, 600.0, W / 2.0, H / 2.0], dtype=np.float32), False
|
|
if arg_str == "auto":
|
|
return None, True
|
|
parts = [float(x) for x in arg_str.split(",")]
|
|
if len(parts) != 4:
|
|
raise ValueError(f"--intrinsics expects 4 values fx,fy,cx,cy or 'auto', got {arg_str!r}")
|
|
return np.array(parts, dtype=np.float32), False
|
|
|
|
|
|
def extract_frames_to_dir(frames_bgr, img_dir: str) -> None:
|
|
"""Write frames as jpg into a directory (input for ViPE SLAM)."""
|
|
os.makedirs(img_dir, exist_ok=True)
|
|
for i, f in enumerate(frames_bgr):
|
|
cv2.imwrite(str(Path(img_dir) / f"{i:06d}.jpg"), f)
|
|
|
|
|
|
def transform_verts_to_world(verts_cam_m: np.ndarray, c2w: np.ndarray) -> np.ndarray:
|
|
"""verts_cam (T,N,3) meters + c2w (T,4,4) -> verts_world (T,N,3) meters."""
|
|
T, N, _ = verts_cam_m.shape
|
|
homog = np.concatenate([verts_cam_m, np.ones((T, N, 1), dtype=np.float32)], axis=-1)
|
|
return np.einsum("tij,tnj->tni", c2w.astype(np.float32), homog)[..., :3].astype(np.float32)
|
|
|
|
|
|
def smooth_pose_rotations(pose_seq: torch.Tensor, window: int) -> torch.Tensor:
|
|
"""Temporally smooth MANO axis-angle joints by averaging in quaternion space."""
|
|
if window <= 1:
|
|
return pose_seq
|
|
if window % 2 == 0:
|
|
raise ValueError("--rotation_smooth_window must be an odd positive integer")
|
|
|
|
n_frames, pose_dim = pose_seq.shape
|
|
if pose_dim % 3:
|
|
raise ValueError(f"Expected axis-angle pose dimension divisible by 3, got {pose_dim}")
|
|
quats = axis_angle_to_quaternion(pose_seq.reshape(n_frames, -1, 3))
|
|
radius = window // 2
|
|
weights = (radius + 1 - torch.arange(-radius, radius + 1, device=pose_seq.device).abs()).to(pose_seq)
|
|
weights = weights / weights.sum()
|
|
smoothed = []
|
|
for frame_idx in range(n_frames):
|
|
indices = torch.arange(frame_idx - radius, frame_idx + radius + 1, device=pose_seq.device)
|
|
indices = indices.clamp(0, n_frames - 1)
|
|
neighbors = quats[indices]
|
|
reference = quats[frame_idx].unsqueeze(0)
|
|
signs = torch.where((neighbors * reference).sum(dim=-1, keepdim=True) < 0, -1.0, 1.0)
|
|
averaged = (neighbors * signs * weights[:, None, None]).sum(dim=0)
|
|
smoothed.append(averaged / averaged.norm(dim=-1, keepdim=True).clamp_min(1e-8))
|
|
return quaternion_to_axis_angle(torch.stack(smoothed)).reshape_as(pose_seq)
|
|
|
|
|
|
def get_camera_geometry(frames_bgr, fix_camera: bool, intr, vipe_estimate: bool, T: int):
|
|
"""Return (intr(4,), c2w(T,4,4), vipe_slam_time_s).
|
|
|
|
fix_camera=True: c2w = identity (no ViPE); intr is the provided/generic value.
|
|
fix_camera=False: run ViPE — gt_intr (c2w only) when intr is known, else default
|
|
(ViPE estimates intr + c2w).
|
|
"""
|
|
if fix_camera:
|
|
if vipe_estimate:
|
|
print("[demo] --fix_camera ignores --intrinsics auto; using generic 600,600,W/2,H/2")
|
|
intr = np.array([600.0, 600.0, frames_bgr[0].shape[1] / 2.0,
|
|
frames_bgr[0].shape[0] / 2.0], dtype=np.float32)
|
|
c2w = np.broadcast_to(np.eye(4, dtype=np.float32), (T, 4, 4)).copy()
|
|
return intr.astype(np.float32), c2w, 0.0
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
img_dir = str(Path(tmp) / "images")
|
|
extract_frames_to_dir(frames_bgr, img_dir)
|
|
if vipe_estimate:
|
|
print("[demo] ViPE SLAM (default, estimating intrinsics + c2w) ...")
|
|
slam = run_vipe_slam(img_dir, tmp, variant="default")
|
|
intr_arr = slam["intrinsics"]
|
|
intr = intr_arr[0] if intr_arr.ndim == 2 else intr_arr
|
|
else:
|
|
print("[demo] ViPE SLAM (gt_intr, estimating c2w only) ...")
|
|
slam = run_vipe_slam(img_dir, tmp, variant="gt_intr", gt_intrinsics_4d=intr)
|
|
c2w = slam["poses"]
|
|
print(f" c2w shape={c2w.shape}, intrinsics={intr}, slam_time={slam['slam_time_s']:.2f}s")
|
|
return intr.astype(np.float32), c2w.astype(np.float32), float(slam["slam_time_s"])
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="HandFlow demo: cam overlay + orthographic view")
|
|
ap.add_argument("--input", required=True, help="Input video path")
|
|
ap.add_argument("--fm_ckpt", required=True, help="HandFlow checkpoint (.pt or DeepSpeed directory)")
|
|
ap.add_argument("--config", default="configs/inference.yaml")
|
|
ap.add_argument("--intrinsics", default=None,
|
|
help="fx,fy,cx,cy | auto | (unset -> default 600,600,W/2,H/2)")
|
|
ap.add_argument("--fix_camera", action="store_true",
|
|
help="Fixed camera: skip ViPE (c2w=identity), ortho view assumes a level fixed camera")
|
|
ap.add_argument("--camera_npz", default=None,
|
|
help="Reuse intrinsics and c2w from a previous result npz instead of rerunning ViPE")
|
|
ap.add_argument("--rotation_smooth_window", type=int, default=0,
|
|
help="Odd temporal window for quaternion-space MANO rotation smoothing (0 disables)")
|
|
ap.add_argument("--side", default="right", choices=["right", "left"])
|
|
ap.add_argument("--view", default=None, choices=["topdown", "side", "third_person"],
|
|
help="Ortho view; default side for --fix_camera else third_person")
|
|
ap.add_argument("--output_dir", default="output/demo")
|
|
ap.add_argument("--device", default="cuda")
|
|
ap.add_argument("--save_npz", default=None, help="Optional: path to save inference results npz")
|
|
args = ap.parse_args()
|
|
|
|
device = torch.device(args.device)
|
|
t0 = time.perf_counter()
|
|
|
|
# ── config ──
|
|
cfg = OmegaConf.load(str(_PROJECT_ROOT / args.config))
|
|
model_cfg = OmegaConf.load(str(_PROJECT_ROOT / cfg.model_yaml))
|
|
|
|
# ── read video ──
|
|
print(f"[demo] Reading video {args.input} ...")
|
|
frames_bgr, fps, (H, W) = read_video_frames(args.input)
|
|
T = len(frames_bgr)
|
|
print(f" {T} frames, {W}x{H}, {fps:.1f} fps")
|
|
t1 = time.perf_counter()
|
|
|
|
# ── intrinsics + camera geometry (c2w) ──
|
|
camera_cache = Path(args.output_dir) / "camera_slam.npz"
|
|
camera_source = Path(args.camera_npz) if args.camera_npz else camera_cache
|
|
if camera_source.exists():
|
|
camera_data = np.load(camera_source)
|
|
intr = np.asarray(camera_data["intrinsics"], dtype=np.float32).reshape(-1)[:4]
|
|
c2w = np.asarray(camera_data["c2w"], dtype=np.float32)
|
|
if c2w.shape[0] < T:
|
|
raise ValueError(f"Camera npz has {c2w.shape[0]} poses for {T} video frames")
|
|
c2w = c2w[:T]
|
|
vipe_t = 0.0
|
|
print(f"[demo] Reusing camera geometry from {camera_source}")
|
|
else:
|
|
intr, vipe_estimate = resolve_intrinsics(args.intrinsics, W, H)
|
|
intr, c2w, vipe_t = get_camera_geometry(frames_bgr, args.fix_camera, intr, vipe_estimate, T)
|
|
if not args.fix_camera:
|
|
os.makedirs(args.output_dir, exist_ok=True)
|
|
np.savez(camera_cache, intrinsics=intr, c2w=c2w)
|
|
print(f"[demo] Cached camera geometry -> {camera_cache}")
|
|
intr_list = [intr] * T
|
|
t2 = time.perf_counter()
|
|
|
|
# ── load models ──
|
|
print("[demo] Loading denoiser / HaMeR / MANO ...")
|
|
mano_root = cfg.eval.mano_root or os.environ.get("MANO_ROOT")
|
|
if not mano_root:
|
|
raise RuntimeError("MANO_ROOT not set: please export MANO_ROOT=/path/to/mano")
|
|
denoiser = load_denoiser_from_ckpt(OmegaConf.merge(model_cfg, cfg), args.fm_ckpt, device)
|
|
denoiser.hamer_backbone.to("cpu")
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
print("[demo] Offloaded unused frozen HaMeR backbone to CPU")
|
|
online_cache = Path(args.output_dir) / "online_hamer.pt"
|
|
online = None if online_cache.exists() else OnlineHaMeRPipeline(device=str(device))
|
|
mano_fk = MANOForwardKinematics(str(mano_root), device)
|
|
t3 = time.perf_counter()
|
|
|
|
# ── Online HaMeR ──
|
|
if online_cache.exists():
|
|
print(f"[demo] Reusing Online HaMeR features from {online_cache}")
|
|
online_result = torch.load(online_cache, map_location="cpu", weights_only=False)
|
|
else:
|
|
print("[demo] Online HaMeR inference ...")
|
|
online_result = online.process_sequence(frames_bgr, intr_list, target_side=args.side)
|
|
torch.save(online_result, online_cache)
|
|
print(f"[demo] Cached Online HaMeR features -> {online_cache}")
|
|
det_valid = online_result["detection_valid"].numpy()
|
|
det_sides = online_result["sides"]
|
|
bbox_xyxy = online_result["bbox_xyxy"].numpy()
|
|
bbox_conf = online_result["bbox_conf"].numpy()
|
|
dominant_side = max(set(det_sides), key=det_sides.count) if det_sides else args.side
|
|
print(f" Detections: {int(det_valid.sum())}/{T} frames, dominant hand side={dominant_side}")
|
|
t4 = time.perf_counter()
|
|
|
|
bf = online_result["backbone_features"]
|
|
if online is not None:
|
|
print("[demo] HaMeR cache ready; restart to release third-party CUDA hooks")
|
|
return
|
|
token_chunks = []
|
|
compressor_batch_size = 1
|
|
with torch.no_grad():
|
|
for s in range(0, T, compressor_batch_size):
|
|
e = min(s + compressor_batch_size, T)
|
|
chunk = denoiser.frame_compressor(bf[s:e].to(device).unsqueeze(0)).squeeze(0)
|
|
token_chunks.append(chunk.detach().cpu())
|
|
image_tokens = torch.cat(token_chunks, dim=0)
|
|
del bf, token_chunks
|
|
|
|
seq_batch = {
|
|
"mano_params": torch.zeros((1, T, 48), dtype=torch.float32),
|
|
"mano_trans": torch.zeros((1, T, 3), dtype=torch.float32),
|
|
"mano_betas": torch.zeros((1, 10), dtype=torch.float32),
|
|
"padding_mask": torch.zeros((1, T), dtype=torch.bool),
|
|
"images": online_result["crop_images"].unsqueeze(0),
|
|
"hamer_landmarks": online_result["hamer_landmarks"].unsqueeze(0),
|
|
"crop_intrinsics": online_result["crop_intrinsics"].unsqueeze(0),
|
|
"hamer_confidence": online_result["hamer_confidence"].unsqueeze(0),
|
|
"image_tokens": image_tokens.unsqueeze(0),
|
|
"side": [dominant_side],
|
|
"source": ["custom"],
|
|
}
|
|
|
|
# ── FM inference (overlapping windows) ──
|
|
win = int(cfg.inference.window_size)
|
|
overlap = int(cfg.inference.overlap_size)
|
|
ode_steps = int(cfg.inference.ode_steps)
|
|
stride = win - overlap
|
|
win_batch, _ = split_sequence_into_windows(seq_batch, win, stride, device)
|
|
win_batch = {k: (v.to(device) if isinstance(v, torch.Tensor) else v) for k, v in win_batch.items()}
|
|
|
|
print("[demo] FM Denoiser inference ...")
|
|
pose_seq, trans_seq, betas_pred = run_fm_inference_with_overlap(
|
|
denoiser, denoiser, win_batch, win, overlap, ode_steps, device,
|
|
overlap_method=cfg.inference.get("overlap_method", "vblend"),
|
|
)
|
|
if args.rotation_smooth_window > 1:
|
|
pose_seq = smooth_pose_rotations(pose_seq, args.rotation_smooth_window)
|
|
print(f" Applied quaternion rotation smoothing: window={args.rotation_smooth_window}")
|
|
nf = pose_seq.shape[0]
|
|
print(f" Output {nf} frames of pose/trans")
|
|
t5 = time.perf_counter()
|
|
|
|
# ── MANO FK -> verts_cam (meters, per-frame hand side from detection) ──
|
|
sides = (det_sides[:nf] if len(det_sides) >= nf
|
|
else det_sides + [det_sides[-1]] * (nf - len(det_sides)))
|
|
verts_cam_m = (mano_fk.verts(pose_seq, betas_pred, trans_seq, sides) / 1000.0
|
|
).cpu().numpy().astype(np.float32) # (nf,778,3) m
|
|
faces = mano_fk.get_faces(args.side)
|
|
t6 = time.perf_counter()
|
|
|
|
# ── world verts + camera positions for the ortho view ──
|
|
n = min(nf, T, c2w.shape[0])
|
|
verts_world = transform_verts_to_world(verts_cam_m[:n], c2w[:n]) # identity if fix_camera
|
|
cam_positions = c2w[:n, :3, 3] # zeros if fix_camera
|
|
|
|
# ── Output 1: cam overlay (clean verts_cam projection onto RGB) ──
|
|
print("[demo] Rendering overlay ...")
|
|
renderer = PhongRenderer(device)
|
|
n_render = min(nf, T)
|
|
out_frames = []
|
|
for fi in range(T):
|
|
frame = frames_bgr[fi]
|
|
if fi < n_render and det_valid[fi]:
|
|
x1, y1, x2, y2 = bbox_xyxy[fi].astype(int)
|
|
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
|
label = f"{det_sides[fi]} {bbox_conf[fi]:.2f}"
|
|
cv2.putText(frame, label, (x1, max(8, y1 - 8)),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, cv2.LINE_AA)
|
|
frame = renderer.render_overlay(frame, verts_cam_m[fi], faces, intr, sides[fi])
|
|
out_frames.append(frame)
|
|
os.makedirs(args.output_dir, exist_ok=True)
|
|
overlay_path = str(Path(args.output_dir) / "overlay.mp4")
|
|
write_video_ffmpeg(out_frames, overlay_path, fps)
|
|
print(f"[demo] ✅ {overlay_path}")
|
|
t7 = time.perf_counter()
|
|
|
|
# ── Output 2: orthographic view (side for fix_camera / level; else world topdown) ──
|
|
view = args.view or ("side" if args.fix_camera else "third_person")
|
|
print(f"[demo] Rendering ortho ({view}) ...")
|
|
traj_frames = renderer.render_ortho_video(verts_world, faces, c2w[:n], view=view, side=args.side)
|
|
ortho_path = str(Path(args.output_dir) / f"ortho_{view}.mp4")
|
|
write_video_ffmpeg(traj_frames, ortho_path, fps)
|
|
print(f"[demo] ✅ {ortho_path}")
|
|
t8 = time.perf_counter()
|
|
|
|
# ── timing breakdown ──
|
|
det_t = float(online_result.get("det_time_s", 0.0))
|
|
hamer_t = float(online_result.get("hamer_time_s", 0.0))
|
|
print(
|
|
f"[timing] read={t1-t0:.1f}s | "
|
|
f"vipe={vipe_t:.1f}s(+frames/subprocess {t2-t1-vipe_t:.1f}s) | "
|
|
f"load={t3-t2:.1f}s | "
|
|
f"hamer={t4-t3:.1f}s (det={det_t:.1f} infer={hamer_t:.1f}) | "
|
|
f"fm={t5-t4:.1f}s | mano={t6-t5:.1f}s | render={t7-t6:.1f}s | ortho={t8-t7:.1f}s | "
|
|
f"total={t8-t0:.1f}s"
|
|
)
|
|
|
|
# ── optional: save inference npz ──
|
|
if args.save_npz:
|
|
np.savez(
|
|
args.save_npz,
|
|
verts_cam=verts_cam_m[:n_render], verts_world=verts_world, faces=faces,
|
|
intrinsics=intr, c2w=c2w[:n], fix_camera=bool(args.fix_camera),
|
|
pose=pose_seq.cpu().numpy(), trans=trans_seq.cpu().numpy(),
|
|
betas=betas_pred.cpu().numpy(), pred_valid=det_valid[:n_render],
|
|
side=args.side, source=args.input, fps=fps,
|
|
)
|
|
print(f"[demo] npz -> {args.save_npz}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|