VLAlert / tools /demo_compare_pipeline.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
48.4 kB
#!/usr/bin/env python
"""Demo comparison pipeline: score all videos with multiple models, generate viz videos.
Models (scored in backbone order to maximise GPU reuse):
1. BADAS (V-JEPA2) β€” 16-frame sliding window
2. VLAlert-v3 β€” sft_x_v3 + danger_v3 + policy_v3_strong
3. VLAlert-v2 β€” sft_x_v2 + danger_v2 + policy_v2_full (5-seed ensemble)
4. VLAlert-X β€” sft_x_v2 + VLAlertXHead (5-seed ensemble, narrow window)
5. VLAlert-M10 β€” qwen3vl4b_cot_belief_perframe + M10 head (5-seed ensemble)
Pipeline:
Phase 1: Extract frames (already done β†’ demo/compare_frames/)
Phase 2: Score all videos model-by-model (one VLM backbone at a time)
Phase 3: Generate comparison videos (left=frame, right=score+action)
Usage:
python tools/demo_compare_pipeline.py [--models v3,X,v2,M10] [--only VIDEO]
"""
from __future__ import annotations
import argparse, cv2, gc, json, logging, sys, time
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from tqdm import tqdm
ROOT = Path("PROJECT_ROOT")
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# ─── Conv3d β†’ Linear patch for Qwen3-VL (64Γ— speedup on Blackwell) ───
import torch.nn as nn
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLVisionPatchEmbed
def _fast_patch_embed_forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
target_dtype = self.proj.weight.dtype
if isinstance(self.proj, nn.Conv3d):
conv = self.proj
out_dim = conv.out_channels
in_dim = (conv.in_channels * conv.kernel_size[0]
* conv.kernel_size[1] * conv.kernel_size[2])
w_flat = conv.weight.detach().reshape(out_dim, in_dim).contiguous()
bias = conv.bias.detach().clone() if conv.bias is not None else None
new_proj = nn.Linear(in_dim, out_dim, bias=bias is not None)
new_proj.weight.data.copy_(w_flat)
if bias is not None:
new_proj.bias.data.copy_(bias)
new_proj.to(device=conv.weight.device, dtype=conv.weight.dtype)
self.proj = new_proj
if hidden_states.dim() > 2 or hidden_states.shape[-1] != self.proj.in_features:
hidden_states = hidden_states.reshape(-1, self.proj.in_features)
return self.proj(hidden_states.to(dtype=target_dtype))
Qwen3VLVisionPatchEmbed.forward = _fast_patch_embed_forward
FRAMES_DIR = ROOT / "demo/compare_frames"
OUT_DIR = ROOT / "demo/compare_results"
OUT_DIR.mkdir(exist_ok=True)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
logger = logging.getLogger("demo")
# ─── BADAS config ───
BADAS_REPO = Path("~/.cache/huggingface/hub/models--nexar-ai--badas-open/"
"snapshots/8fda93711e79d72401b0a4efc151b56455885cd2")
BADAS_MODEL = "facebook/vjepa2-vitl-fpc16-256-ssv2"
BADAS_CKPT = str(BADAS_REPO / "weights" / "badas_open.pth")
# ─── VLAlert configs ───
SFT_V3 = ROOT / "checkpoints/sft_x_v3/best"
SFT_V2 = ROOT / "checkpoints/sft_x_v2/best"
SFT_B0 = ROOT / "checkpoints/VLA/qwen3vl4b_cot_belief_perframe/best"
DANGER_V3 = ROOT / "checkpoints/danger_v3_hazard/best.pt"
DANGER_V2 = ROOT / "checkpoints/danger_v2/seed2/best.pt"
POLICY_V3 = ROOT / "checkpoints/policy_v3_strong/best.pt"
POLICY_V2_SEEDS = [ROOT / f"checkpoints/policy_v2_full/seed{s}/best.pt" for s in range(5)]
POLICY_X_SEEDS = [ROOT / f"checkpoints/policy_x_L4_bal_seed{s}/best.pt" for s in range(5)]
M10_SEEDS = [ROOT / f"checkpoints/Policy/m10_qwen3vl4b_seed{s}/best/policy_head.pt" for s in range(5)]
BASE_MODEL = ROOT / "models/Qwen3-VL-4B-Instruct"
# ─── Qwen2.5-VL-3B config ───
BASE_MODEL_Q25 = ROOT / "models/Qwen2.5-VL-3B-Instruct"
SFT_Q25_LORA = ROOT / "checkpoints/sft/sft_qwen25vl3b_lora_resume/best/vlm_lora"
TTA_HEAD_Q25 = ROOT / "checkpoints/sft/sft_qwen25vl3b_lora_resume/best/tta_head.pt"
def free_gpu():
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
import os
VLM_MAX_DIM = int(os.environ.get("VLM_MAX_DIM", "0"))
def load_frames(video_dir: Path, indices: list[int]) -> list[Image.Image]:
"""Load PIL frames by index from extracted jpg folder."""
out = []
for fi in indices:
for fmt in [f"{fi:06d}.jpg", f"{fi:05d}.jpg", f"{fi:04d}.jpg",
f"{fi:03d}.jpg", f"{fi}.jpg"]:
p = video_dir / fmt
if p.exists():
img = Image.open(p).convert("RGB")
if VLM_MAX_DIM > 0 and max(img.size) > VLM_MAX_DIM:
r = VLM_MAX_DIM / max(img.size)
nw = max(int(img.width * r) // 28 * 28, 28)
nh = max(int(img.height * r) // 28 * 28, 28)
img = img.resize((nw, nh), Image.BILINEAR)
out.append(img)
break
else:
if out:
out.append(out[-1])
else:
out.append(Image.new("RGB", (640, 360)))
return out
def uniform_indices(start, end, n):
if end <= start: return [start] * n
return np.linspace(start, end, n).round().astype(int).tolist()
# ═══════════════════════════════════════════════════════════════
# BADAS scorer
# ═══════════════════════════════════════════════════════════════
class BADASScorer:
def __init__(self):
sys.path.insert(0, str(BADAS_REPO / "src"))
import train.video_training # noqa
from models.vjepa import VJEPAModel
logger.info("[BADAS] loading V-JEPA2...")
self.vjepa = VJEPAModel(
model_name=BADAS_MODEL, checkpoint_path=BADAS_CKPT,
frame_count=16, img_size=224, window_stride=1,
target_fps=8.0, use_sliding_window=False)
self.vjepa.load()
self.device = self.vjepa.device
@torch.no_grad()
def score_tick(self, frames_16: list[Image.Image]) -> float:
proc = self.vjepa.processor(videos=[frames_16], return_tensors="pt")
key = "pixel_values_videos" if "pixel_values_videos" in proc else "pixel_values"
video = proc[key].to(self.device)
if video.dim() == 4: video = video.unsqueeze(0)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
out = self.vjepa.model(video)
logits = out.float() / 2.0
return float(torch.softmax(logits, dim=1)[0, 1].cpu())
def score_video(self, video_dir: Path, n_frames: int, fps: float, **kw) -> list[dict]:
"""Score at 1Hz ticks."""
results = []
tick_interval = max(1, int(fps))
for tick_frame in range(0, n_frames, tick_interval):
end = min(tick_frame, n_frames - 1)
start = max(0, end - 15)
indices = uniform_indices(start, end, 16)
frames = load_frames(video_dir, indices)
p = self.score_tick(frames)
action = "ALERT" if p > 0.5 else ("OBSERVE" if p > 0.07 else "SILENT")
results.append({"frame": tick_frame, "t": tick_frame / fps,
"p_alert": p, "action": action})
return results
# ═══════════════════════════════════════════════════════════════
# VLAlert scorer (v3 or X)
# ═══════════════════════════════════════════════════════════════
class VLAlertScorer:
def __init__(self, sft_path, danger_path, policy_paths, name="VLAlert"):
self.name = name
self.device = "cuda" if torch.cuda.is_available() else "cpu"
# Load DangerHead
from lkalert.models.danger_head import DangerHead
ck = torch.load(danger_path, weights_only=False, map_location="cpu")
self.danger = DangerHead(in_dim=ck["in_dim"],
n_hazards=int(ck.get("n_hazards", 0) or 0)).to(self.device)
self.danger.load_state_dict(ck["model"])
self.danger.eval()
# Load PolicyHead(s)
from lkalert.models.policy_head_v2 import PolicyHeadV2
self.policies = []
for pp in policy_paths:
pk = torch.load(pp, weights_only=False, map_location="cpu")
policy = PolicyHeadV2(
policy_dim=pk.get("policy_dim", pk.get("in_dim", 2560)),
perception_dim_per_query=pk.get("perception_dim_per_query", 512),
k_queries=pk.get("k_queries", 4),
).to(self.device)
sd = pk["model"]
mapped = {}
for k, v in sd.items():
nk = k.replace("fuse.0.", "fuse_pre.0.").replace("fuse.3.", "cls_head.")
mapped[nk] = v
policy.load_state_dict(mapped, strict=False)
policy.eval()
self.policies.append(policy)
# VLM belief cache (lazily populated per video)
self.belief_cache = None
self.sft_path = sft_path
self.vlm_loaded = False
logger.info(f"[{name}] danger + {len(self.policies)} policy heads loaded")
def _ensure_vlm(self):
if self.vlm_loaded: return
logger.info(f"[{self.name}] loading VLM from {self.sft_path}...")
from transformers import AutoProcessor, AutoModelForImageTextToText
from peft import PeftModel
from training.VLA.cot_belief_dataset_v2 import ALL_SPECIAL, BELIEF_OPEN, BELIEF_CLOSE, build_chat_v2
self.processor = AutoProcessor.from_pretrained(BASE_MODEL, trust_remote_code=True)
self.processor.tokenizer.add_special_tokens({"additional_special_tokens": ALL_SPECIAL})
self.processor.tokenizer.padding_side = "right"
base = AutoModelForImageTextToText.from_pretrained(
BASE_MODEL, torch_dtype=torch.bfloat16, trust_remote_code=True)
base.resize_token_embeddings(len(self.processor.tokenizer))
self.vlm = PeftModel.from_pretrained(base, self.sft_path).to(self.device)
self.vlm.eval()
self.belief_open_id = self.processor.tokenizer.convert_tokens_to_ids(BELIEF_OPEN)
self.belief_close_id = self.processor.tokenizer.convert_tokens_to_ids(BELIEF_CLOSE)
self.belief_layers = [20, 24, 28, 32]
self.policy_layer = 33
self.build_chat = build_chat_v2
self.vlm_loaded = True
logger.info(f"[{self.name}] VLM loaded")
@torch.no_grad()
def extract_belief_batch(self, frames_batch: list[list[Image.Image]]):
"""Batch extract beliefs. frames_batch: list of N Γ— [8 PIL images].
Returns belief [N,8,10240], policy [N,8,2560], valid [N,8].
"""
self._ensure_vlm()
from training.VLA.cot_belief_dataset_v2 import SYSTEM_PROMPT_V2, USER_PROMPT_V2
N = len(frames_batch)
texts = []
all_images = []
for frames_8 in frames_batch:
user_content = [{"type": "image", "image": img} for img in frames_8]
user_content.append({"type": "text", "text": USER_PROMPT_V2})
msgs = [
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_V2}]},
{"role": "user", "content": user_content},
]
texts.append(self.processor.apply_chat_template(
msgs, add_generation_prompt=True, tokenize=False))
all_images.extend(frames_8)
inputs = self.processor(text=texts, images=all_images, return_tensors="pt",
padding=True).to(self.device)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
out = self.vlm(**inputs, output_hidden_states=True, return_dict=True)
hs_tuple = out.hidden_states
D = hs_tuple[self.belief_layers[0]].shape[-1]
belief = torch.zeros(N, 8, len(self.belief_layers) * D, dtype=torch.float16)
policy = torch.zeros(N, 8, D, dtype=torch.float16)
valid = torch.zeros(N, 8, dtype=torch.bool)
for i in range(N):
ids = inputs["input_ids"][i]
open_pos = (ids == self.belief_open_id).nonzero(as_tuple=False).flatten().tolist()
close_pos = (ids == self.belief_close_id).nonzero(as_tuple=False).flatten().tolist()
n_blocks = min(len(open_pos), len(close_pos), 8)
for f in range(n_blocks):
o, c = open_pos[f], close_pos[f]
if c <= o + 1:
continue
parts = [hs_tuple[L][i, o+1:c].mean(dim=0).to(torch.float16)
for L in self.belief_layers]
belief[i, f] = torch.cat(parts, dim=-1).cpu()
policy[i, f] = hs_tuple[self.policy_layer][i, c].to(torch.float16).cpu()
valid[i, f] = True
del out, hs_tuple, inputs
torch.cuda.empty_cache()
return belief, policy, valid
@torch.no_grad()
def score_heads_batch(self, belief, policy_pos, valid):
"""Run DangerHead + PolicyHeads on batch. Returns list of (p_alert, p_obs, action, clip_danger)."""
b = belief.to(self.device, dtype=torch.float32)
v = valid.to(self.device)
d_out = self.danger(b, valid_frames=v)
perc = d_out["perception_summary"]
dang = d_out["per_frame"]
pp = policy_pos.to(self.device, dtype=torch.float32)
N = b.shape[0]
prev = torch.full((N,), 3, device=self.device, dtype=torch.long)
probs_list = []
for pol in self.policies:
logits = pol(pp, perc, dang, prev, valid_frames=v)
probs_list.append(torch.softmax(logits, dim=-1))
avg = torch.stack(probs_list).mean(dim=0)
results = []
for i in range(N):
p_alert = float(avg[i, 2].cpu())
p_obs = float(avg[i, 1].cpu())
act_idx = int(avg[i].argmax().cpu())
action = ["SILENT", "OBSERVE", "ALERT"][act_idx]
results.append((p_alert, p_obs, action, float(d_out["clip"][i].cpu())))
return results
def score_video(self, video_dir: Path, n_frames: int, fps: float,
batch_size: int = 2) -> list[dict]:
tick_interval = max(1, int(fps))
tick_frames = list(range(0, n_frames, tick_interval))
all_frame_sets = []
for tf in tick_frames:
end = min(tf + 7, n_frames - 1)
start = max(0, end - 7)
indices = list(range(start, end + 1))
while len(indices) < 8:
indices = [indices[0]] + indices
all_frame_sets.append(load_frames(video_dir, indices[:8]))
results = []
for bi in tqdm(range(0, len(tick_frames), batch_size),
desc=f"{self.name}", ncols=80, leave=False):
batch_frames = all_frame_sets[bi:bi + batch_size]
belief, policy_pos, valid = self.extract_belief_batch(batch_frames)
head_results = self.score_heads_batch(belief, policy_pos, valid)
for j, (p_alert, p_obs, action, clip_d) in enumerate(head_results):
tf = tick_frames[bi + j]
results.append({
"frame": tf, "t": tf / fps,
"p_alert": p_alert, "p_observe": p_obs,
"clip_danger": clip_d, "action": action,
})
return results
def unload_vlm(self):
if self.vlm_loaded:
del self.vlm
self.vlm_loaded = False
free_gpu()
logger.info(f"[{self.name}] VLM unloaded")
# ═══════════════════════════════════════════════════════════════
# VLAlert-X scorer (adaptive window, simplified to narrow)
# ═══════════════════════════════════════════════════════════════
class VLAlertXScorer:
"""Score with VLAlertXHead (narrow window only for demo)."""
def __init__(self, sft_path, x_head_paths, name="VLAlert-X"):
self.name = name
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.sft_path = sft_path
self.vlm_loaded = False
from lkalert.models.components import MultiQueryPMAAggregator
self.heads = []
for hp in x_head_paths:
if not hp.exists():
continue
ck = torch.load(hp, weights_only=False, map_location="cpu")
head_sd = ck["head"]
d_in = head_sd["aggregator.in_proj.weight"].shape[1]
head = _build_vlalert_x_head(d_in)
head.load_state_dict(head_sd)
head.to(self.device).eval()
self.heads.append(head)
logger.info(f"[{name}] {len(self.heads)} VLAlert-X heads loaded")
def _ensure_vlm(self):
if self.vlm_loaded:
return
logger.info(f"[{self.name}] loading VLM from {self.sft_path}...")
from transformers import AutoProcessor, AutoModelForImageTextToText
from peft import PeftModel
from training.VLA.cot_belief_dataset_v2 import ALL_SPECIAL, BELIEF_OPEN, BELIEF_CLOSE
self.processor = AutoProcessor.from_pretrained(BASE_MODEL, trust_remote_code=True)
self.processor.tokenizer.add_special_tokens({"additional_special_tokens": ALL_SPECIAL})
self.processor.tokenizer.padding_side = "right"
base = AutoModelForImageTextToText.from_pretrained(
BASE_MODEL, torch_dtype=torch.bfloat16, trust_remote_code=True)
base.resize_token_embeddings(len(self.processor.tokenizer))
self.vlm = PeftModel.from_pretrained(base, self.sft_path).to(self.device)
self.vlm.eval()
self.belief_open_id = self.processor.tokenizer.convert_tokens_to_ids(BELIEF_OPEN)
self.belief_close_id = self.processor.tokenizer.convert_tokens_to_ids(BELIEF_CLOSE)
self.belief_layers = [20, 24, 28, 32]
self.vlm_loaded = True
logger.info(f"[{self.name}] VLM loaded")
def share_vlm(self, other_scorer):
"""Borrow VLM from another scorer to avoid double-loading."""
other_scorer._ensure_vlm()
self.vlm = other_scorer.vlm
self.processor = other_scorer.processor
self.belief_open_id = other_scorer.belief_open_id
self.belief_close_id = other_scorer.belief_close_id
self.belief_layers = other_scorer.belief_layers
self.vlm_loaded = True
self._shared = True
logger.info(f"[{self.name}] sharing VLM from {other_scorer.name}")
@torch.no_grad()
def _extract_belief(self, frames_8):
self._ensure_vlm()
from training.VLA.cot_belief_dataset_v2 import SYSTEM_PROMPT_V2, USER_PROMPT_V2
user_content = [{"type": "image", "image": img} for img in frames_8]
user_content.append({"type": "text", "text": USER_PROMPT_V2})
msgs = [
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_V2}]},
{"role": "user", "content": user_content},
]
text = self.processor.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False)
inputs = self.processor(text=[text], images=frames_8, return_tensors="pt",
padding=True).to(self.device)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
out = self.vlm(**inputs, output_hidden_states=True, return_dict=True)
hs_tuple = out.hidden_states
ids = inputs["input_ids"][0]
open_pos = (ids == self.belief_open_id).nonzero(as_tuple=False).flatten().tolist()
close_pos = (ids == self.belief_close_id).nonzero(as_tuple=False).flatten().tolist()
n_blocks = min(len(open_pos), len(close_pos), 8)
D = hs_tuple[self.belief_layers[0]].shape[-1]
belief = torch.zeros(1, 8, len(self.belief_layers) * D, dtype=torch.float16)
valid = torch.zeros(1, 8, dtype=torch.bool)
for f in range(n_blocks):
o, c = open_pos[f], close_pos[f]
if c <= o + 1:
continue
parts = [hs_tuple[L][0, o+1:c].mean(dim=0).to(torch.float16) for L in self.belief_layers]
belief[0, f] = torch.cat(parts, dim=-1).cpu()
valid[0, f] = True
del out, hs_tuple, inputs
torch.cuda.empty_cache()
return belief, valid
@torch.no_grad()
def score_video(self, video_dir, n_frames, fps, batch_size=2):
tick_interval = max(1, int(fps))
tick_frames = list(range(0, n_frames, tick_interval))
all_frame_sets = []
for tf in tick_frames:
end = min(tf + 7, n_frames - 1)
start = max(0, end - 7)
indices = list(range(start, end + 1))
while len(indices) < 8:
indices = [indices[0]] + indices
all_frame_sets.append(load_frames(video_dir, indices[:8]))
results = []
for bi in tqdm(range(0, len(tick_frames), batch_size),
desc=f"{self.name}", ncols=80, leave=False):
# VLAlert-X scorer: process one at a time (uses same _extract_belief)
for j in range(min(batch_size, len(tick_frames) - bi)):
belief, valid = self._extract_belief(all_frame_sets[bi + j])
b = belief.to(self.device, dtype=torch.float32)
v = valid.to(self.device)
probs_all = []
for head in self.heads:
agg_out = head.aggregator(b, v)
agg = agg_out[0] if isinstance(agg_out, tuple) else agg_out
flat = agg.reshape(1, -1)
logits = head.policy_head(flat)
probs_all.append(torch.softmax(logits, dim=-1))
avg = torch.stack(probs_all).mean(dim=0)
tf = tick_frames[bi + j]
results.append({"frame": tf, "t": tf / fps,
"p_alert": float(avg[0, 2].cpu()),
"p_observe": float(avg[0, 1].cpu()),
"action": ["SILENT", "OBSERVE", "ALERT"][int(avg.argmax(dim=-1)[0].cpu())]})
return results
def unload_vlm(self):
if self.vlm_loaded and not getattr(self, '_shared', False):
del self.vlm
self.vlm_loaded = False
free_gpu()
logger.info(f"[{self.name}] VLM unloaded")
def _build_vlalert_x_head(d_in):
"""Build VLAlertXHead architecture from checkpoint dims."""
from lkalert.models.components import MultiQueryPMAAggregator
import torch.nn as nn
K, d_out, hidden = 4, 512, 512
agg = MultiQueryPMAAggregator(d_in=d_in, d_out=d_out, K=K, n_heads=4)
policy_head = nn.Sequential(nn.Linear(K * d_out, hidden), nn.GELU(),
nn.Dropout(0.1), nn.Linear(hidden, 3))
alert_prob_head = nn.Sequential(nn.Linear(K * d_out, hidden // 2), nn.GELU(),
nn.Linear(hidden // 2, 1))
hazard_head = nn.Linear(K * d_out, 8)
vjepa_head = nn.Sequential(nn.Linear(K * d_out, hidden), nn.GELU(),
nn.Linear(hidden, 1024))
from lkalert.models.adaptive_window import AdaptiveWindowModule
wm = AdaptiveWindowModule(belief_dim=d_in)
head = nn.Module()
head.aggregator = agg
head.policy_head = policy_head
head.alert_prob_head = alert_prob_head
head.hazard_head = hazard_head
head.vjepa_head = vjepa_head
head.window_module = wm
return head
# ═══════════════════════════════════════════════════════════════
# M10 scorer (older architecture, single-layer 2560 belief)
# ═══════════════════════════════════════════════════════════════
class M10Scorer:
"""Score with MultiQueryPolicyHead (5-seed ensemble) on B0 backbone."""
def __init__(self, sft_path, head_paths, name="VLAlert-M10"):
self.name = name
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.sft_path = sft_path
self.vlm_loaded = False
from lkalert.models.components import MultiQueryPolicyHead
self.heads = []
for hp in head_paths:
if not hp.exists():
continue
sd = torch.load(hp, weights_only=False, map_location="cpu")
d_in = sd["aggregator.in_proj.weight"].shape[1]
head = MultiQueryPolicyHead(hidden_dim=d_in, d_out=512, K=4, n_heads=4)
head.load_state_dict(sd)
head.to(self.device).eval()
self.heads.append(head)
logger.info(f"[{name}] {len(self.heads)} M10 heads loaded")
def _ensure_vlm(self):
if self.vlm_loaded:
return
logger.info(f"[{self.name}] loading VLM from {self.sft_path}...")
from transformers import AutoProcessor, AutoModelForImageTextToText
from peft import PeftModel
from training.VLA.cot_belief_dataset_v2 import ALL_SPECIAL
self.processor = AutoProcessor.from_pretrained(BASE_MODEL, trust_remote_code=True)
self.processor.tokenizer.add_special_tokens({"additional_special_tokens": ALL_SPECIAL})
self.processor.tokenizer.padding_side = "right"
base = AutoModelForImageTextToText.from_pretrained(
BASE_MODEL, torch_dtype=torch.bfloat16, trust_remote_code=True)
base.resize_token_embeddings(len(self.processor.tokenizer))
self.vlm = PeftModel.from_pretrained(base, self.sft_path).to(self.device)
self.vlm.eval()
from training.VLA.cot_belief_dataset_v2 import BELIEF_OPEN, BELIEF_CLOSE
tok = self.processor.tokenizer
self.action_ids = set()
for t in ["<|ACTION_SILENT|>", "<|ACTION_OBSERVE|>", "<|ACTION_ALERT|>"]:
tid = tok.convert_tokens_to_ids(t)
if tid != tok.unk_token_id:
self.action_ids.add(tid)
self.belief_open_id = tok.convert_tokens_to_ids(BELIEF_OPEN)
self.belief_close_id = tok.convert_tokens_to_ids(BELIEF_CLOSE)
self.vlm_loaded = True
logger.info(f"[{self.name}] VLM loaded (single-layer 2560 extraction)")
@torch.no_grad()
def _extract_belief(self, frames_8):
"""Extract last-layer belief [1, 8, 2560] using action-token positions."""
self._ensure_vlm()
from training.VLA.cot_belief_dataset_v2 import SYSTEM_PROMPT_V2, USER_PROMPT_V2
user_content = [{"type": "image", "image": img} for img in frames_8]
user_content.append({"type": "text", "text": USER_PROMPT_V2})
msgs = [
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_V2}]},
{"role": "user", "content": user_content},
]
text = self.processor.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False)
inputs = self.processor(text=[text], images=frames_8, return_tensors="pt",
padding=True).to(self.device)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
out = self.vlm(**inputs, output_hidden_states=True, return_dict=True)
hs_last = out.hidden_states[-1][0] # [T, 2560]
ids = inputs["input_ids"][0]
action_pos = [int(p) for p, t in enumerate(ids.tolist()) if t in self.action_ids]
if len(action_pos) < 1:
close_pos = (ids == self.belief_close_id).nonzero(as_tuple=False).flatten().tolist()
action_pos = close_pos
D = hs_last.shape[-1]
belief = torch.zeros(1, 8, D, dtype=torch.float16)
valid = torch.zeros(1, 8, dtype=torch.bool)
for f in range(min(len(action_pos), 8)):
belief[0, f] = hs_last[action_pos[f]].to(torch.float16).cpu()
valid[0, f] = True
del out, inputs, hs_last
torch.cuda.empty_cache()
return belief, valid
@torch.no_grad()
def score_video(self, video_dir, n_frames, fps, batch_size=2):
tick_interval = max(1, int(fps))
tick_frames = list(range(0, n_frames, tick_interval))
all_frame_sets = []
for tf in tick_frames:
end = min(tf + 7, n_frames - 1)
start = max(0, end - 7)
indices = list(range(start, end + 1))
while len(indices) < 8:
indices = [indices[0]] + indices
all_frame_sets.append(load_frames(video_dir, indices[:8]))
results = []
prev_action = torch.tensor([0], device=self.device, dtype=torch.long)
for bi in tqdm(range(0, len(tick_frames)),
desc=f"{self.name}", ncols=80, leave=False):
belief, valid = self._extract_belief(all_frame_sets[bi])
b = belief.to(self.device, dtype=torch.float32)
v = valid.to(self.device)
tta_m = torch.tensor([5.0], device=self.device)
tta_v = torch.tensor([1.0], device=self.device)
probs_all = []
for head in self.heads:
logits, _ = head(b, v, tta_m, tta_v, prev_action)
probs_all.append(torch.softmax(logits, dim=-1))
avg = torch.stack(probs_all).mean(dim=0)
p_alert = float(avg[0, 2].cpu())
p_obs = float(avg[0, 1].cpu())
action_idx = int(avg.argmax(dim=-1)[0].cpu())
action = ["SILENT", "OBSERVE", "ALERT"][action_idx]
prev_action = torch.tensor([action_idx], device=self.device, dtype=torch.long)
tf = tick_frames[bi]
results.append({"frame": tf, "t": tf / fps,
"p_alert": p_alert, "p_observe": p_obs, "action": action})
return results
def unload_vlm(self):
if self.vlm_loaded:
del self.vlm
self.vlm_loaded = False
free_gpu()
logger.info(f"[{self.name}] VLM unloaded")
# ═══════════════════════════════════════════════════════════════
# Qwen2.5-VL-3B scorer (monolithic TTA head)
# ═══════════════════════════════════════════════════════════════
class Qwen25Scorer:
"""Score with Qwen2.5-VL-3B + TTAHead (TTA regression β†’ threshold β†’ action)."""
def __init__(self, name="VLAlert-2.5"):
self.name = name
self.device = "cuda"
self.vlm = None
def _load(self):
if self.vlm is not None:
return
logger.info(f"[{self.name}] loading Qwen2.5-VL-3B...")
from transformers import AutoProcessor, AutoModelForImageTextToText
from peft import PeftModel
import torch.nn as nn
import torch.nn.functional as F
self.processor = AutoProcessor.from_pretrained(
BASE_MODEL_Q25, trust_remote_code=True)
self.processor.tokenizer.padding_side = "right"
base = AutoModelForImageTextToText.from_pretrained(
BASE_MODEL_Q25, torch_dtype=torch.bfloat16, trust_remote_code=True)
self.vlm = PeftModel.from_pretrained(base, SFT_Q25_LORA).to(self.device)
self.vlm.eval()
class TTAHead(nn.Module):
def __init__(self, hidden_dim, intermediate_dim=512):
super().__init__()
self.net = nn.Sequential(
nn.Linear(hidden_dim, intermediate_dim), nn.GELU(), nn.Dropout(0.1),
nn.Linear(intermediate_dim, intermediate_dim // 2), nn.GELU(), nn.Dropout(0.1),
nn.Linear(intermediate_dim // 2, 2),
)
def forward(self, h):
out = self.net(h)
return F.softplus(out[:, 0]), out[:, 1]
self.tta_head = TTAHead(2048, 512).to(self.device)
sd = torch.load(TTA_HEAD_Q25, weights_only=False, map_location="cpu")
self.tta_head.load_state_dict(sd)
self.tta_head.eval()
logger.info(f"[{self.name}] loaded, GPU: {torch.cuda.memory_allocated()//1024**2}MB")
@torch.no_grad()
def _score_batch(self, frame_sets):
self._load()
N = len(frame_sets)
texts, all_images = [], []
for frames_8 in frame_sets:
uc = [{"type": "image", "image": img} for img in frames_8]
uc.append({"type": "text", "text": "Describe the driving safety situation."})
msgs = [{"role": "user", "content": uc}]
texts.append(self.processor.apply_chat_template(
msgs, add_generation_prompt=True, tokenize=False))
all_images.extend(frames_8)
inputs = self.processor(text=texts, images=all_images,
return_tensors="pt", padding=True).to(self.device)
core = self.vlm.get_base_model().model
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
out = core(
input_ids=inputs["input_ids"],
attention_mask=inputs.get("attention_mask"),
pixel_values=inputs.get("pixel_values"),
image_grid_thw=inputs.get("image_grid_thw"),
use_cache=False, return_dict=True,
)
hs = out.last_hidden_state # [N, L, 2048]
mask = inputs["attention_mask"].unsqueeze(-1).to(hs.dtype)
belief = (hs * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1) # [N, 2048]
tta_mean, _ = self.tta_head(belief.float()) # [N]
results = []
for i in range(N):
tta = float(tta_mean[i].cpu())
if tta < 2.0:
action = "ALERT"
elif tta < 5.0:
action = "OBSERVE"
else:
action = "SILENT"
p_alert = max(0.0, min(1.0, 1.0 - tta / 10.0))
results.append((p_alert, action, tta))
return results
def score_video(self, video_dir, n_frames, fps, batch_size=2):
tick_interval = max(1, int(fps))
tick_frames = list(range(0, n_frames, tick_interval))
all_frame_sets = []
for tf in tick_frames:
end = min(tf + 7, n_frames - 1)
start = max(0, end - 7)
indices = list(range(start, end + 1))
while len(indices) < 8:
indices = [indices[0]] + indices
all_frame_sets.append(load_frames(video_dir, indices[:8]))
results = []
for bi in tqdm(range(0, len(tick_frames), batch_size),
desc=f"{self.name}", ncols=80, leave=False):
batch = all_frame_sets[bi:bi + batch_size]
batch_results = self._score_batch(batch)
for j, (p_alert, action, tta) in enumerate(batch_results):
tf = tick_frames[bi + j]
results.append({"frame": tf, "t": tf / fps,
"p_alert": p_alert, "action": action,
"tta_mean": tta})
return results
def unload_vlm(self):
if self.vlm is not None:
del self.vlm, self.tta_head
self.vlm = None
free_gpu()
logger.info(f"[{self.name}] unloaded")
# ═══════════════════════════════════════════════════════════════
# Visualization
# ═══════════════════════════════════════════════════════════════
ACTION_COLORS = {"SILENT": (0, 200, 0), "OBSERVE": (0, 200, 255), "ALERT": (0, 0, 255)}
def render_comparison_video(video_dir: Path, model_scores: dict[str, list[dict]],
fps: float, n_frames: int, out_path: Path):
"""Render a comparison video: left=frame, right=score curves + actions."""
W_FRAME = 640
H_FRAME = 360
W_PANEL = 400
W_TOTAL = W_FRAME + W_PANEL
H_TOTAL = H_FRAME
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(str(out_path), fourcc, min(fps, 30), (W_TOTAL, H_TOTAL))
# Precompute score arrays interpolated to native fps
model_names = list(model_scores.keys())
colors_bgr = [
(255, 100, 100), # blue-ish for BADAS
(100, 255, 100), # green for VLAlert-v3
(0, 180, 255), # orange for VLAlert-v2
(100, 100, 255), # red for VLAlert-X
(255, 255, 100), # cyan for VLAlert-M10
(200, 100, 255), # pink
]
# Interpolate each model's p_alert to native fps
interp_scores = {}
interp_actions = {}
for mname, results in model_scores.items():
if not results: continue
tick_frames = [r["frame"] for r in results]
tick_palert = [r["p_alert"] for r in results]
tick_actions = [r["action"] for r in results]
# Interpolate p_alert to every frame
all_p = np.interp(range(n_frames), tick_frames, tick_palert)
interp_scores[mname] = all_p
# Nearest-neighbor for actions
all_a = []
for f in range(n_frames):
closest = min(range(len(tick_frames)), key=lambda i: abs(tick_frames[i] - f))
all_a.append(tick_actions[closest])
interp_actions[mname] = all_a
# History window for score plot (last 5 seconds)
history_frames = int(5 * fps)
for f in tqdm(range(n_frames), desc="render", ncols=80, leave=False):
# Load frame
frame_path = video_dir / f"{f:06d}.jpg"
if frame_path.exists():
img = cv2.imread(str(frame_path))
img = cv2.resize(img, (W_FRAME, H_FRAME))
else:
img = np.zeros((H_FRAME, W_FRAME, 3), dtype=np.uint8)
# Create right panel (white background)
panel = np.ones((H_TOTAL, W_PANEL, 3), dtype=np.uint8) * 240
# Draw score curves
t_sec = f / fps
plot_y0 = 30
plot_y1 = H_TOTAL - 80
plot_h = plot_y1 - plot_y0
plot_x0 = 10
plot_x1 = W_PANEL - 10
plot_w = plot_x1 - plot_x0
# Grid lines
for y_val in [0.0, 0.25, 0.5, 0.75, 1.0]:
y = int(plot_y1 - y_val * plot_h)
cv2.line(panel, (plot_x0, y), (plot_x1, y), (200, 200, 200), 1)
cv2.putText(panel, f"{y_val:.1f}", (plot_x1 + 2, y + 4),
cv2.FONT_HERSHEY_SIMPLEX, 0.3, (128, 128, 128), 1)
# Title
cv2.putText(panel, f"t={t_sec:.1f}s", (plot_x0, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
# Draw each model's score curve
win_start = max(0, f - history_frames)
for mi, mname in enumerate(model_names):
if mname not in interp_scores: continue
scores = interp_scores[mname]
color = colors_bgr[mi % len(colors_bgr)]
# Draw curve
for x in range(plot_w - 1):
fi = win_start + int(x * (f - win_start + 1) / plot_w)
fi_next = win_start + int((x + 1) * (f - win_start + 1) / plot_w)
fi = min(fi, n_frames - 1)
fi_next = min(fi_next, n_frames - 1)
y1 = int(plot_y1 - scores[fi] * plot_h)
y2 = int(plot_y1 - scores[fi_next] * plot_h)
cv2.line(panel, (plot_x0 + x, y1), (plot_x0 + x + 1, y2), color, 2)
# Current action label
action = interp_actions[mname][f] if mname in interp_actions else "?"
label_y = H_TOTAL - 70 + mi * 18
act_color = ACTION_COLORS.get(action, (128, 128, 128))
cv2.putText(panel, f"{mname}: ", (5, label_y),
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 0), 1)
cv2.putText(panel, f"{action} ({scores[f]:.2f})", (5 + len(mname) * 8, label_y),
cv2.FONT_HERSHEY_SIMPLEX, 0.4, act_color[::-1], 1)
# Combine frame + panel
combined = np.hstack([img, panel])
writer.write(combined)
writer.release()
logger.info(f" saved β†’ {out_path}")
# ═══════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════
def get_video_info(video_dir: Path):
frames = sorted(video_dir.glob("*.jpg"))
n = len(frames)
# Try to detect fps from parent video
parent_video = None
for ext in [".mp4", ".avi"]:
p = ROOT / "demo/compare" / (video_dir.name + ext)
if p.exists(): parent_video = p; break
fps = 30.0
if parent_video:
cap = cv2.VideoCapture(str(parent_video))
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
cap.release()
return n, fps
def score_one_model(mname, scorer, videos, batch_size=2):
"""Score all videos with one model, save incrementally."""
total_ticks = 0
t0_all = time.time()
for video_dir in videos:
vname = video_dir.name
n_frames, fps = get_video_info(video_dir)
scores_path = OUT_DIR / vname / "scores.json"
scores_path.parent.mkdir(parents=True, exist_ok=True)
cached = json.loads(scores_path.read_text()) if scores_path.exists() else {}
if mname in cached:
logger.info(f" [{mname}] {vname}: cached ({len(cached[mname])} ticks)")
total_ticks += len(cached[mname])
continue
logger.info(f" [{mname}] {vname}: {n_frames} frames @ {fps:.0f}fps...")
t0 = time.time()
results = scorer.score_video(video_dir, n_frames, fps, batch_size=batch_size)
dt = time.time() - t0
cached[mname] = results
scores_path.write_text(json.dumps(cached, indent=2))
total_ticks += len(results)
logger.info(f" [{mname}] {vname}: {len(results)} ticks in {dt:.1f}s")
dt_all = time.time() - t0_all
logger.info(f" [{mname}] done β€” {total_ticks} ticks total in {dt_all:.1f}s")
def render_all_videos(videos, model_names):
"""Re-render comparison videos using all cached scores."""
for video_dir in videos:
vname = video_dir.name
n_frames, fps = get_video_info(video_dir)
scores_path = OUT_DIR / vname / "scores.json"
if not scores_path.exists():
continue
cached = json.loads(scores_path.read_text())
all_scores = {m: cached[m] for m in model_names if m in cached}
if not all_scores:
continue
any_alert = any(
any(r["action"] in ("ALERT", "OBSERVE") for r in results)
for results in all_scores.values()
)
if not any_alert:
logger.info(f" {vname}: all SILENT, skip viz")
continue
out_video = OUT_DIR / vname / "comparison.mp4"
logger.info(f" {vname}: rendering with {list(all_scores.keys())}...")
render_comparison_video(video_dir, all_scores, fps, n_frames, out_video)
ALL_MODELS = ["BADAS", "VLAlert-v3", "VLAlert-v2", "VLAlert-X", "VLAlert-M10"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--models", type=str, default="v3,v2,X,M10,q25",
help="comma-separated: BADAS,v3,v2,X,M10,q25")
ap.add_argument("--only", type=str, default="", help="process only this video name")
ap.add_argument("--batch_size", type=int, default=2,
help="VLM batch size (2 fills ~28GB on 32GB GPU)")
ap.add_argument("--skip_render", action="store_true")
args = ap.parse_args()
videos = sorted([d for d in FRAMES_DIR.iterdir() if d.is_dir()])
if args.only:
videos = [v for v in videos if args.only in v.name]
logger.info(f"Processing {len(videos)} videos")
model_sel = set(args.models.split(","))
scored_names = []
# ── Group 0: BADAS (V-JEPA, separate backbone) ──
if "BADAS" in model_sel:
logger.info("\n" + "=" * 60 + "\n BADAS (V-JEPA2)\n" + "=" * 60)
scorer = BADASScorer()
score_one_model("BADAS", scorer, videos, batch_size=1)
scored_names.append("BADAS")
del scorer
free_gpu()
# ── Group 1: VLAlert-v3 (B3 backbone: sft_x_v3) ──
if "v3" in model_sel:
logger.info("\n" + "=" * 60 + "\n VLAlert-v3 (B3: sft_x_v3)\n" + "=" * 60)
scorer = VLAlertScorer(sft_path=SFT_V3, danger_path=DANGER_V3,
policy_paths=[POLICY_V3], name="VLAlert-v3")
score_one_model("VLAlert-v3", scorer, videos, batch_size=args.batch_size)
scored_names.append("VLAlert-v3")
scorer.unload_vlm()
del scorer
free_gpu()
# ── Group 2: VLAlert-v2 + VLAlert-X (B2 backbone: sft_x_v2, shared VLM) ──
run_v2 = "v2" in model_sel
run_x = "X" in model_sel
if run_v2 or run_x:
logger.info("\n" + "=" * 60 + "\n B2 backbone group (sft_x_v2)\n" + "=" * 60)
v2_scorer = None
x_scorer = None
if run_v2:
v2_paths = [p for p in POLICY_V2_SEEDS if p.exists()]
if v2_paths:
v2_scorer = VLAlertScorer(sft_path=SFT_V2, danger_path=DANGER_V2,
policy_paths=v2_paths, name="VLAlert-v2")
if run_x:
x_paths = [p for p in POLICY_X_SEEDS if p.exists()]
if x_paths:
x_scorer = VLAlertXScorer(sft_path=SFT_V2, x_head_paths=x_paths,
name="VLAlert-X")
# Score VLAlert-v2 first (loads B2 VLM)
if v2_scorer:
score_one_model("VLAlert-v2", v2_scorer, videos, batch_size=args.batch_size)
scored_names.append("VLAlert-v2")
# Score VLAlert-X sharing B2 VLM from v2
if x_scorer:
if v2_scorer and v2_scorer.vlm_loaded:
x_scorer.share_vlm(v2_scorer)
score_one_model("VLAlert-X", x_scorer, videos, batch_size=args.batch_size)
scored_names.append("VLAlert-X")
if v2_scorer:
v2_scorer.unload_vlm()
del v2_scorer
if x_scorer:
del x_scorer
free_gpu()
# ── Group 3: VLAlert-M10 (B0 backbone: qwen3vl4b_cot_belief_perframe) ──
if "M10" in model_sel:
logger.info("\n" + "=" * 60 + "\n VLAlert-M10 (B0: perframe)\n" + "=" * 60)
m10_paths = [p for p in M10_SEEDS if p.exists()]
if m10_paths:
scorer = M10Scorer(sft_path=SFT_B0, head_paths=m10_paths, name="VLAlert-M10")
score_one_model("VLAlert-M10", scorer, videos, batch_size=args.batch_size)
scored_names.append("VLAlert-M10")
scorer.unload_vlm()
del scorer
free_gpu()
# ── Group 4: VLAlert-2.5 (Qwen2.5-VL-3B, monolithic TTA) ──
if "q25" in model_sel:
logger.info("\n" + "=" * 60 + "\n VLAlert-2.5 (Qwen2.5-VL-3B)\n" + "=" * 60)
scorer = Qwen25Scorer(name="VLAlert-2.5")
score_one_model("VLAlert-2.5", scorer, videos, batch_size=args.batch_size)
scored_names.append("VLAlert-2.5")
scorer.unload_vlm()
del scorer
free_gpu()
# ── Render comparison videos with all scored models ──
if not args.skip_render:
# Include previously cached BADAS too
render_names = ["BADAS"] + scored_names if "BADAS" not in scored_names else scored_names
logger.info(f"\n{'='*60}\n Rendering comparisons: {render_names}\n{'='*60}")
render_all_videos(videos, render_names)
logger.info(f"\nβœ… All done! Results in {OUT_DIR}")
if __name__ == "__main__":
main()