| |
| """ |
| MViT video dataset for Nexar collision prediction. |
| |
| Loads raw .mp4 clips and returns [C, T, H, W] tensors for MViT-v2-s. |
| |
| Design choices: |
| - T=16 frames (MViT-v2-s default, matches Kinetics pretraining) |
| - H=W=224 (ImageNet-normalised) |
| - For TRAIN positive videos: sample from 10s window ending at TTE before event |
| - For TRAIN negative videos: sample from the last 10s of the video |
| - For TEST clips: sample from the entire clip (already ~10s) |
| |
| Data-centric filtering (key insight from 1st-place winner): |
| - Use time_of_event - time_of_alert as "clarity score" |
| - Remove positives where the warning is very sudden (< min_warning_s = 0.5s) |
| because they look like normal driving until the very last moment |
| - Keep all negatives and "clear" positives |
| """ |
| from __future__ import annotations |
|
|
| import logging |
| import random |
| from pathlib import Path |
| from typing import List, Optional, Tuple |
|
|
| import cv2 |
| import numpy as np |
| import pandas as pd |
| import torch |
| from torch.utils.data import Dataset |
| from torchvision.transforms import v2 as T |
|
|
| logger = logging.getLogger("Nexar.mvit_dataset") |
|
|
| |
| N_FRAMES = 16 |
| IMG_SIZE = 224 |
| CLIP_DUR_S = 10.0 |
| TTE_LIST = [0.5, 1.0, 1.5] |
|
|
| MEAN = [0.45, 0.45, 0.45] |
| STD = [0.225, 0.225, 0.225] |
|
|
|
|
| def _get_video_info(path: str) -> Tuple[float, int]: |
| """Returns (fps, n_frames).""" |
| try: |
| import decord |
| decord.bridge.set_bridge("native") |
| vr = decord.VideoReader(path) |
| return vr.get_avg_fps(), len(vr) |
| except Exception: |
| cap = cv2.VideoCapture(path) |
| fps = cap.get(cv2.CAP_PROP_FPS) |
| n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
| cap.release() |
| return fps, n |
|
|
|
|
| def _sample_frames( |
| path: str, |
| start_s: float, |
| end_s: float, |
| n_frames: int = N_FRAMES, |
| img_size: int = IMG_SIZE, |
| ) -> Optional[torch.Tensor]: |
| """ |
| Load n_frames uniformly from [start_s, end_s] of the video. |
| Returns FloatTensor [C, T, H, W] in [0, 1], or None on failure. |
| """ |
| try: |
| import decord |
| decord.bridge.set_bridge("native") |
| vr = decord.VideoReader(path, width=img_size, height=img_size) |
| fps = vr.get_avg_fps() |
| n = len(vr) |
|
|
| ts = [start_s + (end_s - start_s) * i / (n_frames - 1) for i in range(n_frames)] |
| indices = [max(0, min(int(t * fps), n - 1)) for t in ts] |
|
|
| frames = vr.get_batch(indices).asnumpy() |
| |
| t_tensor = torch.from_numpy(frames).permute(0, 3, 1, 2).float() / 255.0 |
| return t_tensor.permute(1, 0, 2, 3) |
| except Exception as e: |
| logger.warning(f"Frame sample failed for {path}: {e}") |
| return None |
|
|
|
|
| def _normalize_video(video: torch.Tensor) -> torch.Tensor: |
| """Normalize [C, T, H, W] video with ImageNet stats.""" |
| mean = torch.tensor(MEAN, dtype=video.dtype).view(3, 1, 1, 1) |
| std = torch.tensor(STD, dtype=video.dtype).view(3, 1, 1, 1) |
| return (video - mean) / std |
|
|
|
|
| def _hflip_video(video: torch.Tensor) -> torch.Tensor: |
| """Horizontally flip [C, T, H, W] video.""" |
| return video.flip(-1) |
|
|
|
|
| class VideoTransform: |
| """Simple video transform that handles [C, T, H, W] tensors.""" |
| def __init__(self, train: bool = True): |
| self.train = train |
|
|
| def __call__(self, video: torch.Tensor) -> torch.Tensor: |
| video = _normalize_video(video) |
| if self.train: |
| if torch.rand(1).item() > 0.5: |
| video = _hflip_video(video) |
| |
| factor = 1.0 + (torch.rand(1).item() - 0.5) * 0.2 |
| video = (video * factor).clamp(-5, 5) |
| return video |
|
|
|
|
| def _make_train_transforms(img_size: int = IMG_SIZE) -> VideoTransform: |
| return VideoTransform(train=True) |
|
|
|
|
| def _make_val_transforms() -> VideoTransform: |
| return VideoTransform(train=False) |
|
|
|
|
| class NexarMViTDataset(Dataset): |
| """ |
| Dataset for MViT fine-tuning on Nexar collision prediction. |
| |
| train_mode=True → creates multiple clips per positive video (one per TTE) |
| and augments randomly |
| train_mode=False → creates one clip per video (test or val) |
| """ |
|
|
| def __init__( |
| self, |
| csv_path: str, |
| video_dir: str, |
| train_mode: bool = True, |
| pos_subdir: str = "", |
| neg_subdir: str = "", |
| min_warning_s: float = 0.3, |
| tte_list: List[float] = TTE_LIST, |
| n_frames: int = N_FRAMES, |
| img_size: int = IMG_SIZE, |
| clip_dur_s: float = CLIP_DUR_S, |
| is_test: bool = False, |
| ): |
| self.train_mode = train_mode |
| self.n_frames = n_frames |
| self.img_size = img_size |
| self.clip_dur_s = clip_dur_s |
| self.is_test = is_test |
| self.tfm = _make_train_transforms(img_size) if train_mode else _make_val_transforms() |
|
|
| df = pd.read_csv(csv_path) |
| video_dir = Path(video_dir) |
|
|
| self.samples: List[dict] = [] |
|
|
| if is_test: |
| |
| for _, row in df.iterrows(): |
| vid_id = str(int(float(row["id"]))).zfill(5) |
| vid_path = video_dir / f"{vid_id}.mp4" |
| if not vid_path.exists(): |
| continue |
| self.samples.append({ |
| "vid_id": vid_id, |
| "path": str(vid_path), |
| "label": -1, |
| "start_s": 0.0, |
| "end_s": -1.0, |
| }) |
| else: |
| for _, row in df.iterrows(): |
| vid_id = str(int(float(row["id"]))).zfill(5) |
| label = int(row["target"]) |
| t_event = float(row["time_of_event"]) if pd.notna(row.get("time_of_event")) else None |
| t_alert = float(row["time_of_alert"]) if pd.notna(row.get("time_of_alert")) else None |
|
|
| |
| if pos_subdir and label == 1: |
| vid_path = video_dir / pos_subdir / f"{vid_id}.mp4" |
| elif neg_subdir and label == 0: |
| vid_path = video_dir / neg_subdir / f"{vid_id}.mp4" |
| else: |
| vid_path = video_dir / f"{vid_id}.mp4" |
|
|
| if not vid_path.exists(): |
| continue |
|
|
| if label == 1 and t_event is not None: |
| |
| if t_alert is not None: |
| warning_s = t_event - t_alert |
| if warning_s < min_warning_s: |
| continue |
|
|
| if train_mode: |
| |
| for tte in tte_list: |
| end_s = t_event - tte |
| start_s = max(0.0, end_s - clip_dur_s) |
| self.samples.append({ |
| "vid_id": vid_id, |
| "path": str(vid_path), |
| "label": 1, |
| "start_s": start_s, |
| "end_s": end_s, |
| }) |
| else: |
| |
| end_s = t_event - 0.5 |
| start_s = max(0.0, end_s - clip_dur_s) |
| self.samples.append({ |
| "vid_id": vid_id, |
| "path": str(vid_path), |
| "label": 1, |
| "start_s": start_s, |
| "end_s": end_s, |
| }) |
| else: |
| |
| self.samples.append({ |
| "vid_id": vid_id, |
| "path": str(vid_path), |
| "label": 0, |
| "start_s": -1.0, |
| "end_s": -1.0, |
| }) |
|
|
| n_pos = sum(1 for s in self.samples if s["label"] == 1) |
| n_neg = sum(1 for s in self.samples if s["label"] == 0) |
| logger.info( |
| f"NexarMViTDataset [train={train_mode}, test={is_test}]: " |
| f"{len(self.samples)} samples pos={n_pos} neg={n_neg}" |
| ) |
|
|
| def __len__(self) -> int: |
| return len(self.samples) |
|
|
| def __getitem__(self, idx: int) -> dict: |
| s = self.samples[idx] |
|
|
| |
| start_s, end_s = s["start_s"], s["end_s"] |
| if start_s < 0 or end_s < 0: |
| fps, n_total = _get_video_info(s["path"]) |
| if fps <= 0: |
| fps = 30.0 |
| duration = n_total / fps |
| end_s = duration |
| start_s = max(0.0, duration - self.clip_dur_s) |
|
|
| frames = _sample_frames(s["path"], start_s, end_s, self.n_frames, self.img_size) |
| if frames is None: |
| frames = torch.zeros(3, self.n_frames, self.img_size, self.img_size) |
|
|
| frames = self.tfm(frames) |
|
|
| return { |
| "video": frames, |
| "label": torch.tensor(s["label"], dtype=torch.float32), |
| "vid_id": s["vid_id"], |
| } |
|
|
|
|
| def make_train_val_split( |
| full_csv: str, |
| val_frac: float = 0.15, |
| seed: int = 42, |
| min_warning_s: float = 0.3, |
| ) -> Tuple[pd.DataFrame, pd.DataFrame]: |
| """Stratified split returning (train_df, val_df) DataFrames.""" |
| df = pd.read_csv(full_csv) |
|
|
| |
| pos_df = df[df["target"] == 1].copy() |
| neg_df = df[df["target"] == 0].copy() |
|
|
| if "time_of_event" in df.columns and "time_of_alert" in df.columns: |
| mask = pos_df["time_of_event"].notna() & pos_df["time_of_alert"].notna() |
| pos_df.loc[mask, "warning_s"] = pos_df.loc[mask, "time_of_event"] - pos_df.loc[mask, "time_of_alert"] |
| else: |
| pos_df["warning_s"] = float("nan") |
|
|
| rng = random.Random(seed) |
| pos_idx = pos_df.index.tolist() |
| neg_idx = neg_df.index.tolist() |
| rng.shuffle(pos_idx) |
| rng.shuffle(neg_idx) |
|
|
| n_val_pos = max(1, int(len(pos_idx) * val_frac)) |
| n_val_neg = max(1, int(len(neg_idx) * val_frac)) |
|
|
| val_idx = pos_idx[:n_val_pos] + neg_idx[:n_val_neg] |
| train_idx = pos_idx[n_val_pos:] + neg_idx[n_val_neg:] |
|
|
| return df.loc[train_idx].reset_index(drop=True), df.loc[val_idx].reset_index(drop=True) |
|
|