Initial project import
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
tests/test_deep_scan.py — Unit tests for frame_extractor and deep_scan
|
||||
|
||||
Uses synthetic in-memory videos (cv2.VideoWriter → temp file) so no real
|
||||
video files are required. Tests cover the pure logic, not hardware decoding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.cv.frame_extractor import (
|
||||
get_video_info,
|
||||
grab_frame_at,
|
||||
iter_frames_stepped,
|
||||
open_video,
|
||||
)
|
||||
from src.cv.fingerprinting import text_safe_crop
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: build a tiny synthetic video on disk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FPS = 24
|
||||
WIDTH = 320
|
||||
HEIGHT = 240
|
||||
SECS = 3
|
||||
|
||||
|
||||
def _make_synthetic_video(path: Path, color_bgr: tuple[int, int, int] = (0, 128, 255)) -> Path:
|
||||
"""Write a 3-second single-colour video to *path*."""
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
writer = cv2.VideoWriter(str(path), fourcc, float(FPS), (WIDTH, HEIGHT))
|
||||
frame = np.full((HEIGHT, WIDTH, 3), color_bgr, dtype=np.uint8)
|
||||
for _ in range(FPS * SECS):
|
||||
writer.write(frame)
|
||||
writer.release()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def synthetic_video(tmp_path: Path) -> Path:
|
||||
return _make_synthetic_video(tmp_path / "test.mp4")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_video
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOpenVideo:
|
||||
def test_opens_valid_file(self, synthetic_video: Path) -> None:
|
||||
with open_video(synthetic_video) as cap:
|
||||
assert cap.isOpened()
|
||||
|
||||
def test_raises_on_missing_file(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
with open_video(tmp_path / "ghost.mp4"):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_video_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetVideoInfo:
|
||||
def test_returns_correct_fps(self, synthetic_video: Path) -> None:
|
||||
info = get_video_info(synthetic_video)
|
||||
assert info["fps"] == pytest.approx(FPS, rel=0.05)
|
||||
|
||||
def test_duration_approx(self, synthetic_video: Path) -> None:
|
||||
info = get_video_info(synthetic_video)
|
||||
assert info["duration_s"] == pytest.approx(SECS, rel=0.1)
|
||||
|
||||
def test_resolution(self, synthetic_video: Path) -> None:
|
||||
info = get_video_info(synthetic_video)
|
||||
assert info["width"] == WIDTH
|
||||
assert info["height"] == HEIGHT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# grab_frame_at
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGrabFrameAt:
|
||||
def test_returns_ndarray(self, synthetic_video: Path) -> None:
|
||||
with open_video(synthetic_video) as cap:
|
||||
frame = grab_frame_at(cap, 1.0)
|
||||
assert frame is not None
|
||||
assert isinstance(frame, np.ndarray)
|
||||
assert frame.shape == (HEIGHT, WIDTH, 3)
|
||||
|
||||
def test_returns_none_past_end(self, synthetic_video: Path) -> None:
|
||||
with open_video(synthetic_video) as cap:
|
||||
frame = grab_frame_at(cap, 9999.0)
|
||||
# May return None or a repeated last frame depending on codec;
|
||||
# we only assert no exception is raised.
|
||||
assert frame is None or isinstance(frame, np.ndarray)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iter_frames_stepped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIterFramesStepped:
|
||||
def test_yields_correct_count(self, synthetic_video: Path) -> None:
|
||||
with open_video(synthetic_video) as cap:
|
||||
frames = list(iter_frames_stepped(cap, 0.0, 1.0, 0.5))
|
||||
# Expect timestamps: 0.0, 0.5, 1.0 → 3 frames
|
||||
assert len(frames) == 3
|
||||
|
||||
def test_timestamps_increasing(self, synthetic_video: Path) -> None:
|
||||
with open_video(synthetic_video) as cap:
|
||||
frames = list(iter_frames_stepped(cap, 0.0, 2.0, 0.5))
|
||||
timestamps = [t for t, _ in frames]
|
||||
assert timestamps == sorted(timestamps)
|
||||
|
||||
def test_invalid_step_raises(self, synthetic_video: Path) -> None:
|
||||
with open_video(synthetic_video) as cap:
|
||||
with pytest.raises(ValueError, match="step_s"):
|
||||
list(iter_frames_stepped(cap, 0.0, 1.0, 0.0))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# text_safe_crop integration (sanity: cropped height consistent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCropSanity:
|
||||
def test_crop_reduces_height(self, synthetic_video: Path) -> None:
|
||||
with open_video(synthetic_video) as cap:
|
||||
frame = grab_frame_at(cap, 0.5)
|
||||
assert frame is not None
|
||||
cropped = text_safe_crop(frame, 0.15, 0.30)
|
||||
assert cropped.shape[0] < frame.shape[0]
|
||||
assert cropped.shape[1] == frame.shape[1] # width unchanged
|
||||
Reference in New Issue
Block a user