VLAlert / tools /run_v1_gpt5_cot.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
15.3 kB
"""Generate GPT-5 CoT beliefs for benchmark/v1 train/val/test ticks (parallel).
Reads: benchmark/v1/data/{split}.parquet (tick-level records with frame_indices)
Writes: data/cot_corpus_v3/v1_{split}_perframe.jsonl
Schema (one record per tick, matches SFT trainer expectations):
{
"id": "v1_{split}_{i:06d}",
"video_id": str,
"video_path": str,
"source": str,
"category": str,
"frame_indices": List[int][8],
"actions_per_frame": List[str][8], # SILENT/OBSERVE/ALERT
"beliefs_per_frame": List[str][8], # GPT-5 generated, ≀25 words each
"danger_per_frame": List[float][8], # derived from action label
"tta_per_frame": List[float][8],
"tick_action": str,
"tick_tta_raw": float,
"source_kind": "video_file" | "frame_folder",
"hazard_category": str, # GPT-5 generated
"one_sentence_rationale": str, # GPT-5 generated
"gpt5_model": str,
"in_tokens": int, "out_tokens": int, "cost_usd": float,
}
Cost cap enforced via shared ledger. Resume-on-failure via output-file scan.
Usage:
python tools/run_v1_gpt5_cot.py --split val --parallel 16 --max_cost_usd 200
python tools/run_v1_gpt5_cot.py --split train --parallel 16 --max_cost_usd 1500
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import io
import json
import logging
import os
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Dict, List, Optional
import cv2
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("v1_gpt5_cot")
KEY_PATH = Path("~/Desktop/openai_api_key.txt")
# Per-million-token costs (same as tools/vlalert_x_distill.py)
COSTS = {
"gpt-5.5": (5.00, 15.00),
"gpt-5.4": (3.00, 9.00),
"gpt-5": (2.50, 7.50),
"gpt-4o": (2.50, 10.00),
}
PROMPT = """You are a safety analyst labelling an 8-frame dashcam montage
(2 rows x 4 cols, left-to-right then top-to-bottom = frame 0..7, last
frame is most recent). Output a strict JSON record.
Output schema (no extras, no missing keys):
{
"hazard_category": one of [pedestrian, vrurider, vehicle_cross,
vehicle_oncoming, vehicle_lead, weather, infrastructure, none],
"per_frame_belief": [
{"frame": 0, "belief": "<=25-word phrase describing the scene
and threat status visible in this frame"},
... (exactly 8 entries, frames 0..7)
],
"one_sentence_rationale": "<=25-word summary of the risk evolution"
}
Rules:
- The clip's outcome is unknown -- judge from visual evidence only.
- Each `belief` must be a *phrase*, not a full sentence with a period.
- Use simple physical descriptors (vehicle position, motion cue,
conflict sign), avoid temporal claims like "will collide".
- If the scene is benign, use `hazard_category: none` and briefly note
the dominant safe-driving cue per frame.
"""
# ─────────────── shared cost ledger (thread-safe) ───────────────
class CostLedger:
def __init__(self, path: Path, model: str, max_cost_usd: float):
self.path = path
self.model = model
self.max_cost_usd = max_cost_usd
self.lock = threading.Lock()
if path.exists():
d = json.loads(path.read_text())
self.n_calls = d.get("n_calls", 0)
self.cost_usd = d.get("cost_usd", 0.0)
self.in_tokens = d.get("in_tokens", 0)
self.out_tokens = d.get("out_tokens", 0)
else:
self.n_calls = 0; self.cost_usd = 0.0
self.in_tokens = 0; self.out_tokens = 0
def can_spend(self, projected_usd: float) -> bool:
with self.lock:
return self.cost_usd + projected_usd <= self.max_cost_usd
def add(self, in_tok: int, out_tok: int):
cin, cout = COSTS.get(self.model, (5.0, 15.0))
cost = (in_tok / 1e6) * cin + (out_tok / 1e6) * cout
with self.lock:
self.n_calls += 1
self.in_tokens += in_tok
self.out_tokens += out_tok
self.cost_usd += cost
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps({
"primary_model": self.model,
"n_calls": self.n_calls,
"in_tokens": self.in_tokens,
"out_tokens": self.out_tokens,
"cost_usd": self.cost_usd,
}, indent=2))
return cost
# ─────────────── frame extraction + montage ───────────────
def _load_frames(video_path: str, frame_indices: List[int],
size: int = 256) -> Optional[List[Image.Image]]:
"""Load 8 frames as resized PIL images."""
p = Path(video_path)
if p.suffix.lower() == ".mp4" and p.exists():
cap = cv2.VideoCapture(str(p))
frames = []
for fi in frame_indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, int(fi))
ok, fr = cap.read()
if not ok: return None
fr = cv2.cvtColor(fr, cv2.COLOR_BGR2RGB)
fr = cv2.resize(fr, (size, size), interpolation=cv2.INTER_AREA)
frames.append(Image.fromarray(fr))
cap.release()
return frames if len(frames) == len(frame_indices) else None
elif p.is_dir():
frames = []
for fi in frame_indices:
for w in (3, 4, 5, 6):
fp = p / f"{int(fi):0{w}d}.jpg"
if fp.exists():
img = Image.open(fp).convert("RGB")
img.thumbnail((size, size))
frames.append(img); break
else:
fp = p / "images" / f"{int(fi):06d}.jpg"
if fp.exists():
img = Image.open(fp).convert("RGB")
img.thumbnail((size, size))
frames.append(img)
else:
return None
return frames if len(frames) == len(frame_indices) else None
return None
def _build_montage(frames: List[Image.Image], cell: int = 224) -> Image.Image:
"""2 rows x 4 cols, return PIL."""
canvas = Image.new("RGB", (cell * 4, cell * 2), (0, 0, 0))
for i, im in enumerate(frames):
r, c = i // 4, i % 4
im_r = im.resize((cell, cell))
canvas.paste(im_r, (c * cell, r * cell))
return canvas
def _pil_to_data_url(img: Image.Image) -> str:
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/jpeg;base64,{b64}"
# ─────────────── GPT-5 call ───────────────
def _call_gpt5(client, montage: Image.Image, model: str,
max_retries: int = 3) -> Optional[Dict]:
url = _pil_to_data_url(montage)
last_err = None
for attempt in range(max_retries):
try:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": PROMPT},
{"role": "user", "content": [
{"type": "image_url",
"image_url": {"url": url, "detail": "low"}},
{"type": "text", "text":
"Analyze the 8-frame montage and output the JSON."},
]},
],
max_completion_tokens=3000,
response_format={"type": "json_object"},
)
text = resp.choices[0].message.content
if not text or not text.strip():
last_err = f"empty response (finish_reason={resp.choices[0].finish_reason})"
if attempt < max_retries - 1:
time.sleep(1.0)
continue
data = json.loads(text)
return {
"data": data,
"in_tokens": resp.usage.prompt_tokens,
"out_tokens": resp.usage.completion_tokens,
"model": resp.model,
}
except Exception as e:
last_err = str(e)
if attempt < max_retries - 1:
time.sleep(2.0 * (attempt + 1))
logger.warning(f"GPT call failed after {max_retries} retries: {last_err}")
return None
# ─────────────── per-tick worker ───────────────
def _process_tick(rec: Dict, client, model: str, ledger: CostLedger) -> Optional[Dict]:
if not ledger.can_spend(0.02):
return {"skip_reason": "budget_cap"}
frames = _load_frames(rec["video_path"], rec["frame_indices"])
if frames is None or len(frames) != 8:
return {"skip_reason": "frame_load_failed"}
montage = _build_montage(frames)
result = _call_gpt5(client, montage, model)
if result is None:
return {"skip_reason": "gpt_failed"}
ledger.add(result["in_tokens"], result["out_tokens"])
# Extract per-frame beliefs (defensive: handle multiple formats)
pf = result["data"].get("per_frame_belief", [])
beliefs = [""] * 8
for i, entry in enumerate(pf):
if isinstance(entry, dict):
f = entry.get("frame", i)
b = entry.get("belief", "")
elif isinstance(entry, str):
f, b = i, entry # GPT returned plain string array
else:
continue
try:
f = int(f)
except (TypeError, ValueError):
f = i
if 0 <= f < 8:
beliefs[f] = str(b) if b else ""
# Output record (compatible with SFT trainer)
out = {
"id": rec["id"],
"video_id": rec["video_id"],
"video_path": rec["video_path"],
"source": rec["source"],
"category": rec["category"],
"frame_indices": rec["frame_indices"],
"actions_per_frame": rec["actions_per_frame"],
"beliefs_per_frame": beliefs,
"danger_per_frame": rec["danger_per_frame"],
"tta_per_frame": rec["tta_per_frame"],
"tick_action": rec["tick_action"],
"tick_tta_raw": rec["tick_tta_raw"],
"source_kind": rec["source_kind"],
"hazard_category": result["data"].get("hazard_category", "none"),
"one_sentence_rationale": result["data"].get("one_sentence_rationale", ""),
"gpt5_model": result["model"],
"in_tokens": result["in_tokens"],
"out_tokens": result["out_tokens"],
}
return out
# ─────────────── main ───────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--split", required=True, choices=["train", "val", "test", "all"])
ap.add_argument("--parallel", type=int, default=16)
ap.add_argument("--max_cost_usd", type=float, default=200.0)
ap.add_argument("--model", default="gpt-4o")
ap.add_argument("--max_priority", type=int, default=7,
help="Skip ticks with priority > this (1=highest, 99=skip)")
ap.add_argument("--limit", type=int, default=0,
help="cap n samples (for smoke test)")
args = ap.parse_args()
# Prefer priority-sorted manifest from cot_corpus_v3; fallback to v2.
pri_jsonl = ROOT / f"data/cot_corpus_v3/v1_{args.split}_priority.jsonl"
base_jsonl = ROOT / f"data/cot_corpus_v2/v1_{args.split}_perframe.jsonl"
src_jsonl = pri_jsonl if pri_jsonl.exists() else base_jsonl
if not src_jsonl.exists():
logger.error(f"Source jsonl not found: {src_jsonl}")
return
records = []
n_skip_pri = 0
with src_jsonl.open() as f:
for line in f:
line = line.strip()
if not line: continue
r = json.loads(line)
pri = r.get("priority", 99)
if pri > args.max_priority:
n_skip_pri += 1
continue
records.append(r)
if args.limit:
records = records[:args.limit]
logger.info(f"[load] {len(records):,} records from {src_jsonl}, "
f"skipped {n_skip_pri} (priority > {args.max_priority})")
out_path = ROOT / f"data/cot_corpus_v3/v1_{args.split}_perframe.jsonl"
out_path.parent.mkdir(parents=True, exist_ok=True)
ledger_path = ROOT / f"eval_results/openai_teacher/v1_gpt5_{args.split}_ledger.json"
# Resume: scan existing output for completed IDs
seen = set()
if out_path.exists():
with out_path.open() as f:
for line in f:
try:
d = json.loads(line)
if "id" in d:
seen.add(d["id"])
except Exception:
pass
logger.info(f"[resume] skipping {len(seen):,} already-done")
todo = [r for r in records if r["id"] not in seen]
logger.info(f"[plan] {len(todo):,} ticks to generate, "
f"max cost ${args.max_cost_usd}, parallel={args.parallel}")
# Init OpenAI
os.environ["OPENAI_API_KEY"] = KEY_PATH.read_text().strip()
from openai import OpenAI
client = OpenAI()
ledger = CostLedger(ledger_path, args.model, args.max_cost_usd)
logger.info(f"[ledger] start cost=${ledger.cost_usd:.3f} "
f"of ${args.max_cost_usd}")
n_done, n_failed, n_skipped_budget = 0, 0, 0
t0 = time.time()
out_lock = threading.Lock()
with ThreadPoolExecutor(max_workers=args.parallel) as ex:
futures = {ex.submit(_process_tick, rec, client, args.model, ledger): rec
for rec in todo}
for fut in as_completed(futures):
try:
res = fut.result()
except Exception as e:
logger.warning(f" [worker crash] {e}")
n_failed += 1
continue
if res is None:
n_failed += 1
continue
if "skip_reason" in res:
if res["skip_reason"] == "budget_cap":
n_skipped_budget += 1
# Cancel remaining
for f in futures: f.cancel()
break
else:
n_failed += 1
continue
with out_lock:
with out_path.open("a") as f:
f.write(json.dumps(res) + "\n")
n_done += 1
if n_done % 50 == 0:
el = time.time() - t0
rate = n_done / max(el, 1e-9)
logger.info(f" done={n_done}, failed={n_failed}, "
f"cost=${ledger.cost_usd:.2f}, "
f"rate={rate:.1f}/s, "
f"eta={(len(todo) - n_done) / max(rate, 1e-9) / 60:.0f}min")
logger.info(f"\n[final] done={n_done}, failed={n_failed}, "
f"skipped_budget={n_skipped_budget}, cost=${ledger.cost_usd:.2f}")
if __name__ == "__main__":
main()