traffic-video-analytics / core /video_source.py
salvirezwan's picture
Download HTTP video URLs to temp file before opening with OpenCV
a1d8324
Raw
History Blame Contribute Delete
6.74 kB
"""Video input abstraction β€” file, webcam, or RTSP stream."""
from __future__ import annotations
import tempfile
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Generator
import cv2
import numpy as np
def _maybe_download(source: str | int) -> tuple[str | int, Path | None]:
"""If source is an http/https URL, download it to a temp file.
Returns (local_path, tmp_path) so the caller can clean up the temp file.
For non-URL sources returns (source, None) unchanged.
"""
if not isinstance(source, str):
return source, None
if not (source.startswith("http://") or source.startswith("https://")):
return source, None
suffix = Path(source.split("?")[0]).suffix or ".mp4"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.close()
print(f"[VideoSource] Downloading {source!r} β†’ {tmp.name}")
urllib.request.urlretrieve(source, tmp.name)
print(f"[VideoSource] Download complete: {Path(tmp.name).stat().st_size // 1024} KB")
return tmp.name, Path(tmp.name)
@dataclass
class FrameMeta:
index: int # absolute frame number since source opened
timestamp: float # wall-clock seconds since source opened
source: str # path or stream URL
class VideoSource:
"""Unified interface for reading frames from file, webcam, or RTSP.
Usage:
with VideoSource("traffic.mp4") as src:
for frame, meta in src.stream(skip=2):
process(frame)
"""
def __init__(
self,
source: str | int,
*,
width: int | None = None,
height: int | None = None,
loop: bool = False,
) -> None:
self.source = source
self._target_w = width
self._target_h = height
self.loop = loop
self._cap: cv2.VideoCapture | None = None
self._frame_idx: int = 0
self._t0: float = 0.0
self._tmp_path: Path | None = None # temp file to delete on close
# ── Context manager ───────────────────────────────────────────────────────
def __enter__(self) -> "VideoSource":
self.open()
return self
def __exit__(self, *_) -> None:
self.close()
# ── Lifecycle ─────────────────────────────────────────────────────────────
def open(self) -> None:
local_source, self._tmp_path = _maybe_download(self.source)
self._cap = cv2.VideoCapture(local_source)
if not self._cap.isOpened():
raise RuntimeError(f"Cannot open video source: {self.source!r}")
self._frame_idx = 0
self._t0 = time.monotonic()
def close(self) -> None:
if self._cap is not None:
self._cap.release()
self._cap = None
if self._tmp_path is not None:
try:
self._tmp_path.unlink(missing_ok=True)
except Exception:
pass
self._tmp_path = None
# ── Properties ────────────────────────────────────────────────────────────
@property
def fps(self) -> float:
if self._cap is None:
return 0.0
return self._cap.get(cv2.CAP_PROP_FPS) or 25.0
@property
def width(self) -> int:
if self._cap is None:
return 0
return int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
@property
def height(self) -> int:
if self._cap is None:
return 0
return int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
@property
def frame_count(self) -> int:
"""Total frames in source (-1 for live streams)."""
if self._cap is None:
return -1
n = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
return n if n > 0 else -1
@property
def is_live(self) -> bool:
"""True for webcam or RTSP streams (no fixed frame count)."""
return isinstance(self.source, int) or str(self.source).startswith("rtsp")
# ── Reading ───────────────────────────────────────────────────────────────
def read(self) -> tuple[np.ndarray | None, FrameMeta]:
"""Read a single frame. Returns (None, meta) on end-of-stream."""
if self._cap is None:
raise RuntimeError("VideoSource not opened. Call open() first.")
ret, frame = self._cap.read()
if not ret:
if self.loop and not self.is_live:
self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
ret, frame = self._cap.read()
if not ret:
return None, self._make_meta()
if frame is not None and (self._target_w or self._target_h):
frame = self._resize(frame)
meta = self._make_meta()
self._frame_idx += 1
return frame, meta
def stream(
self, skip: int = 0
) -> Generator[tuple[np.ndarray, FrameMeta], None, None]:
"""Yield (frame, meta) continuously. skip=N processes every (N+1)th frame."""
if self._cap is None:
raise RuntimeError("VideoSource not opened. Call open() first.")
while True:
frame, meta = self.read()
if frame is None:
break
if skip > 0 and (meta.index % (skip + 1)) != 0:
continue
yield frame, meta
# ── Helpers ───────────────────────────────────────────────────────────────
def _make_meta(self) -> FrameMeta:
return FrameMeta(
index=self._frame_idx,
timestamp=time.monotonic() - self._t0,
source=str(self.source),
)
def _resize(self, frame: np.ndarray) -> np.ndarray:
h, w = frame.shape[:2]
target_w = self._target_w or w
target_h = self._target_h or h
if (w, h) == (target_w, target_h):
return frame
return cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
def __repr__(self) -> str:
return (
f"VideoSource(source={self.source!r}, "
f"{self.width}x{self.height} @ {self.fps:.1f}fps)"
)