Download scripts/extract_track.py from zhicao/dreamzero: direct link, hf CLI and curl.
- Browser
- Download file 32.3 kB
-
https://huggingface.co/zhicao/dreamzero/resolve/main/scripts/extract_track.py
- Command line
-
hf download hf://zhicao/dreamzero/scripts/extract_track.py
-
curl -L -o extract_track.py https://huggingface.co/zhicao/dreamzero/resolve/main/scripts/extract_track.py
32.3 kB
| """ | |
| Extract T-Rex 250-point tracks for LeRobot v2 episodes with SAM2 + CoTracker. | |
| Frame-0 masks: one SAM2 predict per hand/view (fixed prompts, no appearance | |
| auto-repair). Then CoTracker tracks: | |
| - head_left: 100 pts (left hand/arm 50 + right hand/arm 50) | |
| - each wrist: 75 pts (fixed 5×5 background grid 25 + hand 50) | |
| Tune prompts in ``trex_track/sam2_prompt_hands.py``. | |
| Example:: | |
| CUDA_VISIBLE_DEVICES=4 python scripts/extract_track.py \\ | |
| --dataset-root data/trex_small --episode-index 0 \\ | |
| --cotracker-device cuda:0 --sam2-device cuda:0 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| from typing import NamedTuple | |
| import numpy as np | |
| _SCRIPT_DIR = Path(__file__).resolve().parent | |
| _DREAMZERO_ROOT = _SCRIPT_DIR.parent | |
| if str(_SCRIPT_DIR) not in sys.path: | |
| sys.path.insert(0, str(_SCRIPT_DIR)) | |
| from trex_track.layout import ( # noqa: E402 | |
| NUM_COMBINED_POINTS, | |
| NUM_HEAD_LEFT, | |
| NUM_HEAD_PER_HAND as NUM_HAND_POINTS, | |
| NUM_HEAD_POINTS, | |
| NUM_WRIST_BACKGROUND as NUM_WRIST_GRID, | |
| NUM_WRIST_HAND, | |
| NUM_WRIST_POINTS, | |
| POINT_SLICES, | |
| TRACK_LAYOUT_VERSION, | |
| VIEW_ORDER, | |
| identity_metadata, | |
| ) | |
| VIDEO_FOLDERS = { | |
| "head_left": "observation.images.head_left", | |
| "left_wrist": "observation.images.left_wrist", | |
| "right_wrist": "observation.images.right_wrist", | |
| } | |
| DEFAULT_OPENPI_ROOT = Path("/scratch2/home/zhicao/openpi") | |
| DEFAULT_CALIB = _DREAMZERO_ROOT / "assets" / "trex_camera_calib.json" | |
| DEFAULT_SAM2_MODEL = os.environ.get("SAM2_MODEL", "facebook/sam2-hiera-large") | |
| DEFAULT_SAM2_LIBS = os.environ.get("SAM2_LIBS", "/scratch1/home/zhicao/physctrl/libs") | |
| class TrackingRuntime(NamedTuple): | |
| """Lazily-created heavy models shared across an episode batch.""" | |
| calib: dict | |
| out_hw: tuple[int, int] | |
| cotracker_model: object | |
| cotracker_device: object | |
| sam2_predictor: object | |
| def _ensure_openpi_on_path(openpi_root: Path) -> Path: | |
| root = openpi_root.expanduser().resolve() | |
| droid = root / "droid" | |
| if not droid.is_dir(): | |
| raise FileNotFoundError(f"openpi droid package not found: {droid}") | |
| p = str(droid) | |
| if p not in sys.path: | |
| sys.path.insert(0, p) | |
| return droid | |
| def _enable_cotracker_sdpa_attention(openpi_root: str | Path) -> None: | |
| """Replace CoTracker's quadratic-memory attention with PyTorch SDPA. | |
| The original implementation materializes an ``[B,H,T,T]`` attention | |
| matrix. Long T-Rex episodes can therefore require more than 80 GB even on | |
| an otherwise empty H100. SDPA uses Flash Attention for CUDA BF16 inputs, | |
| preserving full-sequence attention without materializing that matrix. | |
| """ | |
| import torch.nn.functional as F | |
| cotracker_root = Path(openpi_root).expanduser().resolve() / "co-tracker" | |
| cotracker_path = str(cotracker_root) | |
| if not cotracker_root.is_dir(): | |
| raise FileNotFoundError(f"CoTracker package not found: {cotracker_root}") | |
| if cotracker_path not in sys.path: | |
| sys.path.insert(0, cotracker_path) | |
| from cotracker.models.core.cotracker.blocks import Attention | |
| if bool(getattr(Attention, "_trex_sdpa_enabled", False)): | |
| return | |
| def _sdpa_forward(self, x, context=None, attn_bias=None): | |
| batch, query_steps, _ = x.shape | |
| heads = int(self.heads) | |
| query = self.to_q(x) | |
| inner_dim = int(query.shape[-1]) | |
| head_dim = inner_dim // heads | |
| query = query.reshape(batch, query_steps, heads, head_dim).transpose(1, 2) | |
| context = x if context is None else context | |
| key, value = self.to_kv(context).chunk(2, dim=-1) | |
| context_steps = int(context.shape[1]) | |
| key = key.reshape(batch, context_steps, heads, head_dim).transpose(1, 2) | |
| value = value.reshape(batch, context_steps, heads, head_dim).transpose(1, 2) | |
| attended = F.scaled_dot_product_attention( | |
| query, | |
| key, | |
| value, | |
| attn_mask=attn_bias, | |
| dropout_p=0.0, | |
| is_causal=False, | |
| ) | |
| attended = attended.transpose(1, 2).reshape(batch, query_steps, inner_dim) | |
| return self.to_out(attended) | |
| Attention.forward = _sdpa_forward | |
| Attention._trex_sdpa_enabled = True | |
| def normalize_tracks_xy(tracks: np.ndarray, img_w: int, img_h: int) -> np.ndarray: | |
| out = np.asarray(tracks, dtype=np.float32).copy() | |
| w, h = max(float(img_w), 1.0), max(float(img_h), 1.0) | |
| out = np.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0) | |
| out[..., 0] = np.clip(out[..., 0] / w, 0.0, 1.0) | |
| out[..., 1] = np.clip(out[..., 1] / h, 0.0, 1.0) | |
| return out.astype(np.float32, copy=False) | |
| def normalize_track_result( | |
| tracks: np.ndarray, | |
| visibility: np.ndarray, | |
| img_w: int, | |
| img_h: int, | |
| ) -> tuple[np.ndarray, np.ndarray]: | |
| """Normalize XY and clear visibility for non-finite/out-of-frame points.""" | |
| pixels = np.asarray(tracks, dtype=np.float32) | |
| vis = np.asarray(visibility, dtype=np.float32) | |
| if pixels.ndim != 3 or pixels.shape[-1] != 2: | |
| raise ValueError(f"tracks must be (T,N,2), got {pixels.shape}") | |
| if vis.shape != pixels.shape[:2]: | |
| raise ValueError(f"visibility {vis.shape} does not match tracks {pixels.shape}") | |
| finite = np.isfinite(pixels).all(axis=-1) | |
| in_frame = ( | |
| (pixels[..., 0] >= 0.0) | |
| & (pixels[..., 0] < float(img_w)) | |
| & (pixels[..., 1] >= 0.0) | |
| & (pixels[..., 1] < float(img_h)) | |
| ) | |
| clean_vis = ((vis > 0.5) & finite & in_frame).astype(np.float32) | |
| return normalize_tracks_xy(pixels, img_w, img_h), clean_vis | |
| def load_episode_videos( | |
| dataset_root: Path, | |
| episode_index: int, | |
| *, | |
| out_hw: tuple[int, int], | |
| ) -> dict[str, np.ndarray]: | |
| import cv2 | |
| chunk = episode_index // 1000 | |
| frames_by_view: dict[str, list[np.ndarray]] = {k: [] for k in VIEW_ORDER} | |
| for view in VIEW_ORDER: | |
| rel = VIDEO_FOLDERS[view] | |
| video_path = ( | |
| dataset_root / "videos" / f"chunk-{chunk:03d}" / rel / f"episode_{episode_index:06d}.mp4" | |
| ) | |
| if not video_path.is_file(): | |
| raise FileNotFoundError(video_path) | |
| cap = cv2.VideoCapture(str(video_path)) | |
| while True: | |
| ok, bgr = cap.read() | |
| if not ok: | |
| break | |
| rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) | |
| if (rgb.shape[0], rgb.shape[1]) != out_hw: | |
| rgb = cv2.resize(rgb, (out_hw[1], out_hw[0]), interpolation=cv2.INTER_LINEAR) | |
| frames_by_view[view].append(rgb) | |
| cap.release() | |
| if not frames_by_view[view]: | |
| raise RuntimeError(f"empty video: {video_path}") | |
| return {v: np.stack(frames_by_view[v], axis=0) for v in VIEW_ORDER} | |
| def load_episode_states(dataset_root: Path, episode_index: int) -> tuple[np.ndarray, str]: | |
| import pandas as pd | |
| chunk = episode_index // 1000 | |
| pq_path = dataset_root / "data" / f"chunk-{chunk:03d}" / f"episode_{episode_index:06d}.parquet" | |
| df = pd.read_parquet(pq_path) | |
| states = np.stack([np.asarray(x, dtype=np.float64) for x in df["observation.state"].values], axis=0) | |
| task = "" | |
| if "annotation.task" in df.columns: | |
| task = str(df["annotation.task"].iloc[0]) | |
| return states, task | |
| def _run_cotracker_window( | |
| model, | |
| video_hwc: np.ndarray, | |
| query_xy: np.ndarray, | |
| device: object, | |
| ) -> tuple[np.ndarray, np.ndarray]: | |
| """Run one bounded CoTracker window and immediately release its GPU tensors.""" | |
| import torch | |
| video_np = np.asarray(video_hwc, dtype=np.uint8) | |
| queries_np = np.zeros((int(query_xy.shape[0]), 3), dtype=np.float32) | |
| queries_np[:, 1:] = np.asarray(query_xy, dtype=np.float32) | |
| device_type = torch.device(device).type | |
| video_dtype = torch.bfloat16 if device_type == "cuda" else torch.float32 | |
| video = ( | |
| torch.from_numpy(video_np) | |
| .permute(0, 3, 1, 2) | |
| .unsqueeze(0) | |
| .to(device=device, dtype=video_dtype) | |
| ) | |
| queries = torch.from_numpy(queries_np).unsqueeze(0).to(device) | |
| with torch.inference_mode(), torch.autocast( | |
| device_type=device_type, | |
| dtype=torch.bfloat16, | |
| enabled=device_type == "cuda", | |
| ): | |
| pred_tracks, pred_vis = model( | |
| video, | |
| queries=queries, | |
| backward_tracking=False, | |
| ) | |
| tracks = pred_tracks[0].detach().cpu().numpy().astype(np.float32) | |
| visibility = pred_vis[0].detach().cpu().numpy() | |
| visibility = (visibility > 0.5).astype(np.float32) | |
| del video, queries, pred_tracks, pred_vis | |
| if device_type == "cuda": | |
| torch.cuda.empty_cache() | |
| return tracks, visibility | |
| def _run_cotracker(model, video_hwc: np.ndarray, query_xy: np.ndarray, device: object): | |
| """Track frame-0 queries in bounded, overlapping temporal windows. | |
| ``CoTrackerPredictor`` only copies backward predictions into frames before | |
| each query timestamp. Every query here starts at frame zero, so | |
| ``backward_tracking=True`` cannot change the result and nearly doubles the | |
| peak memory for long T-Rex episodes. | |
| Even with Flash Attention, CoTracker's feature/correlation tensors grow | |
| linearly with the frame count. Each new window is initialized from the | |
| previous trajectory, and overlapping predictions are blended to avoid a | |
| discontinuity at the boundary. | |
| """ | |
| video_np = np.asarray(video_hwc, dtype=np.uint8) | |
| total_frames = int(video_np.shape[0]) | |
| num_queries = int(query_xy.shape[0]) | |
| window_frames = int(os.environ.get("TREX_COTRACKER_WINDOW_FRAMES", "768")) | |
| overlap_frames = int(os.environ.get("TREX_COTRACKER_WINDOW_OVERLAP", "64")) | |
| if window_frames < 2: | |
| raise ValueError("TREX_COTRACKER_WINDOW_FRAMES must be at least 2") | |
| if overlap_frames < 1 or overlap_frames >= window_frames: | |
| raise ValueError( | |
| "TREX_COTRACKER_WINDOW_OVERLAP must be in [1, WINDOW_FRAMES)" | |
| ) | |
| if total_frames <= window_frames: | |
| return _run_cotracker_window(model, video_np, query_xy, device) | |
| step = window_frames - overlap_frames | |
| num_windows = 1 + (total_frames - window_frames + step - 1) // step | |
| print( | |
| f" CoTracker windowing: {total_frames} frames -> {num_windows} " | |
| f"window(s), max={window_frames}, overlap={overlap_frames}" | |
| ) | |
| tracks = np.empty((total_frames, num_queries, 2), dtype=np.float32) | |
| visibility = np.empty((total_frames, num_queries), dtype=np.float32) | |
| start = 0 | |
| filled_end = 0 | |
| while start < total_frames: | |
| end = min(start + window_frames, total_frames) | |
| seed_xy = np.asarray(query_xy if start == 0 else tracks[start], dtype=np.float32) | |
| window_tracks, window_visibility = _run_cotracker_window( | |
| model, | |
| video_np[start:end], | |
| seed_xy, | |
| device, | |
| ) | |
| overlap_end = min(filled_end, end) | |
| existing_frames = max(0, overlap_end - start) | |
| if existing_frames > 0: | |
| alpha = np.linspace( | |
| 0.0, | |
| 1.0, | |
| existing_frames, | |
| dtype=np.float32, | |
| ) | |
| tracks[start:overlap_end] = ( | |
| tracks[start:overlap_end] * (1.0 - alpha[:, None, None]) | |
| + window_tracks[:existing_frames] * alpha[:, None, None] | |
| ) | |
| use_new_visibility = alpha >= 0.5 | |
| visibility[start:overlap_end] = np.where( | |
| use_new_visibility[:, None], | |
| window_visibility[:existing_frames], | |
| visibility[start:overlap_end], | |
| ) | |
| tracks[overlap_end:end] = window_tracks[existing_frames:] | |
| visibility[overlap_end:end] = window_visibility[existing_frames:] | |
| filled_end = max(filled_end, end) | |
| if end >= total_frames: | |
| break | |
| start = end - overlap_frames | |
| return tracks, visibility | |
| def _tracks_episode( | |
| *, | |
| view_images: dict[str, np.ndarray], | |
| out_hw: tuple[int, int], | |
| cotracker_model, | |
| cotracker_device: object, | |
| sam2_predictor=None, | |
| sam2_seed: int | None = None, | |
| ) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], dict[str, np.ndarray | None], dict[str, str]]: | |
| from trex_track.sam2_cotracker_hands import head_hands_50, wrist_hand_50 | |
| from trex_track.trex_projection import make_image_grid | |
| view_tracks_px: dict[str, np.ndarray] = {} | |
| view_vis: dict[str, np.ndarray] = {} | |
| masks: dict[str, np.ndarray | None] = {} | |
| tags: dict[str, str] = {} | |
| # Head: left hand/arm 50, then right hand/arm 50. | |
| head_q, left_m, right_m, head_tag = head_hands_50( | |
| sam2_predictor, | |
| view_images["head_left"][0], | |
| n_points=NUM_HAND_POINTS, | |
| seed=sam2_seed, | |
| ) | |
| if int(head_q.shape[0]) != NUM_HEAD_POINTS: | |
| raise ValueError(f"head queries expect {NUM_HEAD_POINTS}, got {head_q.shape[0]}") | |
| head_trk, head_vis = _run_cotracker( | |
| cotracker_model, view_images["head_left"], head_q, cotracker_device | |
| ) | |
| view_tracks_px["head_left"] = head_trk | |
| view_vis["head_left"] = head_vis | |
| masks["head_left_hand"] = left_m | |
| masks["head_right_hand"] = right_m | |
| tags["head_left"] = head_tag | |
| # Wrists: fixed 5×5 background grid + 50 SAM2 hand points. | |
| for view in ("left_wrist", "right_wrist"): | |
| hand_q, mask, tag = wrist_hand_50( | |
| sam2_predictor, | |
| view_images[view][0], | |
| view, | |
| n_points=NUM_WRIST_HAND, | |
| seed=sam2_seed, | |
| ) | |
| if int(hand_q.shape[0]) != NUM_WRIST_HAND: | |
| raise ValueError(f"{view} hand queries expect {NUM_WRIST_HAND}, got {hand_q.shape[0]}") | |
| grid_q = make_image_grid(out_hw[0], out_hw[1], grid_size=5).astype(np.float32) | |
| q = np.concatenate([grid_q, hand_q], axis=0) | |
| if int(q.shape[0]) != NUM_WRIST_POINTS: | |
| raise ValueError(f"{view} queries expect {NUM_WRIST_POINTS}, got {q.shape[0]}") | |
| trk, vis = _run_cotracker(cotracker_model, view_images[view], q, cotracker_device) | |
| view_tracks_px[view] = trk | |
| view_vis[view] = vis | |
| masks[view] = mask | |
| tags[view] = f"grid25+{tag}" | |
| view_tracks: dict[str, np.ndarray] = {} | |
| for view in VIEW_ORDER: | |
| view_tracks[view], view_vis[view] = normalize_track_result( | |
| view_tracks_px[view], | |
| view_vis[view], | |
| out_hw[1], | |
| out_hw[0], | |
| ) | |
| return view_tracks, view_vis, masks, tags | |
| def _save_seed_overlay( | |
| out_dir: Path, | |
| episode_index: int, | |
| view_images: dict[str, np.ndarray], | |
| masks: dict[str, np.ndarray | None], | |
| view_tracks: dict[str, np.ndarray], | |
| out_hw: tuple[int, int], | |
| ) -> None: | |
| """Save frame-0 overlays with mask + query points for debugging.""" | |
| import cv2 | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| h, w = out_hw | |
| def _draw(view: str, mask: np.ndarray | None, tracks_norm: np.ndarray, color_bgr): | |
| rgb = view_images[view][0] | |
| bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) | |
| if mask is not None and np.asarray(mask).any(): | |
| m = np.asarray(mask, dtype=bool) | |
| bgr[m] = (bgr[m] * 0.35 + np.asarray(color_bgr, dtype=np.float32) * 0.65).astype(np.uint8) | |
| pts = tracks_norm[0] * np.array([w, h], dtype=np.float32) | |
| for p in pts.astype(int): | |
| cv2.circle(bgr, (int(p[0]), int(p[1])), 3, (0, 255, 255), -1, cv2.LINE_AA) | |
| return bgr | |
| # Head: both masks | |
| head = cv2.cvtColor(view_images["head_left"][0], cv2.COLOR_RGB2BGR) | |
| lm, rm = masks.get("head_left_hand"), masks.get("head_right_hand") | |
| if lm is not None and np.asarray(lm).any(): | |
| m = np.asarray(lm, dtype=bool) | |
| head[m] = (head[m] * 0.35 + np.array([0, 255, 0], dtype=np.float32) * 0.65).astype(np.uint8) | |
| if rm is not None and np.asarray(rm).any(): | |
| m = np.asarray(rm, dtype=bool) | |
| head[m] = (head[m] * 0.35 + np.array([0, 165, 255], dtype=np.float32) * 0.65).astype(np.uint8) | |
| pts = view_tracks["head_left"][0] * np.array([w, h], dtype=np.float32) | |
| for i, p in enumerate(pts.astype(int)): | |
| col = (0, 255, 255) if i < NUM_HEAD_LEFT else (255, 255, 0) | |
| cv2.circle(head, (int(p[0]), int(p[1])), 3, col, -1, cv2.LINE_AA) | |
| cv2.imwrite(str(out_dir / f"episode_{episode_index:06d}_head_left_seeds.png"), head) | |
| for view in ("left_wrist", "right_wrist"): | |
| rgb = view_images[view][0] | |
| bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) | |
| mask = masks.get(view) | |
| if mask is not None and np.asarray(mask).any(): | |
| m = np.asarray(mask, dtype=bool) | |
| bgr[m] = (bgr[m] * 0.40 + np.array([0, 0, 220], dtype=np.float32) * 0.60).astype(np.uint8) | |
| pts = view_tracks[view][0] * np.array([w, h], dtype=np.float32) | |
| for i, p in enumerate(pts.astype(int)): | |
| # yellow = fixed background grid; magenta = force-aligned hand | |
| col = (0, 255, 255) if i < NUM_WRIST_GRID else (255, 0, 255) | |
| cv2.drawMarker( | |
| bgr, | |
| (int(p[0]), int(p[1])), | |
| col, | |
| markerType=cv2.MARKER_STAR, | |
| markerSize=8, | |
| thickness=1, | |
| line_type=cv2.LINE_AA, | |
| ) | |
| cv2.imwrite(str(out_dir / f"episode_{episode_index:06d}_{view}_seeds.png"), bgr) | |
| def _save_prompt_overlays( | |
| out_dir: Path, | |
| episode_index: int, | |
| view_images: dict[str, np.ndarray], | |
| masks: dict[str, np.ndarray | None], | |
| ) -> None: | |
| """Save SAM2 prompts (pos/neg/box) + mask overlays; reuse track masks (no 2nd SAM2).""" | |
| import cv2 | |
| from trex_track.sam2_prompt_hands import ( | |
| build_head_prompts, | |
| build_wrist_prompts, | |
| draw_prompt_overlay, | |
| ) | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| panels: list[np.ndarray] = [] | |
| meta_dump: dict[str, object] = {} | |
| def _one( | |
| key: str, | |
| rgb: np.ndarray, | |
| mask: np.ndarray | None, | |
| coords: np.ndarray, | |
| labels: np.ndarray, | |
| box: np.ndarray, | |
| mask_bgr: tuple[int, int, int], | |
| ) -> np.ndarray: | |
| vis = draw_prompt_overlay( | |
| rgb, | |
| mask=mask, | |
| coords=coords, | |
| labels=labels, | |
| box=box, | |
| mask_bgr=mask_bgr, | |
| ) | |
| # Legend | |
| n_pos = int((labels == 1).sum()) | |
| n_neg = int((labels == 0).sum()) | |
| area = int(np.asarray(mask).sum()) if mask is not None else 0 | |
| cv2.putText( | |
| vis, | |
| f"{key} +pos={n_pos} -neg={n_neg} mask_px={area}", | |
| (6, 14), | |
| cv2.FONT_HERSHEY_SIMPLEX, | |
| 0.40, | |
| (255, 255, 255), | |
| 1, | |
| cv2.LINE_AA, | |
| ) | |
| path = out_dir / f"episode_{episode_index:06d}_{key}_prompt.png" | |
| cv2.imwrite(str(path), vis) | |
| print(f"Wrote {path}") | |
| meta_dump[f"{key}_coords"] = np.asarray(coords, dtype=np.float32) | |
| meta_dump[f"{key}_labels"] = np.asarray(labels, dtype=np.int32) | |
| meta_dump[f"{key}_box"] = np.asarray(box, dtype=np.float32) | |
| if mask is not None: | |
| meta_dump[f"{key}_mask"] = np.asarray(mask, dtype=bool) | |
| return vis | |
| # Wrist views | |
| for view, col in (("left_wrist", (0, 0, 220)), ("right_wrist", (0, 0, 220))): | |
| rgb = view_images[view][0] | |
| h, w = rgb.shape[:2] | |
| coords, labels, box = build_wrist_prompts(h, w, view) | |
| panels.append(_one(view, rgb, masks.get(view), coords, labels, box, col)) | |
| # Head: left / right hands (same RGB, two prompt sets) | |
| rgb = view_images["head_left"][0] | |
| h, w = rgb.shape[:2] | |
| for side, col, mkey in ( | |
| ("left", (0, 255, 0), "head_left_hand"), | |
| ("right", (0, 165, 255), "head_right_hand"), | |
| ): | |
| coords, labels, box = build_head_prompts(h, w, side) | |
| panels.append(_one(f"head_{side}", rgb, masks.get(mkey), coords, labels, box, col)) | |
| # Combined 2x2 panel for quick inspection | |
| if len(panels) == 4: | |
| top = np.concatenate(panels[2:4], axis=1) # head_left | head_right | |
| bot = np.concatenate(panels[0:2], axis=1) # left_wrist | right_wrist | |
| # Resize to same width if needed | |
| if top.shape[1] != bot.shape[1]: | |
| tw = max(top.shape[1], bot.shape[1]) | |
| top = cv2.resize(top, (tw, top.shape[0])) | |
| bot = cv2.resize(bot, (tw, bot.shape[0])) | |
| grid = np.concatenate([top, bot], axis=0) | |
| grid_path = out_dir / f"episode_{episode_index:06d}_all_prompts.png" | |
| cv2.imwrite(str(grid_path), grid) | |
| print(f"Wrote {grid_path}") | |
| meta_path = out_dir / f"episode_{episode_index:06d}_prompts.npz" | |
| np.savez_compressed(meta_path, **meta_dump) | |
| print(f"Wrote {meta_path}") | |
| def _atomic_savez(path: Path, **arrays: object) -> None: | |
| """Write an NPZ in the destination directory, then atomically replace.""" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| fd, tmp_name = tempfile.mkstemp( | |
| prefix=f".{path.name}.", | |
| suffix=".tmp", | |
| dir=path.parent, | |
| ) | |
| try: | |
| with os.fdopen(fd, "wb") as file: | |
| np.savez_compressed(file, **arrays) | |
| file.flush() | |
| os.fsync(file.fileno()) | |
| os.replace(tmp_name, path) | |
| except BaseException: | |
| try: | |
| os.unlink(tmp_name) | |
| except FileNotFoundError: | |
| pass | |
| raise | |
| def process_episode( | |
| *, | |
| dataset_root: Path, | |
| episode_index: int, | |
| output_path: Path, | |
| calib: dict | None, | |
| out_hw: tuple[int, int], | |
| cotracker_model, | |
| cotracker_device: object, | |
| save_viz: bool, | |
| viz_out_dir: Path, | |
| viz_fps: int, | |
| viz_trail: int, | |
| sam2_predictor=None, | |
| sam2_seed: int | None = None, | |
| save_sam2_masks_flag: bool = True, | |
| sam2_masks_dir: Path | None = None, | |
| ) -> Path: | |
| from trex_track.sam2_wrist_hand import save_sam2_masks | |
| from trex_track.trex_viz_tracks import render_three_view_combined_video | |
| del calib # Reserved for provenance/backward-compatible callers. | |
| states, task = load_episode_states(dataset_root, episode_index) | |
| view_images = load_episode_videos(dataset_root, episode_index, out_hw=out_hw) | |
| frame_counts = {"parquet": int(states.shape[0])} | |
| frame_counts.update({view: int(view_images[view].shape[0]) for view in VIEW_ORDER}) | |
| if len(set(frame_counts.values())) != 1: | |
| raise ValueError( | |
| f"episode {episode_index}: frame-count mismatch; refusing to truncate: " | |
| f"{frame_counts}" | |
| ) | |
| t_len = int(states.shape[0]) | |
| view_tracks, view_vis, masks, tags = _tracks_episode( | |
| view_images=view_images, | |
| out_hw=out_hw, | |
| cotracker_model=cotracker_model, | |
| cotracker_device=cotracker_device, | |
| sam2_predictor=sam2_predictor, | |
| sam2_seed=sam2_seed, | |
| ) | |
| tracks_combined = np.concatenate([view_tracks[v] for v in VIEW_ORDER], axis=1) | |
| vis_combined = np.concatenate([view_vis[v] for v in VIEW_ORDER], axis=1) | |
| if tracks_combined.shape != (t_len, NUM_COMBINED_POINTS, 2): | |
| raise ValueError( | |
| f"episode {episode_index}: combined tracks have {tracks_combined.shape}, " | |
| f"expected {(t_len, NUM_COMBINED_POINTS, 2)}" | |
| ) | |
| if vis_combined.shape != (t_len, NUM_COMBINED_POINTS): | |
| raise ValueError( | |
| f"episode {episode_index}: combined visibility has {vis_combined.shape}" | |
| ) | |
| out_npz = output_path / f"episode_{episode_index:06d}.npz" | |
| identities = identity_metadata() | |
| _atomic_savez( | |
| out_npz, | |
| tracks=tracks_combined, | |
| vis=vis_combined, | |
| tracks_head_left=view_tracks["head_left"], | |
| tracks_left_wrist=view_tracks["left_wrist"], | |
| tracks_right_wrist=view_tracks["right_wrist"], | |
| vis_head_left=view_vis["head_left"], | |
| vis_left_wrist=view_vis["left_wrist"], | |
| vis_right_wrist=view_vis["right_wrist"], | |
| language=np.array(task), | |
| episode_index=np.array(episode_index, dtype=np.int32), | |
| num_steps=np.array(t_len, dtype=np.int32), | |
| point_slices=np.array(POINT_SLICES, dtype=np.int32), | |
| point_view_ids=np.asarray(identities["view_ids"], dtype=np.int8), | |
| point_hand_ids=np.asarray(identities["hand_ids"], dtype=np.int8), | |
| point_role_ids=np.asarray(identities["role_ids"], dtype=np.int8), | |
| point_local_ids=np.asarray(identities["local_ids"], dtype=np.int16), | |
| point_global_ids=np.asarray(identities["global_ids"], dtype=np.int16), | |
| point_names=np.asarray(identities["point_names"]), | |
| points_per_hand=np.array(NUM_HAND_POINTS, dtype=np.int32), | |
| wrist_grid_points=np.array(NUM_WRIST_GRID, dtype=np.int32), | |
| wrist_hand_points=np.array(NUM_WRIST_HAND, dtype=np.int32), | |
| head_query_source=np.array(tags.get("head_left", "sam2")), | |
| left_wrist_query_source=np.array(tags.get("left_wrist", "sam2")), | |
| right_wrist_query_source=np.array(tags.get("right_wrist", "sam2")), | |
| track_source=np.array("sam2_once_prompt_cotracker"), | |
| tracks_coord_space=np.array("normalized_div_wh"), | |
| track_layout_version=np.array(TRACK_LAYOUT_VERSION), | |
| proj_image_hw=np.array(out_hw, dtype=np.int32), | |
| ) | |
| masks_dir = sam2_masks_dir if sam2_masks_dir is not None else output_path / "sam2_masks" | |
| if save_sam2_masks_flag: | |
| # Save binary masks for wrist + head hands | |
| save_map = { | |
| "left_wrist": masks.get("left_wrist"), | |
| "right_wrist": masks.get("right_wrist"), | |
| "head_left_hand": masks.get("head_left_hand"), | |
| "head_right_hand": masks.get("head_right_hand"), | |
| } | |
| for p in save_sam2_masks(masks_dir, episode_index, save_map): | |
| print(f"Wrote {p}") | |
| _save_seed_overlay(masks_dir, episode_index, view_images, masks, view_tracks, out_hw) | |
| _save_prompt_overlays(masks_dir, episode_index, view_images, masks) | |
| if save_viz: | |
| render_three_view_combined_video( | |
| view_images=view_images, | |
| view_tracks=view_tracks, | |
| view_vis=view_vis, | |
| out_path=viz_out_dir / f"episode_{episode_index:06d}.mp4", | |
| fps=viz_fps, | |
| draw_trail=viz_trail, | |
| ) | |
| print(f"Wrote {out_npz}") | |
| if save_viz: | |
| print(f"Wrote {viz_out_dir / f'episode_{episode_index:06d}.mp4'}") | |
| print(f" tags: {tags}") | |
| return out_npz | |
| def create_tracking_runtime( | |
| *, | |
| calib_path: str | Path = DEFAULT_CALIB, | |
| openpi_root: str | Path = DEFAULT_OPENPI_ROOT, | |
| cotracker_checkpoint: str | Path | None = None, | |
| cotracker_device: str = "", | |
| sam2_model: str = DEFAULT_SAM2_MODEL, | |
| sam2_device: str = "", | |
| sam2_libs: str | Path = DEFAULT_SAM2_LIBS, | |
| image_height: int = 0, | |
| image_width: int = 0, | |
| ) -> TrackingRuntime: | |
| """Load CoTracker and SAM2 once; safe to call from the batch builder.""" | |
| import torch | |
| from trex_track.sam2_wrist_hand import load_sam2_predictor | |
| from trex_track.trex_projection import load_camera_calib | |
| calib = load_camera_calib(calib_path) | |
| if bool(image_height > 0) != bool(image_width > 0): | |
| raise ValueError("image-height and image-width must be set together") | |
| if image_height > 0: | |
| out_hw = (int(image_height), int(image_width)) | |
| else: | |
| out_hw = tuple(int(x) for x in calib.get("video_hw", [180, 320])) | |
| _ensure_openpi_on_path(Path(openpi_root)) | |
| from utils.cotracker_wrist_grid import ( # type: ignore | |
| default_cotracker_checkpoint, | |
| load_cotracker_predictor, | |
| ) | |
| _enable_cotracker_sdpa_attention(openpi_root) | |
| checkpoint_text = str(cotracker_checkpoint or "").strip() | |
| checkpoint = ( | |
| Path(checkpoint_text).expanduser().resolve() | |
| if checkpoint_text | |
| else default_cotracker_checkpoint() | |
| ) | |
| device_text = cotracker_device.strip() or ( | |
| "cuda:0" if torch.cuda.is_available() else "cpu" | |
| ) | |
| device = torch.device(device_text) | |
| print(f" CoTracker: {checkpoint} on {device}") | |
| if device.type == "cuda": | |
| print(" CoTracker attention: BF16 PyTorch SDPA (Flash-compatible)") | |
| cotracker_model = load_cotracker_predictor(checkpoint, device) | |
| sam_device = sam2_device.strip() or device_text | |
| print(f" SAM2: {sam2_model} on {sam_device}") | |
| sam2_predictor = load_sam2_predictor( | |
| model_id=str(sam2_model), | |
| device=sam_device, | |
| sam2_libs=sam2_libs, | |
| ) | |
| return TrackingRuntime( | |
| calib=calib, | |
| out_hw=out_hw, | |
| cotracker_model=cotracker_model, | |
| cotracker_device=device, | |
| sam2_predictor=sam2_predictor, | |
| ) | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="Extract canonical T-Rex SAM2+CoTracker tracks (250 points)" | |
| ) | |
| parser.add_argument( | |
| "--dataset-root", | |
| type=str, | |
| default=str(_DREAMZERO_ROOT / "data" / "trex_small"), | |
| ) | |
| parser.add_argument("--episode-index", type=int, default=0) | |
| parser.add_argument( | |
| "--output-path", | |
| type=str, | |
| default=str(_DREAMZERO_ROOT / "data" / "trex_small_tracks"), | |
| ) | |
| parser.add_argument("--calib-path", type=str, default=str(DEFAULT_CALIB)) | |
| parser.add_argument("--openpi-root", type=str, default=str(DEFAULT_OPENPI_ROOT)) | |
| parser.add_argument("--cotracker-checkpoint", type=str, default="") | |
| parser.add_argument("--cotracker-device", type=str, default="") | |
| parser.add_argument("--image-height", type=int, default=0) | |
| parser.add_argument("--image-width", type=int, default=0) | |
| parser.add_argument("--save-viz", action=argparse.BooleanOptionalAction, default=True) | |
| parser.add_argument("--viz-out-dir", type=str, default="") | |
| parser.add_argument("--viz-fps", type=int, default=10) | |
| parser.add_argument("--viz-trail", type=int, default=15) | |
| parser.add_argument( | |
| "--sam2-model", | |
| type=str, | |
| default=DEFAULT_SAM2_MODEL, | |
| ) | |
| parser.add_argument("--sam2-device", type=str, default="") | |
| parser.add_argument("--sam2-seed", type=int, default=0) | |
| parser.add_argument("--save-sam2-masks", action=argparse.BooleanOptionalAction, default=True) | |
| parser.add_argument("--sam2-masks-dir", type=str, default="") | |
| parser.add_argument( | |
| "--sam2-libs", | |
| type=str, | |
| default=DEFAULT_SAM2_LIBS, | |
| ) | |
| args = parser.parse_args() | |
| dataset_root = Path(args.dataset_root).expanduser().resolve() | |
| output_path = Path(args.output_path).expanduser().resolve() | |
| runtime = create_tracking_runtime( | |
| calib_path=args.calib_path, | |
| openpi_root=args.openpi_root, | |
| cotracker_checkpoint=args.cotracker_checkpoint, | |
| cotracker_device=args.cotracker_device, | |
| sam2_model=args.sam2_model, | |
| sam2_device=args.sam2_device, | |
| sam2_libs=args.sam2_libs, | |
| image_height=args.image_height, | |
| image_width=args.image_width, | |
| ) | |
| viz_out_dir = ( | |
| Path(args.viz_out_dir).expanduser().resolve() | |
| if args.viz_out_dir.strip() | |
| else output_path / "viz_tracks" | |
| ) | |
| sam2_masks_dir = ( | |
| Path(args.sam2_masks_dir).expanduser().resolve() | |
| if args.sam2_masks_dir.strip() | |
| else output_path / "sam2_masks" | |
| ) | |
| print("T-Rex SAM2+CoTracker extraction") | |
| print(f" Dataset: {dataset_root}") | |
| print(f" Episode: {args.episode_index}") | |
| print(f" Output: {output_path}") | |
| print(f" Image: {runtime.out_hw[1]}x{runtime.out_hw[0]}") | |
| print( | |
| f" Points: head={NUM_HEAD_POINTS} (50+50), " | |
| f"wrist={NUM_WRIST_POINTS}x2 (grid{NUM_WRIST_GRID}+hand{NUM_WRIST_HAND}), " | |
| f"total={NUM_COMBINED_POINTS}" | |
| ) | |
| process_episode( | |
| dataset_root=dataset_root, | |
| episode_index=int(args.episode_index), | |
| output_path=output_path, | |
| calib=runtime.calib, | |
| out_hw=runtime.out_hw, | |
| cotracker_model=runtime.cotracker_model, | |
| cotracker_device=runtime.cotracker_device, | |
| save_viz=bool(args.save_viz), | |
| viz_out_dir=viz_out_dir, | |
| viz_fps=int(args.viz_fps), | |
| viz_trail=int(args.viz_trail), | |
| sam2_predictor=runtime.sam2_predictor, | |
| sam2_seed=int(args.sam2_seed), | |
| save_sam2_masks_flag=bool(args.save_sam2_masks), | |
| sam2_masks_dir=sam2_masks_dir, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |