VLAlert / training /VLA /build_cot_labels.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
7.9 kB
"""
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)
# minimal schema validation
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: # noqa: BLE001
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: # noqa: BLE001
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}")
# gpt-4o pricing (2026-04): $2.5/M in, $10/M 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()