| |
| """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)) |
|
|
| |
| 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_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") |
|
|
| |
| 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" |
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
| class BADASScorer: |
| def __init__(self): |
| sys.path.insert(0, str(BADAS_REPO / "src")) |
| import train.video_training |
| 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 |
|
|
|
|
| |
| |
| |
| 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" |
|
|
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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") |
|
|
|
|
| |
| |
| |
| 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): |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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] |
| 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") |
|
|
|
|
| |
| |
| |
| 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 |
| mask = inputs["attention_mask"].unsqueeze(-1).to(hs.dtype) |
| belief = (hs * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1) |
| tta_mean, _ = self.tta_head(belief.float()) |
|
|
| 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") |
|
|
|
|
| |
| |
| |
| 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)) |
|
|
| |
| model_names = list(model_scores.keys()) |
| colors_bgr = [ |
| (255, 100, 100), |
| (100, 255, 100), |
| (0, 180, 255), |
| (100, 100, 255), |
| (255, 255, 100), |
| (200, 100, 255), |
| ] |
|
|
| |
| 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] |
| |
| all_p = np.interp(range(n_frames), tick_frames, tick_palert) |
| interp_scores[mname] = all_p |
| |
| 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_frames = int(5 * fps) |
|
|
| for f in tqdm(range(n_frames), desc="render", ncols=80, leave=False): |
| |
| 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) |
|
|
| |
| panel = np.ones((H_TOTAL, W_PANEL, 3), dtype=np.uint8) * 240 |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| cv2.putText(panel, f"t={t_sec:.1f}s", (plot_x0, 20), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1) |
|
|
| |
| 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)] |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| combined = np.hstack([img, panel]) |
| writer.write(combined) |
|
|
| writer.release() |
| logger.info(f" saved β {out_path}") |
|
|
|
|
| |
| |
| |
| def get_video_info(video_dir: Path): |
| frames = sorted(video_dir.glob("*.jpg")) |
| n = len(frames) |
| |
| 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 = [] |
|
|
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| 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") |
|
|
| |
| if v2_scorer: |
| score_one_model("VLAlert-v2", v2_scorer, videos, batch_size=args.batch_size) |
| scored_names.append("VLAlert-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() |
|
|
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| if not args.skip_render: |
| |
| 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() |
|
|