|
15 | 15 | import os |
16 | 16 | import typing as ty |
17 | 17 |
|
| 18 | +import cv2 |
| 19 | +import numpy as np |
18 | 20 | import pytest |
19 | 21 |
|
20 | 22 | from scenedetect import SceneManager, open_video |
21 | | -from scenedetect.common import FrameTimecode, Timecode |
| 23 | +from scenedetect.common import Timecode |
22 | 24 | from scenedetect.detectors import ContentDetector |
| 25 | +from scenedetect.output import save_images |
23 | 26 | from scenedetect.stats_manager import StatsManager |
24 | 27 |
|
25 | 28 | # Expected scene cuts for `goldeneye-vfr.mp4` detected with ContentDetector() and end_time=10.0s. |
@@ -203,3 +206,44 @@ def test_cfr_frame_num_exact(self, test_movie_clip: str): |
203 | 206 | for expected_frame in range(1, 11): |
204 | 207 | assert video.read() is not False |
205 | 208 | assert video.position.frame_num == expected_frame - 1 |
| 209 | + |
| 210 | + def test_vfr_save_images_opencv_matches_pyav(self, test_vfr_video: str, tmp_path): |
| 211 | + """OpenCV save-images thumbnails should match PyAV thumbnails for all scenes. |
| 212 | +
|
| 213 | + If the OpenCV seek off-by-one bug is present, scene thumbnails will show content from the |
| 214 | + wrong scene; MSE against PyAV (ground truth) will be very high for those scenes. |
| 215 | + """ |
| 216 | + # Run save-images for both backends with 1 image per scene for simplicity. |
| 217 | + scene_lists = {} |
| 218 | + for backend in ("pyav", "opencv"): |
| 219 | + out_dir = tmp_path / backend |
| 220 | + out_dir.mkdir() |
| 221 | + video = open_video(test_vfr_video, backend=backend) |
| 222 | + sm = SceneManager() |
| 223 | + sm.add_detector(ContentDetector()) |
| 224 | + sm.detect_scenes(video=video) |
| 225 | + scene_lists[backend] = sm.get_scene_list() |
| 226 | + assert len(scene_lists[backend]) > 0 |
| 227 | + save_images(scene_lists[backend], video, num_images=1, output_dir=str(out_dir)) |
| 228 | + |
| 229 | + pyav_imgs = sorted((tmp_path / "pyav").glob("*.jpg")) |
| 230 | + opencv_imgs = sorted((tmp_path / "opencv").glob("*.jpg")) |
| 231 | + assert len(pyav_imgs) > 0 |
| 232 | + assert len(pyav_imgs) == len(opencv_imgs), ( |
| 233 | + f"Image count mismatch: pyav={len(pyav_imgs)}, opencv={len(opencv_imgs)}" |
| 234 | + ) |
| 235 | + |
| 236 | + # Compare every corresponding thumbnail. Wrong-scene content produces very high MSE. |
| 237 | + MAX_MSE = 5000 |
| 238 | + for pyav_path, opencv_path in zip(pyav_imgs, opencv_imgs, strict=False): |
| 239 | + img_pyav = cv2.imread(str(pyav_path)) |
| 240 | + img_opencv = cv2.imread(str(opencv_path)) |
| 241 | + assert img_pyav is not None, f"Failed to load {pyav_path}" |
| 242 | + assert img_opencv is not None, f"Failed to load {opencv_path}" |
| 243 | + if img_pyav.shape != img_opencv.shape: |
| 244 | + # Resize opencv image to match pyav dimensions before comparing. |
| 245 | + img_opencv = cv2.resize(img_opencv, (img_pyav.shape[1], img_pyav.shape[0])) |
| 246 | + mse = float(np.mean((img_pyav.astype(np.float32) - img_opencv.astype(np.float32)) ** 2)) |
| 247 | + assert mse < MAX_MSE, ( |
| 248 | + f"Thumbnail mismatch for {pyav_path.name} vs {opencv_path.name}: MSE={mse:.0f}" |
| 249 | + ) |
0 commit comments