| |
| from __future__ import annotations |
|
|
| import argparse |
| import gc |
| import json |
| import os |
| import re |
| from collections import defaultdict |
| from pathlib import Path |
| from typing import Optional |
|
|
| import av |
| import matplotlib |
| import matplotlib.pyplot as plt |
| import numpy as np |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") |
| import torch |
|
|
| from robometer.data.dataset_types import ProgressSample, Trajectory |
| from robometer.evals.eval_server import compute_batch_outputs |
| from robometer.utils.save import load_model_from_hf |
| from robometer.utils.setup_utils import setup_batch_collator |
|
|
| matplotlib.use("Agg") |
| av.logging.set_level(av.logging.ERROR) |
|
|
|
|
| REPO_ROOT = Path(__file__).resolve().parents[1] |
| PROJECT_ROOT = Path(__file__).resolve().parents[2] |
| WORKSPACE_ROOT = Path(__file__).resolve().parents[3] |
| DEFAULT_MODEL_PATH = PROJECT_ROOT / "models" / "Robometer-4B" |
| DEFAULT_VIDEOS_ROOT = WORKSPACE_ROOT / "Videos" |
| DEFAULT_OUTPUT_ROOT = REPO_ROOT / "Benchmark_Eval" |
| DEFAULT_CHUNK = "chunk-000" |
| DEFAULT_CAMERA_DIR = "observation.images.wrist_image_left" |
| DEFAULT_MAX_FRAMES = 128 |
| DEFAULT_MIN_FRAMES = 32 |
| FRAME_BACKOFF_CANDIDATES = [512, 384, 320, 256, 224, 192, 160, 144, 128, 112, 96, 80, 64, 48, 32, 24, 16, 8] |
| DEFAULT_INFERENCE_MODE = "frame_steps" |
| DEFAULT_PREFIX_SAMPLE_FRAMES = 4 |
| DEFAULT_PREFIX_BATCH_SIZE = 1 |
| DEFAULT_DESCRIPTION_EPISODES_PATH = WORKSPACE_ROOT / "Robo-Dopamine" / "Evaluation" / "Benchmark_Eval" / "Description" / "episodes.jsonl" |
|
|
|
|
| def normalize_episode_name(name: str) -> str: |
| name = name.strip() |
| if name.endswith(".mp4"): |
| return name |
| if name.startswith("episode_"): |
| return f"{name}.mp4" |
| if name.isdigit(): |
| return f"episode_{int(name):06d}.mp4" |
| return f"episode_{name}.mp4" |
|
|
|
|
| def chunk_to_dir_name(chunk: str) -> str: |
| m = re.fullmatch(r"chunk-(\d+)(?:_(.+))?", chunk) |
| if not m: |
| return f"Chunk{chunk.split('-')[-1]}" |
| suffix = m.group(2) |
| return f"Chunk{m.group(1)}_{suffix}" if suffix else f"Chunk{m.group(1)}" |
|
|
|
|
| def parse_episode_input(token: str) -> str: |
| return normalize_episode_name(token) |
|
|
|
|
| def load_json(path: Path) -> dict: |
| with path.open("r", encoding="utf-8") as f: |
| return json.load(f) |
|
|
|
|
| def load_episode_catalog(episode_tasks_path: Path, annotations_path: Path) -> list[str]: |
| if episode_tasks_path.exists(): |
| data = load_json(episode_tasks_path) |
| catalog = [] |
| for ep in data.get("episodes", []): |
| episode = ep.get("episode") |
| if isinstance(episode, str) and episode.strip(): |
| catalog.append(episode.strip()) |
| if catalog: |
| return catalog |
| data = load_json(annotations_path) |
| return [ep["episode"] for ep in data.get("episodes", []) if ep.get("episode")] |
|
|
|
|
| def load_episode_record(annotations_path: Path, episode_mp4: str) -> dict: |
| data = load_json(annotations_path) |
| for ep in data.get("episodes", []): |
| if ep.get("episode") == episode_mp4: |
| return ep |
| raise ValueError(f"Episode not found in annotations: {episode_mp4}") |
|
|
|
|
| def load_instruction(episode_tasks_path: Path, episode_mp4: str) -> str: |
| if episode_tasks_path.exists(): |
| data = load_json(episode_tasks_path) |
| for ep in data.get("episodes", []): |
| if ep.get("episode") != episode_mp4: |
| continue |
| for task in ep.get("tasks") or []: |
| if isinstance(task, str) and task.strip(): |
| return task.strip() |
|
|
| m = re.fullmatch(r"episode_(\d+)\.mp4", episode_mp4) |
| if m and DEFAULT_DESCRIPTION_EPISODES_PATH.exists(): |
| target_idx = int(m.group(1)) |
| with DEFAULT_DESCRIPTION_EPISODES_PATH.open("r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| obj = json.loads(line) |
| if obj.get("episode_index") != target_idx: |
| continue |
| for task in obj.get("tasks") or []: |
| if isinstance(task, str) and task.strip(): |
| return task.strip() |
| break |
| return "robot manipulation task" |
|
|
|
|
| def extract_progress_pairs(episode_record: dict) -> list[tuple[str, int, int]]: |
| marks = [a for a in episode_record.get("annotations", []) if a.get("type") == "progress_mark"] |
| camera_ref = episode_record.get("camera_reference") |
| if camera_ref: |
| ref_marks = [m for m in marks if m.get("camera") == camera_ref] |
| if ref_marks: |
| marks = ref_marks |
|
|
| grouped: dict[str, list[int]] = defaultdict(list) |
| for mark in marks: |
| frame = mark.get("frame") |
| if isinstance(frame, int): |
| grouped[str(mark.get("group_label", "UNK"))].append(frame) |
|
|
| pairs: list[tuple[str, int, int]] = [] |
| for label in sorted(grouped): |
| frames = sorted(set(grouped[label])) |
| if len(frames) >= 2: |
| pairs.append((label, frames[0], frames[1])) |
| return pairs |
|
|
|
|
| def load_progress_mark_catalog(annotations_path: Path) -> list[str]: |
| data = load_json(annotations_path) |
| catalog = [] |
| for ep in data.get("episodes", []): |
| episode = ep.get("episode") |
| if isinstance(episode, str) and episode.strip() and extract_progress_pairs(ep): |
| catalog.append(episode.strip()) |
| return catalog |
|
|
|
|
| def parse_episode_selection(token: str, catalog: list[str], progress_catalog: list[str], require_progress_marks: bool = True) -> list[str]: |
| aliases = { |
| "fisrt10": "first10", |
| "frist10": "first10", |
| "first 10": "first10", |
| } |
| token = token.strip() |
| token_lower = aliases.get(token.lower(), token.lower()) |
|
|
| if token_lower == "first10": |
| return (progress_catalog if require_progress_marks else catalog)[:10] |
| if token_lower == "all": |
| return progress_catalog if require_progress_marks else catalog |
|
|
| plus_match = re.fullmatch(r"(.+)\+(\d+)", token) |
| if plus_match: |
| start_episode = parse_episode_input(plus_match.group(1).strip()) |
| count = int(plus_match.group(2)) |
| if count <= 0: |
| raise ValueError("Count after '+' must be >= 1.") |
| if start_episode not in catalog: |
| raise ValueError(f"Invalid episode selection: {plus_match.group(1).strip()}.") |
| if require_progress_marks and start_episode not in progress_catalog: |
| raise ValueError(f"{start_episode[:-4]} does not have usable progress_mark pairs.") |
| base_catalog = progress_catalog if require_progress_marks else catalog |
| start_idx = base_catalog.index(start_episode) |
| return base_catalog[start_idx:start_idx + count] |
|
|
| selected = [] |
| for part in token.split(","): |
| part = part.strip() |
| if not part: |
| continue |
| episode_mp4 = parse_episode_input(part) |
| if episode_mp4 not in catalog: |
| raise ValueError(f"Invalid episode selection: {part}.") |
| if require_progress_marks and episode_mp4 not in progress_catalog: |
| raise ValueError(f"{episode_mp4[:-4]} does not have usable progress_mark pairs.") |
| selected.append(episode_mp4) |
|
|
| return selected |
|
|
|
|
| def select_progress_window( |
| start_episode_raw: str, |
| count: int, |
| catalog: list[str], |
| progress_catalog: list[str], |
| require_progress_marks: bool = True, |
| ) -> list[str]: |
| start_episode = parse_episode_input(start_episode_raw) |
| if count <= 0: |
| raise ValueError("Count must be >= 1.") |
| if start_episode not in catalog: |
| raise ValueError(f"Invalid episode selection: {start_episode_raw}.") |
| if require_progress_marks and start_episode not in progress_catalog: |
| raise ValueError(f"{start_episode[:-4]} does not have usable progress_mark pairs.") |
| base_catalog = progress_catalog if require_progress_marks else catalog |
| start_idx = base_catalog.index(start_episode) |
| return base_catalog[start_idx:start_idx + count] |
|
|
|
|
| def result_path_for(output_root: Path, episode_mp4: str) -> Path: |
| return output_root / episode_mp4[:-4] / "results.json" |
|
|
|
|
| def chunk_output_dir_name(chunk: str, tag: str | None) -> str: |
| base = chunk_to_dir_name(chunk) |
| return f"{base}_{tag}" if tag else base |
|
|
|
|
| def parse_chunk_selection(chunk_arg: str) -> list[str]: |
| chunks = [part.strip() for part in chunk_arg.split(",") if part.strip()] |
| if not chunks: |
| raise ValueError("At least one chunk must be provided.") |
| return chunks |
|
|
|
|
| def cleanup_cuda_memory() -> None: |
| gc.collect() |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
|
|
|
|
| def is_cuda_oom_error(exc: BaseException) -> bool: |
| if isinstance(exc, torch.OutOfMemoryError): |
| return True |
| return "out of memory" in str(exc).lower() |
|
|
|
|
| def build_frame_retry_schedule(max_frames: int, min_frames: int, adaptive: bool) -> list[int]: |
| max_frames = max(1, int(max_frames)) |
| min_frames = max(1, min(int(min_frames), max_frames)) |
| if not adaptive or max_frames == min_frames: |
| return [max_frames] |
|
|
| schedule = [max_frames] |
| for candidate in FRAME_BACKOFF_CANDIDATES: |
| if min_frames <= candidate < max_frames: |
| schedule.append(candidate) |
| if schedule[-1] != min_frames: |
| schedule.append(min_frames) |
| return schedule |
|
|
|
|
| def resolve_episode_list( |
| episode_arg: Optional[str], |
| count: int, |
| catalog: list[str], |
| progress_catalog: list[str], |
| require_progress_marks: bool, |
| ) -> list[str]: |
| if episode_arg: |
| token = episode_arg.strip() |
| token_lower = token.lower() |
| if count > 1: |
| return select_progress_window(token, count, catalog, progress_catalog, require_progress_marks=require_progress_marks) |
| if token_lower in {"first10", "all"} or "," in token or "+" in token: |
| return parse_episode_selection(token, catalog, progress_catalog, require_progress_marks=require_progress_marks) |
| episode_mp4 = parse_episode_input(token) |
| if episode_mp4 not in catalog: |
| raise ValueError(f"Invalid episode selection: {token}.") |
| if require_progress_marks and episode_mp4 not in progress_catalog: |
| raise ValueError(f"{episode_mp4[:-4]} does not have usable progress_mark pairs.") |
| return [episode_mp4] |
|
|
| base_catalog = progress_catalog if require_progress_marks else catalog |
| print(f"[EPISODES] {len(base_catalog)} episodes available") |
| user_selection = input("Episodes (000287 / 000287+5 / 000287,000330 / first10 / all): ").strip() |
| aliases = {"fisrt10": "first10", "frist10": "first10", "first 10": "first10"} |
| token_lower = aliases.get(user_selection.lower(), user_selection.lower()) |
| if token_lower not in {"first10", "all"} and "," not in user_selection and "+" not in user_selection: |
| count_raw = input("How many from this start? (default 5): ").strip() |
| window_count = int(count_raw) if count_raw else 5 |
| return select_progress_window(user_selection, window_count, catalog, progress_catalog, require_progress_marks=require_progress_marks) |
| return parse_episode_selection(user_selection, catalog, progress_catalog, require_progress_marks=require_progress_marks) |
|
|
|
|
| def load_video_frames_with_indices( |
| video_path: Path, |
| *, |
| fps: float, |
| max_frames: int, |
| required_frames: list[int], |
| ) -> tuple[np.ndarray, list[int], int, float]: |
| all_frames, native_fps = load_all_video_frames(video_path) |
| sampled_frames, sampled_indices = sample_video_frames_with_indices( |
| all_frames, |
| native_fps=native_fps, |
| fps=fps, |
| max_frames=max_frames, |
| required_frames=required_frames, |
| ) |
| return sampled_frames, sampled_indices, len(all_frames), native_fps |
|
|
|
|
| def load_all_video_frames(video_path: Path) -> tuple[list[np.ndarray], float]: |
| all_frames: list[np.ndarray] = [] |
| with av.open(str(video_path)) as container: |
| stream = container.streams.video[0] |
| rate = stream.average_rate or stream.guessed_rate |
| native_fps = float(rate) if rate is not None else 1.0 |
| for frame in container.decode(stream): |
| all_frames.append(frame.to_ndarray(format="rgb24")) |
|
|
| if not all_frames: |
| raise RuntimeError(f"Could not extract frames from video: {video_path}") |
| return all_frames, native_fps |
|
|
|
|
| def sample_video_frames_with_indices( |
| all_frames: list[np.ndarray], |
| *, |
| native_fps: float, |
| fps: float, |
| max_frames: int, |
| required_frames: list[int], |
| ) -> tuple[np.ndarray, list[int]]: |
| total_frames = len(all_frames) |
| if fps <= 0: |
| fps = native_fps |
|
|
| if native_fps > 0: |
| desired_frames = int(round(total_frames * (fps / native_fps))) |
| else: |
| desired_frames = total_frames |
| desired_frames = max(1, min(desired_frames, total_frames, max_frames)) |
|
|
| if desired_frames == total_frames: |
| sampled_indices = list(range(total_frames)) |
| else: |
| base_indices = np.linspace(0, total_frames - 1, desired_frames, dtype=int).tolist() |
| snapped_indices = list(base_indices) |
| used_slots: set[int] = set() |
|
|
| |
| for frame_idx in sorted(set(required_frames)): |
| clamped = max(0, min(total_frames - 1, int(frame_idx))) |
| slot = min( |
| (idx for idx in range(desired_frames) if idx not in used_slots), |
| key=lambda idx: (abs(base_indices[idx] - clamped), idx), |
| ) |
| snapped_indices[slot] = clamped |
| used_slots.add(slot) |
|
|
| sampled_indices = sorted(set(snapped_indices)) |
| if len(sampled_indices) < desired_frames: |
| for idx in base_indices: |
| if idx not in sampled_indices: |
| sampled_indices.append(idx) |
| if len(sampled_indices) == desired_frames: |
| break |
| if len(sampled_indices) < desired_frames: |
| for idx in range(total_frames): |
| if idx not in sampled_indices: |
| sampled_indices.append(idx) |
| if len(sampled_indices) == desired_frames: |
| break |
| sampled_indices = sorted(sampled_indices[:desired_frames]) |
|
|
| sampled_frames = np.stack([all_frames[idx] for idx in sampled_indices], axis=0) |
| return sampled_frames, sampled_indices |
|
|
|
|
| def extract_frame(video_path: Path, frame_idx: int) -> Optional[np.ndarray]: |
| with av.open(str(video_path)) as container: |
| stream = container.streams.video[0] |
| for idx, frame in enumerate(container.decode(stream)): |
| if idx == frame_idx: |
| return frame.to_ndarray(format="rgb24") |
| return None |
|
|
|
|
| class RobometerLocalRunner: |
| def __init__(self, model_path: str, device: Optional[torch.device] = None): |
| if device is None: |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| self.device = device |
| self.model_path = model_path |
| self.exp_config = None |
| self.tokenizer = None |
| self.processor = None |
| self.reward_model = None |
| self.batch_collator = None |
| self.is_discrete = False |
| self.num_bins = 10 |
| self._load_model_components() |
|
|
| def _load_model_components(self) -> None: |
| print(f"Loading model from {self.model_path} ...") |
|
|
| self.exp_config, self.tokenizer, self.processor, self.reward_model = load_model_from_hf( |
| model_path=self.model_path, |
| device=self.device, |
| ) |
| self.reward_model.eval() |
| self.batch_collator = setup_batch_collator( |
| self.processor, |
| self.tokenizer, |
| self.exp_config, |
| is_eval=True, |
| ) |
|
|
| loss_config = getattr(self.exp_config, "loss", None) |
| self.is_discrete = ( |
| getattr(loss_config, "progress_loss_type", "l2").lower() == "discrete" |
| if loss_config |
| else False |
| ) |
| self.num_bins = ( |
| getattr(loss_config, "progress_discrete_bins", None) |
| or getattr(self.exp_config.model, "progress_discrete_bins", 10) |
| ) |
|
|
| def reload_model(self) -> None: |
| if self.reward_model is not None: |
| del self.reward_model |
| cleanup_cuda_memory() |
| self._load_model_components() |
|
|
| @torch.inference_mode() |
| def _run_progress_samples(self, progress_samples: list[ProgressSample]) -> tuple[list[list[float]], list[list[float]]]: |
| batch = self.batch_collator(progress_samples) |
|
|
| progress_inputs = batch["progress_inputs"] |
| for key, value in progress_inputs.items(): |
| if hasattr(value, "to"): |
| progress_inputs[key] = value.to(self.device) |
|
|
| results = compute_batch_outputs( |
| self.reward_model, |
| self.tokenizer, |
| progress_inputs, |
| sample_type="progress", |
| is_discrete_mode=self.is_discrete, |
| num_bins=self.num_bins, |
| ) |
|
|
| progress_pred = results.get("progress_pred", []) or [] |
| outputs_success = results.get("outputs_success", {}) |
| success_probs = outputs_success.get("success_probs", []) if outputs_success else [] |
| return progress_pred, success_probs |
|
|
| @torch.inference_mode() |
| def _compute_rewards_whole_trajectory(self, video_frames: np.ndarray, task: str) -> tuple[np.ndarray, np.ndarray]: |
| traj = Trajectory( |
| frames=video_frames, |
| frames_shape=tuple(video_frames.shape), |
| task=task, |
| id="0", |
| metadata={"subsequence_length": int(video_frames.shape[0])}, |
| video_embeddings=None, |
| ) |
| progress_sample = ProgressSample(trajectory=traj, sample_type="progress") |
| progress_pred, success_probs = self._run_progress_samples([progress_sample]) |
|
|
| progress_array = ( |
| np.array(progress_pred[0], dtype=np.float32) |
| if progress_pred and len(progress_pred) > 0 |
| else np.array([], dtype=np.float32) |
| ) |
|
|
| success_array = ( |
| np.array(success_probs[0], dtype=np.float32) |
| if success_probs and len(success_probs) > 0 |
| else np.array([], dtype=np.float32) |
| ) |
|
|
| return progress_array, success_array |
|
|
| @torch.inference_mode() |
| def _compute_rewards_frame_steps( |
| self, |
| video_frames: np.ndarray, |
| task: str, |
| *, |
| prefix_sample_frames: int, |
| prefix_batch_size: int, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| total_frames = int(video_frames.shape[0]) |
| if total_frames <= 0: |
| return np.array([], dtype=np.float32), np.array([], dtype=np.float32) |
|
|
| prefix_sample_frames = max(1, int(prefix_sample_frames)) |
| batch_size = max(1, int(prefix_batch_size)) |
|
|
| prefix_samples: list[ProgressSample] = [] |
| metadata = {"subsequence_length": total_frames} |
| for prefix_len in range(1, total_frames + 1): |
| indices = np.linspace(0, prefix_len - 1, prefix_sample_frames, dtype=int) |
| sub_frames = video_frames[indices] |
| traj = Trajectory( |
| frames=sub_frames, |
| frames_shape=tuple(sub_frames.shape), |
| task=task, |
| id="0", |
| metadata=metadata, |
| video_embeddings=None, |
| ) |
| prefix_samples.append(ProgressSample(trajectory=traj, sample_type="progress")) |
|
|
| progress_values: list[float] = [] |
| success_values: list[float] = [] |
| cursor = 0 |
|
|
| while cursor < len(prefix_samples): |
| current_samples = prefix_samples[cursor:cursor + batch_size] |
| try: |
| progress_pred, success_probs = self._run_progress_samples(current_samples) |
| except RuntimeError as exc: |
| if not is_cuda_oom_error(exc): |
| raise |
| if batch_size == 1: |
| raise |
| next_batch_size = max(1, batch_size // 2) |
| print( |
| f"[OOM] frame_steps batch_size={batch_size} failed; retrying with batch_size={next_batch_size}" |
| ) |
| cleanup_cuda_memory() |
| batch_size = next_batch_size |
| continue |
|
|
| for sub_pred in progress_pred: |
| if not sub_pred: |
| raise RuntimeError("Robometer returned empty progress predictions for a frame-step prefix") |
| progress_values.append(float(sub_pred[-1])) |
|
|
| for idx in range(len(current_samples)): |
| sub_success = success_probs[idx] if idx < len(success_probs) else [] |
| success_values.append(float(sub_success[-1]) if sub_success else np.nan) |
|
|
| cursor += len(current_samples) |
|
|
| success_array = np.array(success_values, dtype=np.float32) |
| if success_array.size > 0 and np.isnan(success_array).all(): |
| success_array = np.array([], dtype=np.float32) |
|
|
| return np.array(progress_values, dtype=np.float32), success_array |
|
|
| @torch.inference_mode() |
| def compute_rewards_per_frame( |
| self, |
| video_frames: np.ndarray, |
| task: str, |
| *, |
| inference_mode: str = DEFAULT_INFERENCE_MODE, |
| prefix_sample_frames: int = DEFAULT_PREFIX_SAMPLE_FRAMES, |
| prefix_batch_size: int = DEFAULT_PREFIX_BATCH_SIZE, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| if inference_mode == "frame_steps": |
| return self._compute_rewards_frame_steps( |
| video_frames=video_frames, |
| task=task, |
| prefix_sample_frames=prefix_sample_frames, |
| prefix_batch_size=prefix_batch_size, |
| ) |
| if inference_mode == "whole": |
| return self._compute_rewards_whole_trajectory(video_frames=video_frames, task=task) |
| raise ValueError(f"Unsupported inference_mode: {inference_mode}") |
|
|
|
|
| def compute_rewards_per_frame_local( |
| model_path: str, |
| video_frames: np.ndarray, |
| task: str, |
| device: Optional[torch.device] = None, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| runner = RobometerLocalRunner(model_path=model_path, device=device) |
| return runner.compute_rewards_per_frame(video_frames=video_frames, task=task) |
|
|
|
|
| def map_frame_to_sample(frame_idx: int, sampled_indices: list[int]) -> tuple[int, int]: |
| nearest_pos = min(range(len(sampled_indices)), key=lambda idx: abs(sampled_indices[idx] - frame_idx)) |
| return nearest_pos, sampled_indices[nearest_pos] |
|
|
|
|
| def build_mark_points( |
| frame_pairs: list[tuple[str, int, int]], |
| sampled_indices: list[int], |
| progress_pred: np.ndarray, |
| ) -> list[dict]: |
| points = [] |
| for label, f0, f1 in frame_pairs: |
| for suffix, frame_idx in (("1", f0), ("2", f1)): |
| sample_pos, sampled_frame = map_frame_to_sample(frame_idx, sampled_indices) |
| progress = float(progress_pred[sample_pos]) |
| points.append( |
| { |
| "group": label, |
| "tag": f"{label}{suffix}", |
| "original_frame": frame_idx, |
| "sample_index": sample_pos, |
| "sampled_frame": sampled_frame, |
| "frame_error": sampled_frame - frame_idx, |
| "progress": progress, |
| } |
| ) |
| return points |
|
|
|
|
| def write_overview_plot( |
| output_dir: Path, |
| sampled_indices: list[int], |
| progress_pred: np.ndarray, |
| mark_points: list[dict], |
| ) -> None: |
| fig, ax = plt.subplots(figsize=(10.5, 4.8)) |
| ax.plot(sampled_indices, progress_pred, linewidth=2.0, color="#444444") |
|
|
| palette = plt.get_cmap("tab10") |
| grouped: dict[str, list[dict]] = defaultdict(list) |
| for point in mark_points: |
| grouped[point["group"]].append(point) |
|
|
| for idx, label in enumerate(sorted(grouped)): |
| pts = sorted(grouped[label], key=lambda item: item["original_frame"]) |
| color = palette(idx % 10) |
| ax.plot( |
| [pt["sampled_frame"] for pt in pts], |
| [pt["progress"] for pt in pts], |
| marker="o", |
| linewidth=1.8, |
| markersize=7, |
| color=color, |
| ) |
| for pt in pts: |
| ax.text( |
| pt["sampled_frame"], |
| pt["progress"] + 0.03, |
| f"{pt['tag']} {pt['progress']:.3f}", |
| color=color, |
| fontsize=9, |
| ha="center", |
| ) |
|
|
| ax.set_xlabel("Original Frame") |
| ax.set_ylabel("Progress") |
| ax.set_ylim(-0.05, 1.05) |
| ax.set_title("Robometer Progress Curve") |
| ax.grid(True, linestyle="--", alpha=0.35) |
| fig.tight_layout() |
| fig.savefig(output_dir / "overview.png", dpi=180) |
| plt.close(fig) |
|
|
|
|
| def write_group_cards( |
| output_dir: Path, |
| video_path: Path, |
| sampled_indices: list[int], |
| progress_pred: np.ndarray, |
| mark_points: list[dict], |
| ) -> None: |
| groups_dir = output_dir / "groups" |
| groups_dir.mkdir(parents=True, exist_ok=True) |
| palette = plt.get_cmap("tab10") |
|
|
| grouped: dict[str, list[dict]] = defaultdict(list) |
| for point in mark_points: |
| grouped[point["group"]].append(point) |
|
|
| for idx, label in enumerate(sorted(grouped)): |
| pts = sorted(grouped[label], key=lambda item: item["original_frame"]) |
| color = palette(idx % 10) |
| first = pts[0] |
| second = pts[-1] |
| img0 = extract_frame(video_path, first["original_frame"]) |
| img1 = extract_frame(video_path, second["original_frame"]) |
|
|
| frame_gap = abs(second["original_frame"] - first["original_frame"]) |
| x_min = max(0, min(first["original_frame"], second["original_frame"]) - max(25, frame_gap)) |
| x_max = max(first["original_frame"], second["original_frame"]) + max(25, frame_gap) |
|
|
| local_x = [] |
| local_y = [] |
| for x, y in zip(sampled_indices, progress_pred, strict=False): |
| if x_min <= x <= x_max: |
| local_x.append(x) |
| local_y.append(float(y)) |
| if not local_x: |
| local_x = sampled_indices |
| local_y = progress_pred.tolist() |
|
|
| fig = plt.figure(figsize=(9.5, 6.5)) |
| gs = fig.add_gridspec(2, 2, height_ratios=[1.45, 1.0]) |
| ax_img0 = fig.add_subplot(gs[0, 0]) |
| ax_img1 = fig.add_subplot(gs[0, 1]) |
| ax_curve = fig.add_subplot(gs[1, :]) |
|
|
| for ax, img, title in ( |
| (ax_img0, img0, f"{first['tag']} frame {first['original_frame']}"), |
| (ax_img1, img1, f"{second['tag']} frame {second['original_frame']}"), |
| ): |
| ax.axis("off") |
| ax.set_title(title, fontsize=10) |
| if img is None: |
| ax.text(0.5, 0.5, "frame read failed", ha="center", va="center", fontsize=9) |
| else: |
| ax.imshow(img) |
|
|
| ax_curve.plot(local_x, local_y, marker="o", linewidth=1.6, markersize=4, color="#666666") |
| ax_curve.plot( |
| [pt["sampled_frame"] for pt in pts], |
| [pt["progress"] for pt in pts], |
| marker="o", |
| linewidth=2.0, |
| markersize=7, |
| color=color, |
| ) |
| for pt in pts: |
| ax_curve.text( |
| pt["sampled_frame"], |
| pt["progress"] + 0.03, |
| f"{pt['tag']} {pt['progress']:.3f}", |
| color=color, |
| fontsize=9, |
| ha="center", |
| ) |
|
|
| ax_curve.set_xlim(x_min, x_max) |
| ax_curve.set_ylim(-0.05, 1.05) |
| ax_curve.set_xlabel("Original Frame") |
| ax_curve.set_ylabel("Progress") |
| ax_curve.set_title(f"{label} Group") |
| ax_curve.grid(True, linestyle="--", alpha=0.35) |
| ax_curve.text( |
| 0.015, |
| 0.97, |
| "\n".join( |
| f"{pt['tag']}: frame={pt['original_frame']}, sampled={pt['sampled_frame']}, progress={pt['progress']:.3f}" |
| for pt in pts |
| ), |
| transform=ax_curve.transAxes, |
| va="top", |
| ha="left", |
| fontsize=9, |
| bbox={"facecolor": "white", "edgecolor": "#cccccc", "alpha": 0.92, "boxstyle": "round,pad=0.35"}, |
| ) |
| fig.tight_layout() |
| fig.savefig(groups_dir / f"{label}.png", dpi=180) |
| plt.close(fig) |
|
|
|
|
| def save_results(output_dir: Path, payload: dict) -> None: |
| with (output_dir / "results.json").open("w", encoding="utf-8") as f: |
| json.dump(payload, f, indent=2, ensure_ascii=False) |
| f.write("\n") |
|
|
|
|
| def run_episode( |
| *, |
| episode_mp4: str, |
| chunk: str, |
| fps: float, |
| max_frames: int, |
| min_frames: int, |
| adaptive_max_frames: bool, |
| inference_mode: str, |
| prefix_sample_frames: int, |
| prefix_batch_size: int, |
| runner: RobometerLocalRunner, |
| overwrite: bool, |
| videos_root_base: Path, |
| allow_no_marks: bool, |
| tag: str | None = None, |
| ) -> None: |
| direct_chunk_dir = videos_root_base / chunk |
| if direct_chunk_dir.exists(): |
| videos_root = direct_chunk_dir |
| else: |
| chunk_filtered = f"{chunk}_filtered" |
| videos_root = videos_root_base / chunk_filtered |
| annotations_path = videos_root / "annotations.json" |
| episode_tasks_path = videos_root / "episode_tasks.json" |
| video_path = videos_root / DEFAULT_CAMERA_DIR / episode_mp4 |
| output_dir = DEFAULT_OUTPUT_ROOT / chunk_output_dir_name(chunk, tag) / episode_mp4[:-4] |
|
|
| if not overwrite and (output_dir / "results.json").exists(): |
| print(f"[SKIP] {episode_mp4[:-4]} already done") |
| return |
|
|
| episode_record = load_episode_record(annotations_path, episode_mp4) |
| instruction = load_instruction(episode_tasks_path, episode_mp4) |
| frame_pairs = extract_progress_pairs(episode_record) |
| if not frame_pairs and not allow_no_marks: |
| raise ValueError(f"{episode_mp4} has no usable progress_mark pairs") |
| if not video_path.exists(): |
| raise FileNotFoundError(f"Video not found: {video_path}") |
|
|
| required_frames = [frame for _, f0, f1 in frame_pairs for frame in (f0, f1)] |
|
|
| print(f"[RUN] {episode_mp4[:-4]}") |
| print(f"Loading frames from {video_path} ...") |
| all_frames, native_fps = load_all_video_frames(video_path) |
| total_frames = len(all_frames) |
| retry_schedule = ( |
| build_frame_retry_schedule(max_frames, min_frames, adaptive_max_frames) |
| if inference_mode == "whole" |
| else [max_frames] |
| ) |
| print( |
| f"[MODE] {inference_mode}" |
| + ( |
| f" (prefix_sample_frames={prefix_sample_frames}, prefix_batch_size={prefix_batch_size})" |
| if inference_mode == "frame_steps" |
| else "" |
| ) |
| ) |
|
|
| progress_pred: np.ndarray | None = None |
| success_probs: np.ndarray | None = None |
| sampled_indices: list[int] | None = None |
| used_max_frames = retry_schedule[0] |
|
|
| for attempt_idx, frame_budget in enumerate(retry_schedule, start=1): |
| frames, sampled_indices = sample_video_frames_with_indices( |
| all_frames, |
| native_fps=native_fps, |
| fps=fps, |
| max_frames=frame_budget, |
| required_frames=required_frames, |
| ) |
| print( |
| f"Loaded {total_frames} total frames; sampled {len(frames)} frames at fps={fps:g} " |
| f"(max_frames={frame_budget}, try {attempt_idx}/{len(retry_schedule)})" |
| ) |
| try: |
| progress_pred, success_probs = runner.compute_rewards_per_frame( |
| video_frames=frames, |
| task=instruction, |
| inference_mode=inference_mode, |
| prefix_sample_frames=prefix_sample_frames, |
| prefix_batch_size=prefix_batch_size, |
| ) |
| used_max_frames = frame_budget |
| break |
| except RuntimeError as exc: |
| if inference_mode != "whole" or not is_cuda_oom_error(exc): |
| raise |
| del frames |
| cleanup_cuda_memory() |
| if attempt_idx == len(retry_schedule): |
| raise |
| next_budget = retry_schedule[attempt_idx] |
| print( |
| f"[OOM] {episode_mp4[:-4]} hit CUDA OOM at max_frames={frame_budget}; " |
| f"retrying with max_frames={next_budget}" |
| ) |
| runner.reload_model() |
|
|
| if progress_pred is None or success_probs is None or sampled_indices is None: |
| raise RuntimeError(f"Failed to compute rewards for {episode_mp4[:-4]}") |
| if progress_pred.size == 0: |
| raise RuntimeError("Robometer returned empty progress predictions") |
| if progress_pred.size != len(sampled_indices): |
| raise RuntimeError( |
| f"Progress length mismatch: got {progress_pred.size} predictions for {len(sampled_indices)} sampled frames" |
| ) |
|
|
| mark_points = build_mark_points(frame_pairs, sampled_indices, progress_pred) |
| output_dir.mkdir(parents=True, exist_ok=True) |
|
|
| np.save(str(output_dir / "progress.npy"), progress_pred) |
| np.save(str(output_dir / "success_probs.npy"), success_probs) |
|
|
| payload = { |
| "episode": episode_mp4, |
| "chunk": chunk, |
| "instruction": instruction, |
| "video_path": str(video_path), |
| "num_total_frames": total_frames, |
| "native_fps": native_fps, |
| "sample_fps": float(fps), |
| "num_sampled_frames": len(sampled_indices), |
| "max_frames_used": used_max_frames, |
| "inference_mode": inference_mode, |
| "prefix_sample_frames": int(prefix_sample_frames), |
| "sampled_original_frame_indices": sampled_indices, |
| "progress_marks": [{"group": label, "frame_pair": [f0, f1]} for label, f0, f1 in frame_pairs], |
| "mark_points": mark_points, |
| "progress_min": float(np.min(progress_pred)), |
| "progress_max": float(np.max(progress_pred)), |
| "progress_mean": float(np.mean(progress_pred)), |
| } |
| save_results(output_dir, payload) |
| write_overview_plot(output_dir, sampled_indices, progress_pred, mark_points) |
| write_group_cards(output_dir, video_path, sampled_indices, progress_pred, mark_points) |
|
|
| print(f"[OK] Saved results to {output_dir}") |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description="Run Robometer local inference aligned to progress_mark frames.") |
| parser.add_argument("--episode", default=None, help="000287 / episode_000287 / episode_000287.mp4") |
| parser.add_argument( |
| "--count", |
| type=int, |
| default=1, |
| help="when --episode is set, run this many progress_mark episodes from that start", |
| ) |
| parser.add_argument("--chunk", default=DEFAULT_CHUNK) |
| parser.add_argument("--videos-root", type=Path, default=DEFAULT_VIDEOS_ROOT) |
| parser.add_argument("--model-path", default=str(DEFAULT_MODEL_PATH)) |
| parser.add_argument("--fps", type=float, default=3.0) |
| parser.add_argument("--max-frames", type=int, default=DEFAULT_MAX_FRAMES) |
| parser.add_argument("--min-frames", type=int, default=DEFAULT_MIN_FRAMES) |
| parser.add_argument( |
| "--inference-mode", |
| choices=["frame_steps", "whole"], |
| default=DEFAULT_INFERENCE_MODE, |
| help="frame_steps: stable dense curve via prefix inference; whole: one forward pass on the full sampled trajectory", |
| ) |
| parser.add_argument("--prefix-sample-frames", type=int, default=DEFAULT_PREFIX_SAMPLE_FRAMES) |
| parser.add_argument("--tag", type=str, default=None, |
| help="Optional suffix appended to chunk output dir (e.g. 'original' → Chunk000_original)") |
| parser.add_argument("--prefix-batch-size", type=int, default=DEFAULT_PREFIX_BATCH_SIZE) |
| parser.add_argument( |
| "--adaptive-max-frames", |
| dest="adaptive_max_frames", |
| action="store_true", |
| help="on CUDA OOM, retry an episode with smaller frame budgets", |
| ) |
| parser.add_argument( |
| "--no-adaptive-max-frames", |
| dest="adaptive_max_frames", |
| action="store_false", |
| help="disable dynamic frame-budget retry", |
| ) |
| parser.add_argument("--overwrite", action="store_true", help="rerun episodes even if results.json already exists") |
| parser.add_argument("--allow-no-marks", action="store_true", help="allow episodes without progress_mark pairs") |
| parser.set_defaults(adaptive_max_frames=True) |
| args = parser.parse_args() |
|
|
| if "CUDA_VISIBLE_DEVICES" not in os.environ: |
| user_gpu = input("GPU id (default 5): ").strip() |
| os.environ["CUDA_VISIBLE_DEVICES"] = user_gpu if user_gpu else "5" |
|
|
| chunks = parse_chunk_selection(args.chunk) |
| runner = RobometerLocalRunner(model_path=args.model_path) |
| ran_any = False |
|
|
| for chunk in chunks: |
| direct_chunk_dir = args.videos_root / chunk |
| if direct_chunk_dir.exists(): |
| videos_root = direct_chunk_dir |
| else: |
| chunk_filtered = f"{chunk}_filtered" |
| videos_root = args.videos_root / chunk_filtered |
| annotations_path = videos_root / "annotations.json" |
| episode_tasks_path = videos_root / "episode_tasks.json" |
| catalog = load_episode_catalog(episode_tasks_path, annotations_path) |
| progress_catalog = load_progress_mark_catalog(annotations_path) |
| require_progress_marks = not bool(args.allow_no_marks) |
|
|
| episode_list = resolve_episode_list( |
| episode_arg=args.episode, |
| count=args.count, |
| catalog=catalog, |
| progress_catalog=progress_catalog, |
| require_progress_marks=require_progress_marks, |
| ) |
|
|
| output_root = DEFAULT_OUTPUT_ROOT / chunk_output_dir_name(chunk, args.tag) |
| if not args.overwrite: |
| filtered = [] |
| for episode_mp4 in episode_list: |
| if result_path_for(output_root, episode_mp4).exists(): |
| print(f"[SKIP] {episode_mp4[:-4]} already done") |
| continue |
| filtered.append(episode_mp4) |
| episode_list = filtered |
|
|
| if not episode_list: |
| print(f"[OK] {chunk}: nothing to run.") |
| continue |
|
|
| ran_any = True |
| print(f"[CHUNK] {chunk}") |
| print(f"[QUEUE] {', '.join(ep[:-4] for ep in episode_list)}") |
| for episode_mp4 in episode_list: |
| run_episode( |
| episode_mp4=episode_mp4, |
| chunk=chunk, |
| fps=float(args.fps), |
| max_frames=int(args.max_frames), |
| min_frames=int(args.min_frames), |
| adaptive_max_frames=bool(args.adaptive_max_frames), |
| inference_mode=str(args.inference_mode), |
| prefix_sample_frames=int(args.prefix_sample_frames), |
| prefix_batch_size=int(args.prefix_batch_size), |
| runner=runner, |
| overwrite=args.overwrite, |
| videos_root_base=args.videos_root, |
| allow_no_marks=bool(args.allow_no_marks), |
| tag=args.tag, |
| ) |
|
|
| if not ran_any: |
| print("[OK] Nothing to run.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|