#!/usr/bin/env python3 """ RoboFAC dataset loader for the generic dataset converter for Robometer model training. Loads MINT-SJTU/RoboFAC-dataset structure: realworld_data//videos/*.mp4 and simulation_data. Task descriptions from the dataset card: https://huggingface.co/datasets/MINT-SJTU/RoboFAC-dataset """ import json import re from pathlib import Path from dataset_upload.helpers import generate_unique_id, load_sentence_transformer_model from dataset_upload.video_helpers import load_video_frames from tqdm import tqdm # Official task name -> language description (from MINT-SJTU/RoboFAC-dataset dataset card). # Task names match the card exactly (including typos: UprightStask, PegInsetionSide). TASK_NAME_TO_DESCRIPTION: dict[str, str] = { "SpinStack": "Pick up the cube on the spinning disc and stack it on another cube on the disc.", "SpinPullStack": "Pull out the cube on the spinning disc and stack it on another cube on the disc.", "MicrowaveTask": "Put the spoon on the table into the cup. Open the door of microwave, put the cup into the microwave and close the door.", "SafeTask": "Put the gold bar into the safe, close the door of the safe and rotate the cross knob on the door to lock it.", "ToolsTask": "Choose the correct (L-shaped) tools, grasp it to pull the correct (2-pins) charger and plug it.", "UprightStask": "Upright the peg and stack it on the cube.", "PegInsetionSide": "Insert the peg into the hole on the side of the block.", "PullCubeTool": "Grasp the L-shaped tool and pull the cube by it.", "PlugCharger": "Grasp the charger and plug it into the receptacle.", "InsertCylinder": "Upright the cylinder and insert it into the middle hole on the shelf.", "PlaceCube": "Pick up the cube and place it into the box.", "LiftPegUpright": "Lift the peg and upright it.", "PickCube": "Pick the cube to the target position.", "PullCube": "Pull the cube to the red and white target.", "PushCube": "Push the cube to the red and white target.", "StackCube": "Pick up the cube and stack it on another cube.", } # Simulation path task names (e.g. UprightStack-v1) may differ from card names; map to card key. _SIMULATION_PATH_TO_TASK_KEY: dict[str, str] = { "UprightStack": "UprightStask", "PegInsertionSide": "PegInsetionSide", } class RoboFACFrameLoader: """Pickle-able loader that reads RoboFAC video files on demand.""" def __init__(self, file_path: str): self.file_path = file_path def __call__(self): """Load frames from video file. Returns np.ndarray (T, H, W, 3) uint8.""" return load_video_frames(Path(self.file_path)) def _snake_to_camel(snake: str) -> str: """Convert snake_case to CamelCase (e.g. insert_cylinder -> InsertCylinder).""" return "".join(word.capitalize() for word in snake.split("_") if word) def _realworld_folder_to_task_description(folder_name: str) -> str: """Convert realworld folder name to task description using dataset-card mapping. E.g. so100_insert_cylinder_error -> InsertCylinder -> full description. Falls back to title-cased name if not in TASK_NAME_TO_DESCRIPTION. """ name = folder_name.replace("so100_", "").replace("_error", "").strip("_") task_key = _snake_to_camel(name) return TASK_NAME_TO_DESCRIPTION.get(task_key) or name.replace("_", " ").strip().title() def _find_mp4_under(path: Path) -> list[Path]: """Find all .mp4 files under path (recursive). Handles videos/ or videos/chunk-000/ etc.""" if not path.exists(): return [] return sorted(path.rglob("*.mp4")) def _simulation_quality_from_folder(folder_name: str) -> str: """Map simulation_data subfolder to quality_label (successful / failure).""" name_lower = folder_name.lower() if "success" in name_lower and "fail" not in name_lower: return "successful" if "fail" in name_lower or "error" in name_lower: return "failure" return "failure" # default for unknown def _simulation_path_task_to_description(path_task_name: str) -> str: """Map simulation path task name to dataset-card description. success_data/ and failure_data/ have one subfolder per task variant, e.g.: UprightStack-v1, LiftPegUpright-box, PickCube-apple, MicrowaveTask-fork. Strip the trailing - to get the base task name for lookup. """ # Strip variant suffix: -v1, -box, -apple, -fork, -gen1, etc. base = re.sub(r"-[a-zA-Z0-9_]+$", "", path_task_name) task_key = _SIMULATION_PATH_TO_TASK_KEY.get(base, base) return TASK_NAME_TO_DESCRIPTION.get(task_key) or path_task_name.replace("-", " ").replace("_", " ").strip().title() TASK_IDENTIFICATION_KEY = "Task identification" def _extract_task_identification_desc(annos: dict) -> str: """Extract assistant 'value' from annos['Task identification'] conversation only. We use only the "Task identification" section (not other anno keys). The value is the assistant's reply in that conversation, e.g. "Insert the cylinder into the middle hole of the shelf." """ # Prefer exact key, then case-insensitive match for "Task identification" task_id = annos.get(TASK_IDENTIFICATION_KEY) if task_id is None: for k, v in annos.items(): if k.strip().lower() == TASK_IDENTIFICATION_KEY.lower(): task_id = v break if not isinstance(task_id, list): return "" for turn in task_id: if turn.get("from") == "assistant" and "value" in turn: return turn["value"].strip() return "" def _load_test_qa_annos(root: Path) -> tuple[dict[str, str], dict[str, str]]: """Load test_qa_realworld and test_qa_sim annos_per_video_split*.json and build video_id -> description. Each JSON is a dict: { "video_id": { "video": "path", "task": "InsertCylinder", "annos": { "Task identification": [ { "from": "human", "value": "..." }, { "from": "assistant", "value": "Insert the cylinder into the middle hole of the shelf." } ] } } }. We use the "Task identification" assistant value as the language description. Tries root/test_qa_realworld, root/test_qa_sim, and root/main/... for each. """ video_id_to_desc: dict[str, str] = {} task_folder_to_desc: dict[str, str] = {} dirs_to_try = [ root / "test_qa_realworld", root / "test_qa_sim", root / "main" / "test_qa_realworld", root / "main" / "test_qa_sim", ] for annos_dir in dirs_to_try: if not annos_dir.is_dir(): continue for path in sorted(annos_dir.glob("annos_per_video_split*.json")): with open(path, encoding="utf-8") as f: data = json.load(f) for video_id, entry in data.items(): if not isinstance(entry, dict): continue annos = entry.get("annos", {}) desc = _extract_task_identification_desc(annos) if not desc: continue video_id_to_desc[video_id] = desc video_path_str = entry.get("video", "") if video_path_str: stem = Path(video_path_str).stem video_id_to_desc[stem] = desc task_folder_to_desc[video_path_str.split("/")[0]] = desc task = entry.get("task", "") if task: task_folder_to_desc[task] = desc if video_id_to_desc: print(f" Loaded {len(video_id_to_desc)} video_id->description from test_qa_* annos") return video_id_to_desc, task_folder_to_desc # Human prompt that indicates task-identification QA in training_qa.json (we only extract from these). _TRAINING_QA_TASK_IDENTIFICATION_PROMPT = "identify what task the robot is doing" def _extract_task_desc_from_training_qa_conversations(convs: list) -> str: """Extract language description from training_qa conversations. Only use the assistant reply when the human asked the task-identification question (e.g. 'Can you identify what task the robot is doing in the provided video?'). """ if not isinstance(convs, list): return "" for i, turn in enumerate(convs): if turn.get("from") != "human" or "value" not in turn: continue human_val = (turn.get("value") or "").lower() if _TRAINING_QA_TASK_IDENTIFICATION_PROMPT not in human_val: continue # Next turn should be assistant with the task description if i + 1 < len(convs) and convs[i + 1].get("from") == "assistant" and "value" in convs[i + 1]: return (convs[i + 1].get("value") or "").strip() return "" def _load_training_qa(root: Path) -> tuple[dict[str, str], dict[str, str]]: """Load training_qa.json and build video_id -> description and task_folder -> description. training_qa.json is a list of { "id": "09936392-adb5-4f34-9410-7c7305d9c76b", "video": "dataset_success_cleaned/MicrowaveTask-fork/stack_error/09936392-adb5-4f34-9410-7c7305d9c76b.mp4", "conversations": [ { "from": "human", "value": "