| """ |
| Generate structured CoT labels for Nexar train clips using GPT-4o as teacher. |
| |
| Schema (enforced by response_format json_object): |
| { |
| "scene": "short description of the driving scene", |
| "critical_objects": ["list", "of", "hazardous agents"], |
| "threat_analysis": "short reasoning about what could collide and when", |
| "verdict": "yes" | "no", |
| "confidence": integer 0-100 |
| } |
| |
| Usage: |
| export OPENAI_API_KEY=$(cat ~/Desktop/openai_api_key.txt) |
| python -m training.VLA.build_cot_labels \ |
| --train_csv nexar-collision-prediction/train.csv \ |
| --video_dir nexar-collision-prediction/train \ |
| --out data/vla_cot/train_cot.jsonl \ |
| --n_clips 30 --n_frames 8 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import random |
| import sys |
| import time |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from pathlib import Path |
|
|
| import pandas as pd |
| from tqdm import tqdm |
|
|
| try: |
| from openai import OpenAI |
| except ImportError as e: |
| raise SystemExit("pip install openai") from e |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parents[2])) |
| from training.VLA.frame_utils import pil_to_data_url, sample_frames_from_mp4 |
|
|
| SYSTEM_PROMPT = ( |
| "You are a senior driving-safety analyst reviewing dashcam footage. " |
| "You will see 8 uniformly-sampled frames from a short clip and a ground-truth label " |
| "indicating whether the clip ends in a collision or near-collision. " |
| "Produce a concise chain-of-thought in strict JSON with this exact schema:\n" |
| '{\n' |
| ' "scene": "<=25-word scene description (road type, weather, lighting, traffic)",\n' |
| ' "critical_objects": ["each item is an agent/object that matters, <=6 words, max 4 items"],\n' |
| ' "threat_analysis": "<=40-word reasoning on kinematics and likely collision path",\n' |
| ' "verdict": "yes" or "no",\n' |
| ' "confidence": integer 0-100\n' |
| '}\n' |
| "Rules:\n" |
| "- verdict MUST match the ground-truth label.\n" |
| "- Be specific and grounded — reference colors, positions, and motions actually visible.\n" |
| "- NEVER say \"based on the label\"; write as if you inferred yourself.\n" |
| "- Output JSON only, no prose." |
| ) |
|
|
| USER_TEMPLATE = ( |
| "Ground-truth label for this clip: collision = {label}.\n" |
| "Analyze the 8 frames (earliest → latest, left-to-right) and output the JSON." |
| ) |
|
|
|
|
| def build_messages(label: int, frames, detail: str = "low"): |
| content = [] |
| for img in frames: |
| content.append( |
| {"type": "image_url", "image_url": {"url": pil_to_data_url(img), "detail": detail}} |
| ) |
| label_word = "YES" if label == 1 else "NO" |
| content.append({"type": "text", "text": USER_TEMPLATE.format(label=label_word)}) |
| return [ |
| {"role": "system", "content": SYSTEM_PROMPT}, |
| {"role": "user", "content": content}, |
| ] |
|
|
|
|
| def call_gpt4o(client, clip_id, label, frames, model: str, max_retries: int = 3, detail: str = "low"): |
| messages = build_messages(label, frames, detail=detail) |
| last_err = None |
| for attempt in range(max_retries): |
| try: |
| resp = client.chat.completions.create( |
| model=model, |
| messages=messages, |
| temperature=0.2, |
| max_tokens=350, |
| response_format={"type": "json_object"}, |
| timeout=60, |
| ) |
| raw = resp.choices[0].message.content |
| parsed = json.loads(raw) |
| |
| assert parsed.get("verdict") in ("yes", "no"), "bad verdict" |
| assert isinstance(parsed.get("confidence"), (int, float)), "bad confidence" |
| assert (parsed["verdict"] == "yes") == (label == 1), "verdict/label mismatch" |
| return {"id": clip_id, "label": int(label), "cot": parsed, "usage": resp.usage.model_dump() if resp.usage else None} |
| except (json.JSONDecodeError, AssertionError, Exception) as e: |
| last_err = e |
| if attempt + 1 < max_retries: |
| time.sleep(2 * (attempt + 1)) |
| return {"id": clip_id, "label": int(label), "cot": None, "error": str(last_err)} |
|
|
|
|
| def load_done(out_path: Path): |
| done = set() |
| if out_path.exists(): |
| with out_path.open() as f: |
| for line in f: |
| try: |
| rec = json.loads(line) |
| if rec.get("cot") is not None: |
| done.add(str(rec["id"])) |
| except Exception: |
| continue |
| return done |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--train_csv", required=True) |
| ap.add_argument("--video_dir", required=True) |
| ap.add_argument("--out", required=True) |
| ap.add_argument("--n_clips", type=int, default=30, help="total clips (balanced pos/neg)") |
| ap.add_argument("--n_frames", type=int, default=8) |
| ap.add_argument("--resize_short", type=int, default=336) |
| ap.add_argument("--model", default="gpt-4o") |
| ap.add_argument("--detail", default="low", choices=["low", "high", "auto"]) |
| ap.add_argument("--workers", type=int, default=4) |
| ap.add_argument("--seed", type=int, default=0) |
| ap.add_argument("--skip_ids", default=None, help="comma-separated IDs to exclude (e.g. eval split)") |
| args = ap.parse_args() |
|
|
| api_key = os.environ.get("OPENAI_API_KEY") |
| if not api_key: |
| raise SystemExit("Set OPENAI_API_KEY (source the file into env)") |
|
|
| df = pd.read_csv(args.train_csv, dtype={"id": str}) |
| df["id"] = df["id"].astype(str).str.zfill(5) |
|
|
| skip = set() |
| if args.skip_ids: |
| skip = set(s.strip().zfill(5) for s in args.skip_ids.split(",") if s.strip()) |
| df = df[~df["id"].isin(skip)] |
|
|
| rng = random.Random(args.seed) |
| pos = df[df["target"] == 1]["id"].tolist() |
| neg = df[df["target"] == 0]["id"].tolist() |
| rng.shuffle(pos); rng.shuffle(neg) |
| half = args.n_clips // 2 |
| picked = pos[:half] + neg[:args.n_clips - half] |
| rng.shuffle(picked) |
|
|
| out_path = Path(args.out) |
| out_path.parent.mkdir(parents=True, exist_ok=True) |
| done = load_done(out_path) |
| todo = [pid for pid in picked if pid not in done] |
| print(f"[cot] picked={len(picked)} already_done={len(done)} todo={len(todo)}") |
|
|
| video_dir = Path(args.video_dir) |
| client = OpenAI(api_key=api_key) |
|
|
| def worker(pid): |
| label = int(df[df["id"] == pid]["target"].iloc[0]) |
| video_path = video_dir / f"{pid}.mp4" |
| if not video_path.exists(): |
| return {"id": pid, "label": label, "cot": None, "error": "missing_mp4"} |
| try: |
| frames = sample_frames_from_mp4(video_path, n_frames=args.n_frames, resize_short=args.resize_short) |
| except Exception as e: |
| return {"id": pid, "label": label, "cot": None, "error": f"frame_err:{e}"} |
| return call_gpt4o(client, pid, label, frames, model=args.model, detail=args.detail) |
|
|
| total_tokens_in = 0 |
| total_tokens_out = 0 |
| n_ok = 0 |
| n_err = 0 |
| with out_path.open("a") as fout, ThreadPoolExecutor(max_workers=args.workers) as ex: |
| futs = {ex.submit(worker, pid): pid for pid in todo} |
| for fut in tqdm(as_completed(futs), total=len(futs), desc="cot"): |
| rec = fut.result() |
| fout.write(json.dumps(rec) + "\n") |
| fout.flush() |
| if rec.get("cot") is not None: |
| n_ok += 1 |
| u = rec.get("usage") or {} |
| total_tokens_in += u.get("prompt_tokens", 0) |
| total_tokens_out += u.get("completion_tokens", 0) |
| else: |
| n_err += 1 |
|
|
| print(f"[cot] ok={n_ok} err={n_err} prompt_tokens={total_tokens_in} compl_tokens={total_tokens_out}") |
| |
| est_usd = total_tokens_in * 2.5 / 1e6 + total_tokens_out * 10 / 1e6 |
| print(f"[cot] est cost: ${est_usd:.4f}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|