| """Rewrite DoTA per-frame action labels in cot_corpus_v3 manifests per user rule: |
| |
| For each DoTA clip with valid anomaly_start / anomaly_end: |
| - [anomaly_start, anomaly_end] → ALERT |
| - In the 3-second pre-anomaly window [anomaly_start - 30, anomaly_start - 1] |
| (30 frames @ 10fps), use BADAS to find t_observe: |
| - t_observe = first frame index where BADAS p_alert > threshold |
| - If no frame crosses threshold, no OBSERVE labels (SILENT→ALERT direct) |
| - [t_observe, anomaly_start - 1] → OBSERVE |
| - All other frames → SILENT |
| For DoTA clips without anomaly (negatives) → all frames SILENT. |
| |
| Threshold is derived from the per-clip BADAS @ anomaly_start distribution |
| (eval_results/badas_dota_anomaly_start.json). Default: 25th percentile of |
| that distribution. Override via --threshold or --threshold_strategy. |
| |
| USAGE |
| # First, score BADAS at anomaly_start (one-time): |
| python tools/badas_dota_anomaly_start.py |
| |
| # Then score BADAS on every pre-anomaly anchor frame (this script): |
| python tools/relabel_dota_corpus.py --threshold_strategy mean |
| # or: |
| python tools/relabel_dota_corpus.py --threshold 0.05 |
| |
| Reads: data/cot_corpus_v3/v4_sft_{train,val,test}_full_relabeled.jsonl |
| eval_results/badas_dota_anomaly_start.json |
| Writes: data/cot_corpus_v3/v4_sft_{train,val,test}_full_relabeled2.jsonl |
| eval_results/badas_dota_pre_anomaly_scores.json (per-clip pre-window BADAS) |
| """ |
| from __future__ import annotations |
| import argparse |
| import json |
| import logging |
| import sys |
| import time |
| from collections import Counter, defaultdict |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| from PIL import Image |
| from tqdm import tqdm |
| from torch.utils.data import DataLoader, Dataset |
|
|
| ROOT = Path("PROJECT_ROOT") |
| BADAS_REPO = Path("~/.cache/huggingface/hub/models--nexar-ai--badas-open/" |
| "snapshots/8fda93711e79d72401b0a4efc151b56455885cd2") |
| sys.path.insert(0, str(BADAS_REPO / "src")) |
| import train.video_training |
| from models.vjepa import VJEPAModel |
|
|
| DOTA_FRAMES = ROOT / "DoTA/frames" |
| META_TRAIN = ROOT / "DoTA/metadata_train.json" |
| META_VAL = ROOT / "DoTA/metadata_val.json" |
| COT_DIR = ROOT / "data/cot_corpus_v3" |
| ANOMALY_JSON = ROOT / "eval_results/badas_dota_anomaly_start.json" |
| PREWIN_JSON = ROOT / "eval_results/badas_dota_pre_anomaly_scores.json" |
|
|
| DOTA_FPS = 10.0 |
| PREWIN_SECONDS = 2.0 |
| PREWIN_FRAMES = int(PREWIN_SECONDS * DOTA_FPS) |
| FRAME_COUNT = 16 |
| IMG_SIZE = 224 |
| MODEL_NAME = "facebook/vjepa2-vitl-fpc16-256-ssv2" |
| CKPT_PATH = str(BADAS_REPO / "weights" / "badas_open.pth") |
| TEMPERATURE = 2.0 |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| logger = logging.getLogger("dota_relabel") |
|
|
|
|
| |
|
|
| def load_pil_frames_causal(video_name: str, anchor_frame: int, |
| frame_count: int = FRAME_COUNT) -> list[Image.Image]: |
| folder = DOTA_FRAMES / video_name / "images" |
| if not folder.is_dir(): |
| return [] |
| avail = sorted(int(p.stem) for p in folder.glob("*.jpg")) |
| if not avail: return [] |
| avail_np = np.array(avail) |
| wanted = list(range(anchor_frame - frame_count + 1, anchor_frame + 1)) |
| out = [] |
| for w in wanted: |
| if w < avail[0]: w = avail[0] |
| k = int(avail_np[np.abs(avail_np - w).argmin()]) |
| for width in (6, 5, 4, 3): |
| cand = folder / f"{k:0{width}d}.jpg" |
| if cand.exists(): |
| out.append(Image.open(cand).convert("RGB")) |
| break |
| return out |
|
|
|
|
| class AnchorDS(Dataset): |
| """Each item is (video, anchor_frame) for the pre-anomaly window.""" |
| def __init__(self, items: list[tuple[str, int]], processor): |
| self.items = items |
| self.processor = processor |
|
|
| def __len__(self): return len(self.items) |
|
|
| def __getitem__(self, i): |
| vname, anchor = self.items[i] |
| frames = load_pil_frames_causal(vname, anchor) |
| if len(frames) < FRAME_COUNT: |
| if frames: |
| frames = [frames[0]] * (FRAME_COUNT - len(frames)) + frames |
| else: |
| frames = [Image.new("RGB", (IMG_SIZE, IMG_SIZE))] * FRAME_COUNT |
| proc = self.processor(videos=[frames], return_tensors="pt") |
| if "pixel_values_videos" in proc: |
| video = proc["pixel_values_videos"].squeeze(0) |
| elif "pixel_values" in proc: |
| video = proc["pixel_values"].squeeze(0) |
| else: |
| video = list(proc.values())[0].squeeze(0) |
| return {"video": video, "video_name": vname, "anchor": int(anchor)} |
|
|
|
|
| def coll(batch): |
| return { |
| "videos": torch.stack([b["video"] for b in batch]), |
| "video_name": [b["video_name"] for b in batch], |
| "anchor": [b["anchor"] for b in batch], |
| } |
|
|
|
|
| @torch.no_grad() |
| def forward(model, videos, device): |
| """bf16 autocast for 2× speedup; softmax computed in fp32 for numerical safety.""" |
| videos = videos.to(device, non_blocking=True) |
| with torch.autocast(device_type="cuda", dtype=torch.bfloat16): |
| out = model(videos) |
| logits = out.float() / TEMPERATURE |
| probs = torch.softmax(logits, dim=1)[:, 1] |
| return probs.cpu().numpy() |
|
|
|
|
| |
|
|
| def derive_threshold(strategy: str, override: float | None = None) -> float: |
| if override is not None and override > 0: |
| logger.info(f"[threshold] using override = {override:.4f}") |
| return float(override) |
| if not ANOMALY_JSON.exists(): |
| raise FileNotFoundError(f"{ANOMALY_JSON} not found — run tools/badas_dota_anomaly_start.py first") |
| d = json.loads(ANOMALY_JSON.read_text()) |
| scores = [r["p_alert_at_anomaly_start"] for r in d["per_clip"].values()] |
| arr = np.asarray(scores, dtype=np.float64) |
| options = { |
| "mean": float(arr.mean()), |
| "median": float(np.median(arr)), |
| "p25": float(np.percentile(arr, 25)), |
| "p10": float(np.percentile(arr, 10)), |
| } |
| logger.info(f"[threshold] distribution at anomaly_start (N={arr.size}):") |
| for k, v in options.items(): |
| logger.info(f" {k:8s} = {v:.4f}") |
| return options[strategy] |
|
|
|
|
| |
|
|
| def rewrite_dota_labels(actions_pf: list[str], tta_pf: list[float], |
| tick_action: str, tick_tta: float, |
| anomaly_start: int, anomaly_end: int, |
| t_observe: int | None, |
| frame_indices: list[int]) -> tuple[list[str], str]: |
| """For each of the 8 frame indices in this tick, assign: |
| [anomaly_start, anomaly_end] → ALERT |
| [t_observe, anomaly_start - 1] → OBSERVE (if t_observe is not None) |
| else → SILENT |
| """ |
| new_actions = [] |
| for f in frame_indices: |
| if anomaly_start is not None and anomaly_end is not None and \ |
| anomaly_start <= f <= anomaly_end: |
| new_actions.append("ALERT") |
| elif (t_observe is not None and anomaly_start is not None |
| and t_observe <= f < anomaly_start): |
| new_actions.append("OBSERVE") |
| else: |
| new_actions.append("SILENT") |
| |
| new_tick = new_actions[-1] |
| return new_actions, new_tick |
|
|
|
|
| |
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--threshold_strategy", choices=["mean", "median", "p25", "p10"], |
| default="p25", |
| help="how to derive the OBSERVE threshold from the per-clip " |
| "BADAS @ anomaly_start distribution") |
| ap.add_argument("--threshold", type=float, default=0.0, |
| help="override threshold (>0)") |
| ap.add_argument("--batch_size", type=int, default=8) |
| ap.add_argument("--num_workers", type=int, default=2) |
| ap.add_argument("--skip_badas", action="store_true", |
| help="reuse existing pre-window BADAS scores (no GPU run)") |
| args = ap.parse_args() |
|
|
| threshold = derive_threshold(args.threshold_strategy, args.threshold or None) |
| logger.info(f"[threshold] FINAL = {threshold:.4f} (strategy={args.threshold_strategy})") |
|
|
| |
| meta = {} |
| for p in (META_TRAIN, META_VAL): |
| meta.update(json.loads(p.read_text())) |
| items = [] |
| skipped = 0 |
| for vid, m in meta.items(): |
| a_start = m.get("anomaly_start"); a_end = m.get("anomaly_end") |
| if a_start is None or a_start <= 0: |
| skipped += 1; continue |
| if not (DOTA_FRAMES / vid / "images").is_dir(): |
| skipped += 1; continue |
| win_lo = max(0, a_start - PREWIN_FRAMES) |
| win_hi = a_start - 1 |
| items.append({"video_name": vid, "anomaly_start": int(a_start), |
| "anomaly_end": int(a_end) if a_end else None, |
| "pre_anchors": list(range(win_lo, win_hi + 1))}) |
| logger.info(f"DoTA clips with anomaly_start: {len(items)} (skipped {skipped})") |
|
|
| |
| if not args.skip_badas: |
| logger.info(f"Loading V-JEPA2 …") |
| vjepa = VJEPAModel(model_name=MODEL_NAME, checkpoint_path=CKPT_PATH, |
| frame_count=FRAME_COUNT, img_size=IMG_SIZE, |
| window_stride=1, target_fps=8.0, |
| use_sliding_window=False) |
| vjepa.load() |
| device = vjepa.device |
|
|
| flat = [(it["video_name"], a) for it in items for a in it["pre_anchors"]] |
| logger.info(f" total anchors to score: {len(flat)}") |
|
|
| |
| per_anchor: dict[tuple[str, int], float] = {} |
| ckpt_path = PREWIN_JSON.parent / "_pre_anomaly_anchors_ckpt.json" |
| if ckpt_path.exists(): |
| ck = json.loads(ckpt_path.read_text()) |
| |
| for k, v in ck.items(): |
| vname, anchor = k.rsplit("|", 1) |
| per_anchor[(vname, int(anchor))] = float(v) |
| logger.info(f" [resume] loaded {len(per_anchor)} anchors from {ckpt_path}") |
| flat = [t for t in flat if t not in per_anchor] |
| logger.info(f" {len(flat)} anchors remaining") |
|
|
| ds = AnchorDS(flat, processor=vjepa.processor) |
| loader = DataLoader(ds, batch_size=args.batch_size, shuffle=False, |
| num_workers=args.num_workers, collate_fn=coll, |
| pin_memory=True, |
| persistent_workers=(args.num_workers > 0), |
| prefetch_factor=4 if args.num_workers > 0 else None) |
|
|
| def _save_ckpt(): |
| tmp = {f"{k[0]}|{k[1]}": v for k, v in per_anchor.items()} |
| ckpt_path.parent.mkdir(parents=True, exist_ok=True) |
| tmp_path = ckpt_path.with_suffix(".json.tmp") |
| tmp_path.write_text(json.dumps(tmp)) |
| tmp_path.replace(ckpt_path) |
|
|
| SAVE_EVERY = 5000 |
| pbar = tqdm(total=len(flat), desc="badas", ncols=110, |
| unit="anc", smoothing=0.05, dynamic_ncols=False) |
| n_done = 0 |
| for batch in loader: |
| probs = forward(vjepa.model, batch["videos"], device) |
| for vn, an, p in zip(batch["video_name"], batch["anchor"], probs): |
| per_anchor[(vn, int(an))] = float(p) |
| n_done += len(probs) |
| pbar.update(len(probs)) |
| if n_done % 200 == 0: |
| pbar.set_postfix(gpu_GB=f"{torch.cuda.memory_allocated()/1e9:.1f}") |
| if n_done % SAVE_EVERY == 0: |
| _save_ckpt() |
| pbar.close() |
|
|
| _save_ckpt() |
| logger.info(f"[ckpt] final save → {ckpt_path}") |
|
|
| |
| scores_by_clip: dict[str, dict] = {} |
| for it in items: |
| vname = it["video_name"] |
| per_frame = {int(a): per_anchor.get((vname, int(a)), float("nan")) |
| for a in it["pre_anchors"]} |
| scores_by_clip[vname] = { |
| "anomaly_start": it["anomaly_start"], |
| "pre_anchors": it["pre_anchors"], |
| "scores": per_frame, |
| } |
| PREWIN_JSON.parent.mkdir(parents=True, exist_ok=True) |
| PREWIN_JSON.write_text(json.dumps(scores_by_clip, indent=2)) |
| logger.info(f"[save] {PREWIN_JSON} ({len(scores_by_clip)} clips)") |
| else: |
| if not PREWIN_JSON.exists(): |
| raise FileNotFoundError(f"--skip_badas set but {PREWIN_JSON} doesn't exist") |
| scores_by_clip = json.loads(PREWIN_JSON.read_text()) |
| logger.info(f"[skip_badas] loaded {len(scores_by_clip)} clips from {PREWIN_JSON}") |
|
|
| |
| t_observe_by_clip: dict[str, int | None] = {} |
| n_with_obs = n_without = 0 |
| for vname, info in scores_by_clip.items(): |
| anchors = info["pre_anchors"] |
| scs = info["scores"] |
| |
| first_cross = None |
| for a in sorted(anchors): |
| v = scs.get(str(a), scs.get(a)) |
| if v is None or not np.isfinite(v): continue |
| if v > threshold: |
| first_cross = int(a); break |
| t_observe_by_clip[vname] = first_cross |
| if first_cross is None: n_without += 1 |
| else: n_with_obs += 1 |
| logger.info(f"[t_observe] {n_with_obs} clips have OBSERVE window, " |
| f"{n_without} go SILENT→ALERT direct (no crossing)") |
|
|
| |
| for split_tag in ["v4_sft_train_full", "v4_sft_val_full", "v4_sft_test_full"]: |
| in_path = COT_DIR / f"{split_tag}_relabeled.jsonl" |
| out_path = COT_DIR / f"{split_tag}_relabeled2.jsonl" |
| if not in_path.exists(): |
| logger.warning(f"[skip] {in_path} not found") |
| continue |
| n_total = n_dota = n_changed = 0 |
| before = Counter(); after = Counter() |
| with in_path.open() as fin, out_path.open("w") as fout: |
| for ln in fin: |
| ln = ln.strip() |
| if not ln: continue |
| rec = json.loads(ln) |
| n_total += 1 |
| src = rec.get("source", "") |
| if src != "dota": |
| fout.write(json.dumps(rec) + "\n"); continue |
| n_dota += 1 |
| |
| vid_raw = rec.get("video_id") or rec.get("clip_id") or "" |
| vid = vid_raw.replace("dota_", "", 1) if vid_raw.startswith("dota_") else vid_raw |
| m = meta.get(vid, {}) |
| a_start = m.get("anomaly_start"); a_end = m.get("anomaly_end") |
| t_obs = t_observe_by_clip.get(vid) |
|
|
| frame_idx = rec.get("frame_indices", []) |
| if len(frame_idx) != 8 or a_start is None or a_start <= 0: |
| |
| new_acts = ["SILENT"] * 8 |
| new_tick = "SILENT" |
| else: |
| new_acts, new_tick = rewrite_dota_labels( |
| rec.get("actions_per_frame", []), |
| rec.get("tta_per_frame", []), |
| rec.get("tick_action", ""), |
| rec.get("tick_tta_raw", -1.0), |
| a_start, a_end, t_obs, frame_idx) |
| before[rec.get("tick_action", "?")] += 1 |
| rec["actions_per_frame"] = new_acts |
| rec["tick_action"] = new_tick |
| after[new_tick] += 1 |
| if rec.get("tick_action") != before: |
| n_changed += 1 |
| fout.write(json.dumps(rec) + "\n") |
| logger.info(f"[{split_tag}] N={n_total} DoTA={n_dota} saved → {out_path}") |
| logger.info(f" before tick_action: {dict(before)}") |
| logger.info(f" after tick_action: {dict(after)}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|