#!/usr/bin/env python3 """Run a single counterfactual test on an existing run directory. Loads model, dataset, and scene data from a previous run, then executes one counterfactual test and saves the result + comparison image. Much faster than re-running the full diagnostic pipeline. Usage: python run_single_counterfactual.py [--param key=value ...] python run_single_counterfactual.py --list """ from __future__ import annotations import argparse import json import os import sys import time def main(): parser = argparse.ArgumentParser( description="Run a single counterfactual test on an existing run directory", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s outputs/mthirumalai/finetuned_model background_substitution %(prog)s outputs/mthirumalai/finetuned_model distractor_insertion --param position='[200,200]' %(prog)s outputs/mthirumalai/finetuned_model task_string_swap --viz-only %(prog)s --list """, ) parser.add_argument("run_dir", nargs="?", help="Path to existing run directory") parser.add_argument("test_name", nargs="?", help="Counterfactual test name") parser.add_argument("--list", action="store_true", help="List available tests") parser.add_argument("--param", action="append", default=[], help="Test parameter as key=value (repeatable)") parser.add_argument("--viz-only", action="store_true", help="Only regenerate visualization from existing result.json") parser.add_argument("--device", default="auto", help="Device (cuda/cpu/auto)") parser.add_argument("--episode", type=int, default=None, help="Episode index (default: from manifest)") args = parser.parse_args() # Import here so --list/--help are fast _ensure_imports() if args.list: _list_tests() return if not args.run_dir or not args.test_name: parser.error("run_dir and test_name are required (or use --list)") run_dir = args.run_dir test_name = args.test_name # Validate run directory if not os.path.isdir(run_dir): print(f"ERROR: Run directory not found: {run_dir}") sys.exit(1) manifest_path = os.path.join(run_dir, "run_manifest.json") if not os.path.exists(manifest_path): print(f"ERROR: No run_manifest.json in {run_dir}") sys.exit(1) # Parse test params test_params = _parse_params(args.param) if args.viz_only: _regenerate_viz(run_dir, test_name) return # Load manifest for model/dataset info with open(manifest_path) as f: manifest = json.load(f) cli_args = manifest.get("cli_args", {}) model_id = cli_args.get("model") dataset_id = manifest.get("dataset_info", {}).get("dataset_id") or cli_args.get("dataset") image_key = cli_args.get("image_key") image_map_str = cli_args.get("image_map") episode_idx = args.episode if args.episode is not None else cli_args.get("episode", 0) if not model_id: print("ERROR: Cannot determine model from manifest. Specify --model?") sys.exit(1) if not dataset_id: print("ERROR: Cannot determine dataset from manifest.") sys.exit(1) # Device import torch device = args.device if device == "auto": if torch.cuda.is_available(): device = "cuda" elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): device = "mps" else: device = "cpu" print(f"\n{'=' * 50}") print(f" Quick Counterfactual Test") print(f"{'=' * 50}") print(f" Run dir: {run_dir}") print(f" Test: {test_name}") print(f" Model: {model_id}") print(f" Dataset: {dataset_id}") print(f" Device: {device}") if test_params: print(f" Params: {test_params}") print(f"{'=' * 50}\n") # ── Load model ── t0 = time.time() print(" Loading model...", end="", flush=True) from lerobot.policies.smolvla.modeling_smolvla import SmolVLAPolicy policy = SmolVLAPolicy.from_pretrained(model_id) policy.to(device) policy.eval() print(f" done ({time.time() - t0:.1f}s)") # ── Load dataset ── t0 = time.time() print(" Loading dataset...", end="", flush=True) from lerobot.datasets.lerobot_dataset import LeRobotDataset dataset = LeRobotDataset(dataset_id) print(f" done ({time.time() - t0:.1f}s)") # Resolve image key if image_key is None: from smolvla_inspect.data import find_image_keys image_keys = find_image_keys(dataset) image_key = image_keys[0] if image_keys else "observation.images.top" # Parse image map image_map = None if image_map_str: from smolvla_inspect.data import parse_image_map image_map = parse_image_map(image_map_str) # ── Load scene data ── print(" Loading scene data...", end="", flush=True) scene = _load_scene(run_dir) if scene is None: print("\n WARNING: No scene data found — running scene detection...") scene = _detect_scene(dataset, episode_idx, image_key, device) else: print(" done") # ── Get sample ── print(" Loading sample...", end="", flush=True) first_frame_idx = _get_first_frame_idx(dataset, episode_idx) sample = dataset[first_frame_idx] print(f" done (frame {first_frame_idx})") # ── Validate test name ── from smolvla_inspect.diagnostic.registry import REGISTRY primitive_name = f"counterfactual.{test_name}" if primitive_name not in REGISTRY: print(f"\n ERROR: Unknown test '{test_name}'") print(f" Available: {', '.join(n.removeprefix('counterfactual.') for n in REGISTRY if n.startswith('counterfactual.'))}") sys.exit(1) # ── Apply default params if not provided ── test_params = _apply_defaults(test_name, test_params, scene) # ── Run the test ── spec = REGISTRY[primitive_name] params = dict(test_params) params.update({ "policy": policy, "sample": sample, "dataset": dataset, "image_key": image_key, "device": device, "image_map": image_map, }) if "segmentation" in spec.fn.__code__.co_varnames: params["segmentation"] = scene if "episode_idx" in spec.fn.__code__.co_varnames: params["episode_idx"] = episode_idx print(f"\n Running {test_name}...", flush=True) t0 = time.time() result = spec.fn(**params) elapsed = time.time() - t0 # ── Save result ── import numpy as np cf_dir = os.path.join(run_dir, "diagnostic", "counterfactuals", test_name) os.makedirs(cf_dir, exist_ok=True) # Save result.json result_dict = { "hypothesis_id": result.hypothesis_id, "test_type": result.test_type, "action_delta_l2": float(result.action_delta_l2), "action_delta_per_dim": [float(x) for x in result.action_delta_per_dim], "gradcam_shift": float(result.gradcam_shift) if result.gradcam_shift else 0.0, "attribution_shift_per_region": result.attribution_shift_per_region or {}, "confirmed": result.confirmed, "metrics": result.metrics or {}, } result_path = os.path.join(cf_dir, "result.json") with open(result_path, "w") as f: json.dump(result_dict, f, indent=2) # Save comparison image if result.visual_comparison is not None: from PIL import Image comp_path = os.path.join(cf_dir, "comparison.png") Image.fromarray(result.visual_comparison).save(comp_path) print(f" Saved: {comp_path}") print(f"\n Result ({elapsed:.1f}s):") print(f" Action delta (L2): {result.action_delta_l2:.4f}") print(f" Confirmed: {result.confirmed}") print(f" Saved to: {result_path}") print(f"{'=' * 50}\n") def _ensure_imports(): """Check that the package is importable.""" try: import smolvla_inspect # noqa: F401 except ImportError: # Try adding the project root to sys.path root = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, root) def _list_tests(): """Print available counterfactual tests.""" _ensure_imports() # Force registry population by importing the counterfactual module import smolvla_inspect.diagnostic.counterfactual # noqa: F401 from smolvla_inspect.diagnostic.registry import list_primitives print("\nAvailable counterfactual tests:\n") for spec in list_primitives(category="counterfactual"): name = spec.name.removeprefix("counterfactual.") print(f" {name}") print(f" {spec.description}") if spec.param_schema: print(f" Params: {spec.param_schema}") print() def _parse_params(param_list: list[str]) -> dict: """Parse --param key=value arguments into a dict.""" params = {} for p in param_list: if "=" not in p: print(f"ERROR: Invalid param '{p}' — expected key=value") sys.exit(1) key, val = p.split("=", 1) # Try JSON parsing for lists, numbers, bools try: params[key] = json.loads(val) except (json.JSONDecodeError, ValueError): params[key] = val return params def _load_scene(run_dir: str): """Reconstruct SceneSegmentation from saved files.""" import numpy as np from smolvla_inspect.diagnostic.models import SceneSegmentation, DetectedObject scene_dir = os.path.join(run_dir, "diagnostic", "scene") det_path = os.path.join(scene_dir, "detections.json") seg_path = os.path.join(scene_dir, "segmentation.npz") if not os.path.exists(det_path): return None with open(det_path) as f: det_data = json.load(f) seg_data = {} if os.path.exists(seg_path): seg_data = dict(np.load(seg_path)) objects = [] for det in det_data["objects"]: mask_key = det["label"].replace(" ", "_") mask = seg_data.get(mask_key) if mask is not None: mask = mask.astype(bool) objects.append(DetectedObject( label=det["label"], box=tuple(det["box"]), score=det["score"], mask=mask, )) bg_mask = seg_data.get("background") if bg_mask is not None: bg_mask = bg_mask.astype(bool) h, w = det_data["image_shape"] return SceneSegmentation( objects=objects, background_mask=bg_mask, image_shape=(h, w), ) def _get_first_frame_idx(dataset, episode_idx: int) -> int: """Get the dataset index of the first frame in an episode.""" try: return dataset.meta.episodes["dataset_from_index"][episode_idx] except (AttributeError, KeyError): try: return dataset.episode_data_index["from"][episode_idx].item() except (AttributeError, KeyError): return episode_idx * 200 def _detect_scene(dataset, episode_idx: int, image_key: str, device: str): """Run scene detection from scratch (fallback when no saved scene).""" from smolvla_inspect.diagnostic.scene import detect_scene first_idx = _get_first_frame_idx(dataset, episode_idx) sample = dataset[first_idx] return detect_scene(sample, image_key, device) def _apply_defaults(test_name: str, params: dict, scene) -> dict: """Fill in sensible defaults for test params that weren't provided.""" if test_name == "background_substitution": params.setdefault("replacement", "gray") elif test_name == "object_relocation": if "target_object" not in params and scene: params["target_object"] = _pick_target(scene) params.setdefault("shift_pixels", [100, -80]) elif test_name == "object_recolor": if "target_object" not in params and scene: params["target_object"] = _pick_target(scene) params.setdefault("hue_shift", 0.5) elif test_name == "occlusion_targeted": if "target_object" not in params and scene: params["target_object"] = _pick_target(scene) params.setdefault("fill", "gray") elif test_name == "distractor_insertion": params.setdefault("position", [256, 256]) params.setdefault("distractor_size", 80) elif test_name == "task_string_swap": params.setdefault("replacement_task", "do nothing") elif test_name == "lighting_perturbation": params.setdefault("brightness_delta", 0.3) params.setdefault("contrast_delta", 0.3) elif test_name == "temporal_consistency": params.setdefault("perturbation_type", "background_substitution") params.setdefault("num_frames", 5) return params def _pick_target(scene) -> str: """Pick the most likely manipulation target from scene objects.""" skip = {"robot gripper", "robot arm", "gripper", "arm"} for obj in scene.objects: if obj.label.lower() not in skip and obj.mask is not None: return obj.label # Fallback to first object with a mask for obj in scene.objects: if obj.mask is not None: return obj.label return scene.objects[0].label if scene.objects else "object" def _regenerate_viz(run_dir: str, test_name: str): """Regenerate only the visualization from an existing result.json.""" import numpy as np cf_dir = os.path.join(run_dir, "diagnostic", "counterfactuals", test_name) result_path = os.path.join(cf_dir, "result.json") if not os.path.exists(result_path): print(f"ERROR: No result.json at {result_path}") print(f" Run the test first (without --viz-only)") sys.exit(1) with open(result_path) as f: result_data = json.load(f) # For task_string_swap, regenerate the action delta chart if test_name == "task_string_swap": from smolvla_inspect.diagnostic.counterfactual import _make_action_delta_chart metrics = result_data.get("metrics", {}) baseline = metrics.get("baseline_actions") modified = metrics.get("modified_actions") if baseline is None or modified is None: # Reconstruct from deltas (approximate — modified = baseline + delta) # but we don't have absolute values, so the grouped-bar top panel # won't render. Re-run the test without --viz-only instead. print(" WARNING: result.json does not contain baseline/modified actions.") print(" Re-run the test without --viz-only to get the two-panel chart.") print(" (Older results lack this data; only delta bars will be shown.)") return baseline = np.array(baseline) modified = np.array(modified) original_task = metrics.get("original_task", "original task") replacement_task = metrics.get("replacement_task", "replacement task") chart = _make_action_delta_chart(baseline, modified, original_task, replacement_task) from PIL import Image comp_path = os.path.join(cf_dir, "comparison.png") Image.fromarray(chart).save(comp_path) print(f" Regenerated chart: {comp_path}") else: print(f" --viz-only currently supports: task_string_swap") print(f" For image-based tests, re-run the test (model needed for comparison).") sys.exit(1) if __name__ == "__main__": main()