| |
| """ |
| SFT Dataset β manifest-based, dual-head (hazard + TTA). |
| |
| Sample categories |
| ----------------- |
| ego_positive : ego-vehicle crash; hazard_label=1, TTA supervised |
| non_ego : accident in scene, not ego-relevant; hazard_label=0 (soft, weight=0.35), |
| no TTA supervision; near-accident windows oversampled |
| safe_neg : no accident; hazard_label=0, no TTA supervision |
| |
| Pre-risky windows from ego_positive videos are tagged as safe_neg |
| with hazard_weight=0.8 (slightly soft β annotation boundary may be imprecise). |
| |
| All timestamps are 20 Hz frame indices (0.05 s / frame). |
| Folder assignment is the source of truth; accident boolean is ignored. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import random |
| from collections import defaultdict |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import torch |
| from PIL import Image |
| from torch.utils.data import Dataset, DataLoader, Sampler |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") |
| logger = logging.getLogger(__name__) |
|
|
| |
| FRAME_RATE = 20 |
| FRAME_INTERVAL = 1.0 / FRAME_RATE |
|
|
| WINDOW_STD = 40 |
| WINDOW_EXT = 60 |
| MAX_FRAMES_PER_SAMPLE = 8 |
| FRAME_SAMPLE_RATE = 4 |
|
|
| MAX_TTA = 10.0 |
| MIN_TTA = 0.1 |
|
|
| |
| W_EGO_POS = 1.0 |
| W_SAFE_NEG = 1.0 |
| W_PRE_RISKY = 0.8 |
| W_NON_EGO = 0.35 |
|
|
| |
| POS_STRIDE_NORMAL = 10 |
| POS_STRIDE_CLOSE = 5 |
| POS_CLOSE_TTA_S = 3.0 |
| PRE_RISKY_BUFFER_S = 3.0 |
|
|
| |
| NEG_STRIDE = 10 |
| NEG_NUM_OFFSETS = 3 |
| NONEEGO_STRIDE = 10 |
| NONEEGO_NEAR_STRIDE = 5 |
| NONEEGO_NEAR_PRE_S = 3.0 |
| NONEEGO_NEAR_POST_S = 1.0 |
|
|
| |
| NEG_POS_RATIO = 2.0 |
| NONEEGO_NEG_FLOOR = 0.30 |
|
|
|
|
| |
|
|
| @dataclass |
| class VideoInfo: |
| video_id: str |
| source: str |
| category: str |
| source_dir: Path |
| num_frames: int |
| accident_frame: Optional[int] |
| risky_frame: Optional[int] |
| metadata: Dict[str, Any] = field(default_factory=dict) |
|
|
| @property |
| def is_ego_positive(self) -> bool: |
| return self.category == "ego_positive" |
|
|
| @property |
| def is_non_ego(self) -> bool: |
| return self.category == "non_ego" |
|
|
|
|
| @dataclass |
| class TTASample: |
| video_id: str |
| source: str |
| category: str |
| source_dir: str |
| frame_indices: List[int] |
|
|
| |
| hazard_label: float |
| hazard_weight: float |
| tta_label: float |
| is_ego_positive: bool |
| is_non_ego: bool |
| is_censored: bool |
|
|
| |
| accident_frame: Optional[int] |
| risky_frame: Optional[int] |
| window_end: int |
| window_len: int |
| window_type: str |
| tta_raw: float |
| tta_cap: float |
| difficulty: str |
| metadata: Dict[str, Any] = field(default_factory=dict) |
|
|
|
|
| |
|
|
| def _safe_int(x: Any) -> Optional[int]: |
| if x is None: |
| return None |
| try: |
| return int(float(str(x).strip())) |
| except Exception: |
| return None |
|
|
|
|
| def _classify_difficulty(tta: float, category: str) -> str: |
| if category == "safe_neg": |
| return "easy" |
| if category == "non_ego": |
| return "hard" |
| |
| if tta <= 2.0: |
| return "easy" |
| if tta <= 5.0: |
| return "hard" |
| return "medium" |
|
|
|
|
| def _load_manifest(path: Path) -> List[Dict[str, Any]]: |
| with open(path, "r") as f: |
| obj = json.load(f) |
| return obj.get("videos", []) |
|
|
|
|
| |
|
|
| class SFTDataset(Dataset): |
| """ |
| Args |
| ---- |
| manifests : list of Path / str pointing to manifest JSON files. |
| Each file's "videos" list is loaded; split is already encoded |
| in the manifest (train vs val). |
| split : "train" or "val" β controls stochastic frame sampling. |
| """ |
|
|
| def __init__( |
| self, |
| manifests: List[Any], |
| split: str = "train", |
| seed: int = 42, |
| debug: bool = False, |
| debug_samples: int = 100, |
| |
| pos_stride: int = POS_STRIDE_NORMAL, |
| neg_stride: int = NEG_STRIDE, |
| max_frames: int = MAX_FRAMES_PER_SAMPLE, |
| frame_sample_rate: int = FRAME_SAMPLE_RATE, |
| multi_window: bool = True, |
| neg_pos_ratio: float = NEG_POS_RATIO, |
| ): |
| self.split = split |
| self.seed = seed |
| self.debug = debug |
| self.debug_samples = debug_samples |
| self.pos_stride = pos_stride |
| self.neg_stride = neg_stride |
| self.max_frames = max_frames |
| self.frame_sample_rate = frame_sample_rate |
| self.multi_window = multi_window |
| self.neg_pos_ratio = neg_pos_ratio |
| self.stochastic = (split == "train") |
|
|
| random.seed(seed) |
| np.random.seed(seed) |
|
|
| self.videos: List[VideoInfo] = [] |
| self.samples: List[TTASample] = [] |
|
|
| for m in manifests: |
| self._load_manifest(Path(m)) |
|
|
| self._balance() |
|
|
| if debug and len(self.samples) > debug_samples: |
| self.samples = random.sample(self.samples, debug_samples) |
|
|
| if split == "train": |
| random.shuffle(self.samples) |
|
|
| self._log_stats() |
|
|
| |
|
|
| def _load_manifest(self, path: Path) -> None: |
| if not path.exists(): |
| logger.warning(f"Manifest not found: {path}") |
| return |
| entries = _load_manifest(path) |
| for e in entries: |
| vi = VideoInfo( |
| video_id = e["video_id"], |
| source = e["source"], |
| category = e["category"], |
| source_dir = Path(e["source_dir"]), |
| num_frames = int(e["num_frames"]), |
| accident_frame= _safe_int(e.get("accident_frame")), |
| risky_frame = _safe_int(e.get("risky_frame")), |
| metadata = dict(e.get("metadata", {})), |
| ) |
| self.videos.append(vi) |
| self._generate_samples(vi) |
|
|
| |
|
|
| def _generate_samples(self, vi: VideoInfo) -> None: |
| if vi.is_ego_positive: |
| self._gen_ego_positive(vi) |
| elif vi.is_non_ego: |
| self._gen_non_ego(vi) |
| else: |
| self._gen_safe_neg(vi) |
|
|
| |
|
|
| def _gen_ego_positive(self, vi: VideoInfo) -> None: |
| n = vi.num_frames |
| acc = vi.accident_frame |
| rsk = vi.risky_frame |
|
|
| base_win = WINDOW_EXT if self.multi_window else WINDOW_STD |
|
|
| |
| if rsk is not None: |
| safe_end = max(base_win, rsk) |
| pre_risky_buffer = int(PRE_RISKY_BUFFER_S / FRAME_INTERVAL) |
| start = max(base_win, rsk - pre_risky_buffer - base_win) |
| if start < safe_end and safe_end > base_win: |
| |
| for we in range(start + base_win, safe_end, self.neg_stride): |
| self._add_safe_neg(vi, we, WINDOW_STD, neg_tag="pre_risky", |
| weight=W_PRE_RISKY) |
| if self.multi_window: |
| self._add_safe_neg(vi, we, WINDOW_EXT, neg_tag="pre_risky", |
| weight=W_PRE_RISKY) |
|
|
| |
| if rsk is not None: |
| buf = int(PRE_RISKY_BUFFER_S / FRAME_INTERVAL) |
| pos_start = max(0, rsk - buf) |
| else: |
| pos_start = 0 |
|
|
| seen: set = set() |
|
|
| def add_pos(we: int) -> None: |
| if we in seen: |
| return |
| seen.add(we) |
| cur = we - 1 |
| tta_raw = (acc - cur) * FRAME_INTERVAL |
| self._add_ego_pos(vi, we, WINDOW_STD, tta_raw) |
| if self.multi_window: |
| self._add_ego_pos(vi, we, WINDOW_EXT, tta_raw) |
|
|
| |
| if self.split == "train": |
| targets_s = [2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 7.0, 8.0] |
| repeats = 2 |
| jitter = 3 |
| else: |
| targets_s = [2.0, 3.0, 4.0, 5.0, 6.0, 7.0] |
| repeats = 1 |
| jitter = 0 |
|
|
| for tta_t in targets_s: |
| off = int(round(tta_t / FRAME_INTERVAL)) |
| we_base = (acc - off) + 1 |
| for _ in range(repeats): |
| j = random.randint(-jitter, jitter) if jitter else 0 |
| we = we_base + j |
| we = max(base_win, min(we, acc + 1)) |
| if we - 1 >= pos_start: |
| add_pos(we) |
|
|
| |
| we = max(base_win, pos_start + base_win) |
| while we <= acc + 1: |
| if we - 1 >= pos_start: |
| add_pos(we) |
| cur = we - 1 |
| tta = (acc - cur) * FRAME_INTERVAL |
| we += POS_STRIDE_CLOSE if tta <= POS_CLOSE_TTA_S else self.pos_stride |
|
|
| def _add_ego_pos(self, vi: VideoInfo, window_end: int, window_len: int, tta_raw: float) -> None: |
| n = vi.num_frames |
| acc = vi.accident_frame |
|
|
| window_end = max(window_len, min(window_end, n)) |
| if window_end <= 0: |
| return |
| if tta_raw < MIN_TTA: |
| return |
|
|
| is_censored = tta_raw > MAX_TTA |
| tta_label = min(tta_raw, MAX_TTA) if not is_censored else MAX_TTA |
|
|
| fi = self._sample_frames(window_end - window_len, window_end) |
| meta = dict(vi.metadata) |
| meta["neg_tag"] = "ego_pos" |
|
|
| self.samples.append(TTASample( |
| video_id = vi.video_id, |
| source = vi.source, |
| category = "ego_positive", |
| source_dir = str(vi.source_dir), |
| frame_indices = fi, |
| hazard_label = 1.0, |
| hazard_weight = W_EGO_POS, |
| tta_label = tta_label, |
| is_ego_positive = True, |
| is_non_ego = False, |
| is_censored = is_censored, |
| accident_frame = acc, |
| risky_frame = vi.risky_frame, |
| window_end = window_end, |
| window_len = window_len, |
| window_type = "extended" if window_len == WINDOW_EXT else "standard", |
| tta_raw = tta_raw, |
| tta_cap = MAX_TTA, |
| difficulty = _classify_difficulty(tta_label, "ego_positive"), |
| metadata = meta, |
| )) |
|
|
| def _add_safe_neg(self, vi: VideoInfo, window_end: int, window_len: int, |
| neg_tag: str = "neg_video", weight: float = W_SAFE_NEG) -> None: |
| n = vi.num_frames |
| window_end = max(window_len, min(window_end, n)) |
| if window_end <= 0: |
| return |
|
|
| fi = self._sample_frames(window_end - window_len, window_end) |
| meta = dict(vi.metadata) |
| meta["neg_tag"] = neg_tag |
|
|
| self.samples.append(TTASample( |
| video_id = vi.video_id, |
| source = vi.source, |
| category = "safe_neg", |
| source_dir = str(vi.source_dir), |
| frame_indices = fi, |
| hazard_label = 0.0, |
| hazard_weight = weight, |
| tta_label = MAX_TTA, |
| is_ego_positive = False, |
| is_non_ego = False, |
| is_censored = True, |
| accident_frame = None, |
| risky_frame = None, |
| window_end = window_end, |
| window_len = window_len, |
| window_type = "standard", |
| tta_raw = float("inf"), |
| tta_cap = MAX_TTA, |
| difficulty = _classify_difficulty(MAX_TTA, "safe_neg"), |
| metadata = meta, |
| )) |
|
|
| |
|
|
| def _gen_safe_neg(self, vi: VideoInfo) -> None: |
| n = vi.num_frames |
| offsets = [int(i * self.neg_stride / NEG_NUM_OFFSETS) |
| for i in range(NEG_NUM_OFFSETS)] |
| for off in offsets: |
| we = WINDOW_STD + off |
| while we <= n: |
| self._add_safe_neg(vi, we, WINDOW_STD, neg_tag="neg_video", |
| weight=W_SAFE_NEG) |
| we += self.neg_stride |
|
|
| |
|
|
| def _gen_non_ego(self, vi: VideoInfo) -> None: |
| n = vi.num_frames |
| acc = vi.accident_frame |
|
|
| |
| |
| |
| video_cap = acc if acc is not None else n |
|
|
| |
| if acc is not None: |
| near_pre = int(NONEEGO_NEAR_PRE_S / FRAME_INTERVAL) |
| near_start = max(0, acc - near_pre) |
| near_end = acc |
| else: |
| near_start = near_end = -1 |
|
|
| |
| offsets = [int(i * NONEEGO_STRIDE / max(1, NEG_NUM_OFFSETS - 1)) |
| for i in range(NEG_NUM_OFFSETS)] |
| for off in offsets: |
| we = WINDOW_STD + off |
| while we <= video_cap: |
| self._add_non_ego(vi, we, WINDOW_STD, near=(near_start <= we <= near_end)) |
| we += NONEEGO_STRIDE |
|
|
| |
| if acc is not None and near_end > near_start: |
| we = near_start + WINDOW_STD |
| while we <= near_end: |
| self._add_non_ego(vi, we, WINDOW_STD, near=True) |
| we += NONEEGO_NEAR_STRIDE |
|
|
| def _add_non_ego(self, vi: VideoInfo, window_end: int, window_len: int, |
| near: bool = False) -> None: |
| n = vi.num_frames |
| window_end = max(window_len, min(window_end, n)) |
| if window_end <= 0: |
| return |
|
|
| fi = self._sample_frames(window_end - window_len, window_end) |
| meta = dict(vi.metadata) |
| meta["neg_tag"] = "non_ego_near" if near else "non_ego" |
|
|
| self.samples.append(TTASample( |
| video_id = vi.video_id, |
| source = vi.source, |
| category = "non_ego", |
| source_dir = str(vi.source_dir), |
| frame_indices = fi, |
| hazard_label = 0.0, |
| hazard_weight = W_NON_EGO, |
| tta_label = MAX_TTA, |
| is_ego_positive = False, |
| is_non_ego = True, |
| is_censored = True, |
| accident_frame = None, |
| risky_frame = None, |
| window_end = window_end, |
| window_len = window_len, |
| window_type = "standard", |
| tta_raw = float("inf"), |
| tta_cap = MAX_TTA, |
| difficulty = "hard", |
| metadata = meta, |
| )) |
|
|
| |
|
|
| def _sample_frames(self, start: int, end: int) -> List[int]: |
| start = max(0, start) |
| end = max(start + 1, end) |
| pool = list(range(start, end, max(1, self.frame_sample_rate))) |
| if len(pool) <= self.max_frames: |
| return pool |
| if self.stochastic and self.max_frames >= 3: |
| first = pool[0] |
| last = pool[-1] |
| mid = pool[1:-1] |
| k = self.max_frames - 2 |
| chosen = sorted(random.sample(mid, k=min(k, len(mid)))) |
| return [first] + chosen + [last] |
| else: |
| idx = np.linspace(0, len(pool) - 1, self.max_frames, dtype=int) |
| return [pool[i] for i in idx] |
|
|
| |
|
|
| def _balance(self) -> None: |
| pos = [s for s in self.samples if s.is_ego_positive] |
| non_ego = [s for s in self.samples if s.is_non_ego] |
| neg = [s for s in self.samples if not s.is_ego_positive and not s.is_non_ego] |
|
|
| n_pos = len(pos) |
| if n_pos == 0: |
| return |
|
|
| |
| target_total_neg = int(n_pos * self.neg_pos_ratio) |
|
|
| |
| target_ne = max(len(non_ego), int(target_total_neg * NONEEGO_NEG_FLOOR)) |
| target_neg = max(0, target_total_neg - target_ne) |
|
|
| |
| if len(non_ego) > target_ne: |
| non_ego = random.sample(non_ego, target_ne) |
|
|
| |
| if len(neg) > target_neg: |
| neg = random.sample(neg, target_neg) |
|
|
| self.samples = pos + non_ego + neg |
| random.shuffle(self.samples) |
|
|
| |
|
|
| def _log_stats(self) -> None: |
| n = len(self.samples) |
| cats = defaultdict(int) |
| tags = defaultdict(int) |
| for s in self.samples: |
| cats[s.category] += 1 |
| tags[str(s.metadata.get("neg_tag", ""))] += 1 |
|
|
| n_cens = sum(1 for s in self.samples if s.is_censored) |
| logger.info("=" * 55) |
| logger.info(f"SFTDataset [{self.split}] total={n}") |
| for cat, cnt in sorted(cats.items()): |
| logger.info(f" {cat:20s}: {cnt:6d} ({100*cnt/max(1,n):.1f}%)") |
| logger.info(f" {'censored':20s}: {n_cens:6d}") |
| pos = [s for s in self.samples if s.is_ego_positive and not s.is_censored] |
| if pos: |
| ttas = [s.tta_label for s in pos] |
| logger.info(f" TTA pos: mean={np.mean(ttas):.2f} " |
| f"min={min(ttas):.2f} max={max(ttas):.2f} " |
| f"std={np.std(ttas):.2f}") |
| logger.info(f" neg_tags: { {k: v for k,v in sorted(tags.items()) if k} }") |
| logger.info("=" * 55) |
|
|
| |
|
|
| def __len__(self) -> int: |
| return len(self.samples) |
|
|
| def __getitem__(self, idx: int) -> Dict[str, Any]: |
| s = self.samples[idx] |
| src = Path(s.source_dir) |
|
|
| images: List[Any] = [] |
| for fi in s.frame_indices: |
| img = self._load_frame(src, fi) |
| if img is not None: |
| images.append(img) |
|
|
| if not images: |
| logger.warning(f"No frames loaded for {s.video_id} idx={idx}; using blank.") |
| images = [Image.new("RGB", (384, 384), (64, 64, 64))] |
|
|
| return { |
| "video_id": s.video_id, |
| "source": s.source, |
| "category": s.category, |
| "images": images, |
| "frame_indices": s.frame_indices, |
| "hazard_label": float(s.hazard_label), |
| "hazard_weight": float(s.hazard_weight), |
| "tta_label": float(s.tta_label), |
| "is_ego_positive":bool(s.is_ego_positive), |
| "is_non_ego": bool(s.is_non_ego), |
| "is_censored": bool(s.is_censored), |
| "tta_raw": float(s.tta_raw) if np.isfinite(s.tta_raw) else MAX_TTA, |
| "tta_cap": float(s.tta_cap), |
| "window_type": s.window_type, |
| "difficulty": s.difficulty, |
| "metadata": s.metadata, |
| } |
|
|
| @staticmethod |
| 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 sft_collate_fn(batch: List[Dict[str, Any]]) -> Dict[str, Any]: |
| return { |
| "video_ids": [b["video_id"] for b in batch], |
| "sources": [b["source"] for b in batch], |
| "categories": [b["category"] for b in batch], |
| "images": [b["images"] for b in batch], |
| "frame_indices": [b["frame_indices"] for b in batch], |
| "metadata": [b["metadata"] for b in batch], |
| "window_types": [b["window_type"] for b in batch], |
| "difficulties": [b["difficulty"] for b in batch], |
|
|
| "hazard_labels": torch.tensor([b["hazard_label"] for b in batch], dtype=torch.float32), |
| "hazard_weights": torch.tensor([b["hazard_weight"] for b in batch], dtype=torch.float32), |
| "tta_labels": torch.tensor([b["tta_label"] for b in batch], dtype=torch.float32), |
| "is_ego_positive":torch.tensor([b["is_ego_positive"]for b in batch], dtype=torch.bool), |
| "is_non_ego": torch.tensor([b["is_non_ego"] for b in batch], dtype=torch.bool), |
| "is_censored": torch.tensor([b["is_censored"] for b in batch], dtype=torch.bool), |
| "tta_caps": torch.tensor([b["tta_cap"] for b in batch], dtype=torch.float32), |
| "tta_raws": torch.tensor([b["tta_raw"] for b in batch], dtype=torch.float32), |
| } |
|
|
|
|
| |
|
|
| if __name__ == "__main__": |
| import sys |
| manifest_dir = Path("PROJECT_ROOT/data/sft_manifests") |
| train_manifests = [ |
| manifest_dir / "nexar_train.json", |
| manifest_dir / "dada_pos_train.json", |
| manifest_dir / "dada_noneego_train.json", |
| manifest_dir / "dada_neg_train.json", |
| ] |
| ds = SFTDataset(train_manifests, split="train", debug=True, debug_samples=40) |
| print(f"\nSize: {len(ds)}") |
| item = ds[0] |
| print(f" video_id={item['video_id']} category={item['category']}") |
| print(f" hazard_label={item['hazard_label']} hazard_weight={item['hazard_weight']}") |
| print(f" tta_label={item['tta_label']:.2f} is_censored={item['is_censored']}") |
| print(f" n_images={len(item['images'])}") |
|
|
| loader = DataLoader(ds, batch_size=4, collate_fn=sft_collate_fn, num_workers=0) |
| b = next(iter(loader)) |
| print(f"\nBatch hazard_labels: {b['hazard_labels']}") |
| print(f"Batch tta_labels: {b['tta_labels']}") |
| print(f"Batch is_ego_pos: {b['is_ego_positive']}") |
| print(f"Batch is_censored: {b['is_censored']}") |
|
|