Download robometer/scripts/benchmark_progress_mark_local.py from Vio1etV/robometer_framework: direct link, hf CLI and curl.
- Browser
- Download file 39 kB
-
https://huggingface.co/Vio1etV/robometer_framework/resolve/main/robometer/scripts/benchmark_progress_mark_local.py
- Command line
-
hf download hf://Vio1etV/robometer_framework/robometer/scripts/benchmark_progress_mark_local.py
-
curl -L -o benchmark_progress_mark_local.py https://huggingface.co/Vio1etV/robometer_framework/resolve/main/robometer/scripts/benchmark_progress_mark_local.py
39 kB
| #!/usr/bin/env python3 | |
| 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() | |
| # Snap the nearest sampling slots onto the required progress_mark frames. | |
| 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() | |
| 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 | |
| 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 | |
| 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 | |
| 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() | |