""" tests/test_fingerprinting.py — Unit tests for src/cv/fingerprinting.py Tests run WITHOUT requiring real video files. """ from __future__ import annotations import numpy as np import pytest from src.cv.fingerprinting import ( text_safe_crop, extract_hs_histograms, compare_histograms, hist_to_bytes, bytes_to_hist, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def solid_blue_frame() -> np.ndarray: """256×256 solid blue BGR frame.""" frame = np.zeros((256, 256, 3), dtype=np.uint8) frame[:, :] = (255, 0, 0) # BGR blue return frame @pytest.fixture def solid_red_frame() -> np.ndarray: """256×256 solid red BGR frame.""" frame = np.zeros((256, 256, 3), dtype=np.uint8) frame[:, :] = (0, 0, 255) # BGR red return frame # --------------------------------------------------------------------------- # text_safe_crop # --------------------------------------------------------------------------- class TestTextSafeCrop: def test_removes_correct_rows(self, solid_blue_frame: np.ndarray) -> None: cropped = text_safe_crop(solid_blue_frame, crop_top=0.15, crop_bottom=0.30) h = solid_blue_frame.shape[0] # 256 expected_h = int(h * (1.0 - 0.30)) - int(h * 0.15) assert cropped.shape[0] == expected_h def test_zero_crop_returns_same_size(self, solid_blue_frame: np.ndarray) -> None: cropped = text_safe_crop(solid_blue_frame, crop_top=0.0, crop_bottom=0.0) assert cropped.shape == solid_blue_frame.shape def test_invalid_top_raises(self, solid_blue_frame: np.ndarray) -> None: with pytest.raises(ValueError, match="crop_top"): text_safe_crop(solid_blue_frame, crop_top=1.0, crop_bottom=0.0) def test_invalid_bottom_raises(self, solid_blue_frame: np.ndarray) -> None: with pytest.raises(ValueError, match="crop_bottom"): text_safe_crop(solid_blue_frame, crop_top=0.0, crop_bottom=-0.1) def test_overlapping_crops_raise(self, solid_blue_frame: np.ndarray) -> None: with pytest.raises(ValueError, match="must be < 1.0"): text_safe_crop(solid_blue_frame, crop_top=0.6, crop_bottom=0.5) # --------------------------------------------------------------------------- # Histograms # --------------------------------------------------------------------------- class TestHistograms: def test_output_shape(self, solid_blue_frame: np.ndarray) -> None: luma, sat = extract_hs_histograms(solid_blue_frame, bins_hue=50, bins_sat=60) assert luma.shape == (50,) assert sat.shape == (60,) def test_normalised(self, solid_blue_frame: np.ndarray) -> None: import numpy as np luma, sat = extract_hs_histograms(solid_blue_frame, bins_hue=50, bins_sat=60) # L2-normalised → norm ≈ 1.0 assert np.linalg.norm(luma) == pytest.approx(1.0, abs=1e-5) assert np.linalg.norm(sat) == pytest.approx(1.0, abs=1e-5) def test_same_frame_correl_is_one(self, solid_blue_frame: np.ndarray) -> None: import cv2 luma, _ = extract_hs_histograms(solid_blue_frame, bins_hue=50, bins_sat=60) score = compare_histograms(luma, luma, method=cv2.HISTCMP_CORREL) assert score == pytest.approx(1.0, abs=1e-5) def test_different_frames_correl_lower( self, solid_blue_frame: np.ndarray, solid_red_frame: np.ndarray, ) -> None: import cv2 luma_b, _ = extract_hs_histograms(solid_blue_frame, 50, 60) luma_r, _ = extract_hs_histograms(solid_red_frame, 50, 60) score = compare_histograms(luma_b, luma_r, method=cv2.HISTCMP_CORREL) assert score < 1.0 # --------------------------------------------------------------------------- # Serialisation round-trip # --------------------------------------------------------------------------- class TestSerialisation: def test_round_trip(self, solid_blue_frame: np.ndarray) -> None: luma, _ = extract_hs_histograms(solid_blue_frame, 50, 60) restored = bytes_to_hist(hist_to_bytes(luma)) np.testing.assert_array_almost_equal(luma, restored)