| |
| """ |
| PolicyDataset β loads policy label manifests for Stage 1 supervised warm-start. |
| |
| Two modes: |
| Image mode (default): loads raw frames; requires full VLM forward at each step. |
| Used for: make_belief_cache.py, evaluate_policy.py. |
| |
| Cache mode (--belief_cache_dir): loads pre-computed belief vectors from |
| data/belief_cache/{split}.pt produced by make_belief_cache.py. |
| Used for: warm_start_trainer.py (fast, ~1000Γ speed-up). |
| In cache mode __getitem__ returns belief/tta tensors instead of images. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from collections import Counter |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Sequence |
|
|
| import numpy as np |
| import torch |
| from PIL import Image |
| from torch.utils.data import Dataset |
|
|
| logger = logging.getLogger("Policy.dataset") |
|
|
| MAX_FRAMES = 8 |
|
|
| ACTION_NAMES = {0: "SILENT", 1: "OBSERVE", 2: "ALERT"} |
|
|
| SAMPLING_SCHEMES = ("original", "uniform", "last_biased", "last_2s") |
| SOURCE_FILTERS = ("all", "nexar", "multisrc", "dada", "dad") |
|
|
|
|
| |
|
|
| def _load_frame(src_dir: Path, frame_idx: int) -> Optional[Image.Image]: |
| for fmt in ["{:03d}", "{:04d}", "{:05d}", "{:06d}", "{}"]: |
| for ext in [".jpg", ".jpeg", ".png"]: |
| p = src_dir / (fmt.format(frame_idx) + ext) |
| if p.exists(): |
| try: |
| return Image.open(p).convert("RGB") |
| except Exception: |
| pass |
| return None |
|
|
|
|
| def _resample_indices( |
| base: Sequence[int], |
| n_frames: int, |
| scheme: str = "original", |
| ) -> List[int]: |
| """Resample frame indices within the window [base[0], base[-1]]. |
| |
| `base` is the manifest's baked frame_indices (typically length 8, event-window |
| biased). We treat its min/max as the sampling window and redraw `n_frames` |
| indices inside that window, rounded to int. |
| |
| Schemes: |
| original β return `base[:n_frames]` (classic behavior) |
| uniform β evenly spaced indices across [min, max] |
| last_biased β 25% of frames from first half, 75% from second half |
| last_2s β all frames crammed into the last 2 s (~60 frames @ 30 fps) |
| assumed at the tail of the window |
| """ |
| if not base: |
| return [] |
| if scheme == "original" or n_frames == len(base): |
| return list(base[:n_frames]) |
|
|
| lo, hi = int(base[0]), int(base[-1]) |
| if hi <= lo: |
| return [lo] * n_frames |
|
|
| if scheme == "uniform": |
| idx = np.linspace(lo, hi, n_frames) |
| elif scheme == "last_biased": |
| n_head = max(1, n_frames // 4) |
| n_tail = n_frames - n_head |
| mid = (lo + hi) // 2 |
| head = np.linspace(lo, mid, n_head, endpoint=False) |
| tail = np.linspace(mid, hi, n_tail) |
| idx = np.concatenate([head, tail]) |
| elif scheme == "last_2s": |
| |
| two_s = min(hi - lo, 60) |
| start = hi - two_s |
| idx = np.linspace(start, hi, n_frames) |
| else: |
| raise ValueError(f"unknown sampling scheme: {scheme}") |
|
|
| return [int(round(x)) for x in idx] |
|
|
|
|
| def _load_frames( |
| source_dir: str, |
| frame_indices: List[int], |
| n_frames: int = MAX_FRAMES, |
| ) -> List[Image.Image]: |
| src = Path(source_dir) |
| imgs = [] |
| for idx in frame_indices[:n_frames]: |
| img = _load_frame(src, idx) |
| if img is not None: |
| imgs.append(img) |
| if not imgs: |
| imgs = [Image.new("RGB", (384, 384), (64, 64, 64))] |
| return imgs |
|
|
|
|
| |
|
|
| class PolicyDataset(Dataset): |
| """ |
| Args: |
| manifests : list of paths to JSON files from make_policy_labels.py |
| split : "train" or "val" (for logging) |
| belief_cache_path : optional path to .pt file from make_belief_cache.py; |
| when supplied, __getitem__ returns cached tensors |
| instead of PIL images (fast training mode) |
| debug : if True, truncate to first debug_samples |
| debug_samples : cap on samples in debug mode |
| """ |
|
|
| def __init__( |
| self, |
| manifests: List[Any], |
| split: str = "train", |
| belief_cache_path: Optional[Any] = None, |
| debug: bool = False, |
| debug_samples: int = 64, |
| n_frames: int = MAX_FRAMES, |
| sampling: str = "original", |
| source_filter: str = "all", |
| ): |
| self.split = split |
| self.n_frames = int(n_frames) |
| self.sampling = sampling |
| self.source_filter = source_filter |
| assert sampling in SAMPLING_SCHEMES, ( |
| f"sampling must be one of {SAMPLING_SCHEMES}, got {sampling}") |
| assert source_filter in SOURCE_FILTERS, ( |
| f"source_filter must be one of {SOURCE_FILTERS}, got {source_filter}") |
| self.samples: List[dict] = [] |
|
|
| for m in manifests: |
| m = Path(m) |
| if not m.exists(): |
| logger.warning(f"Policy label manifest not found: {m}") |
| continue |
| with open(m) as f: |
| data = json.load(f) |
| chunk = data.get("samples", data if isinstance(data, list) else []) |
| self.samples.extend(chunk) |
| logger.info( |
| f"Loaded {len(chunk)} samples from {m.name} " |
| f"labels={data.get('label_counts', {}) if isinstance(data, dict) else 'n/a'} " |
| f"excluded={data.get('excluded', {}) if isinstance(data, dict) else 'n/a'}" |
| ) |
|
|
| |
| |
| if source_filter != "all": |
| keep = { |
| "nexar": {"nexar"}, |
| "multisrc": {"nexar", "dada"}, |
| "dada": {"dada"}, |
| "dad": {"dad"}, |
| }[source_filter] |
| before = len(self.samples) |
| self.samples = [s for s in self.samples if s.get("source") in keep] |
| logger.info( |
| f"source_filter={source_filter}: {before} β {len(self.samples)} samples" |
| ) |
|
|
| if debug: |
| self.samples = self.samples[:debug_samples] |
|
|
| |
| self._cache: Optional[dict] = None |
| if belief_cache_path is not None: |
| p = Path(belief_cache_path) |
| if not p.exists(): |
| raise FileNotFoundError(f"Belief cache not found: {p}") |
| cache = torch.load(p, map_location="cpu", weights_only=True) |
| |
| n = len(self.samples) |
| |
| |
| |
| |
| if "beliefs" in cache: |
| beliefs = cache["beliefs"][:n] |
| if beliefs.dim() == 3: |
| dtype = beliefs.dtype |
| beliefs = beliefs.float().mean(dim=1).to(dtype) |
| elif "beliefs_frame" in cache: |
| raw = cache["beliefs_frame"][:n] |
| dtype = raw.dtype |
| vf = cache.get("valid_frames") |
| if vf is not None: |
| vmask = vf[:n].float().unsqueeze(-1) |
| denom = vmask.sum(dim=1).clamp(min=1.0) |
| beliefs = (raw.float() * vmask).sum(dim=1) / denom |
| else: |
| beliefs = raw.float().mean(dim=1) |
| beliefs = beliefs.to(dtype) |
| else: |
| raise KeyError( |
| f"Belief cache {p.name} has neither 'beliefs' nor 'beliefs_frame' key" |
| ) |
| self._cache = { |
| "beliefs": beliefs, |
| "tta_means": cache["tta_means"][:n], |
| "tta_vars": cache["tta_vars"][:n], |
| } |
| logger.info( |
| f"Loaded belief cache from {p.name} ({n} entries, " |
| f"belief_dim={self._cache['beliefs'].shape[-1]})" |
| ) |
|
|
| label_dist = Counter(ACTION_NAMES[s["action_label"]] for s in self.samples) |
| cat_dist = Counter(s["category"] for s in self.samples) |
| mode = "cached" if self._cache is not None else "image" |
| logger.info( |
| f"PolicyDataset [{split}, {mode}]: {len(self.samples)} samples. " |
| f"Labels: {dict(label_dist)}. Categories: {dict(cat_dist)}" |
| ) |
|
|
| def __len__(self) -> int: |
| return len(self.samples) |
|
|
| def __getitem__(self, idx: int) -> Dict[str, Any]: |
| s = self.samples[idx] |
| base = { |
| "action_label": int(s["action_label"]), |
| "ce_weight": float(s["ce_weight"]), |
| "category": s["category"], |
| "tta_raw": float(s["tta_raw"]), |
| "video_id": s["video_id"], |
| } |
|
|
| if self._cache is not None: |
| |
| base["belief"] = self._cache["beliefs"][idx] |
| base["tta_mean"] = self._cache["tta_means"][idx] |
| base["tta_var"] = self._cache["tta_vars"][idx] |
| else: |
| |
| |
| base_idx = s["frame_indices"] |
| if self.sampling != "original" or self.n_frames != MAX_FRAMES: |
| frame_idx = _resample_indices(base_idx, self.n_frames, self.sampling) |
| else: |
| frame_idx = base_idx |
| base["images"] = _load_frames(s["source_dir"], frame_idx, |
| n_frames=self.n_frames) |
| base["metadata"] = s.get("metadata", {}) |
| base["frame_indices_used"] = frame_idx |
|
|
| return base |
|
|
|
|
| def policy_collate_fn(batch: List[Dict[str, Any]]) -> Dict[str, Any]: |
| """Works for both image-mode and cache-mode batches.""" |
| out: Dict[str, Any] = { |
| "action_labels": torch.tensor([b["action_label"] for b in batch], dtype=torch.long), |
| "ce_weights": torch.tensor([b["ce_weight"] for b in batch], dtype=torch.float32), |
| "categories": [b["category"] for b in batch], |
| "tta_raws": torch.tensor([b["tta_raw"] for b in batch], dtype=torch.float32), |
| "video_ids": [b["video_id"] for b in batch], |
| } |
| if "belief" in batch[0]: |
| |
| out["beliefs"] = torch.stack([b["belief"] for b in batch]) |
| out["tta_means"] = torch.stack([b["tta_mean"] for b in batch]) |
| out["tta_vars"] = torch.stack([b["tta_var"] for b in batch]) |
| else: |
| |
| out["images"] = [b["images"] for b in batch] |
| out["metadata"] = [b["metadata"] for b in batch] |
| return out |
|
|