VLAlert / training /DPO /make_dpo_pairs.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
12 kB
#!/usr/bin/env python3
"""
Generate DPO preference-pair manifests from SFT video manifests.
Pair logic
----------
For each ego_positive video:
chosen : windows where TTA ∈ [CHOSEN_TTA_MIN, CHOSEN_TTA_MAX] β†’ model SHOULD alert
rejected : windows where TTA > REJECTED_EARLY_MIN β†’ too early to alert
windows where TTA < REJECTED_LATE_MAX β†’ too late (useless)
For each safe_neg / non_ego video:
These are NEVER-alert windows. They are paired cross-video against
a randomly sampled ego_pos chosen window (same source preferred).
Output
------
data/dpo_pairs/
nexar_train.json
dada_train.json
nexar_val.json
dada_val.json
Usage
-----
cd PROJECT_ROOT
python -m training.DPO.make_dpo_pairs \
--manifest_dir data/sft_manifests \
--out_dir data/dpo_pairs
"""
from __future__ import annotations
import argparse
import json
import logging
import random
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("DPO.make_pairs")
# ── constants (must match SFT dataset.py) ────────────────────────────────────
FRAME_RATE = 20
WINDOW_LEN = 40 # 2.0 s
SAMPLE_RATE = 4 # keep every 4th frame inside window
MAX_FRAMES = 8
# Alert timing targets
CHOSEN_TTA_MIN = 1.5 # seconds (sweet-spot alert window)
CHOSEN_TTA_MAX = 5.0
CHOSEN_TTA_STEPS = [2.0, 2.5, 3.0, 3.5, 4.0, 4.5] # chosen TTA values to sample
REJECTED_EARLY_MIN = 5.5 # too early
REJECTED_EARLY_STEPS = [6.0, 7.0, 8.0, 9.0]
REJECTED_LATE_MAX = 1.0 # too late (reaction impossible)
REJECTED_LATE_STEPS = [0.5, 1.0]
RANDOM_SEED = 42
# ─────────────────────────────────────────────────────────────────────────────
# Frame index helpers
# ─────────────────────────────────────────────────────────────────────────────
def build_window(
window_end: int,
num_frames: int,
window_len: int = WINDOW_LEN,
sample_rate: int = SAMPLE_RATE,
max_frames: int = MAX_FRAMES,
) -> Optional[List[int]]:
"""Return sampled frame indices for a window ending at `window_end` (exclusive).
Returns None if the window falls outside [0, num_frames)."""
window_start = window_end - window_len
if window_start < 0 or window_end > num_frames:
return None
indices = list(range(window_start, window_end, sample_rate))[:max_frames]
if not indices:
return None
return indices
def window_entry(
source_dir: str,
frame_indices: List[int],
window_end: int,
tta_true: float,
label: str,
metadata: dict,
) -> dict:
return {
"source_dir": source_dir,
"frame_indices": frame_indices,
"window_end": window_end,
"tta_true": round(tta_true, 3),
"label": label,
"metadata": metadata,
}
# ─────────────────────────────────────────────────────────────────────────────
# Pair generators
# ─────────────────────────────────────────────────────────────────────────────
def generate_ego_pos_pairs(video: dict) -> List[dict]:
"""Return (chosen, rejected) pairs for a single ego_pos video."""
src = video["source_dir"]
nf = video["num_frames"]
af = video["accident_frame"]
meta = video["metadata"]
vid = video["video_id"]
source = video["source"]
if af is None:
return []
chosen_windows: List[Tuple[float, List[int], int]] = [] # (tta, indices, w_end)
rejected_windows: List[Tuple[float, str, List[int], int]] = [] # (tta, label, ...)
# ── chosen windows ────────────────────────────────────────────────────────
for tta in CHOSEN_TTA_STEPS:
w_end = af - round(tta * FRAME_RATE)
idxs = build_window(w_end, nf)
if idxs is not None:
chosen_windows.append((tta, idxs, w_end))
# ── rejected early ────────────────────────────────────────────────────────
for tta in REJECTED_EARLY_STEPS:
w_end = af - round(tta * FRAME_RATE)
idxs = build_window(w_end, nf)
if idxs is not None:
rejected_windows.append((tta, "too_early", idxs, w_end))
# ── rejected late ─────────────────────────────────────────────────────────
for tta in REJECTED_LATE_STEPS:
w_end = af - round(tta * FRAME_RATE)
idxs = build_window(w_end, nf)
if idxs is not None:
rejected_windows.append((tta, "too_late", idxs, w_end))
if not chosen_windows or not rejected_windows:
return []
pairs = []
for c_tta, c_idxs, c_wend in chosen_windows:
for r_tta, r_label, r_idxs, r_wend in rejected_windows:
pairs.append({
"pair_id": f"{vid}_c{c_tta}_r{r_tta}_{r_label}",
"video_id": vid,
"source": source,
"pair_type": "timing",
"chosen": window_entry(src, c_idxs, c_wend, c_tta, "timely_alert", meta),
"rejected": window_entry(src, r_idxs, r_wend, r_tta, r_label, meta),
})
return pairs
def generate_neg_windows(video: dict) -> List[dict]:
"""Return 'never-alert' window entries for safe_neg / non_ego videos."""
src = video["source_dir"]
nf = video["num_frames"]
meta = video["metadata"]
cat = video["category"]
# Sample windows from the middle third of the video
start = nf // 3
end = 2 * nf // 3
entries = []
stride = max(1, (end - start) // 3)
for w_end in range(start + WINDOW_LEN, end, stride):
idxs = build_window(w_end, nf)
if idxs is not None:
entries.append(window_entry(src, idxs, w_end, tta_true=999.0, label=cat, metadata=meta))
return entries[:3] # cap at 3 windows per video
# ─────────────────────────────────────────────────────────────────────────────
# Manifest processing
# ─────────────────────────────────────────────────────────────────────────────
def process_manifests(
manifests: List[Path],
split: str,
rng: random.Random,
max_cross_pairs: int = 3,
) -> List[dict]:
"""Build all DPO pairs from a list of manifest files."""
all_videos: List[dict] = []
for m in manifests:
if not m.exists():
logger.warning(f"Manifest not found: {m}")
continue
with open(m) as f:
data = json.load(f)
vids = data.get("videos", [])
logger.info(f" {m.name}: {len(vids)} videos")
all_videos.extend(vids)
ego_pos = [v for v in all_videos if v["category"] == "ego_positive"]
neg_vids = [v for v in all_videos if v["category"] in ("safe_neg", "non_ego")]
pairs: List[dict] = []
# ── within-video timing pairs (ego_pos) ───────────────────────────────────
for v in ego_pos:
pairs.extend(generate_ego_pos_pairs(v))
# ── cross-type pairs (neg window vs chosen ego_pos window) ───────────────
if ego_pos and neg_vids:
# Build pool of chosen windows from ego_pos (for cross-pairing)
chosen_pool: Dict[str, List[dict]] = {} # source β†’ [chosen_entry]
for v in ego_pos:
sub_pairs = generate_ego_pos_pairs(v)
for p in sub_pairs:
src = v["source"]
chosen_pool.setdefault(src, []).append(
(v["video_id"], p["chosen"])
)
for nv in neg_vids:
neg_entries = generate_neg_windows(nv)
if not neg_entries:
continue
src = nv["source"]
pool = chosen_pool.get(src, [])
if not pool:
pool = [item for items in chosen_pool.values() for item in items]
if not pool:
continue
for ne in neg_entries[:max_cross_pairs]:
vid_c, c_entry = rng.choice(pool)
pairs.append({
"pair_id": f"cross_{nv['video_id']}_{vid_c}",
"video_id": nv["video_id"],
"source": nv["source"],
"pair_type": "category",
"chosen": c_entry,
"rejected": ne,
})
logger.info(f" Split={split}: {len(pairs)} total pairs "
f"({sum(1 for p in pairs if p['pair_type']=='timing')} timing, "
f"{sum(1 for p in pairs if p['pair_type']=='category')} category)")
return pairs
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser("make_dpo_pairs")
parser.add_argument("--manifest_dir", default="data/sft_manifests")
parser.add_argument("--out_dir", default="data/dpo_pairs")
parser.add_argument("--seed", type=int, default=RANDOM_SEED)
args = parser.parse_args()
mdir = Path(args.manifest_dir)
odir = Path(args.out_dir)
odir.mkdir(parents=True, exist_ok=True)
rng = random.Random(args.seed)
splits = {
"nexar_train": [mdir / "nexar_train.json"],
"dada_train": [mdir / "dada_pos_train.json",
mdir / "dada_noneego_train.json",
mdir / "dada_neg_train.json"],
"nexar_val": [mdir / "nexar_val.json"],
"dada_val": [mdir / "dada_pos_val.json",
mdir / "dada_noneego_val.json"],
}
for name, manifests in splits.items():
split = "train" if "train" in name else "val"
logger.info(f"\nProcessing {name} ...")
pairs = process_manifests(manifests, split, rng)
if split == "train":
rng.shuffle(pairs)
out_path = odir / f"{name}.json"
with open(out_path, "w") as f:
json.dump({"name": name, "split": split,
"num_pairs": len(pairs), "pairs": pairs}, f)
logger.info(f" Saved {len(pairs)} pairs β†’ {out_path}")
logger.info("\nβœ… DPO pair manifests generated.")
if __name__ == "__main__":
main()