| """Phase annotate: CLI script to add all phase/affordance columns to parquet files in a single pass. |
| |
| Produces 8 columns: |
| - phase.subgoal, phase.progress (from gripper segmentation) |
| - affordance.type, affordance.granularity (rule-derived from above) |
| - phase.subgoal_object, phase.subgoal_target, phase.subgoal_descriptor, phase.subgoal_relation |
| (structured fields resolved from parsed instructions) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| import os |
| import shutil |
| from collections import Counter, defaultdict |
| from multiprocessing import Pool |
| from pathlib import Path |
| from typing import Dict, List, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| from phaseaware_data_construction.gripper_segmenter import SegmentationResult, segment_episode |
| from phaseaware_data_construction.instruction_parser import Subgoal, parse_instruction |
| from phaseaware_data_construction.affordance_deriver import derive_affordance_columns |
| from phaseaware_data_construction.subgoal_columns import build_label_to_fields, resolve_frame_fields |
|
|
| logging.basicConfig( |
| level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" |
| ) |
| logger = logging.getLogger(__name__) |
|
|
| |
| GRIPPER_STATE_DIM = 7 |
| GRIPPER_ACTION_DIM = 6 |
|
|
|
|
| def load_task_map(src_dir: Path) -> Dict[int, str]: |
| """Load task_index → instruction mapping from meta/tasks.jsonl.""" |
| tasks_path = src_dir / "meta" / "tasks.jsonl" |
| task_map: Dict[int, str] = {} |
| with open(tasks_path) as f: |
| for line in f: |
| d = json.loads(line.strip()) |
| task_map[d["task_index"]] = d["task"] |
| return task_map |
|
|
|
|
| def parse_all_tasks(task_map: Dict[int, str]) -> Dict[int, List[Subgoal]]: |
| """Pre-parse all task instructions into subgoal sequences.""" |
| parsed: Dict[int, List[Subgoal]] = {} |
| for idx, instruction in task_map.items(): |
| parsed[idx] = parse_instruction(instruction) |
| return parsed |
|
|
|
|
| def find_episode_parquets(src_dir: Path) -> List[Path]: |
| """Find all episode parquet files under data/.""" |
| data_dir = src_dir / "data" |
| parquets = sorted(data_dir.rglob("episode_*.parquet")) |
| return parquets |
|
|
|
|
| def process_single_episode(args: Tuple) -> dict: |
| """Process a single episode parquet. Designed for multiprocessing.""" |
| parquet_path, subgoal_map, signal_source, threshold, median_filter_size = args |
| parquet_path = Path(parquet_path) |
|
|
| try: |
| df = pd.read_parquet(parquet_path) |
| state = np.array(df["observation.state"].tolist()) |
| task_idx = int(df["task_index"].iloc[0]) |
| episode_idx = int(df["episode_index"].iloc[0]) |
|
|
| |
| if signal_source == "action": |
| action = np.array(df["action"].tolist()) |
| gripper_signal = action[:, GRIPPER_ACTION_DIM] |
| else: |
| gripper_signal = state[:, GRIPPER_STATE_DIM] |
|
|
| subgoals = subgoal_map.get(task_idx) |
| if subgoals is None: |
| return { |
| "path": str(parquet_path), |
| "episode": episode_idx, |
| "status": "error", |
| "error": f"Unknown task_index {task_idx}", |
| } |
|
|
| result = segment_episode( |
| gripper_signal, subgoals, |
| signal_source=signal_source, |
| threshold=threshold, |
| median_filter_size=median_filter_size, |
| ) |
|
|
| |
| aff_types, aff_grans = derive_affordance_columns( |
| result.frame_subgoals, result.frame_progress |
| ) |
|
|
| |
| label_map = build_label_to_fields(subgoals) |
| sg_objects, sg_targets, sg_descriptors, sg_relations = resolve_frame_fields( |
| result.frame_subgoals, label_map |
| ) |
|
|
| return { |
| "path": str(parquet_path), |
| "episode": episode_idx, |
| "task_index": task_idx, |
| "status": "ok", |
| "n_frames": len(df), |
| "n_cycles_detected": result.n_cycles_detected, |
| "n_cycles_expected": result.n_cycles_expected, |
| "warnings": result.warnings, |
| "frame_subgoals": result.frame_subgoals, |
| "frame_progress": result.frame_progress, |
| "aff_types": aff_types, |
| "aff_grans": aff_grans, |
| "sg_objects": sg_objects, |
| "sg_targets": sg_targets, |
| "sg_descriptors": sg_descriptors, |
| "sg_relations": sg_relations, |
| } |
| except Exception as e: |
| return { |
| "path": str(parquet_path), |
| "episode": -1, |
| "status": "error", |
| "error": str(e), |
| } |
|
|
|
|
| def write_annotated_parquet( |
| src_path: Path, output_path: Path, result: dict |
| ): |
| """Read src parquet, add all 8 phase/affordance columns, write to output.""" |
| df = pd.read_parquet(src_path) |
| df["phase.subgoal"] = result["frame_subgoals"] |
| df["phase.progress"] = result["frame_progress"] |
| df["affordance.type"] = result["aff_types"] |
| df["affordance.granularity"] = result["aff_grans"] |
| df["phase.subgoal_object"] = result["sg_objects"] |
| df["phase.subgoal_target"] = result["sg_targets"] |
| df["phase.subgoal_descriptor"] = result["sg_descriptors"] |
| df["phase.subgoal_relation"] = result["sg_relations"] |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| df.to_parquet(output_path, index=False) |
|
|
|
|
| def validate_output(output_dir: Path, subgoal_map: Dict[int, List[Subgoal]]): |
| """Validate the annotated output parquets.""" |
| parquets = find_episode_parquets(output_dir) |
| if not parquets: |
| logger.error("No parquets found in output directory") |
| return |
|
|
| total_frames = 0 |
| total_episodes = 0 |
| suspicious = [] |
| order_mismatches = [] |
| sg_frame_counts: Counter = Counter() |
| progress_counts: Counter = Counter() |
| type_counts: Counter = Counter() |
| gran_counts: Counter = Counter() |
| sg_per_episode: defaultdict = defaultdict(list) |
|
|
| for p in parquets: |
| df = pd.read_parquet(p) |
| total_episodes += 1 |
| total_frames += len(df) |
|
|
| if "phase.subgoal" not in df.columns or "phase.progress" not in df.columns: |
| logger.error(f"Missing phase columns in {p}") |
| continue |
|
|
| |
| all_cols = [ |
| "phase.subgoal", "phase.progress", |
| "affordance.type", "affordance.granularity", |
| "phase.subgoal_object", "phase.subgoal_target", |
| "phase.subgoal_descriptor", "phase.subgoal_relation", |
| ] |
| missing_cols = [c for c in all_cols if c not in df.columns] |
| if missing_cols: |
| logger.warning(f"{p}: missing columns {missing_cols}") |
|
|
| null_sg = df["phase.subgoal"].isna().sum() |
| null_pr = df["phase.progress"].isna().sum() |
| if null_sg > 0 or null_pr > 0: |
| logger.warning(f"{p}: {null_sg} null subgoals, {null_pr} null progress") |
|
|
| |
| episode_sgs = df["phase.subgoal"].tolist() |
| episode_pr = df["phase.progress"].tolist() |
|
|
| |
| seen_order = [] |
| prev = None |
| for sg in episode_sgs: |
| if sg != prev: |
| seen_order.append(sg) |
| prev = sg |
|
|
| ep_idx = int(df["episode_index"].iloc[0]) |
| task_idx = int(df["task_index"].iloc[0]) |
| sg_per_episode[task_idx].append(len(seen_order)) |
|
|
| |
| if task_idx in subgoal_map: |
| expected_labels = [sg.label() for sg in subgoal_map[task_idx]] |
| |
| missing = [lbl for lbl in expected_labels if lbl not in seen_order] |
| |
| |
| ei = 0 |
| out_of_order = False |
| for lbl in seen_order: |
| if ei < len(expected_labels) and lbl == expected_labels[ei]: |
| ei += 1 |
| if ei < len(expected_labels): |
| out_of_order = True |
|
|
| if missing or out_of_order: |
| order_mismatches.append(( |
| p.name, ep_idx, task_idx, |
| expected_labels, seen_order, |
| missing, out_of_order, |
| )) |
|
|
| for sg in episode_sgs: |
| sg_frame_counts[sg] += 1 |
| for pr in episode_pr: |
| progress_counts[pr] += 1 |
| if "affordance.type" in df.columns: |
| for t in df["affordance.type"]: |
| type_counts[t] += 1 |
| if "affordance.granularity" in df.columns: |
| for g in df["affordance.granularity"]: |
| gran_counts[g] += 1 |
|
|
| |
| sg_counts_ep = Counter(episode_sgs) |
| n = len(df) |
| for sg, cnt in sg_counts_ep.items(): |
| if cnt < 5: |
| suspicious.append((p.name, ep_idx, sg, cnt, "too_short")) |
| if cnt > 0.8 * n and len(sg_counts_ep) > 1: |
| suspicious.append((p.name, ep_idx, sg, cnt, "too_long")) |
|
|
| |
| logger.info(f"\n{'='*60}") |
| logger.info(f"Validation Summary") |
| logger.info(f"{'='*60}") |
| logger.info(f"Total episodes: {total_episodes}") |
| logger.info(f"Total frames: {total_frames}") |
| logger.info(f"\nProgress distribution:") |
| for pr, cnt in progress_counts.most_common(): |
| logger.info(f" {pr}: {cnt} ({cnt/total_frames*100:.1f}%)") |
|
|
| logger.info(f"\nSubgoal type frame counts (top 20):") |
| for sg, cnt in sg_frame_counts.most_common(20): |
| logger.info(f" {sg}: {cnt}") |
|
|
| if type_counts: |
| logger.info(f"\naffordance.type distribution:") |
| for t, cnt in type_counts.most_common(): |
| logger.info(f" {t}: {cnt} ({cnt/total_frames*100:.1f}%)") |
|
|
| if gran_counts: |
| logger.info(f"\naffordance.granularity distribution:") |
| for g, cnt in gran_counts.most_common(): |
| logger.info(f" {g}: {cnt} ({cnt/total_frames*100:.1f}%)") |
|
|
| |
| if order_mismatches: |
| logger.warning(f"\nSubgoal order mismatches ({len(order_mismatches)}):") |
| for name, ep, tidx, expected, seen, missing, ooo in order_mismatches: |
| logger.warning(f" {name} ep={ep} task={tidx}:") |
| logger.warning(f" expected : {expected}") |
| logger.warning(f" seen : {seen}") |
| if missing: |
| logger.warning(f" missing : {missing}") |
| if ooo: |
| logger.warning(f" out-of-order: expected labels not in correct relative order") |
| else: |
| logger.info(f"\nAll episodes match expected subgoal order.") |
|
|
| if suspicious: |
| logger.warning(f"\nSuspicious episodes ({len(suspicious)}):") |
| for name, ep, sg, cnt, reason in suspicious[:20]: |
| logger.warning(f" {name} ep={ep}: {sg} has {cnt} frames ({reason})") |
| else: |
| logger.info(f"\nNo suspicious episodes found.") |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description="Add all phase/affordance columns (8 total) to LeRobot parquet files." |
| ) |
| parser.add_argument("--src_dir", type=str, required=True, help="Source dataset directory") |
| parser.add_argument("--output_dir", type=str, required=True, help="Output directory") |
| parser.add_argument("--num_workers", type=int, default=1, help="Number of parallel workers") |
| parser.add_argument("--signal_source", type=str, default="action", |
| choices=["action", "state"], |
| help="Gripper signal source: 'action' (binary, more reliable) or 'state' (continuous)") |
| parser.add_argument("--gripper_threshold", type=float, default=0.02, |
| help="Gripper binarization threshold (state dim 7: > -threshold → closed)") |
| parser.add_argument("--median_filter_size", type=int, default=5, |
| help="Median filter kernel size") |
| parser.add_argument("--dry_run", action="store_true", |
| help="Don't write files, only print stats") |
| parser.add_argument("--validate", action="store_true", |
| help="Validate existing output instead of annotating") |
| args = parser.parse_args() |
|
|
| src_dir = Path(args.src_dir) |
| output_dir = Path(args.output_dir) |
|
|
| |
| if args.validate: |
| task_map = load_task_map(src_dir) |
| subgoal_map = parse_all_tasks(task_map) |
| validate_output(output_dir, subgoal_map) |
| return |
|
|
| |
| logger.info(f"Source: {src_dir}") |
| logger.info(f"Output: {output_dir}") |
|
|
| |
| task_map = load_task_map(src_dir) |
| subgoal_map = parse_all_tasks(task_map) |
| logger.info(f"Parsed {len(task_map)} tasks:") |
| for idx, sgs in subgoal_map.items(): |
| logger.info(f" task {idx}: {task_map[idx]} → {[sg.label() for sg in sgs]}") |
|
|
| |
| parquets = find_episode_parquets(src_dir) |
| logger.info(f"Found {len(parquets)} episode parquets") |
|
|
| if not parquets: |
| logger.error("No episode parquets found!") |
| return |
|
|
| |
| work_args = [ |
| (str(p), subgoal_map, args.signal_source, args.gripper_threshold, args.median_filter_size) |
| for p in parquets |
| ] |
|
|
| |
| if args.num_workers > 1: |
| with Pool(args.num_workers) as pool: |
| results = pool.map(process_single_episode, work_args) |
| else: |
| results = [process_single_episode(a) for a in work_args] |
|
|
| |
| ok_count = 0 |
| err_count = 0 |
| warn_count = 0 |
| cycle_match = 0 |
| cycle_mismatch = 0 |
|
|
| for r in results: |
| if r["status"] == "error": |
| err_count += 1 |
| logger.error(f"Episode {r['episode']}: {r.get('error', 'unknown error')}") |
| continue |
| ok_count += 1 |
| if r["warnings"]: |
| warn_count += 1 |
| for w in r["warnings"]: |
| logger.warning(f"Episode {r['episode']}: {w}") |
| if r["n_cycles_detected"] == r["n_cycles_expected"]: |
| cycle_match += 1 |
| else: |
| cycle_mismatch += 1 |
|
|
| logger.info(f"\n{'='*60}") |
| logger.info(f"Processing Summary") |
| logger.info(f"{'='*60}") |
| logger.info(f"OK: {ok_count}, Errors: {err_count}, With warnings: {warn_count}") |
| logger.info(f"Cycle match: {cycle_match}, Cycle mismatch: {cycle_mismatch}") |
| logger.info(f"Match rate: {cycle_match/(ok_count or 1)*100:.1f}%") |
|
|
| if args.dry_run: |
| logger.info("Dry run — no files written.") |
| return |
|
|
| |
| logger.info(f"\nWriting annotated parquets to {output_dir}") |
|
|
| |
| src_meta = src_dir / "meta" |
| dst_meta = output_dir / "meta" |
| if src_meta.exists(): |
| if dst_meta.exists(): |
| shutil.rmtree(dst_meta) |
| shutil.copytree(src_meta, dst_meta) |
| logger.info(f"Copied meta/ directory") |
|
|
| |
| src_video = src_dir / "video" |
| dst_video = output_dir / "video" |
| if src_video.exists() and not dst_video.exists(): |
| dst_video.parent.mkdir(parents=True, exist_ok=True) |
| os.symlink(src_video.resolve(), dst_video) |
| logger.info(f"Symlinked video/ directory") |
|
|
| |
| written = 0 |
| for r in results: |
| if r["status"] != "ok": |
| continue |
| src_path = Path(r["path"]) |
| rel_path = src_path.relative_to(src_dir) |
| out_path = output_dir / rel_path |
| write_annotated_parquet(src_path, out_path, r) |
| written += 1 |
|
|
| logger.info(f"Written {written} annotated parquets") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|