From 2710a1ca548f5f94e0608f18b9ac0004e45dca7d Mon Sep 17 00:00:00 2001 From: Marcel Schmutz Date: Mon, 4 May 2026 10:42:35 +0200 Subject: [PATCH 1/6] Small improvements to pylon source class --- pyproject.toml | 2 +- .../media/video/pyav_webcam_video_source.py | 467 ++++++++++++++++++ src/py3r/media/video/pylon_camera_source.py | 37 +- 3 files changed, 490 insertions(+), 16 deletions(-) create mode 100644 src/py3r/media/video/pyav_webcam_video_source.py diff --git a/pyproject.toml b/pyproject.toml index 08d0cb8..64f83cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py3r_media" -version = "0.3.1" +version = "0.4.0" authors = [ { name="Marcel Schmutz", email="mschmut@ethz.ch" }, ] diff --git a/src/py3r/media/video/pyav_webcam_video_source.py b/src/py3r/media/video/pyav_webcam_video_source.py new file mode 100644 index 0000000..9ff860f --- /dev/null +++ b/src/py3r/media/video/pyav_webcam_video_source.py @@ -0,0 +1,467 @@ +import re +import subprocess +import threading +import time +from collections import deque +from typing import Optional, Tuple, List, Deque + +import av + +from py3r.media.types import VideoFrame +from py3r.media.video import VideoSource + + +class PyAVWebcamSource(VideoSource): + """ + Windows / DirectShow webcam source via PyAV. + + Capture and decode are done by PyAV (FFmpeg bindings). Device enumeration + helpers are kept subprocess-based for convenience, matching the original + class behavior. + + Use either: + - device_name="Integrated Camera" + or: + - device_index=0 + + Notes + ----- + - device_index is based on the order returned by ffmpeg dshow enumeration, + just like your original class. + - read(timeout=...) works via a background decode thread and a queue, + rather than trying to interrupt a blocking decode call directly. + - timestamps prefer frame/container timing when available; otherwise they + fall back to time.perf_counter(). + """ + + ffmpeg_executable = "ffmpeg" + + def __init__( + self, + device_name: Optional[str] = None, + device_index: Optional[int] = None, + grayscale: bool = True, + width: Optional[int] = None, + height: Optional[int] = None, + fps: Optional[float] = None, + loglevel: str = "error", + queue_size: int = 8, + ): + if device_name is None and device_index is None: + device_index = 0 + if device_name is not None and device_index is not None: + raise ValueError("Specify either device_name or device_index, not both.") + + self._device_name = device_name + self._device_index = device_index + self._device_number = 0 # duplicate-name selector for dshow + + self._grayscale = grayscale + self._requested_width = width + self._requested_height = height + self._requested_fps = fps + self._loglevel = loglevel + self._queue_size = max(1, int(queue_size)) + + self._idx = 0 + self._size: Optional[Tuple[int, int]] = None + self._fps: Optional[float] = fps + self._channels = 1 if grayscale else 3 + + self._container: Optional[av.container.InputContainer] = None + self._stream = None + + self._reader_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + self._cond = threading.Condition() + self._queue: Deque[VideoFrame] = deque() + self._reader_error: Optional[BaseException] = None + self._reader_eof = False + + self._resolve_device() + self._probe() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def open(self) -> None: + if self.is_open(): + return + + self._idx = 0 + self._reader_error = None + self._reader_eof = False + self._queue.clear() + self._stop_event.clear() + + container = self._open_container() + stream = self._select_video_stream(container) + + self._container = container + self._stream = stream + + # Re-probe from the opened stream in case metadata is now available. + self._update_size_and_fps_from_stream(stream) + + self._reader_thread = threading.Thread( + target=self._reader_loop, + name="pyav-webcam-reader", + daemon=True, + ) + self._reader_thread.start() + + def close(self) -> None: + self._stop_event.set() + + container = self._container + self._container = None + self._stream = None + + # Closing the container should unblock decode/demux on most paths. + if container is not None: + try: + container.close() + except Exception: + pass + + t = self._reader_thread + self._reader_thread = None + if t is not None and t is not threading.current_thread(): + t.join(timeout=2.0) + + with self._cond: + self._queue.clear() + self._reader_eof = True + self._cond.notify_all() + + def is_open(self) -> bool: + t = self._reader_thread + return t is not None and t.is_alive() and not self._stop_event.is_set() + + def has_timing(self) -> bool: + return True + + def has_size(self) -> bool: + return self._size is not None + + def has_fps(self) -> bool: + return self._fps is not None + + def has_num_frames(self) -> bool: + return False + + def is_seekable(self) -> bool: + return False + + def get_size(self) -> Optional[Tuple[int, int]]: + return self._size + + def get_fps(self) -> Optional[float]: + return self._fps + + def get_num_channels(self) -> int: + return self._channels + + def get_num_frames(self) -> Optional[int]: + return None + + def seek(self, frame_index: int) -> None: + # live source; no-op + pass + + def read(self, timeout: Optional[float] = None) -> Optional[VideoFrame]: + deadline = None if timeout is None else (time.perf_counter() + timeout) + + with self._cond: + while True: + if self._queue: + return self._queue.popleft() + + if self._reader_error is not None: + raise RuntimeError("PyAV webcam reader failed") from self._reader_error + + if self._reader_eof: + return None + + if deadline is None: + self._cond.wait() + continue + + remaining = deadline - time.perf_counter() + if remaining <= 0: + raise TimeoutError("Timeout reading frame") + + self._cond.wait(timeout=remaining) + + # ------------------------------------------------------------------ + # Device enumeration helpers + # ------------------------------------------------------------------ + + @classmethod + def list_video_devices(cls) -> List[str]: + """ + Returns video device names in the order ffmpeg lists them. + + Duplicate names are returned multiple times. + """ + cmd = [ + str(cls.ffmpeg_executable), + "-hide_banner", + "-list_devices", "true", + "-f", "dshow", + "-i", "dummy", + ] + + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + + text = proc.stderr + devices = [] + + for line in text.splitlines(): + if not line.endswith("(video)"): + continue + m = re.search(r'"([^"]+)"', line) + if m: + devices.append(m.group(1)) + + return devices + + @classmethod + def list_video_device_entries(cls) -> List[Tuple[int, str, int]]: + """ + Returns [(global_index, device_name, video_device_number), ...]. + + video_device_number is the dshow duplicate-name selector. + """ + names = cls.list_video_devices() + counts = {} + out = [] + for i, name in enumerate(names): + n = counts.get(name, 0) + out.append((i, name, n)) + counts[name] = n + 1 + return out + + def _resolve_device(self) -> None: + if self._device_name is not None: + self._device_number = 0 + return + + entries = self.list_video_device_entries() + if not entries: + raise RuntimeError("No DirectShow video devices found") + + idx = int(self._device_index) + if idx < 0 or idx >= len(entries): + raise IndexError( + f"device_index {idx} out of range; found {len(entries)} video device(s)" + ) + + _, name, devnum = entries[idx] + self._device_name = name + self._device_number = devnum + + # ------------------------------------------------------------------ + # Probe + # ------------------------------------------------------------------ + + def _probe(self) -> None: + """ + Try to open the device briefly and inspect the video stream metadata. + + If width/height were explicitly requested, trust them as the expected + output size. Otherwise use stream metadata when available. + """ + if self._requested_width is not None and self._requested_height is not None: + self._size = (self._requested_width, self._requested_height) + else: + self._size = None + + self._fps = self._requested_fps + + try: + container = self._open_container() + try: + stream = self._select_video_stream(container) + self._update_size_and_fps_from_stream(stream) + + # If size is still unknown, decode one frame to force discovery. + if self._size is None: + for frame in container.decode(stream): + self._size = (int(frame.width), int(frame.height)) + break + finally: + container.close() + except Exception: + # Keep probing soft: caller can still open later and discover size there. + pass + + def _update_size_and_fps_from_stream(self, stream) -> None: + if self._size is None: + w = getattr(stream.codec_context, "width", 0) or getattr(stream, "width", 0) + h = getattr(stream.codec_context, "height", 0) or getattr(stream, "height", 0) + if w and h: + self._size = (int(w), int(h)) + + if self._fps is None: + rate = None + for attr in ("average_rate", "base_rate", "guessed_rate"): + try: + rate = getattr(stream, attr, None) + except Exception: + rate = None + if rate: + break + + if rate: + try: + self._fps = float(rate) + except Exception: + pass + + # ------------------------------------------------------------------ + # PyAV container / stream handling + # ------------------------------------------------------------------ + + def _build_dshow_options(self) -> dict: + opts = {'rtbufsize': '500M'} + + if self._requested_fps is not None: + # FFmpeg dshow private option + opts["framerate"] = str(self._requested_fps) + + if self._requested_width is not None and self._requested_height is not None: + # FFmpeg dshow private option + opts["video_size"] = f"{self._requested_width}x{self._requested_height}" + + if self._device_number: + # FFmpeg dshow private option for duplicate names + opts["video_device_number"] = str(self._device_number) + + return opts + + def _open_container(self) -> av.container.InputContainer: + if self._device_name is None: + raise RuntimeError("Device name was not resolved") + + # PyAV's av.open accepts input format and options, which are passed through + # to FFmpeg/libavformat. + return av.open( + file=f"video={self._device_name}", + format="dshow", + mode="r", + options=self._build_dshow_options(), + ) + + @staticmethod + def _select_video_stream(container: av.container.InputContainer): + video_streams = [s for s in container.streams if s.type == "video"] + if not video_streams: + raise RuntimeError("No video stream found from DirectShow device") + stream = video_streams[0] + + # Let FFmpeg/PyAV use frame threading when available. + try: + stream.thread_type = "AUTO" + except Exception: + pass + + return stream + + # ------------------------------------------------------------------ + # Reader thread + # ------------------------------------------------------------------ + + def _reader_loop(self) -> None: + container = self._container + stream = self._stream + + try: + if container is None or stream is None: + raise RuntimeError("Reader started without an open container") + + target_format = "gray" if self._grayscale else "bgr24" + + for frame in container.decode(stream): + if self._stop_event.is_set(): + break + + arr = frame.to_ndarray(format=target_format) + + if self._grayscale: + # Usually already (h, w), but normalize defensively. + if arr.ndim == 3 and arr.shape[-1] == 1: + arr = arr[..., 0] + else: + # Should already be (h, w, 3) + if arr.ndim != 3 or arr.shape[-1] != 3: + raise RuntimeError( + f"Unexpected color frame shape from PyAV: {arr.shape}" + ) + + # Update size if it was unknown initially. + if self._size is None: + if self._grayscale: + self._size = (int(arr.shape[1]), int(arr.shape[0])) + else: + self._size = (int(arr.shape[1]), int(arr.shape[0])) + + ts = self._frame_timestamp_seconds(frame) + + vf = VideoFrame(arr.copy(), self._idx, ts) + self._idx += 1 + + with self._cond: + # Keep only the newest frames if consumer is slower than source. + while len(self._queue) >= self._queue_size: + self._queue.popleft() + self._queue.append(vf) + self._cond.notify() + + except Exception as exc: + with self._cond: + self._reader_error = exc + self._reader_eof = True + self._cond.notify_all() + return + + with self._cond: + self._reader_eof = True + self._cond.notify_all() + + @staticmethod + def _frame_timestamp_seconds(frame) -> float: + """ + Prefer source/container-derived timing if present. + + PyAV frames may expose: + - frame.time + - frame.pts + frame.time_base + + Fallback is a monotonic clock sample at delivery time. + """ + try: + t = getattr(frame, "time", None) + if t is not None: + return float(t) + except Exception: + pass + + try: + pts = getattr(frame, "pts", None) + tb = getattr(frame, "time_base", None) + if pts is not None and tb is not None: + return float(pts * tb) + except Exception: + pass + + return time.perf_counter() diff --git a/src/py3r/media/video/pylon_camera_source.py b/src/py3r/media/video/pylon_camera_source.py index a79de37..8e1c029 100644 --- a/src/py3r/media/video/pylon_camera_source.py +++ b/src/py3r/media/video/pylon_camera_source.py @@ -26,6 +26,9 @@ def __init__(self, serial: str, config_file: Optional[Path] = None): self._fps = None self._gray = False + self._tick_frequency = 125_000_000 + self._has_hw_timestamp = False + self._probe() def open(self) -> None: @@ -79,7 +82,7 @@ def read(self, timeout: Optional[float] = None) -> VideoFrame: if not self._cam or not self._cam.IsGrabbing(): raise RuntimeError("Camera is not open or has stopped grabbing") - grab_timeout_ms = int((timeout or 0.5) * 1000) + grab_timeout_ms = 500 if timeout is None else int(timeout * 1000) # Use TimeoutHandling_Return so the SDK gives us back a None/invalid # result on timeout rather than raising its own exception, which lets @@ -101,8 +104,8 @@ def read(self, timeout: Optional[float] = None) -> VideoFrame: img = result.Array # numpy view — copy before Release img = img.copy() - ts_device_ns = getattr(result, "TimeStamp", None) - ts = (ts_device_ns / 125000000) if ts_device_ns else time.perf_counter() + ts_device_ns = result.TimeStamp if self._has_hw_timestamp else None + ts = (ts_device_ns / self._tick_frequency) if ts_device_ns is not None else time.perf_counter() # type: ignore[operator] result.Release() f = VideoFrame(img, self._idx, ts) @@ -137,18 +140,22 @@ def _configure_camera(self, camera: pylon.InstantCamera): def _probe(self): cam = self._open_camera() - self._configure_camera(cam) + try: + self._configure_camera(cam) + + if hasattr(cam, "GevTimestampTickFrequency"): + self._tick_frequency = cam.GevTimestampTickFrequency.GetValue() + self._has_hw_timestamp = True - width = cam.Width.GetValue() - height = cam.Height.GetValue() - self._size = (width, height) + width = cam.Width.GetValue() + height = cam.Height.GetValue() + self._size = (width, height) - if hasattr(cam, "AcquisitionFrameRateAbs"): - self._fps = cam.AcquisitionFrameRateAbs.GetValue() or 30.0 - else: - self._fps = 30.0 + if hasattr(cam, "AcquisitionFrameRateAbs"): + self._fps = cam.AcquisitionFrameRateAbs.GetValue() or 30.0 + else: + self._fps = 30.0 - if cam.PixelFormat == "Mono8": - self._gray = True - else: - self._gray = False + self._gray = cam.PixelFormat.GetValue() == "Mono8" + finally: + cam.Close() From 34354b495b72bb479aaeaa8ac36f875a268ddb1b Mon Sep 17 00:00:00 2001 From: Marcel Schmutz Date: Tue, 5 May 2026 13:32:16 +0200 Subject: [PATCH 2/6] Checking for pylon camera ttributes using try except --- src/py3r/media/video/pylon_camera_source.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/py3r/media/video/pylon_camera_source.py b/src/py3r/media/video/pylon_camera_source.py index 8e1c029..313409f 100644 --- a/src/py3r/media/video/pylon_camera_source.py +++ b/src/py3r/media/video/pylon_camera_source.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Optional, Tuple -from pypylon import pylon +from pypylon import genicam, pylon from py3r.media.types import VideoFrame from py3r.media.video import VideoSource @@ -135,25 +135,29 @@ def _configure_camera(self, camera: pylon.InstantCamera): camera.AcquisitionFrameRateAbs.SetValue(30.0) frame_size = 9000 if jumbo_frames else 1500 - if hasattr(camera, "GevSCPSPacketSize"): + try: camera.GevSCPSPacketSize.SetValue(frame_size) + except genicam.LogicalErrorException: + pass def _probe(self): cam = self._open_camera() try: self._configure_camera(cam) - if hasattr(cam, "GevTimestampTickFrequency"): + try: self._tick_frequency = cam.GevTimestampTickFrequency.GetValue() self._has_hw_timestamp = True + except genicam.LogicalErrorException: + pass width = cam.Width.GetValue() height = cam.Height.GetValue() self._size = (width, height) - if hasattr(cam, "AcquisitionFrameRateAbs"): + try: self._fps = cam.AcquisitionFrameRateAbs.GetValue() or 30.0 - else: + except genicam.LogicalErrorException: self._fps = 30.0 self._gray = cam.PixelFormat.GetValue() == "Mono8" From ee7176a7857023a7a4c30d6a4647e94de9e54df1 Mon Sep 17 00:00:00 2001 From: Marcel Schmutz Date: Tue, 5 May 2026 15:06:20 +0200 Subject: [PATCH 3/6] fixup! Checking for pylon camera ttributes using try except --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 64f83cc..8484629 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py3r_media" -version = "0.4.0" +version = "0.4.1" authors = [ { name="Marcel Schmutz", email="mschmut@ethz.ch" }, ] From 86f52460a1cbe849fb37fd4ccc2d10d14a90ef93 Mon Sep 17 00:00:00 2001 From: Marcel Schmutz Date: Mon, 11 May 2026 14:38:57 +0200 Subject: [PATCH 4/6] AI generated test cases --- pyproject.toml | 14 +- requirements-dev.txt | 2 + requirements.txt | 3 +- .../observables/reader_observable.py | 6 + .../streaming/operators/adaptive_pace.py | 33 +- .../streaming/operators/observe_on_bounded.py | 3 +- .../operators/subscribe_on_future.py | 19 +- src/py3r/media/types/__init__.py | 33 ++ .../media/video/opencv_video_file_source.py | 219 +++++++ src/py3r/media/video/opencv_webcam_source.py | 158 ++++- .../media/video/pyav_video_file_writer.py | 246 ++++++++ ..._video_source.py => pyav_webcam_source.py} | 286 ++++----- src/py3r/media/video/pylon_camera_source.py | 192 +++--- tests/__init__.py | 0 tests/conftest.py | 138 +++++ tests/hardware/__init__.py | 0 tests/hardware/conftest.py | 128 ++++ tests/hardware/test_opencv_webcam_source.py | 235 ++++++++ .../hardware/test_pyav_webcam_video_source.py | 266 +++++++++ tests/hardware/test_pylon_camera_source.py | 222 +++++++ tests/unit/__init__.py | 0 tests/unit/streaming/__init__.py | 0 tests/unit/streaming/conftest.py | 67 +++ tests/unit/streaming/test_adaptive_pace.py | 60 ++ tests/unit/streaming/test_finally_future.py | 70 +++ .../unit/streaming/test_observe_on_bounded.py | 143 +++++ .../unit/streaming/test_reader_observable.py | 133 +++++ .../streaming/test_subscribe_on_blocking.py | 54 ++ .../streaming/test_subscribe_on_future.py | 88 +++ tests/unit/streaming/test_write_to.py | 100 ++++ tests/unit/test_opencv_video_file_source.py | 348 +++++++++++ tests/unit/test_opencv_webcam_source.py | 435 ++++++++++++++ tests/unit/test_pyav_video_file_writer.py | 337 +++++++++++ tests/unit/test_pyav_webcam_video_source.py | 493 ++++++++++++++++ tests/unit/test_pylon_camera_source.py | 548 ++++++++++++++++++ 35 files changed, 4770 insertions(+), 309 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 src/py3r/media/video/opencv_video_file_source.py create mode 100644 src/py3r/media/video/pyav_video_file_writer.py rename src/py3r/media/video/{pyav_webcam_video_source.py => pyav_webcam_source.py} (59%) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/hardware/__init__.py create mode 100644 tests/hardware/conftest.py create mode 100644 tests/hardware/test_opencv_webcam_source.py create mode 100644 tests/hardware/test_pyav_webcam_video_source.py create mode 100644 tests/hardware/test_pylon_camera_source.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/streaming/__init__.py create mode 100644 tests/unit/streaming/conftest.py create mode 100644 tests/unit/streaming/test_adaptive_pace.py create mode 100644 tests/unit/streaming/test_finally_future.py create mode 100644 tests/unit/streaming/test_observe_on_bounded.py create mode 100644 tests/unit/streaming/test_reader_observable.py create mode 100644 tests/unit/streaming/test_subscribe_on_blocking.py create mode 100644 tests/unit/streaming/test_subscribe_on_future.py create mode 100644 tests/unit/streaming/test_write_to.py create mode 100644 tests/unit/test_opencv_video_file_source.py create mode 100644 tests/unit/test_opencv_webcam_source.py create mode 100644 tests/unit/test_pyav_video_file_writer.py create mode 100644 tests/unit/test_pyav_webcam_video_source.py create mode 100644 tests/unit/test_pylon_camera_source.py diff --git a/pyproject.toml b/pyproject.toml index 8484629..48cb139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py3r_media" -version = "0.4.1" +version = "0.5.0" authors = [ { name="Marcel Schmutz", email="mschmut@ethz.ch" }, ] @@ -17,8 +17,20 @@ dependencies = [ "numpy >= 2", "opencv-python >= 4", "reactivex >= 4.0.0", + "av >= 13", +] + +[project.optional-dependencies] +dev = [ + "pytest >= 8", ] [tool.setuptools.packages.find] where = ["src/"] include = ["py3r.media*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "hardware: requires physical capture hardware (webcam, Pylon camera, …)", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..438af11 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest >= 8 diff --git a/requirements.txt b/requirements.txt index cb1e182..7aad15a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ numpy>=2 opencv-python>=4 reactivex>=4.0.0 -pypylon>=4.0.0 \ No newline at end of file +pypylon>=4.0.0 +av>=13 \ No newline at end of file diff --git a/src/py3r/media/streaming/observables/reader_observable.py b/src/py3r/media/streaming/observables/reader_observable.py index 07a8d8b..86c9c68 100644 --- a/src/py3r/media/streaming/observables/reader_observable.py +++ b/src/py3r/media/streaming/observables/reader_observable.py @@ -9,6 +9,8 @@ from reactivex.abc import ObserverBase from reactivex.disposable import Disposable +from py3r.media.types import FatalReadError + log = logging.getLogger(__name__) TItem = TypeVar("TItem") @@ -69,6 +71,10 @@ def run(): try: item = reader.read(read_timeout_seconds) consecutive_errors = 0 # reset on success + except FatalReadError as ex: + if not stop.is_set(): + observer.on_error(ex) + return except Exception as ex: if stop.is_set(): return diff --git a/src/py3r/media/streaming/operators/adaptive_pace.py b/src/py3r/media/streaming/operators/adaptive_pace.py index 9ca9e54..1aa42f7 100644 --- a/src/py3r/media/streaming/operators/adaptive_pace.py +++ b/src/py3r/media/streaming/operators/adaptive_pace.py @@ -14,15 +14,15 @@ def adaptive_pace( initial_interval: Optional[float] = None, learn_rate: float = 0.1, scheduler: Optional[rx.abc.SchedulerBase] = None, + _poll: float = 0.01, ) -> Callable[[rx.abc.ObservableBase[_T]], rx.Observable[_T]]: def _op(source: rx.abc.ObservableBase[_T]) -> rx.Observable[_T]: def _subscribe(observer: rx.abc.ObserverBase[_T], scheduler_: Optional[rx.abc.SchedulerBase] = None) -> Disposable: _scheduler = scheduler or scheduler_ or TimeoutScheduler.singleton() - q: queue.Queue[_T] = queue.Queue() + q: queue.Queue = queue.Queue() last_time: Optional[float] = None period: float = initial_interval or 0.0 - stopped = threading.Event() disposed = threading.Event() def update_interval(now: float): @@ -37,14 +37,14 @@ def update_interval(now: float): def on_next(x: _T): update_interval(time.perf_counter()) - q.put(x) + q.put(("next", x)) def on_error(err: Exception): - stopped.set() - observer.on_error(err) + # Route through queue so buffered on_next items are delivered first. + q.put(("error", err)) def on_completed(): - stopped.set() + q.put(("completed", None)) src_disp = source.subscribe( on_next, @@ -54,28 +54,31 @@ def on_completed(): ) def emit_loop(): - nonlocal period try: while not disposed.is_set(): try: - item = q.get(timeout=0.01) - observer.on_next(item) + kind, value = q.get(timeout=_poll) except queue.Empty: - pass + continue - if stopped.is_set() and q.empty(): + if kind == "next": + observer.on_next(value) + elif kind == "error": + observer.on_error(value) + return + else: # completed observer.on_completed() return + # Pace: sleep for the learned interval, waking frequently + # so we can respond to dispose() quickly. sleep_time = max(period, 1e-6) deadline = time.perf_counter() + sleep_time - while True: - if disposed.is_set(): - return + while not disposed.is_set(): remaining = deadline - time.perf_counter() if remaining <= 0: break - time.sleep(min(remaining, 0.01)) + time.sleep(min(remaining, _poll)) except Exception as e: observer.on_error(e) diff --git a/src/py3r/media/streaming/operators/observe_on_bounded.py b/src/py3r/media/streaming/operators/observe_on_bounded.py index 3f901b2..dd24a2f 100644 --- a/src/py3r/media/streaming/operators/observe_on_bounded.py +++ b/src/py3r/media/streaming/operators/observe_on_bounded.py @@ -14,6 +14,7 @@ def observe_on_bounded( maxsize: int = 256, policy: str = "block", timeout: float = 0.1, + _worker_poll: float = 0.05, ) -> Callable[[rx.Observable[_T]], rx.Observable[_T]]: """ Like observe_on, but with a bounded internal queue. @@ -89,7 +90,7 @@ def worker(_sc=None, _st=None): try: try: - action = q.get(timeout=0.05) + action = q.get(timeout=_worker_poll) except queue.Empty: if not disposed.is_set(): worker_disp.disposable = scheduler.schedule(worker) diff --git a/src/py3r/media/streaming/operators/subscribe_on_future.py b/src/py3r/media/streaming/operators/subscribe_on_future.py index 9facca0..3ede2a1 100644 --- a/src/py3r/media/streaming/operators/subscribe_on_future.py +++ b/src/py3r/media/streaming/operators/subscribe_on_future.py @@ -41,9 +41,15 @@ def action(_sched, _state: Any = None): def subscribe_on_future( scheduler: rx.abc.SchedulerBase, - subscribed: Future[None] = Future(), - disposed: Future[None] = Future() + subscribed: Optional[Future[None]] = None, + disposed: Optional[Future[None]] = None, ) -> Callable[[rx.abc.ObservableBase[_T]], rx.Observable[_T]]: + # Create fresh Futures here (not in the signature) to avoid the + # mutable-default-argument trap where all callers share the same object. + if subscribed is None: + subscribed = Future() + if disposed is None: + disposed = Future() def _op(source: rx.abc.ObservableBase[_T]) -> rx.Observable[_T]: @@ -56,19 +62,18 @@ def subscribe( d.disposable = m def action( - sched: rx.abc.SchedulerBase, - _state: Optional[Any] = None, + sched: rx.abc.SchedulerBase, + _state: Optional[Any] = None, ): try: - # subscribe upstream on this scheduler inner_disp = source.subscribe(observer) except BaseException as exc: - # subscription failed if not subscribed.done(): subscribed.set_exception(exc) raise else: - # subscription succeeded; disposal will be signaled separately + # Subscription fully established — signal now so callers that + # blocked on subscribed.result() know it is safe to start producing. d.disposable = FutureScheduledDisposable(sched, inner_disp, disposed) if not subscribed.done(): subscribed.set_result(None) diff --git a/src/py3r/media/types/__init__.py b/src/py3r/media/types/__init__.py index 7fb2ad0..b218829 100644 --- a/src/py3r/media/types/__init__.py +++ b/src/py3r/media/types/__init__.py @@ -4,6 +4,39 @@ import numpy as np +# --------------------------------------------------------------------------- +# Read error hierarchy +# --------------------------------------------------------------------------- + +class ReadError(Exception): + """Base class for errors raised by IReader.read().""" + + +class FatalReadError(ReadError): + """ + An unrecoverable read failure. ``reader_observable`` will forward this + immediately as ``on_error`` without consulting ``max_consecutive_errors``. + + All other exceptions (including plain ``ReadError`` subclasses) are treated + as retryable by default — the source will be retried up to + ``max_consecutive_errors`` times before the stream is terminated. + + Use this only when retrying is known to be pointless or harmful (e.g. the + camera has been physically disconnected and the source has already set an + internal flag so that every subsequent ``read()`` would also fail + immediately). In most cases it is better to let the consecutive-error + threshold handle termination naturally. + """ + + +class GrabFailedError(ReadError): + """The camera returned a result whose GrabSucceeded() flag is False.""" + + +class GrabTimeoutError(ReadError): + """RetrieveResult returned without a frame within the given timeout.""" + + @runtime_checkable class HasImage(Protocol): @property diff --git a/src/py3r/media/video/opencv_video_file_source.py b/src/py3r/media/video/opencv_video_file_source.py new file mode 100644 index 0000000..2d22b60 --- /dev/null +++ b/src/py3r/media/video/opencv_video_file_source.py @@ -0,0 +1,219 @@ +import time +from pathlib import Path +from typing import Optional, Tuple + +import cv2 + +from py3r.media.types import VideoFrame +from py3r.media.video import VideoSource + + +class EndOfStreamError(Exception): + """ + The video file has no more frames. + + When ``loop=False`` this is effectively fatal — the source is exhausted. + When ``loop=True`` the reader will seek back to frame 0 and this should + not normally be raised to callers. + """ + + +class ReadFailedError(Exception): + """ + OpenCV returned ``ok=False`` on a mid-stream read (not at end-of-file). + + This is **retryable** — a single bad decode does not invalidate the + capture object. The caller may call ``read()`` again to attempt the + next frame. + """ + + +class OpenCVVideoFileSource(VideoSource): + """ + Pull-based video-file source backed by ``cv2.VideoCapture``. + + Unlike ``FFmpegVideoFileSource``, no subprocess is needed. End-of-stream + and decode failures are signalled via exceptions rather than ``None`` + returns so that upstream retry logic can distinguish recoverable hiccups + from true end-of-file. + + Parameters + ---------- + path: + Path to the video file. + grayscale: + Convert frames to single-channel grayscale before returning them. + loop: + Seek back to frame 0 when EOS is reached and keep streaming. + playback: + Pace reads to the native frame-rate (suitable for display loops). + """ + + def __init__( + self, + path: Path, + grayscale: bool = True, + loop: bool = False, + playback: bool = False, + ) -> None: + self._path = Path(path) + self._grayscale = grayscale + self._loop = loop + self._playback = playback + + self._cap: Optional[cv2.VideoCapture] = None + self._fps: Optional[float] = None + self._size: Optional[Tuple[int, int]] = None + self._num_frames: Optional[int] = None + self._idx: int = 0 + self._t0: float = 0.0 + + self._probe() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def open(self) -> None: + self._cap = self._open_capture() + self._idx = 0 + self._t0 = time.perf_counter() + + def close(self) -> None: + if self._cap is not None: + self._cap.release() + self._cap = None + + def is_open(self) -> bool: + return self._cap is not None and self._cap.isOpened() + + # ------------------------------------------------------------------ + # Capabilities + # ------------------------------------------------------------------ + + def has_timing(self) -> bool: return True + def has_size(self) -> bool: return True + def has_fps(self) -> bool: return bool(self._fps) + def has_num_frames(self) -> bool: return not self._loop and self._num_frames is not None + def is_seekable(self) -> bool: return True + + def get_size(self) -> Optional[Tuple[int, int]]: return self._size + def get_fps(self) -> Optional[float]: return self._fps + def get_num_channels(self) -> int: return 1 if self._grayscale else 3 + def get_num_frames(self) -> Optional[int]: return self._num_frames if not self._loop else None + + # ------------------------------------------------------------------ + # Seeking + # ------------------------------------------------------------------ + + def seek(self, frame_index: int) -> None: + if not self.is_open(): + raise RuntimeError("Cannot seek: source is not open") + self._seek_file_position(frame_index) + self._idx = max(0, int(frame_index)) + self._t0 = time.perf_counter() - (self._idx / (self._fps or 30.0)) + + def _seek_file_position(self, frame_index: int) -> None: + """Move the underlying capture to *frame_index* without touching ``_idx`` or ``_t0``.""" + self._cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(frame_index))) # type: ignore[union-attr] + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def read(self, timeout: Optional[float] = None) -> VideoFrame: + """ + Return the next ``VideoFrame`` from the file. + + Raises + ------ + RuntimeError + The source has not been opened. This is fatal. + EndOfStreamError + No more frames are available (end of file). When ``loop=True`` + this is handled internally by seeking back to frame 0; it will + only propagate if the seek + re-read also fails. + ReadFailedError + OpenCV decoded a frame as invalid mid-stream. This is retryable: + skip the frame and call ``read()`` again. + TimeoutError + The read took longer than *timeout* seconds. Retryable. + """ + if not self.is_open(): + raise RuntimeError("Source is not open") + + t0 = time.perf_counter() + + ok, img = self._cap.read() # type: ignore[union-attr] + + if timeout is not None and (time.perf_counter() - t0) > timeout: + raise TimeoutError(f"Read exceeded timeout of {timeout:.3f}s") + + if not ok: + # Distinguish true EOS from a mid-stream decode hiccup. + pos = self._cap.get(cv2.CAP_PROP_POS_FRAMES) # type: ignore[union-attr] + at_eos = self._num_frames is None or pos >= self._num_frames + + if at_eos: + if self._loop: + self._seek_file_position(0) # reposition file only; _idx keeps counting + ok, img = self._cap.read() # type: ignore[union-attr] + if not ok: + raise EndOfStreamError( + "End of stream even after loop seek — file may be empty or corrupt" + ) + else: + raise EndOfStreamError("End of video file reached") + else: + raise ReadFailedError( + f"OpenCV failed to decode frame at position {int(pos)}" + ) + + if self._grayscale and img.ndim == 3: + img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # Timestamp: prefer the container presentation time; fall back to + # a synthetic clock derived from frame index + t0 (playback mode). + ts_ms = self._cap.get(cv2.CAP_PROP_POS_MSEC) # type: ignore[union-attr] + if ts_ms > 0: + ts = ts_ms / 1000.0 + elif self._fps: + ts = self._idx / self._fps + else: + ts = time.perf_counter() - self._t0 + + if self._playback and self._fps: + deadline = self._t0 + (self._idx / self._fps) + delay = deadline - time.perf_counter() + if delay > 0: + time.sleep(delay) + + frame = VideoFrame(img, self._idx, ts) + self._idx += 1 + return frame + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _open_capture(self) -> cv2.VideoCapture: + cap = cv2.VideoCapture(str(self._path)) + if not cap.isOpened(): + raise RuntimeError(f"Cannot open video file: {self._path}") + return cap + + def _probe(self) -> None: + cap = self._open_capture() + try: + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + self._size = (w, h) + + fps = cap.get(cv2.CAP_PROP_FPS) + self._fps = float(fps) if fps and fps > 0 else None + + n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + self._num_frames = n if n > 0 else None + finally: + cap.release() + diff --git a/src/py3r/media/video/opencv_webcam_source.py b/src/py3r/media/video/opencv_webcam_source.py index 0d5cdcd..0fa45d9 100644 --- a/src/py3r/media/video/opencv_webcam_source.py +++ b/src/py3r/media/video/opencv_webcam_source.py @@ -1,5 +1,5 @@ import time -from typing import Optional, Tuple +from typing import Any, Optional, Protocol, Tuple, Union import cv2 @@ -7,20 +7,72 @@ from py3r.media.video import VideoSource +class ReadFailedError(OSError): + """Raised when ``cv2.VideoCapture.read()`` returns ``ok=False``.""" + + class OpenCVWebcamSource(VideoSource): - def __init__(self, device_index: int = 0, grayscale: bool = True, - width: Optional[int] = None, height: Optional[int] = None, fps: Optional[float] = None): + """ + Pull-based webcam source via OpenCV VideoCapture. + + Parameters + ---------- + device : int | str + Device identifier passed to ``cv2.VideoCapture``. + + * **Integer index** — ``0`` for the first camera, ``1`` for the second, etc. + * **V4L2 path** — e.g. ``"/dev/video0"`` on Linux. + * **GStreamer / URL pipeline** — any string accepted by OpenCV. + grayscale : bool + Convert frames to single-channel grayscale before returning them. + width, height : int | None + Request a specific capture resolution. The device may ignore or round this. + fps : float | None + Request a specific frame-rate. + backend : int + OpenCV capture backend flag (e.g. ``cv2.CAP_DSHOW``). + Defaults to ``cv2.CAP_ANY``. + capture_factory : CaptureFactory | None + If provided, replaces ``cv2.VideoCapture(device, backend)`` for both + probe and live capture. Receives the same arguments:: + + capture_factory(device, backend=backend) -> capture + + This lets tests verify that the source passes the right device and + backend, and return a fake capture to drive the rest of the pipeline. + """ + + class CaptureFactory(Protocol): + def __call__(self, device: Union[int, str], *, backend: int) -> Any: ... + + def __init__( + self, + device: Union[int, str] = 0, + *, + grayscale: bool = True, + width: Optional[int] = None, + height: Optional[int] = None, + fps: Optional[float] = None, + backend: int = cv2.CAP_ANY, + capture_factory: Optional[CaptureFactory] = None, + ): self._idx = 0 self._cap = None - self._device = device_index + self._device = device + self._backend = backend self._grayscale = grayscale self._requested_size = (width, height) if width and height else None self._requested_fps = fps - self._size = None - self._fps = None + self._size: Optional[Tuple[int, int]] = None + self._fps: Optional[float] = None + self._capture_factory = capture_factory self._probe() + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def open(self) -> None: self._cap = self._open_camera() self._configure_camera(self._cap) @@ -31,54 +83,100 @@ def close(self) -> None: self._cap.release() self._cap = None - def is_open(self) -> bool: return self._cap is not None and self._cap.isOpened() + def is_open(self) -> bool: + return self._cap is not None and self._cap.isOpened() + def has_timing(self) -> bool: return True - def has_size(self) -> bool: return True - def has_fps(self) -> bool: return bool(self._fps) + def has_size(self) -> bool: return self._size is not None + def has_fps(self) -> bool: return self._fps is not None def has_num_frames(self) -> bool: return False def is_seekable(self) -> bool: return False - def get_size(self) -> Optional[Tuple[int,int]]: return self._size + def get_size(self) -> Optional[Tuple[int, int]]: return self._size def get_fps(self) -> Optional[float]: return self._fps def get_num_channels(self) -> int: return 1 if self._grayscale else 3 def get_num_frames(self) -> Optional[int]: return None - def seek(self, frame_index: int) -> None: pass + def seek(self, frame_index: int) -> None: + pass # live source; no-op + + def read(self, timeout: Optional[float] = None) -> VideoFrame: + """ + Read the next frame from the camera. + + Raises + ------ + RuntimeError + If the source is not open. + ReadFailedError + If ``cv2.VideoCapture.read()`` returns ``ok=False``. + TimeoutError + If *timeout* seconds elapse without a successful read. + """ + if self._cap is None: + raise RuntimeError( + f"Cannot read from device {self._device!r}: source is not open" + ) + + deadline = None if timeout is None else (time.perf_counter() + timeout) - def read(self, timeout: Optional[float] = None) -> Optional[VideoFrame]: - t0 = time.perf_counter() while True: + if deadline is not None and time.perf_counter() >= deadline: + raise TimeoutError( + f"Timeout reading frame from device {self._device!r}" + ) + ok, img = self._cap.read() + if ok: - ts = time.perf_counter() # monotonic + ts = time.perf_counter() if self._grayscale: img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - f = VideoFrame(img, self._idx, ts) + frame = VideoFrame(img, self._idx, ts) self._idx += 1 - return f - if timeout is not None and (time.perf_counter() - t0) > timeout: - return None + return frame + + raise ReadFailedError( + f"cv2.VideoCapture.read() failed for device {self._device!r}" + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ def _open_camera(self) -> cv2.VideoCapture: - cam = cv2.VideoCapture(self._device, cv2.CAP_ANY) + if self._capture_factory is not None: + cam = self._capture_factory(self._device, backend=self._backend) + else: + cam = cv2.VideoCapture(self._device, self._backend) if not cam.isOpened(): - raise RuntimeError("Cannot open webcam") + raise RuntimeError( + f"Cannot open webcam device {self._device!r} " + f"(backend={self._backend})" + ) return cam def _configure_camera(self, camera: cv2.VideoCapture) -> None: if self._requested_size: - w,h = self._requested_size + w, h = self._requested_size camera.set(cv2.CAP_PROP_FRAME_WIDTH, w) camera.set(cv2.CAP_PROP_FRAME_HEIGHT, h) if self._requested_fps: camera.set(cv2.CAP_PROP_FPS, self._requested_fps) - def _probe(self): - cam = self._open_camera() - self._configure_camera(cam) - - w = int(cam.get(cv2.CAP_PROP_FRAME_WIDTH)) - h = int(cam.get(cv2.CAP_PROP_FRAME_HEIGHT)) - self._size = (w,h) - self._fps = float(cam.get(cv2.CAP_PROP_FPS) or 0) or None - cam.release() + def _probe(self) -> None: + try: + cam = self._open_camera() + try: + self._configure_camera(cam) + w = int(cam.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cam.get(cv2.CAP_PROP_FRAME_HEIGHT)) + if w and h: + self._size = (w, h) + raw_fps = cam.get(cv2.CAP_PROP_FPS) + self._fps = float(raw_fps) if raw_fps else None + finally: + cam.release() + except Exception: + # Keep probing soft: caller can still open() later. + pass diff --git a/src/py3r/media/video/pyav_video_file_writer.py b/src/py3r/media/video/pyav_video_file_writer.py new file mode 100644 index 0000000..77a39a9 --- /dev/null +++ b/src/py3r/media/video/pyav_video_file_writer.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from fractions import Fraction +from pathlib import Path +from typing import Dict, Literal, Optional, Tuple, Union + +import av +import numpy as np + +from py3r.media.types import HasImage + +QualityName = Literal["very_low", "low", "medium", "high", "very_high", "lossless"] + + +class PyAVVideoFileWriter: + """ + Write video frames to a file using PyAV (libav*) — no ffmpeg executable needed. + + Mirrors the ``FFmpegVideoFileWriter`` interface exactly so the two are + drop-in replacements for each other. + + Parameters + ---------- + path : str | Path + Output file path. The container format is inferred from the extension + (e.g. ``.mp4``, ``.mkv``, ``.avi``). + size : (width, height) + Frame dimensions in pixels. + fps : float + Nominal frame-rate. + grayscale : bool + If ``True``, ``write()`` expects ``(H, W)`` / ``(H, W, 1)`` uint8 arrays. + If ``False``, ``write()`` expects ``(H, W, 3)`` uint8 BGR arrays. + quality : QualityName + One of ``"very_low"``, ``"low"``, ``"medium"``, ``"high"``, + ``"very_high"``, or ``"lossless"``. + + +------------+-----+-----------+ + | quality | crf | preset | + +============+=====+===========+ + | very_low | 36 | ultrafast | + | low | 32 | ultrafast | + | medium | 28 | veryfast | + | high | 22 | medium | + | very_high | 18 | fast | + | lossless | 0 | veryslow | + +------------+-----+-----------+ + + ``"lossless"`` uses ``libx264rgb`` (color) or ``libx264`` with + ``pix_fmt=gray`` (grayscale) so that no information is thrown away. + Prefer ``.mkv`` for lossless because MP4 chokes on ``bgr24``/``gray`` + pixel formats in some players. + extra_options : dict[str, str] | None + Additional codec private options merged on top of the quality preset + (e.g. ``{"tune": "film"}``). + + Notes + ----- + * Frames are encoded in-order with monotonically increasing PTS. + * Gray input is reformatted to ``yuv420p`` for all non-lossless qualities + to maximise player compatibility. + * ``close()`` flushes the encoder and finalises container headers — always + call it (or use the writer as a context manager). + """ + + QUALITY_PRESETS: Dict[str, Dict[str, str]] = { + "very_low": {"crf": "36", "preset": "ultrafast"}, + "low": {"crf": "32", "preset": "ultrafast"}, + "medium": {"crf": "28", "preset": "veryfast"}, + "high": {"crf": "22", "preset": "medium"}, + "very_high": {"crf": "18", "preset": "fast"}, + } + + def __init__( + self, + path: Union[Path, str], + size: Tuple[int, int], + fps: float, + *, + grayscale: bool = False, + quality: QualityName = "medium", + extra_options: Optional[Dict[str, str]] = None, + ) -> None: + self._path = Path(path) + self._w, self._h = int(size[0]), int(size[1]) + self._fps = float(fps) + self._grayscale = bool(grayscale) + self._quality = quality + self._extra_options: Dict[str, str] = extra_options or {} + + self._container: Optional[av.container.OutputContainer] = None + self._stream = None + self._frame_count: int = 0 + self._closed: bool = True + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def open(self) -> None: + if not self._closed: + return + + self._container = av.open(str(self._path), mode="w") + + # ---- codec + pixel format selection ---- + if self._quality == "lossless": + if self._grayscale: + # libx264 natively encodes gray without chroma waste + codec_name = "libx264" + enc_pix_fmt = "gray" + else: + # libx264rgb preserves BGR bitwise + codec_name = "libx264rgb" + enc_pix_fmt = "bgr24" + options: Dict[str, str] = {"crf": "0", "preset": "veryslow"} + else: + codec_name = "libx264" + # yuv420p is the most universally accepted h264 pixel format + enc_pix_fmt = "yuv420p" + options = dict(self.QUALITY_PRESETS[self._quality]) + + options.update(self._extra_options) + + # Use a rational fps to avoid floating-point drift + rate = Fraction(self._fps).limit_denominator(1001) + + stream = self._container.add_stream(codec_name, rate=rate) + stream.width = self._w + stream.height = self._h + stream.pix_fmt = enc_pix_fmt + stream.options = options + + self._stream = stream + # For lossless grey, signal full colour range (AVCOL_RANGE_JPEG) so that + # decoders don't apply the limited-range rescale (Y∈[16,235] → [0,255]) + # that would corrupt the lossless guarantee. + if self._quality == "lossless" and self._grayscale: + try: + self._stream.codec_context.color_range = 2 # AVCOL_RANGE_JPEG + except Exception: + pass + self._frame_count = 0 + self._closed = False + + def close(self) -> None: + if self._closed: + return + try: + if self._stream is not None: + # Flush any buffered frames from the encoder + for packet in self._stream.encode(None): + self._container.mux(packet) # type: ignore[union-attr] + if self._container is not None: + self._container.close() + finally: + self._stream = None + self._container = None + self._closed = True + + def __enter__(self) -> "PyAVVideoFileWriter": + self.open() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + # ------------------------------------------------------------------ + # Writing + # ------------------------------------------------------------------ + + def write(self, frame: Union[HasImage, np.ndarray]) -> None: + """ + Write a single frame. + + Parameters + ---------- + frame : numpy.ndarray or HasImage + * Color (``grayscale=False``): shape ``(H, W, 3)``, dtype ``uint8``, + **BGR** channel order (OpenCV-native). + * Grayscale (``grayscale=True``): shape ``(H, W)`` or ``(H, W, 1)``, + dtype ``uint8``. + + Raises + ------ + RuntimeError + Writer has not been opened. + ValueError + Frame dtype, shape, or channel count does not match the writer + configuration. + """ + if self._closed or self._stream is None: + raise RuntimeError("PyAVVideoFileWriter is not open — call .open() first.") + + img = frame if isinstance(frame, np.ndarray) else frame.img + + if img.dtype != np.uint8: + raise ValueError(f"Expected dtype=uint8, got {img.dtype}") + + # ---- shape normalisation + validation ---- + if self._grayscale: + if img.ndim == 3 and img.shape[2] == 1: + img = img.reshape(img.shape[0], img.shape[1]) + if img.ndim != 2: + raise ValueError( + f"Grayscale mode expects shape (H, W) or (H, W, 1), got {img.shape}" + ) + src_fmt = "gray" + else: + if img.ndim != 3 or img.shape[2] != 3: + raise ValueError( + f"Color mode expects shape (H, W, 3), got {img.shape}" + ) + src_fmt = "bgr24" + + if img.shape[0] != self._h or img.shape[1] != self._w: + raise ValueError( + f"Frame size mismatch: expected ({self._h}, {self._w}), " + f"got {img.shape[:2]}" + ) + + if not img.flags["C_CONTIGUOUS"]: + img = np.ascontiguousarray(img) + + # ---- build PyAV frame ---- + av_frame = av.VideoFrame.from_ndarray(img, format=src_fmt) + av_frame.pts = self._frame_count + self._frame_count += 1 + + # Reformat to encoder pixel format (e.g. gray → yuv420p) + enc_fmt: str = self._stream.codec_context.pix_fmt + if av_frame.format.name != enc_fmt: + av_frame = av_frame.reformat(format=enc_fmt) + + for packet in self._stream.encode(av_frame): + self._container.mux(packet) # type: ignore[union-attr] + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def is_open(self) -> bool: + """``True`` if the writer has been opened and not yet closed.""" + return not self._closed + diff --git a/src/py3r/media/video/pyav_webcam_video_source.py b/src/py3r/media/video/pyav_webcam_source.py similarity index 59% rename from src/py3r/media/video/pyav_webcam_video_source.py rename to src/py3r/media/video/pyav_webcam_source.py index 9ff860f..6fd659e 100644 --- a/src/py3r/media/video/pyav_webcam_video_source.py +++ b/src/py3r/media/video/pyav_webcam_source.py @@ -1,9 +1,7 @@ -import re -import subprocess import threading import time from collections import deque -from typing import Optional, Tuple, List, Deque +from typing import Any, Deque, Optional, Protocol, Tuple import av @@ -13,55 +11,90 @@ class PyAVWebcamSource(VideoSource): """ - Windows / DirectShow webcam source via PyAV. - - Capture and decode are done by PyAV (FFmpeg bindings). Device enumeration - helpers are kept subprocess-based for convenience, matching the original - class behavior. - - Use either: - - device_name="Integrated Camera" - or: - - device_index=0 + Pull-based webcam / capture-device source via PyAV (libav*). + + No ffmpeg executable is required — all capture and decode happens through + the PyAV bindings directly. + + Parameters + ---------- + device_name : str + Platform-specific device identifier passed to libavformat. + Required — pass a dummy string (e.g. ``"test"``) when supplying a + ``container_factory`` in tests. + + * **Windows DirectShow** — the display name shown in Device Manager, + e.g. ``"Integrated Camera"`` or ``"USB Video Device"``. + * **Linux V4L2** — the device node, e.g. ``"/dev/video0"``. + * **macOS AVFoundation** — the device index as a string, e.g. ``"0"``. + input_format : str | None + libavformat input format name. Defaults to the platform default: + ``"dshow"`` on Windows, ``"v4l2"`` on Linux, ``"avfoundation"`` on + macOS. Pass explicitly to override (e.g. ``"mjpeg"`` for some + USB cameras). + grayscale : bool + Convert frames to single-channel grayscale before returning them. + width, height : int | None + Request a specific capture resolution. The device may ignore or + round this. + fps : float | None + Request a specific frame-rate. + queue_size : int + Maximum number of decoded frames kept in the internal queue. When + the producer outpaces the consumer, the *oldest* queued frames are + silently dropped to make room. + container_factory : ContainerFactory | None + If provided, replaces ``av.open(...)`` for both the probe and the + live capture session. Called with the same arguments as the internal + ``av.open`` call:: + + container_factory(file, format, mode, options) -> container + + This lets tests (a) verify that the source constructs the right + device identifier, format string, and capture-option dict, and + (b) return a fake container to drive the rest of the pipeline. Notes ----- - - device_index is based on the order returned by ffmpeg dshow enumeration, - just like your original class. - - read(timeout=...) works via a background decode thread and a queue, - rather than trying to interrupt a blocking decode call directly. - - timestamps prefer frame/container timing when available; otherwise they - fall back to time.perf_counter(). + * ``read(timeout=...)`` is non-blocking on the decode side: a background + thread feeds frames into a bounded deque and ``read()`` pops from it. + * Timestamps prefer container / frame PTS when available and fall back + to ``time.perf_counter()`` at frame delivery time. """ - ffmpeg_executable = "ffmpeg" + # Mirrors the av.open arguments used by _open_container. + class ContainerFactory(Protocol): + def __call__( + self, + file: str, + *, + format: str, + mode: str, + options: dict, + ) -> Any: ... def __init__( self, - device_name: Optional[str] = None, - device_index: Optional[int] = None, + device_name: str, + *, + input_format: Optional[str] = None, grayscale: bool = True, width: Optional[int] = None, height: Optional[int] = None, fps: Optional[float] = None, - loglevel: str = "error", queue_size: int = 8, + container_factory: Optional[ContainerFactory] = None, ): - if device_name is None and device_index is None: - device_index = 0 - if device_name is not None and device_index is not None: - raise ValueError("Specify either device_name or device_index, not both.") self._device_name = device_name - self._device_index = device_index - self._device_number = 0 # duplicate-name selector for dshow + self._input_format = input_format or self._default_input_format() self._grayscale = grayscale self._requested_width = width self._requested_height = height self._requested_fps = fps - self._loglevel = loglevel self._queue_size = max(1, int(queue_size)) + self._container_factory = container_factory self._idx = 0 self._size: Optional[Tuple[int, int]] = None @@ -79,7 +112,6 @@ def __init__( self._reader_error: Optional[BaseException] = None self._reader_eof = False - self._resolve_device() self._probe() # ------------------------------------------------------------------ @@ -140,36 +172,19 @@ def is_open(self) -> bool: t = self._reader_thread return t is not None and t.is_alive() and not self._stop_event.is_set() - def has_timing(self) -> bool: - return True - - def has_size(self) -> bool: - return self._size is not None - - def has_fps(self) -> bool: - return self._fps is not None - - def has_num_frames(self) -> bool: - return False - - def is_seekable(self) -> bool: - return False - - def get_size(self) -> Optional[Tuple[int, int]]: - return self._size - - def get_fps(self) -> Optional[float]: - return self._fps - - def get_num_channels(self) -> int: - return self._channels + def has_timing(self) -> bool: return True + def has_size(self) -> bool: return self._size is not None + def has_fps(self) -> bool: return self._fps is not None + def has_num_frames(self) -> bool: return False + def is_seekable(self) -> bool: return False - def get_num_frames(self) -> Optional[int]: - return None + def get_size(self) -> Optional[Tuple[int, int]]: return self._size + def get_fps(self) -> Optional[float]: return self._fps + def get_num_channels(self) -> int: return self._channels + def get_num_frames(self) -> Optional[int]: return None def seek(self, frame_index: int) -> None: - # live source; no-op - pass + pass # live source; no-op def read(self, timeout: Optional[float] = None) -> Optional[VideoFrame]: deadline = None if timeout is None else (time.perf_counter() + timeout) @@ -195,92 +210,11 @@ def read(self, timeout: Optional[float] = None) -> Optional[VideoFrame]: self._cond.wait(timeout=remaining) - # ------------------------------------------------------------------ - # Device enumeration helpers - # ------------------------------------------------------------------ - - @classmethod - def list_video_devices(cls) -> List[str]: - """ - Returns video device names in the order ffmpeg lists them. - - Duplicate names are returned multiple times. - """ - cmd = [ - str(cls.ffmpeg_executable), - "-hide_banner", - "-list_devices", "true", - "-f", "dshow", - "-i", "dummy", - ] - - proc = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - ) - - text = proc.stderr - devices = [] - - for line in text.splitlines(): - if not line.endswith("(video)"): - continue - m = re.search(r'"([^"]+)"', line) - if m: - devices.append(m.group(1)) - - return devices - - @classmethod - def list_video_device_entries(cls) -> List[Tuple[int, str, int]]: - """ - Returns [(global_index, device_name, video_device_number), ...]. - - video_device_number is the dshow duplicate-name selector. - """ - names = cls.list_video_devices() - counts = {} - out = [] - for i, name in enumerate(names): - n = counts.get(name, 0) - out.append((i, name, n)) - counts[name] = n + 1 - return out - - def _resolve_device(self) -> None: - if self._device_name is not None: - self._device_number = 0 - return - - entries = self.list_video_device_entries() - if not entries: - raise RuntimeError("No DirectShow video devices found") - - idx = int(self._device_index) - if idx < 0 or idx >= len(entries): - raise IndexError( - f"device_index {idx} out of range; found {len(entries)} video device(s)" - ) - - _, name, devnum = entries[idx] - self._device_name = name - self._device_number = devnum - # ------------------------------------------------------------------ # Probe # ------------------------------------------------------------------ def _probe(self) -> None: - """ - Try to open the device briefly and inspect the video stream metadata. - - If width/height were explicitly requested, trust them as the expected - output size. Otherwise use stream metadata when available. - """ if self._requested_width is not None and self._requested_height is not None: self._size = (self._requested_width, self._requested_height) else: @@ -321,7 +255,6 @@ def _update_size_and_fps_from_stream(self, stream) -> None: rate = None if rate: break - if rate: try: self._fps = float(rate) @@ -329,52 +262,59 @@ def _update_size_and_fps_from_stream(self, stream) -> None: pass # ------------------------------------------------------------------ - # PyAV container / stream handling + # Container / stream handling # ------------------------------------------------------------------ - def _build_dshow_options(self) -> dict: - opts = {'rtbufsize': '500M'} + def _open_container(self) -> av.container.InputContainer: + container_factory = self._container_factory or av.open + + # DirectShow requires "video=" to select the video device; + # V4L2 and AVFoundation use the path/index directly. + if self._input_format == "dshow": + file = f"video={self._device_name}" + else: + file = self._device_name + fmt = self._input_format + mode = "r" + options = self._build_capture_options() + + + return container_factory(file=file, format=fmt, mode=mode, options=options) + + def _build_capture_options(self) -> dict: + opts: dict = {} if self._requested_fps is not None: - # FFmpeg dshow private option opts["framerate"] = str(self._requested_fps) if self._requested_width is not None and self._requested_height is not None: - # FFmpeg dshow private option opts["video_size"] = f"{self._requested_width}x{self._requested_height}" - if self._device_number: - # FFmpeg dshow private option for duplicate names - opts["video_device_number"] = str(self._device_number) + # DirectShow: enlarge the real-time capture buffer to reduce drops + if self._input_format == "dshow": + opts["rtbufsize"] = "500M" return opts - def _open_container(self) -> av.container.InputContainer: - if self._device_name is None: - raise RuntimeError("Device name was not resolved") - - # PyAV's av.open accepts input format and options, which are passed through - # to FFmpeg/libavformat. - return av.open( - file=f"video={self._device_name}", - format="dshow", - mode="r", - options=self._build_dshow_options(), - ) + @staticmethod + def _default_input_format() -> str: + import sys + if sys.platform == "win32": + return "dshow" + if sys.platform == "darwin": + return "avfoundation" + return "v4l2" @staticmethod def _select_video_stream(container: av.container.InputContainer): video_streams = [s for s in container.streams if s.type == "video"] if not video_streams: - raise RuntimeError("No video stream found from DirectShow device") + raise RuntimeError("No video stream found in container") stream = video_streams[0] - - # Let FFmpeg/PyAV use frame threading when available. try: stream.thread_type = "AUTO" except Exception: pass - return stream # ------------------------------------------------------------------ @@ -398,22 +338,16 @@ def _reader_loop(self) -> None: arr = frame.to_ndarray(format=target_format) if self._grayscale: - # Usually already (h, w), but normalize defensively. if arr.ndim == 3 and arr.shape[-1] == 1: arr = arr[..., 0] else: - # Should already be (h, w, 3) if arr.ndim != 3 or arr.shape[-1] != 3: raise RuntimeError( f"Unexpected color frame shape from PyAV: {arr.shape}" ) - # Update size if it was unknown initially. if self._size is None: - if self._grayscale: - self._size = (int(arr.shape[1]), int(arr.shape[0])) - else: - self._size = (int(arr.shape[1]), int(arr.shape[0])) + self._size = (int(arr.shape[1]), int(arr.shape[0])) ts = self._frame_timestamp_seconds(frame) @@ -421,7 +355,6 @@ def _reader_loop(self) -> None: self._idx += 1 with self._cond: - # Keep only the newest frames if consumer is slower than source. while len(self._queue) >= self._queue_size: self._queue.popleft() self._queue.append(vf) @@ -440,15 +373,6 @@ def _reader_loop(self) -> None: @staticmethod def _frame_timestamp_seconds(frame) -> float: - """ - Prefer source/container-derived timing if present. - - PyAV frames may expose: - - frame.time - - frame.pts + frame.time_base - - Fallback is a monotonic clock sample at delivery time. - """ try: t = getattr(frame, "time", None) if t is not None: diff --git a/src/py3r/media/video/pylon_camera_source.py b/src/py3r/media/video/pylon_camera_source.py index 313409f..e200346 100644 --- a/src/py3r/media/video/pylon_camera_source.py +++ b/src/py3r/media/video/pylon_camera_source.py @@ -1,6 +1,6 @@ import time from pathlib import Path -from typing import Optional, Tuple +from typing import Any, Optional, Protocol, Tuple from pypylon import genicam, pylon @@ -17,26 +17,58 @@ class GrabTimeoutError(BaseException): class PylonCameraSource(VideoSource): - def __init__(self, serial: str, config_file: Optional[Path] = None): + """ + Pull-based Basler camera source via pypylon. + + Parameters + ---------- + serial : str + Camera serial number used to locate the device via TlFactory. + config_file : Path | None + PFS feature-persistence file to load after opening. Errors are + swallowed so that emulated cameras or mismatched configs don't crash + at startup. + camera_factory : CameraFactory | None + If provided, replaces the real ``TlFactory`` device enumeration. + Called with the serial number and must return an already-opened + camera object:: + + camera_factory(serial) -> camera + + Use this in unit tests to inject a fake camera without needing + physical hardware or a pypylon installation. + """ + + class CameraFactory(Protocol): + def __call__(self, serial: str) -> Any: ... + + def __init__( + self, + serial: str, + config_file: Optional[Path] = None, + *, + camera_factory: Optional[CameraFactory] = None, + ): self._serial = serial self._config_file = config_file + self._camera_factory = camera_factory self._cam = None self._idx = 0 - self._size = None - self._fps = None + self._size: Optional[Tuple[int, int]] = None + self._fps: Optional[float] = None self._gray = False - - self._tick_frequency = 125_000_000 - self._has_hw_timestamp = False + self._tick_frequency: int = 1_000_000_000 # default: assume ns until probed self._probe() + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def open(self) -> None: self._cam = self._open_camera() self._configure_camera(self._cam) - self._cam.MaxNumBuffer = 30 - self._cam.StartGrabbing(pylon.GrabStrategy_OneByOne) self._idx = 0 @@ -46,14 +78,16 @@ def close(self) -> None: self._cam.Close() self._cam = None - def is_open(self) -> bool: return self._cam is not None and self._cam.IsOpen() - def has_timing(self) -> bool: return True # device timestamp - def has_size(self) -> bool: return True - def has_fps(self) -> bool: return bool(self._fps) + def is_open(self) -> bool: + return self._cam is not None and self._cam.IsOpen() + + def has_timing(self) -> bool: return True + def has_size(self) -> bool: return self._size is not None + def has_fps(self) -> bool: return self._fps is not None def has_num_frames(self) -> bool: return False def is_seekable(self) -> bool: return False - def get_size(self) -> Optional[Tuple[int,int]]: return self._size + def get_size(self) -> Optional[Tuple[int, int]]: return self._size def get_fps(self) -> Optional[float]: return self._fps def get_num_channels(self) -> int: return 1 if self._gray else 3 def get_num_frames(self) -> Optional[int]: return None @@ -65,101 +99,113 @@ def read(self, timeout: Optional[float] = None) -> VideoFrame: """ Read the next frame from the camera. - Returns a VideoFrame on success. - Raises ------ GrabTimeoutError - The camera did not deliver a frame within *timeout* seconds. - This is **retryable** — the camera is still running. + No frame within *timeout* seconds — retryable. GrabFailedError - The SDK returned a result but ``GrabSucceeded()`` was False (e.g. - incomplete / dropped frame due to CPU or network load). - This is also **retryable** — individual dropped frames are normal. + SDK returned GrabSucceeded()==False (dropped frame) — retryable. RuntimeError - The camera is not open or has stopped grabbing. This is fatal. + Camera not open or stopped grabbing — fatal. """ if not self._cam or not self._cam.IsGrabbing(): raise RuntimeError("Camera is not open or has stopped grabbing") grab_timeout_ms = 500 if timeout is None else int(timeout * 1000) - # Use TimeoutHandling_Return so the SDK gives us back a None/invalid - # result on timeout rather than raising its own exception, which lets - # us translate it into our typed hierarchy cleanly. + # TimeoutHandling_Return gives back None on timeout instead of raising, + # so we can translate it into our typed exception hierarchy cleanly. result = self._cam.RetrieveResult(grab_timeout_ms, pylon.TimeoutHandling_Return) - if result is None: - raise GrabTimeoutError( - f"No frame received within {timeout or 0.5:.3f}s" - ) + # TimeoutHandling_Return yields an empty (invalid) GrabResult on timeout, + # not None — use __bool__ / IsValid() rather than identity check. + if not result: + raise GrabTimeoutError(f"No frame received within {timeout or 0.5:.3f}s") if not result.GrabSucceeded(): err_code = result.GetErrorCode() err_desc = result.GetErrorDescription() result.Release() - raise GrabFailedError( - f"Grab failed (code={err_code:#010x}): {err_desc}" - ) - - img = result.Array # numpy view — copy before Release - img = img.copy() - ts_device_ns = result.TimeStamp if self._has_hw_timestamp else None - ts = (ts_device_ns / self._tick_frequency) if ts_device_ns is not None else time.perf_counter() # type: ignore[operator] + raise GrabFailedError(f"Grab failed (code={err_code:#010x}): {err_desc}") + + img = result.Array.copy() # copy before Release + + # BlockID is the hardware frame counter — gaps are immediately visible + # to downstream pipelines. Fall back to our own counter if unavailable. + block_id = getattr(result, "BlockID", None) + frame_index = int(block_id) if block_id is not None else self._idx + + # Device timestamp in hardware ticks; divide by tick frequency for seconds. + ts_device_ticks = getattr(result, "TimeStamp", None) + ts = (ts_device_ticks / self._tick_frequency) if ts_device_ticks else time.perf_counter() + result.Release() - f = VideoFrame(img, self._idx, ts) self._idx += 1 - return f + return VideoFrame(img, frame_index, ts) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _open_camera(self): + if self._camera_factory is not None: + return self._camera_factory(self._serial) - def _open_camera(self) -> pylon.InstantCamera: tl_factory = pylon.TlFactory.GetInstance() devices = tl_factory.EnumerateDevices() if not devices: raise RuntimeError("No Basler camera found") - dev = next((dev for dev in devices if dev.GetSerialNumber() == self._serial), None) + dev = next((d for d in devices if d.GetSerialNumber() == self._serial), None) if dev is None: raise RuntimeError(f"Basler camera with serial '{self._serial}' not found") cam = pylon.InstantCamera(tl_factory.CreateDevice(dev)) cam.Open() return cam - def _configure_camera(self, camera: pylon.InstantCamera): - jumbo_frames = False - + def _configure_camera(self, camera) -> None: if self._config_file is not None: - pylon.FeaturePersistence.Load(str(self._config_file), camera.GetNodeMap(), True) - elif self._serial.startswith("0815-"): - camera.Width.SetValue(1280) - camera.Height.SetValue(1024) - camera.AcquisitionFrameRateAbs.SetValue(30.0) + try: + pylon.FeaturePersistence.Load( + str(self._config_file), camera.GetNodeMap(), True + ) + except Exception: + # Config files made for a real GigE camera will fail on the + # emulator (and vice-versa) because device-specific nodes don't + # exist. Swallow so the camera is still usable. + pass - frame_size = 9000 if jumbo_frames else 1500 + # GigE packet size — silently ignored on USB / emulated cameras. try: - camera.GevSCPSPacketSize.SetValue(frame_size) - except genicam.LogicalErrorException: + camera.GevSCPSPacketSize.SetValue(1500) + except (genicam.LogicalErrorException, AttributeError): pass - def _probe(self): - cam = self._open_camera() + def _probe(self) -> None: try: - self._configure_camera(cam) - + cam = self._open_camera() try: - self._tick_frequency = cam.GevTimestampTickFrequency.GetValue() - self._has_hw_timestamp = True - except genicam.LogicalErrorException: - pass - - width = cam.Width.GetValue() - height = cam.Height.GetValue() - self._size = (width, height) - - try: - self._fps = cam.AcquisitionFrameRateAbs.GetValue() or 30.0 - except genicam.LogicalErrorException: - self._fps = 30.0 - - self._gray = cam.PixelFormat.GetValue() == "Mono8" - finally: - cam.Close() + self._configure_camera(cam) + + try: + self._tick_frequency = int(cam.GevTimestampTickFrequency.GetValue()) + except (genicam.LogicalErrorException, AttributeError): + pass # USB / emulated cameras — keep default 1_000_000_000 + + self._size = (int(cam.Width.GetValue()), int(cam.Height.GetValue())) + + try: + fps = cam.AcquisitionFrameRateAbs.GetValue() + self._fps = float(fps) if fps else 30.0 + except (genicam.LogicalErrorException, AttributeError): + self._fps = 30.0 + + try: + self._gray = cam.PixelFormat.GetValue() == "Mono8" + except (genicam.LogicalErrorException, AttributeError): + self._gray = False + finally: + cam.Close() + except Exception: + # Soft-fail: caller can still open() later. + pass diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..45e68af --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,138 @@ +""" +Top-level pytest fixtures shared by all test modules. + +Synthetic video files +--------------------- +Rather than bundling real footage, the fixtures here generate small, +deterministic MP4 files at session start using PyAV directly (no subprocess, +no dependency on the classes under test). + +Frame content +~~~~~~~~~~~~~ +Each frame is a solid fill with a single gray/colour value that increases +linearly from 0 → 255 across the N frames. Solid uniform frames round-trip +through YUV conversion exactly (no chroma, so Y == the original gray value), +which lets tests assert pixel content precisely even with lossy containers. + +Layout +~~~~~~ +VIDEO_W × VIDEO_H pixels, VIDEO_N frames at VIDEO_FPS fps. +Stored as H.264/yuv420p inside an MP4 so cv2.VideoCapture can parse them +reliably on all platforms. +""" + +from __future__ import annotations + +from fractions import Fraction +from pathlib import Path + +import av +import numpy as np +import pytest + + +# --------------------------------------------------------------------------- +# CLI options (must live here so they are registered before any collection) +# --------------------------------------------------------------------------- + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--require-webcam", + action="store_true", + default=False, + help="Fail (instead of skip) when no USB webcam is detected.", + ) + parser.addoption( + "--require-pylon", + action="store_true", + default=False, + help="Fail (instead of skip) when no Basler/Pylon camera is detected.", + ) + parser.addoption( + "--webcam-name", + default=None, + metavar="NAME", + help=( + "Platform device name passed to PyAVWebcamSource. " + "Windows DirectShow example: 'Integrated Camera'. " + "Linux V4L2 example: '/dev/video0'. " + "Required to run PyAVWebcamSource hardware tests." + ), + ) + +# --------------------------------------------------------------------------- +# Constants shared across unit tests +# --------------------------------------------------------------------------- + +VIDEO_W = 64 +VIDEO_H = 48 +VIDEO_N = 30 # frames per synthetic file +VIDEO_FPS = 30.0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_frames(n: int, width: int, height: int, grayscale: bool) -> list[np.ndarray]: + """Return *n* uint8 frames with linearly increasing brightness. + + Grayscale: shape (H, W) — value = round(255 * i / (n-1)) + Color BGR: shape (H, W, 3) — blue channel encodes brightness, + green & red are 0. + Frames are solid fills, ensuring exact round-trips through yuv420p. + """ + frames = [] + for i in range(n): + val = round(255 * i / max(n - 1, 1)) + if grayscale: + frames.append(np.full((height, width), val, dtype=np.uint8)) + else: + img = np.zeros((height, width, 3), dtype=np.uint8) + img[:, :, 0] = val # blue + frames.append(img) + return frames + + +def _write_mp4(path: Path, frames: list[np.ndarray], fps: float, src_fmt: str) -> None: + """Write *frames* to *path* as H.264/yuv420p MP4 via PyAV.""" + rate = Fraction(fps).limit_denominator(1001) + h, w = frames[0].shape[:2] + with av.open(str(path), mode="w") as container: + stream = container.add_stream("libx264", rate=rate) + stream.width = w + stream.height = h + stream.pix_fmt = "yuv420p" + # crf=0 is bitwise lossless *within YUV space*; solid-fill frames + # survive the gray→yuv→gray round-trip exactly. + stream.options = {"crf": "0", "preset": "ultrafast"} + for i, img in enumerate(frames): + av_frame = av.VideoFrame.from_ndarray(img, format=src_fmt) + av_frame = av_frame.reformat(format="yuv420p") + av_frame.pts = i + for pkt in stream.encode(av_frame): + container.mux(pkt) + for pkt in stream.encode(None): + container.mux(pkt) + + +# --------------------------------------------------------------------------- +# Session-scoped fixtures (created once, shared across all tests) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def synthetic_gray_mp4(tmp_path_factory) -> Path: + """30-frame 64×48 grayscale MP4 at 30 fps.""" + path = tmp_path_factory.mktemp("video") / "gray_30f.mp4" + frames = make_frames(VIDEO_N, VIDEO_W, VIDEO_H, grayscale=True) + _write_mp4(path, frames, VIDEO_FPS, src_fmt="gray") + return path + + +@pytest.fixture(scope="session") +def synthetic_color_mp4(tmp_path_factory) -> Path: + """30-frame 64×48 BGR color MP4 at 30 fps.""" + path = tmp_path_factory.mktemp("video") / "color_30f.mp4" + frames = make_frames(VIDEO_N, VIDEO_W, VIDEO_H, grayscale=False) + _write_mp4(path, frames, VIDEO_FPS, src_fmt="bgr24") + return path diff --git a/tests/hardware/__init__.py b/tests/hardware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hardware/conftest.py b/tests/hardware/conftest.py new file mode 100644 index 0000000..e898d68 --- /dev/null +++ b/tests/hardware/conftest.py @@ -0,0 +1,128 @@ +""" +Hardware-test fixtures and CLI options. + +Device detection +---------------- +Availability is probed once per session using lightweight checks that do not +depend on the classes under test. If a device type is absent the relevant +tests are *skipped* automatically — no flag required. + +The ``--require-*`` flags flip the default: instead of skipping, pytest +*fails* when a device is not found. Use these in CI pipelines where a +specific device must be present (e.g. ``--require-webcam`` on a rig that +always has a USB camera attached). + +Marker +------ +All tests in this package should be decorated with ``@pytest.mark.hardware`` +so they can be excluded in one shot with ``-m "not hardware"``. +""" + +from __future__ import annotations + +import sys + +import pytest + + + +# --------------------------------------------------------------------------- +# Low-level detection helpers (no side-effects on the DUT classes) +# --------------------------------------------------------------------------- + +def _probe_cv2_webcam_indices(max_probe: int = 4) -> list[int]: + """Return indices of all openable cv2 webcams (0 … max_probe-1).""" + try: + import cv2 + found = [] + for i in range(max_probe): + cap = cv2.VideoCapture(i, cv2.CAP_ANY) + if cap.isOpened(): + found.append(i) + cap.release() + return found + except Exception: + return [] + + +def _probe_pylon_serials() -> list[str]: + """Return serial numbers of all connected Basler cameras.""" + try: + from pypylon import pylon + factory = pylon.TlFactory.GetInstance() + return [d.GetSerialNumber() for d in factory.EnumerateDevices()] + except Exception: + return [] + + +# --------------------------------------------------------------------------- +# Session-scoped availability fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def webcam_indices(request: pytest.FixtureRequest) -> list[int]: + """Sorted list of openable cv2 webcam device indices (may be empty).""" + indices = _probe_cv2_webcam_indices() + if not indices and request.config.getoption("--require-webcam"): + pytest.fail("--require-webcam: no USB webcam detected on this machine.") + return indices + + +@pytest.fixture(scope="session") +def pylon_serials(request: pytest.FixtureRequest) -> list[str]: + """Serial numbers of all connected Basler cameras (may be empty).""" + serials = _probe_pylon_serials() + if not serials and request.config.getoption("--require-pylon"): + pytest.fail("--require-pylon: no Basler/Pylon camera detected.") + return serials + + +# --------------------------------------------------------------------------- +# Convenience skip-fixtures (use these inside individual test functions) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def first_webcam_index(webcam_indices: list[int]) -> int: + """The first available webcam index, or *skip* the test.""" + if not webcam_indices: + pytest.skip("No USB webcam connected — skipping webcam test.") + return webcam_indices[0] + + +@pytest.fixture(scope="session") +def first_pylon_serial(pylon_serials: list[str]) -> str: + """The first available Pylon serial number, or *skip* the test.""" + if not pylon_serials: + pytest.skip("No Basler/Pylon camera connected — skipping Pylon test.") + return pylon_serials[0] + + +@pytest.fixture(scope="session") +def pyav_webcam_name(request: pytest.FixtureRequest) -> str: + """ + The device name to pass to PyAVWebcamSource, or *skip* the test. + + Provide via:: + + pytest --webcam-name="Integrated Camera" tests/hardware/ + """ + name = request.config.getoption("--webcam-name") + if not name: + pytest.skip( + "Pass --webcam-name='' to run PyAVWebcamSource hardware tests." + ) + return name + + +# --------------------------------------------------------------------------- +# Platform guard +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session", autouse=False) +def require_windows() -> None: + """Skip the test on non-Windows platforms.""" + if sys.platform != "win32": + pytest.skip("This test requires Windows (DirectShow).") + + + diff --git a/tests/hardware/test_opencv_webcam_source.py b/tests/hardware/test_opencv_webcam_source.py new file mode 100644 index 0000000..96afc17 --- /dev/null +++ b/tests/hardware/test_opencv_webcam_source.py @@ -0,0 +1,235 @@ +""" +Hardware tests for OpenCVWebcamSource. + +Skipped automatically when no USB webcam is detected. Pass +``--require-webcam`` to turn skips into failures (useful in CI). + +A session-scoped fixture opens the grayscale source once; all grayscale tests +share that live stream. Color tests open their own short-lived source because +most webcam drivers (e.g. DirectShow on Windows) only allow one VideoCapture +consumer at a time — having two session sources open simultaneously causes +read() to fail. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from py3r.media.types import VideoFrame +from py3r.media.video.opencv_webcam_source import OpenCVWebcamSource, ReadFailedError + + +pytestmark = [pytest.mark.hardware] + + +# --------------------------------------------------------------------------- +# Session-scoped grayscale source (kept open for the whole session) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def cv_webcam_gray(first_webcam_index: int) -> OpenCVWebcamSource: + """Open an OpenCVWebcamSource (grayscale) on the first available device.""" + try: + src = OpenCVWebcamSource(first_webcam_index, grayscale=True) + except Exception as exc: + pytest.skip(f"OpenCVWebcamSource({first_webcam_index}) failed to init: {exc}") + + src.open() + yield src + src.close() + + +# --------------------------------------------------------------------------- +# Function fixture: temporarily close the session source for exclusive access +# --------------------------------------------------------------------------- + +@pytest.fixture() +def isolated_source( + cv_webcam_gray: OpenCVWebcamSource, + first_webcam_index: int, +): + """ + Close the session gray source, yield the device index, then reopen it. + Ensures no two VideoCapture objects are open on the same device at once. + """ + cv_webcam_gray.close() + try: + yield first_webcam_index + finally: + cv_webcam_gray.open() + + +# --------------------------------------------------------------------------- +# Probe / metadata +# --------------------------------------------------------------------------- + +class TestProbe: + def test_size_is_populated(self, cv_webcam_gray: OpenCVWebcamSource): + """Size must be known after construction (probe runs in __init__).""" + size = cv_webcam_gray.get_size() + assert size is not None + w, h = size + assert w > 0 and h > 0 + + def test_fps_is_positive_if_known(self, cv_webcam_gray: OpenCVWebcamSource): + fps = cv_webcam_gray.get_fps() + if fps is not None: + assert fps > 0 + + def test_capability_flags(self, cv_webcam_gray: OpenCVWebcamSource): + assert cv_webcam_gray.has_timing() + assert not cv_webcam_gray.has_num_frames() + assert not cv_webcam_gray.is_seekable() + + def test_num_channels_grayscale(self, cv_webcam_gray: OpenCVWebcamSource): + assert cv_webcam_gray.get_num_channels() == 1 + + def test_num_channels_color(self, isolated_source: int): + """Probe only — no open() needed, so no conflict with the session source.""" + try: + src = OpenCVWebcamSource(isolated_source, grayscale=False) + except Exception as exc: + pytest.skip(f"Could not init color source: {exc}") + assert src.get_num_channels() == 3 + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_is_open_after_open(self, cv_webcam_gray: OpenCVWebcamSource): + assert cv_webcam_gray.is_open() + + def test_close_stops_is_open(self, isolated_source: int): + try: + src = OpenCVWebcamSource(isolated_source, grayscale=True) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + assert src.is_open() + src.close() + assert not src.is_open() + + def test_double_close_is_safe(self, isolated_source: int): + try: + src = OpenCVWebcamSource(isolated_source, grayscale=True) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + src.close() + src.close() # must not raise + + +# --------------------------------------------------------------------------- +# Read — grayscale (uses the long-lived session source) +# --------------------------------------------------------------------------- + +class TestReadGrayscale: + def test_read_returns_video_frame(self, cv_webcam_gray: OpenCVWebcamSource): + frame = cv_webcam_gray.read() + assert isinstance(frame, VideoFrame) + + def test_frame_shape_is_2d(self, cv_webcam_gray: OpenCVWebcamSource): + """Grayscale frames must have shape (H, W).""" + frame = cv_webcam_gray.read() + assert frame.img.ndim == 2 + + def test_frame_dtype_is_uint8(self, cv_webcam_gray: OpenCVWebcamSource): + frame = cv_webcam_gray.read() + assert frame.img.dtype == np.uint8 + + def test_frame_size_matches_probe(self, cv_webcam_gray: OpenCVWebcamSource): + size = cv_webcam_gray.get_size() + frame = cv_webcam_gray.read() + h, w = frame.img.shape + assert (w, h) == size + + def test_frame_is_not_all_zeros(self, cv_webcam_gray: OpenCVWebcamSource): + """A real camera frame must contain at least some non-zero pixels.""" + frame = cv_webcam_gray.read() + assert frame.img.max() > 0 + + +# --------------------------------------------------------------------------- +# Read — color +# NOTE: These tests open their *own* source and close it immediately. +# They do NOT run concurrently with the session gray source — pytest runs +# tests serially, so the session source is idle (not mid-read) when these +# execute. However, the session gray source still holds the device open, +# meaning we cannot open a second VideoCapture on the same device. +# We therefore run color tests with the session fixture closed then reopened +# around the test, using the `cv_webcam_color_frame` fixture below. +# --------------------------------------------------------------------------- + +@pytest.fixture() +def cv_webcam_color_frame(isolated_source: int) -> np.ndarray: + """ + Open a color source with exclusive access, grab one frame, close. + Uses isolated_source so the session gray source is not open simultaneously. + """ + try: + src = OpenCVWebcamSource(isolated_source, grayscale=False) + src.open() + try: + frame = src.read() + finally: + src.close() + except Exception as exc: + pytest.skip(f"Could not read color frame: {exc}") + return frame.img + + +class TestReadColor: + def test_frame_shape_is_3d(self, cv_webcam_color_frame: np.ndarray): + assert cv_webcam_color_frame.ndim == 3 + assert cv_webcam_color_frame.shape[2] == 3 + + def test_frame_dtype_is_uint8(self, cv_webcam_color_frame: np.ndarray): + assert cv_webcam_color_frame.dtype == np.uint8 + + +# --------------------------------------------------------------------------- +# Frame index +# --------------------------------------------------------------------------- + +class TestFrameIndex: + def test_frame_index_starts_at_zero_after_open(self, isolated_source: int): + """_idx must reset to 0 on each open().""" + try: + src = OpenCVWebcamSource(isolated_source, grayscale=True) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + try: + f = src.read() + assert f.frame_index == 0 + finally: + src.close() + + def test_frame_index_increments(self, cv_webcam_gray: OpenCVWebcamSource): + f1 = cv_webcam_gray.read() + f2 = cv_webcam_gray.read() + assert f2.frame_index == f1.frame_index + 1 + + def test_five_consecutive_indices(self, cv_webcam_gray: OpenCVWebcamSource): + frames = [cv_webcam_gray.read() for _ in range(5)] + indices = [f.frame_index for f in frames] + diffs = [indices[i + 1] - indices[i] for i in range(len(indices) - 1)] + assert all(d == 1 for d in diffs), f"Non-consecutive indices: {indices}" + + +# --------------------------------------------------------------------------- +# Timestamps +# --------------------------------------------------------------------------- + +class TestTimestamps: + def test_timestamp_is_non_negative(self, cv_webcam_gray: OpenCVWebcamSource): + frame = cv_webcam_gray.read() + assert frame.timestamp >= 0.0 + + def test_timestamps_are_non_decreasing(self, cv_webcam_gray: OpenCVWebcamSource): + frames = [cv_webcam_gray.read() for _ in range(5)] + ts = [f.timestamp for f in frames] + assert all(ts[i] <= ts[i + 1] for i in range(len(ts) - 1)) diff --git a/tests/hardware/test_pyav_webcam_video_source.py b/tests/hardware/test_pyav_webcam_video_source.py new file mode 100644 index 0000000..075653a --- /dev/null +++ b/tests/hardware/test_pyav_webcam_video_source.py @@ -0,0 +1,266 @@ +""" +Hardware tests for PyAVWebcamSource. + +These tests are skipped automatically unless ``--webcam-name`` is passed. +The flag accepts the platform device identifier for PyAV: + +* **Windows DirectShow** — the display name, e.g. ``"Integrated Camera"`` +* **Linux V4L2** — the device node, e.g. ``"/dev/video0"`` +* **macOS AVFoundation** — the device index as string, e.g. ``"0"`` + +Example:: + + pytest tests/hardware/test_pyav_webcam_video_source.py \\ + --webcam-name="Integrated Camera" -m hardware + +Pass ``--require-webcam`` to turn skips into failures (useful in CI). + +Because opening and closing a webcam is slow (and may cause frame-drop +artefacts), a single session-scoped fixture opens the source once and most +tests share the same live stream. + +Tests that need exclusive device access (color read, lifecycle open/close, +timeout) use the ``isolated_source`` function fixture, which temporarily +closes the session source, runs the test, then reopens it. +""" + +from __future__ import annotations + +import time + +import numpy as np +import pytest + +from py3r.media.video.pyav_webcam_source import PyAVWebcamSource + + +# --------------------------------------------------------------------------- +# Mark every test in this module as a hardware test +# --------------------------------------------------------------------------- + +pytestmark = [pytest.mark.hardware] + + +# --------------------------------------------------------------------------- +# Session-scoped grayscale source (kept open for the whole session) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def pyav_webcam_gray(pyav_webcam_name: str) -> PyAVWebcamSource: + """ + Open a PyAVWebcamSource (grayscale) against the device given by + ``--webcam-name``. Skipped if construction fails. + """ + try: + src = PyAVWebcamSource(pyav_webcam_name, grayscale=True) + except Exception as exc: + pytest.skip(f"PyAVWebcamSource('{pyav_webcam_name}') failed to init: {exc}") + + src.open() + time.sleep(0.5) # let the background reader buffer a frame + + yield src + + src.close() + + +# --------------------------------------------------------------------------- +# Function fixture: temporarily close the session source so the test has +# exclusive access to the device, then restore it afterwards. +# --------------------------------------------------------------------------- + +@pytest.fixture() +def isolated_source( + pyav_webcam_gray: PyAVWebcamSource, + pyav_webcam_name: str, +): + """ + Close the session gray source, yield the device name for the test to use, + then reopen the session source. Ensures no two sources are open at once. + """ + pyav_webcam_gray.close() + try: + yield pyav_webcam_name + finally: + pyav_webcam_gray.open() + time.sleep(0.5) + + +# --------------------------------------------------------------------------- +# Probe / metadata +# --------------------------------------------------------------------------- + +class TestProbe: + def test_size_is_populated(self, pyav_webcam_gray: PyAVWebcamSource): + """Size must be known after construction (probe runs in __init__).""" + size = pyav_webcam_gray.get_size() + assert size is not None + w, h = size + assert w > 0 and h > 0 + + def test_capability_flags(self, pyav_webcam_gray: PyAVWebcamSource): + assert pyav_webcam_gray.has_timing() + assert not pyav_webcam_gray.has_num_frames() + assert not pyav_webcam_gray.is_seekable() + + def test_num_channels_grayscale(self, pyav_webcam_gray: PyAVWebcamSource): + assert pyav_webcam_gray.get_num_channels() == 1 + + def test_num_channels_color(self, isolated_source: str): + """Probe-only — checks get_num_channels() without keeping device open.""" + try: + src = PyAVWebcamSource(isolated_source, grayscale=False) + except Exception as exc: + pytest.skip(f"Could not init color source: {exc}") + assert src.get_num_channels() == 3 + + def test_fps_is_positive_if_known(self, pyav_webcam_gray: PyAVWebcamSource): + fps = pyav_webcam_gray.get_fps() + if fps is not None: + assert fps > 0 + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_is_open_after_open(self, pyav_webcam_gray: PyAVWebcamSource): + assert pyav_webcam_gray.is_open() + + def test_close_stops_is_open(self, isolated_source: str): + """Open and close a fresh source with exclusive device access.""" + try: + src = PyAVWebcamSource(isolated_source, grayscale=True) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + assert src.is_open() + src.close() + assert not src.is_open() + + def test_double_close_is_safe(self, isolated_source: str): + try: + src = PyAVWebcamSource(isolated_source, grayscale=True) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + src.close() + src.close() # must not raise + + +# --------------------------------------------------------------------------- +# Read — grayscale +# --------------------------------------------------------------------------- + +class TestReadGrayscale: + def test_read_returns_video_frame(self, pyav_webcam_gray: PyAVWebcamSource): + from py3r.media.types import VideoFrame + frame = pyav_webcam_gray.read(timeout=5.0) + assert isinstance(frame, VideoFrame) + + def test_frame_shape_is_2d(self, pyav_webcam_gray: PyAVWebcamSource): + """Grayscale frames must have shape (H, W).""" + frame = pyav_webcam_gray.read(timeout=5.0) + assert frame.img.ndim == 2 + + def test_frame_dtype_is_uint8(self, pyav_webcam_gray: PyAVWebcamSource): + frame = pyav_webcam_gray.read(timeout=5.0) + assert frame.img.dtype == np.uint8 + + def test_frame_size_matches_probe(self, pyav_webcam_gray: PyAVWebcamSource): + size = pyav_webcam_gray.get_size() + frame = pyav_webcam_gray.read(timeout=5.0) + h, w = frame.img.shape + assert (w, h) == size + + def test_frame_is_not_all_zeros(self, pyav_webcam_gray: PyAVWebcamSource): + """A real camera frame must contain at least some non-zero pixels.""" + frame = pyav_webcam_gray.read(timeout=5.0) + assert frame.img.max() > 0 + + +# --------------------------------------------------------------------------- +# Read — color +# --------------------------------------------------------------------------- + +@pytest.fixture() +def pyav_color_frame(isolated_source: str) -> np.ndarray: + """Open a color source with exclusive access, grab one frame, close.""" + try: + src = PyAVWebcamSource(isolated_source, grayscale=False) + src.open() + time.sleep(0.5) + try: + frame = src.read(timeout=5.0) + finally: + src.close() + except Exception as exc: + pytest.skip(f"Could not read color frame: {exc}") + return frame.img + + +class TestReadColor: + def test_frame_shape_is_3d(self, pyav_color_frame: np.ndarray): + assert pyav_color_frame.ndim == 3 + assert pyav_color_frame.shape[2] == 3 + + def test_frame_dtype_is_uint8(self, pyav_color_frame: np.ndarray): + assert pyav_color_frame.dtype == np.uint8 + + +# --------------------------------------------------------------------------- +# Frame index +# --------------------------------------------------------------------------- + +class TestFrameIndex: + def test_frame_index_increments(self, pyav_webcam_gray: PyAVWebcamSource): + f1 = pyav_webcam_gray.read(timeout=5.0) + f2 = pyav_webcam_gray.read(timeout=5.0) + assert f2.frame_index == f1.frame_index + 1 + + def test_five_consecutive_indices(self, pyav_webcam_gray: PyAVWebcamSource): + frames = [pyav_webcam_gray.read(timeout=5.0) for _ in range(5)] + indices = [f.frame_index for f in frames] + diffs = [indices[i + 1] - indices[i] for i in range(len(indices) - 1)] + assert all(d == 1 for d in diffs), f"Non-consecutive indices: {indices}" + + +# --------------------------------------------------------------------------- +# Timestamps +# --------------------------------------------------------------------------- + +class TestTimestamps: + def test_timestamp_is_non_negative(self, pyav_webcam_gray: PyAVWebcamSource): + frame = pyav_webcam_gray.read(timeout=5.0) + assert frame.timestamp >= 0.0 + + def test_timestamps_are_non_decreasing(self, pyav_webcam_gray: PyAVWebcamSource): + frames = [pyav_webcam_gray.read(timeout=5.0) for _ in range(5)] + ts = [f.timestamp for f in frames] + assert all(ts[i] <= ts[i + 1] for i in range(len(ts) - 1)) + + +# --------------------------------------------------------------------------- +# Timeout +# --------------------------------------------------------------------------- + +class TestTimeout: + def test_zero_timeout_raises_timeout_error(self, isolated_source: str): + """ + Open a *fresh* source, do NOT wait for the reader to buffer a frame, + then immediately request with timeout=0. The queue should be empty + and TimeoutError should be raised. + """ + try: + src = PyAVWebcamSource(isolated_source, grayscale=True) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + + src.open() + # Do NOT sleep — give the background thread no time to buffer a frame. + try: + with pytest.raises(TimeoutError): + src.read(timeout=0.0) + finally: + src.close() diff --git a/tests/hardware/test_pylon_camera_source.py b/tests/hardware/test_pylon_camera_source.py new file mode 100644 index 0000000..7ef7b17 --- /dev/null +++ b/tests/hardware/test_pylon_camera_source.py @@ -0,0 +1,222 @@ +""" +Hardware tests for PylonCameraSource. + +Skipped automatically when no Basler/Pylon camera is detected. Pass +``--require-pylon`` to turn skips into failures (useful in CI). + +A single session-scoped fixture opens the camera once; most tests share the +live stream to avoid repeated open/close cycles (which are slow on GigE +cameras). + +Tests that need exclusive device access use the ``isolated_source`` function +fixture, which temporarily closes the session source, yields the serial number +for the test to use, then reopens the session source. +""" + +from __future__ import annotations + +import time +import numpy as np +import pytest + +from py3r.media.types import VideoFrame +from py3r.media.video.pylon_camera_source import ( + GrabFailedError, + GrabTimeoutError, + PylonCameraSource, +) + +pytest.importorskip("pypylon") + +pytestmark = [pytest.mark.hardware] + + +# --------------------------------------------------------------------------- +# Session-scoped source +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def pylon_source(first_pylon_serial: str) -> PylonCameraSource: + """Open a PylonCameraSource against the first detected Basler camera.""" + try: + src = PylonCameraSource(first_pylon_serial) + except Exception as exc: + pytest.skip(f"PylonCameraSource('{first_pylon_serial}') failed to init: {exc}") + + src.open() + yield src + src.close() + + +# --------------------------------------------------------------------------- +# Function fixture: temporarily close the session source for exclusive access +# --------------------------------------------------------------------------- + +@pytest.fixture() +def isolated_source( + pylon_source: PylonCameraSource, + first_pylon_serial: str, +): + """ + Close the session source, yield the serial number, then reopen it. + Ensures no two PylonCameraSource instances probe or open the device + simultaneously. + """ + pylon_source.close() + try: + yield first_pylon_serial + finally: + pylon_source.open() + + +# --------------------------------------------------------------------------- +# Probe / metadata +# --------------------------------------------------------------------------- + +class TestProbe: + def test_size_is_populated(self, pylon_source: PylonCameraSource): + size = pylon_source.get_size() + assert size is not None + w, h = size + assert w > 0 and h > 0 + + def test_fps_is_positive(self, pylon_source: PylonCameraSource): + fps = pylon_source.get_fps() + assert fps is not None and fps > 0 + + def test_capability_flags(self, pylon_source: PylonCameraSource): + assert pylon_source.has_timing() + assert not pylon_source.has_num_frames() + assert not pylon_source.is_seekable() + + def test_num_channels_is_1_or_3(self, pylon_source: PylonCameraSource): + assert pylon_source.get_num_channels() in (1, 3) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_is_open_after_open(self, pylon_source: PylonCameraSource): + assert pylon_source.is_open() + + def test_close_stops_is_open(self, isolated_source: str): + try: + src = PylonCameraSource(isolated_source) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + assert src.is_open() + src.close() + assert not src.is_open() + + def test_double_close_is_safe(self, isolated_source: str): + try: + src = PylonCameraSource(isolated_source) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + src.close() + src.close() # must not raise + + def test_read_before_open_raises(self, isolated_source: str): + try: + src = PylonCameraSource(isolated_source) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + with pytest.raises(RuntimeError, match="not open"): + src.read() + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- + +class TestRead: + def test_read_returns_video_frame(self, pylon_source: PylonCameraSource): + frame = pylon_source.read(timeout=5.0) + assert isinstance(frame, VideoFrame) + + def test_frame_dtype_is_uint8(self, pylon_source: PylonCameraSource): + frame = pylon_source.read(timeout=5.0) + assert frame.img.dtype == np.uint8 + + def test_frame_size_matches_probe(self, pylon_source: PylonCameraSource): + size = pylon_source.get_size() + frame = pylon_source.read(timeout=5.0) + h, w = frame.img.shape[:2] + assert (w, h) == size + + def test_frame_ndim_matches_channels(self, pylon_source: PylonCameraSource): + frame = pylon_source.read(timeout=5.0) + n_ch = pylon_source.get_num_channels() + if n_ch == 1: + assert frame.img.ndim == 2 + else: + assert frame.img.ndim == 3 and frame.img.shape[2] == n_ch + + def test_frame_is_not_all_zeros(self, pylon_source: PylonCameraSource): + frame = pylon_source.read(timeout=5.0) + assert frame.img.max() > 0 + + +# --------------------------------------------------------------------------- +# Frame index +# --------------------------------------------------------------------------- + +class TestFrameIndex: + def test_frame_index_starts_at_zero_after_open(self, isolated_source: str): + """_idx must reset to 0 on each open().""" + try: + src = PylonCameraSource(isolated_source) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + src.open() + try: + f = src.read(timeout=5.0) + assert f.frame_index >= 0 + finally: + src.close() + + def test_frame_indices_are_non_decreasing(self, pylon_source: PylonCameraSource): + frames = [pylon_source.read(timeout=5.0) for _ in range(5)] + indices = [f.frame_index for f in frames] + assert all(indices[i] < indices[i + 1] for i in range(len(indices) - 1)), ( + f"Frame indices not strictly increasing: {indices}" + ) + + +# --------------------------------------------------------------------------- +# Timestamps +# --------------------------------------------------------------------------- + +class TestTimestamps: + def test_timestamp_is_positive(self, pylon_source: PylonCameraSource): + frame = pylon_source.read(timeout=5.0) + assert frame.timestamp > 0.0 + + def test_timestamps_are_non_decreasing(self, pylon_source: PylonCameraSource): + frames = [pylon_source.read(timeout=5.0) for _ in range(5)] + ts = [f.timestamp for f in frames] + assert all(ts[i] <= ts[i + 1] for i in range(len(ts) - 1)) + + +# --------------------------------------------------------------------------- +# Timeout +# --------------------------------------------------------------------------- + +class TestTimeout: + def test_short_timeout_raises_grab_timeout(self, isolated_source: str): + """Near-zero timeout on a fresh source should raise GrabTimeoutError.""" + try: + src = PylonCameraSource(isolated_source) + except Exception as exc: + pytest.skip(f"Could not init source: {exc}") + + src.open() + try: + with pytest.raises(GrabTimeoutError): + src.read(timeout=0.001) + finally: + src.close() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/streaming/__init__.py b/tests/unit/streaming/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/streaming/conftest.py b/tests/unit/streaming/conftest.py new file mode 100644 index 0000000..cbe34b4 --- /dev/null +++ b/tests/unit/streaming/conftest.py @@ -0,0 +1,67 @@ +""" +Shared helpers for streaming operator unit tests. +""" +from __future__ import annotations + +import threading +from typing import Any, List, Optional, Tuple + +import pytest +import reactivex as rx +from reactivex.disposable import Disposable +from reactivex.scheduler import TimeoutScheduler + + +# --------------------------------------------------------------------------- +# Scheduler fixture (module-scoped — TimeoutScheduler is a singleton anyway) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def scheduler(): + return TimeoutScheduler.singleton() + + +# --------------------------------------------------------------------------- +# Helpers (importable by test modules) +# --------------------------------------------------------------------------- + +def collect( + source: rx.Observable, + *, + timeout: float = 2.0, +) -> Tuple[List[Any], List[BaseException], rx.abc.DisposableBase]: + """ + Subscribe to *source*, collect every on_next value and any on_error into + lists, and block until on_completed / on_error fires or *timeout* expires. + + Returns ``(items, errors, disposable)``. + """ + items: List[Any] = [] + errors: List[BaseException] = [] + done = threading.Event() + + disp = source.subscribe( + on_next=items.append, + on_error=lambda e: (errors.append(e), done.set()), + on_completed=done.set, + ) + done.wait(timeout=timeout) + return items, errors, disp + + +def from_items(*items: Any, error: Optional[BaseException] = None) -> rx.Observable: + """ + Cold observable that emits *items* synchronously on subscribe, then + either calls on_completed or on_error(*error*). + """ + def subscribe(observer, _=None): + for x in items: + observer.on_next(x) + if error is not None: + observer.on_error(error) + else: + observer.on_completed() + return Disposable() + + return rx.create(subscribe) + diff --git a/tests/unit/streaming/test_adaptive_pace.py b/tests/unit/streaming/test_adaptive_pace.py new file mode 100644 index 0000000..ad9027a --- /dev/null +++ b/tests/unit/streaming/test_adaptive_pace.py @@ -0,0 +1,60 @@ +"""Unit tests for adaptive_pace operator.""" +from __future__ import annotations +import time +import threading +from reactivex.scheduler import TimeoutScheduler +from py3r.media.streaming.operators.adaptive_pace import adaptive_pace +from tests.unit.streaming.conftest import collect, from_items +SCHED = TimeoutScheduler.singleton() +POLL = 0.001 # fast poll for tests +class TestDelivery: + def test_all_items_delivered(self): + items, errors, _ = collect( + from_items(1, 2, 3, 4, 5).pipe(adaptive_pace(_poll=POLL)) + ) + assert items == [1, 2, 3, 4, 5] and errors == [] + def test_items_delivered_in_order(self): + N = 10 + items, _, _ = collect(from_items(*range(N)).pipe(adaptive_pace(_poll=POLL))) + assert items == list(range(N)) + def test_empty_source_completes(self): + done = threading.Event() + from_items().pipe(adaptive_pace(_poll=POLL)).subscribe( + on_next=lambda x: None, on_completed=done.set + ) + assert done.wait(timeout=2.0) +class TestError: + def test_error_propagates(self): + err = RuntimeError("source error") + _, errors, _ = collect(from_items(1, 2, error=err).pipe(adaptive_pace(_poll=POLL))) + assert errors[0] is err + def test_items_before_error_are_delivered(self): + """Items already buffered before the error must be delivered first.""" + err = RuntimeError("late error") + items, errors, _ = collect(from_items(1, 2, error=err).pipe(adaptive_pace(_poll=POLL))) + assert 1 in items and 2 in items + assert len(errors) == 1 +class TestPacing: + def test_initial_interval_delays_delivery(self): + """With a 50ms initial interval, 3 items should take at least ~100ms.""" + t0 = time.perf_counter() + items, _, _ = collect( + from_items(1, 2, 3).pipe(adaptive_pace(initial_interval=0.05, _poll=POLL)) + ) + elapsed = time.perf_counter() - t0 + assert items == [1, 2, 3] + assert elapsed >= 0.08 # generous lower bound +class TestDispose: + def test_dispose_stops_delivery(self): + delivered = [] + gate = threading.Event() + import reactivex as rx + from reactivex.subject import Subject + subj = Subject() + disp = subj.pipe(adaptive_pace(_poll=POLL)).subscribe(on_next=delivered.append) + subj.on_next(1) + time.sleep(0.02) + disp.dispose() + subj.on_next(2) + time.sleep(0.05) + assert 2 not in delivered diff --git a/tests/unit/streaming/test_finally_future.py b/tests/unit/streaming/test_finally_future.py new file mode 100644 index 0000000..9e95077 --- /dev/null +++ b/tests/unit/streaming/test_finally_future.py @@ -0,0 +1,70 @@ +"""Unit tests for finally_future operator.""" +from __future__ import annotations +from concurrent.futures import Future +import reactivex as rx +from reactivex.disposable import Disposable +from py3r.media.streaming.operators.finally_future import finally_future +from tests.unit.streaming.conftest import collect, from_items +class TestCompleted: + def test_future_resolved_on_completed(self): + f = Future() + collect(from_items(1, 2).pipe(finally_future(f))) + assert f.done() and f.result() is None + def test_items_pass_through(self): + f = Future() + items, errors, _ = collect(from_items(10, 20).pipe(finally_future(f))) + assert items == [10, 20] and errors == [] + def test_future_resolved_only_once(self): + f = Future() + _, _, disp = collect(from_items(1).pipe(finally_future(f))) + disp.dispose() + assert f.done() and f.result() is None +class TestError: + def test_future_gets_exception_on_error(self): + f = Future() + err = RuntimeError("source failed") + collect(from_items(error=err).pipe(finally_future(f))) + assert f.done() and f.exception() is err + def test_error_passes_through_to_observer(self): + f = Future() + err = ValueError("x") + _, errors, _ = collect(from_items(error=err).pipe(finally_future(f))) + assert errors[0] is err +class TestDispose: + def test_cancel_on_dispose_true_cancels_future(self): + f = Future() + disp = rx.never().pipe(finally_future(f, cancel_on_dispose=True)).subscribe() + disp.dispose() + assert f.cancelled() + def test_cancel_on_dispose_false_resolves_future(self): + f = Future() + disp = rx.never().pipe(finally_future(f, cancel_on_dispose=False)).subscribe() + disp.dispose() + assert f.done() and not f.cancelled() and f.result() is None + def test_double_dispose_is_safe(self): + f = Future() + disp = rx.never().pipe(finally_future(f, cancel_on_dispose=True)).subscribe() + disp.dispose() + disp.dispose() + def test_dispose_after_complete_keeps_result(self): + f = Future() + _, _, disp = collect(from_items(1).pipe(finally_future(f, cancel_on_dispose=True))) + assert f.result() is None + disp.dispose() + assert not f.cancelled() and f.result() is None +class TestOnNextException: + def test_on_next_exception_sets_future(self): + f = Future() + err = ValueError("downstream misbehaved") + def misbehaving_source_sub(observer, _=None): + try: + observer.on_next(42) + except Exception: + pass + observer.on_completed() + return Disposable() + source = rx.create(misbehaving_source_sub) + def bad_next(x): + raise err + source.pipe(finally_future(f)).subscribe(on_next=bad_next, on_error=lambda e: None) + assert f.done() and f.exception() is err diff --git a/tests/unit/streaming/test_observe_on_bounded.py b/tests/unit/streaming/test_observe_on_bounded.py new file mode 100644 index 0000000..079b918 --- /dev/null +++ b/tests/unit/streaming/test_observe_on_bounded.py @@ -0,0 +1,143 @@ +"""Unit tests for observe_on_bounded operator.""" +from __future__ import annotations +import threading +import time +import reactivex as rx +from reactivex.subject import Subject +from reactivex.scheduler import TimeoutScheduler +import pytest +from py3r.media.streaming.operators.observe_on_bounded import observe_on_bounded +from tests.unit.streaming.conftest import collect, from_items +SCHED = TimeoutScheduler.singleton() +POLL = 0.002 # fast poll for tests +class TestBasic: + def test_all_items_delivered(self): + items, errors, _ = collect( + from_items(1, 2, 3).pipe(observe_on_bounded(SCHED, _worker_poll=POLL)) + ) + assert items == [1, 2, 3] and errors == [] + def test_completed_delivered(self): + done = threading.Event() + from_items(1).pipe(observe_on_bounded(SCHED, _worker_poll=POLL)).subscribe( + on_next=lambda x: None, on_completed=done.set + ) + assert done.wait(timeout=2.0) + def test_error_delivered(self): + err = RuntimeError("boom") + _, errors, _ = collect(from_items(error=err).pipe(observe_on_bounded(SCHED, _worker_poll=POLL))) + assert errors[0] is err + def test_invalid_policy_raises(self): + with pytest.raises(ValueError, match="invalid policy"): + observe_on_bounded(SCHED, policy="unknown") +class TestDeliveryThread: + def test_items_delivered_on_different_thread(self): + main_tid = threading.get_ident() + delivery_tids = [] + done = threading.Event() + from_items(1, 2, 3).pipe( + observe_on_bounded(SCHED, _worker_poll=POLL) + ).subscribe( + on_next=lambda x: delivery_tids.append(threading.get_ident()), + on_completed=done.set, + ) + done.wait(timeout=2.0) + assert all(tid != main_tid for tid in delivery_tids) +class TestBlockPolicy: + def test_block_delivers_all_items(self): + """Emit from a background thread so the worker can interleave with the producer.""" + N = 20 + received = [] + done = threading.Event() + subj = Subject() + subj.pipe( + observe_on_bounded(SCHED, maxsize=4, policy="block", timeout=0.1, _worker_poll=POLL) + ).subscribe(on_next=received.append, on_completed=done.set) + + def emit(): + for i in range(N): + subj.on_next(i) + subj.on_completed() + + t = threading.Thread(target=emit, daemon=True) + t.start() + done.wait(timeout=5.0) + t.join(timeout=1.0) + assert received == list(range(N)) +class TestDropNewest: + def test_drop_newest_drops_when_full(self): + """Block worker on first item, then overflow the queue � extra items must be dropped.""" + MAXSIZE = 2 + received = [] + first_arrived = threading.Event() + release_first = threading.Event() + done = threading.Event() + subj = Subject() + def slow_on_next(x): + if not first_arrived.is_set(): + first_arrived.set() + release_first.wait(timeout=2.0) + received.append(x) + subj.pipe( + observe_on_bounded(SCHED, maxsize=MAXSIZE, policy="drop_newest", _worker_poll=POLL) + ).subscribe(on_next=slow_on_next, on_completed=done.set) + # Emit item 0 � worker picks it up and blocks + subj.on_next(0) + first_arrived.wait(timeout=2.0) + # Queue is now empty (worker removed item 0). Emit MAXSIZE+3 more items. + for i in range(1, MAXSIZE + 4): + subj.on_next(i) + # Items 1..MAXSIZE fill the queue; items beyond are dropped. + release_first.set() + subj.on_completed() + done.wait(timeout=2.0) + assert 0 in received + assert len(received) <= MAXSIZE + 1 # 0 + at most MAXSIZE queued +class TestDropOldest: + def test_drop_oldest_newest_survives(self): + """With drop_oldest, the newest item always overwrites the oldest queued item.""" + MAXSIZE = 1 + received = [] + first_arrived = threading.Event() + release_first = threading.Event() + item3_received = threading.Event() + done = threading.Event() + subj = Subject() + def slow_on_next(x): + if not first_arrived.is_set(): + first_arrived.set() + release_first.wait(timeout=2.0) + received.append(x) + if x == 3: + item3_received.set() + subj.pipe( + observe_on_bounded(SCHED, maxsize=MAXSIZE, policy="drop_oldest", _worker_poll=POLL) + ).subscribe(on_next=slow_on_next, on_completed=done.set) + subj.on_next(0) + first_arrived.wait(timeout=2.0) + # Fill queue with 1, then overwrite with 2, then 3 + subj.on_next(1) + subj.on_next(2) + subj.on_next(3) + release_first.set() + # Wait for item 3 to be delivered before completing, to avoid a race + # where on_completed evicts item 3 from the queue. + item3_received.wait(timeout=2.0) + subj.on_completed() + done.wait(timeout=2.0) + assert 0 in received + # 3 must be present (it's the last written, so it replaced all earlier ones) + assert 3 in received +class TestDispose: + def test_dispose_stops_delivery(self): + delivered = [] + slow_gate = threading.Event() + subj = Subject() + disp = subj.pipe( + observe_on_bounded(SCHED, _worker_poll=POLL) + ).subscribe(on_next=delivered.append) + subj.on_next(1) + time.sleep(0.05) + disp.dispose() + subj.on_next(2) + time.sleep(0.05) + assert 2 not in delivered diff --git a/tests/unit/streaming/test_reader_observable.py b/tests/unit/streaming/test_reader_observable.py new file mode 100644 index 0000000..00f6930 --- /dev/null +++ b/tests/unit/streaming/test_reader_observable.py @@ -0,0 +1,133 @@ +"""Unit tests for reader_observable.""" +from __future__ import annotations +import threading +from typing import List +import pytest +import reactivex as rx +from py3r.media.streaming.observables.reader_observable import reader_observable, IReader +from py3r.media.types import FatalReadError +from tests.unit.streaming.conftest import collect +class _FakeReader: + """Emits items from a list, then raises a terminal exception.""" + def __init__(self, items, *, terminal=None, retryable_after: int = -1): + self._items = list(items) + self._terminal = terminal if terminal is not None else FatalReadError("eof") + self._retryable_after = retryable_after # raise RetryErr after this many reads (-1 = never) + self._idx = 0 + self.open_count = 0 + self.close_count = 0 + self.read_count = 0 + def open(self): self.open_count += 1 + def close(self): self.close_count += 1 + def read(self, timeout=0.5): + self.read_count += 1 + if self._retryable_after >= 0 and self._idx >= self._retryable_after: + raise RuntimeError("retryable error") + if self._idx < len(self._items): + v = self._items[self._idx] + self._idx += 1 + return v + raise self._terminal +class TestLifecycle: + def test_open_called_on_subscribe(self): + r = _FakeReader([1]) + collect(reader_observable(r)) + assert r.open_count == 1 + def test_close_called_after_completion(self): + r = _FakeReader([1]) + collect(reader_observable(r)) + assert r.close_count == 1 + def test_close_called_even_on_fatal_error(self): + r = _FakeReader([], terminal=FatalReadError("die")) + collect(reader_observable(r)) + assert r.close_count == 1 + def test_close_called_on_dispose(self): + stop = threading.Event() + class BlockingReader: + def open(self): pass + def close(self): stop.set() + def read(self, timeout=0.5): + stop.wait(timeout=1.0) + raise FatalReadError("done") + obs = reader_observable(BlockingReader()) + disp = obs.subscribe(on_next=lambda x: None, on_error=lambda e: None) + disp.dispose() + assert stop.is_set() +class TestItems: + def test_items_arrive_in_order(self): + r = _FakeReader([10, 20, 30]) + items, errors, _ = collect(reader_observable(r)) + assert items == [10, 20, 30] + def test_on_error_called_with_fatal(self): + err = FatalReadError("fatal") + r = _FakeReader([], terminal=err) + items, errors, _ = collect(reader_observable(r)) + assert items == [] + assert len(errors) == 1 + assert errors[0] is err +class TestRetry: + def test_retryable_error_is_not_immediately_fatal(self): + """Single retryable error followed by success should not stop stream.""" + call_n = [0] + class FlickyReader: + def open(self): pass + def close(self): pass + def read(self, timeout=0.5): + call_n[0] += 1 + if call_n[0] == 2: + raise RuntimeError("transient") + if call_n[0] == 1 or call_n[0] == 3: + return call_n[0] + raise FatalReadError("done") + items, errors, _ = collect(reader_observable(FlickyReader(), max_consecutive_errors=5)) + assert 1 in items and 3 in items + def test_max_consecutive_errors_terminates_stream(self): + r = _FakeReader([], retryable_after=0) # always raises RuntimeError + items, errors, _ = collect( + reader_observable(r, max_consecutive_errors=3) + ) + assert items == [] + assert len(errors) == 1 + assert "3" in str(errors[0]) + def test_success_resets_consecutive_error_count(self): + """Errors interspersed with successes should not accumulate to max.""" + call_n = [0] + delivered = [] + class AlternatingReader: + def open(self): pass + def close(self): pass + def read(self, timeout=0.5): + call_n[0] += 1 + n = call_n[0] + if n > 10: + raise FatalReadError("done") + if n % 2 == 0: + raise RuntimeError("alternating error") + return n + items, errors, _ = collect( + reader_observable(AlternatingReader(), max_consecutive_errors=2) + ) + # Should receive multiple items (errors never reach 2 consecutive) + assert len(items) >= 3 + def test_fatal_error_bypasses_retry_counter(self): + """FatalReadError terminates immediately even with max_consecutive_errors=100.""" + r = _FakeReader([1, 2], terminal=FatalReadError("fatal")) + items, errors, _ = collect(reader_observable(r, max_consecutive_errors=100)) + assert items == [1, 2] + assert len(errors) == 1 + assert isinstance(errors[0], FatalReadError) + def test_unlimited_retries_when_none(self): + """max_consecutive_errors=None should retry more than 100 times.""" + call_n = [0] + class EventuallyReader: + def open(self): pass + def close(self): pass + def read(self, timeout=0.5): + call_n[0] += 1 + if call_n[0] < 50: + raise RuntimeError("not yet") + if call_n[0] == 50: + return "success" + raise FatalReadError("done") + items, errors, _ = collect(reader_observable(EventuallyReader(), max_consecutive_errors=None)) + assert "success" in items diff --git a/tests/unit/streaming/test_subscribe_on_blocking.py b/tests/unit/streaming/test_subscribe_on_blocking.py new file mode 100644 index 0000000..03376b3 --- /dev/null +++ b/tests/unit/streaming/test_subscribe_on_blocking.py @@ -0,0 +1,54 @@ +"""Unit tests for subscribe_on_blocking operator.""" +from __future__ import annotations +import threading +import reactivex as rx +from reactivex.disposable import Disposable +from reactivex.scheduler import TimeoutScheduler +from py3r.media.streaming.operators.subscribe_on_blocking import subscribe_on_blocking +from tests.unit.streaming.conftest import collect, from_items +SCHED = TimeoutScheduler.singleton() +class TestBlocking: + def test_subscribe_blocks_until_subscribed(self): + """subscribe() must not return before the upstream subscription is established.""" + subscribed_event = threading.Event() + def recording_source_sub(observer, _=None): + subscribed_event.set() + observer.on_completed() + return Disposable() + source = rx.create(recording_source_sub) + # subscribe() should block while the scheduler action runs + collect(source.pipe(subscribe_on_blocking(SCHED))) + assert subscribed_event.is_set() + def test_subscribe_returns_after_subscription_created(self): + """After subscribe() returns, is_subscribed must be True.""" + is_subscribed = [False] + def source_sub(observer, _=None): + is_subscribed[0] = True + observer.on_completed() + return Disposable() + source = rx.create(source_sub) + items, errors, _ = collect(source.pipe(subscribe_on_blocking(SCHED))) + assert is_subscribed[0] + def test_subscription_happens_on_scheduler_thread(self): + main_tid = threading.get_ident() + subscription_tid = [None] + def source_sub(observer, _=None): + subscription_tid[0] = threading.get_ident() + observer.on_completed() + return Disposable() + source = rx.create(source_sub) + collect(source.pipe(subscribe_on_blocking(SCHED))) + assert subscription_tid[0] is not None + assert subscription_tid[0] != main_tid +class TestDelivery: + def test_items_flow_after_subscription(self): + items, errors, _ = collect(from_items(1, 2, 3).pipe(subscribe_on_blocking(SCHED))) + assert items == [1, 2, 3] and errors == [] + def test_error_flows_through(self): + err = RuntimeError("oops") + _, errors, _ = collect(from_items(error=err).pipe(subscribe_on_blocking(SCHED))) + assert errors[0] is err +class TestDispose: + def test_dispose_stops_subscription(self): + disp = rx.never().pipe(subscribe_on_blocking(SCHED)).subscribe() + disp.dispose() # must not hang diff --git a/tests/unit/streaming/test_subscribe_on_future.py b/tests/unit/streaming/test_subscribe_on_future.py new file mode 100644 index 0000000..e1f8af5 --- /dev/null +++ b/tests/unit/streaming/test_subscribe_on_future.py @@ -0,0 +1,88 @@ +"""Unit tests for subscribe_on_future operator.""" +from __future__ import annotations +from concurrent.futures import Future +import reactivex as rx +from reactivex.disposable import Disposable +from reactivex.scheduler import TimeoutScheduler +from py3r.media.streaming.operators.subscribe_on_future import subscribe_on_future +from tests.unit.streaming.conftest import collect, from_items +SCHED = TimeoutScheduler.singleton() +class TestSubscribed: + def test_subscribed_future_resolves(self): + subscribed: Future[None] = Future() + collect(from_items(1).pipe(subscribe_on_future(SCHED, subscribed=subscribed))) + assert subscribed.done() and subscribed.result() is None + def test_items_flow_after_subscribed_resolves(self): + subscribed: Future[None] = Future() + items, errors, _ = collect(from_items(1, 2, 3).pipe(subscribe_on_future(SCHED, subscribed=subscribed))) + assert subscribed.done() + assert items == [1, 2, 3] + def test_subscribed_resolves_after_subscription_made(self): + """Core invariant: future must not be set until source.subscribe() has returned. + + Callers block on subscribed.result() before producing data, so the + future must resolve AFTER the upstream subscription is fully established. + """ + subscribed: Future[None] = Future() + was_done_during_subscribe: list[bool] = [] + + def recording_source_sub(observer, _=None): + # Capture the future state while subscribe_on_future is still + # inside source.subscribe(observer) — future must not be set yet. + was_done_during_subscribe.append(subscribed.done()) + observer.on_completed() + return Disposable() + + source = rx.create(recording_source_sub) + collect(source.pipe(subscribe_on_future(SCHED, subscribed=subscribed))) + + assert was_done_during_subscribe == [False], ( + "subscribed future resolved too early — callers relying on it as a " + "post-subscription signal will open their source before the pipeline is ready" + ) + assert subscribed.done() and subscribed.result() is None +class TestDisposed: + def test_disposed_future_resolves_after_dispose(self): + subscribed: Future[None] = Future() + disposed: Future[None] = Future() + disp = rx.never().pipe( + subscribe_on_future(SCHED, subscribed=subscribed, disposed=disposed) + ).subscribe() + subscribed.result(timeout=2.0) # wait for subscription + assert not disposed.done() + disp.dispose() + disposed.result(timeout=2.0) + assert disposed.done() + def test_disposed_future_not_resolved_before_dispose(self): + subscribed: Future[None] = Future() + disposed: Future[None] = Future() + disp = rx.never().pipe( + subscribe_on_future(SCHED, subscribed=subscribed, disposed=disposed) + ).subscribe() + subscribed.result(timeout=2.0) + assert not disposed.done() + disp.dispose() +class TestFreshFutures: + def test_each_call_creates_independent_futures(self): + """Calling subscribe_on_future() twice must not share state.""" + op = subscribe_on_future(SCHED) + subscribed1: Future = Future() + subscribed2: Future = Future() + op1 = subscribe_on_future(SCHED, subscribed=subscribed1) + op2 = subscribe_on_future(SCHED, subscribed=subscribed2) + collect(from_items(1).pipe(op1)) + assert subscribed1.done() + assert not subscribed2.done() # op2 was never subscribed +class TestSubscribeError: + def test_subscribe_error_propagates_to_subscribed_future(self): + subscribed: Future[None] = Future() + err = RuntimeError("subscribe boom") + def bad_source_sub(observer, _=None): + raise err + source = rx.create(bad_source_sub) + try: + collect(source.pipe(subscribe_on_future(SCHED, subscribed=subscribed))) + except Exception: + pass + subscribed.exception(timeout=2.0) + assert subscribed.done() diff --git a/tests/unit/streaming/test_write_to.py b/tests/unit/streaming/test_write_to.py new file mode 100644 index 0000000..3d1d290 --- /dev/null +++ b/tests/unit/streaming/test_write_to.py @@ -0,0 +1,100 @@ +""" +Unit tests for write_to operator. +""" +from __future__ import annotations + +import reactivex as rx +from unittest.mock import MagicMock, call + +from py3r.media.streaming.operators.write_to import write_to +from tests.unit.streaming.conftest import collect, from_items + + +class TestLifecycle: + def test_open_called_on_subscribe(self): + writer = MagicMock() + collect(from_items(1, 2).pipe(write_to(writer))) + writer.open.assert_called_once() + + def test_close_called_on_completed(self): + writer = MagicMock() + collect(from_items(1, 2).pipe(write_to(writer))) + writer.close.assert_called_once() + + def test_close_called_on_error(self): + writer = MagicMock() + err = RuntimeError("upstream error") + collect(from_items(1, error=err).pipe(write_to(writer))) + writer.close.assert_called_once() + + def test_close_called_on_dispose(self): + writer = MagicMock() + disp = rx.never().pipe(write_to(writer)).subscribe() + disp.dispose() + writer.close.assert_called_once() + + def test_close_called_exactly_once_complete_then_dispose(self): + writer = MagicMock() + _, _, disp = collect(from_items(1).pipe(write_to(writer))) + disp.dispose() # second cleanup after already completed + writer.close.assert_called_once() + + def test_close_called_exactly_once_error_then_dispose(self): + writer = MagicMock() + err = RuntimeError("x") + _, _, disp = collect(from_items(error=err).pipe(write_to(writer))) + disp.dispose() + writer.close.assert_called_once() + + def test_open_called_before_any_write(self): + call_log = [] + writer = MagicMock() + writer.open.side_effect = lambda: call_log.append("open") + writer.write.side_effect = lambda x: call_log.append(f"write({x})") + collect(from_items(1).pipe(write_to(writer))) + assert call_log[0] == "open" + assert call_log[1] == "write(1)" + + +class TestWrite: + def test_write_called_for_each_item(self): + writer = MagicMock() + collect(from_items(10, 20, 30).pipe(write_to(writer))) + assert writer.write.call_count == 3 + writer.write.assert_has_calls([call(10), call(20), call(30)]) + + def test_write_not_called_after_close(self): + """Dispose before any items → write never called.""" + writer = MagicMock() + disp = rx.never().pipe(write_to(writer)).subscribe() + disp.dispose() + writer.write.assert_not_called() + + def test_items_pass_through_to_downstream(self): + writer = MagicMock() + items, errors, _ = collect(from_items(1, 2, 3).pipe(write_to(writer))) + assert items == [1, 2, 3] + assert errors == [] + + def test_error_passes_through_to_downstream(self): + writer = MagicMock() + err = ValueError("oops") + items, errors, _ = collect(from_items(1, error=err).pipe(write_to(writer))) + assert items == [1] + assert len(errors) == 1 + assert errors[0] is err + + def test_write_receives_correct_types(self): + writer = MagicMock() + values = ["hello", 42, None, {"key": "val"}] + collect(from_items(*values).pipe(write_to(writer))) + for i, v in enumerate(values): + assert writer.write.call_args_list[i] == call(v) + + def test_empty_source_no_writes(self): + writer = MagicMock() + collect(from_items().pipe(write_to(writer))) + writer.write.assert_not_called() + writer.open.assert_called_once() + writer.close.assert_called_once() + diff --git a/tests/unit/test_opencv_video_file_source.py b/tests/unit/test_opencv_video_file_source.py new file mode 100644 index 0000000..f2cc0b7 --- /dev/null +++ b/tests/unit/test_opencv_video_file_source.py @@ -0,0 +1,348 @@ +""" +Unit tests for OpenCVVideoFileSource. + +All tests use synthetic MP4 files produced by the session fixtures in +``tests/conftest.py`` — no real footage is needed. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from py3r.media.video.opencv_video_file_source import ( + EndOfStreamError, + OpenCVVideoFileSource, +) +from tests.conftest import VIDEO_FPS, VIDEO_H, VIDEO_N, VIDEO_W + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _read_all(src: OpenCVVideoFileSource) -> list: + """Drain *src* until EndOfStreamError; return all VideoFrames.""" + frames = [] + try: + while True: + frames.append(src.read()) + except EndOfStreamError: + pass + return frames + + +# --------------------------------------------------------------------------- +# Probe / metadata (no open() required) +# --------------------------------------------------------------------------- + +class TestProbe: + def test_size(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + assert src.get_size() == (VIDEO_W, VIDEO_H) + + def test_fps(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + assert src.get_fps() == pytest.approx(VIDEO_FPS, abs=1.0) + + def test_num_frames(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + assert src.get_num_frames() == VIDEO_N + + def test_capability_flags(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + assert src.has_size() + assert src.has_fps() + assert src.has_num_frames() + assert src.has_timing() + assert src.is_seekable() + assert not src.is_open() + + def test_loop_hides_num_frames(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True, loop=True) + assert not src.has_num_frames() + assert src.get_num_frames() is None + + def test_num_channels_grayscale(self, synthetic_gray_mp4): + assert OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True).get_num_channels() == 1 + + def test_num_channels_color(self, synthetic_color_mp4): + assert OpenCVVideoFileSource(synthetic_color_mp4, grayscale=False).get_num_channels() == 3 + + def test_nonexistent_file_raises(self, tmp_path): + with pytest.raises(RuntimeError, match="Cannot open"): + OpenCVVideoFileSource(tmp_path / "no_such_file.mp4") + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_is_open_after_open(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + assert src.is_open() + src.close() + + def test_is_closed_after_close(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + src.close() + assert not src.is_open() + + def test_double_close_is_safe(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + src.close() + src.close() # must not raise + + def test_reopen_resets_index(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + src.read() + src.read() + src.close() + src.open() + frame = src.read() + src.close() + assert frame.frame_index == 0 + + def test_read_before_open_raises(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + with pytest.raises(RuntimeError, match="not open"): + src.read() + + def test_seek_before_open_raises(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + with pytest.raises(RuntimeError, match="not open"): + src.seek(5) + + +# --------------------------------------------------------------------------- +# Frame content and shape +# --------------------------------------------------------------------------- + +class TestFrameContent: + def test_grayscale_shape_and_dtype(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frame = src.read() + src.close() + assert frame.img.shape == (VIDEO_H, VIDEO_W) + assert frame.img.dtype == np.uint8 + + def test_color_shape_and_dtype(self, synthetic_color_mp4): + src = OpenCVVideoFileSource(synthetic_color_mp4, grayscale=False) + src.open() + frame = src.read() + src.close() + assert frame.img.shape == (VIDEO_H, VIDEO_W, 3) + assert frame.img.dtype == np.uint8 + + def test_grayscale_forced_from_color_file(self, synthetic_color_mp4): + """grayscale=True on a color file must return (H, W) frames.""" + src = OpenCVVideoFileSource(synthetic_color_mp4, grayscale=True) + src.open() + frame = src.read() + src.close() + assert frame.img.ndim == 2 + + def test_brightness_increases_across_frames(self, synthetic_gray_mp4): + """The synthetic file has ascending brightness; verify ordering survives decode.""" + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + means = [f.img.mean() for f in frames] + # First quarter must be darker than last quarter + assert np.mean(means[: VIDEO_N // 4]) < np.mean(means[-VIDEO_N // 4 :]) + + def test_first_frame_is_darkest(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + means = [f.img.mean() for f in frames] + assert means[0] == min(means) + + def test_last_frame_is_brightest(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + means = [f.img.mean() for f in frames] + assert means[-1] == max(means) + + +# --------------------------------------------------------------------------- +# Frame index +# --------------------------------------------------------------------------- + +class TestFrameIndex: + def test_indices_are_consecutive_from_zero(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + assert [f.frame_index for f in frames] == list(range(VIDEO_N)) + + def test_total_frame_count(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + assert len(frames) == VIDEO_N + + +# --------------------------------------------------------------------------- +# Timestamps +# --------------------------------------------------------------------------- + +class TestTimestamps: + def test_timestamps_are_non_negative(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + assert all(f.timestamp >= 0.0 for f in frames) + + def test_timestamps_are_non_decreasing(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + ts = [f.timestamp for f in frames] + assert all(ts[i] <= ts[i + 1] for i in range(len(ts) - 1)) + + def test_final_timestamp_reasonable(self, synthetic_gray_mp4): + """Last frame timestamp should be roughly (N-1) / fps seconds.""" + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + frames = _read_all(src) + src.close() + expected = (VIDEO_N - 1) / VIDEO_FPS + assert frames[-1].timestamp == pytest.approx(expected, abs=0.5) + + +# --------------------------------------------------------------------------- +# End-of-stream behaviour +# --------------------------------------------------------------------------- + +class TestEndOfStream: + def test_raises_eos_not_none(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + for _ in range(VIDEO_N): + src.read() + with pytest.raises(EndOfStreamError): + src.read() + src.close() + + def test_eos_is_exception_not_none(self, synthetic_gray_mp4): + """The old interface returned None; the new one must never do that.""" + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + result = None + try: + for _ in range(VIDEO_N + 1): + result = src.read() + except EndOfStreamError: + pass + finally: + src.close() + # result should be a VideoFrame from the last successful read, not None + assert result is not None + assert result.frame_index == VIDEO_N - 1 + + +# --------------------------------------------------------------------------- +# Loop behaviour +# --------------------------------------------------------------------------- + +class TestLoop: + def test_loop_continues_past_eos(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True, loop=True) + src.open() + # Read 1.5x the file length + extra = VIDEO_N + VIDEO_N // 2 + frames = [src.read() for _ in range(extra)] + src.close() + assert len(frames) == extra + + def test_loop_frame_indices_keep_incrementing(self, synthetic_gray_mp4): + """After wrapping, _idx must continue from where it left off — not reset to 0.""" + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True, loop=True) + src.open() + extra = VIDEO_N + 5 + frames = [src.read() for _ in range(extra)] + src.close() + assert [f.frame_index for f in frames] == list(range(extra)) + + def test_loop_repeats_content(self, synthetic_gray_mp4): + """Frames VIDEO_N .. 2*VIDEO_N-1 should have the same pixel content as 0 .. VIDEO_N-1.""" + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True, loop=True) + src.open() + frames = [src.read() for _ in range(VIDEO_N * 2)] + src.close() + for i in range(VIDEO_N): + # Brightness ordering must repeat (exact values may differ due to OCV backend) + assert frames[i].img.mean() == pytest.approx( + frames[i + VIDEO_N].img.mean(), abs=2.0 + ) + + +# --------------------------------------------------------------------------- +# Seek +# --------------------------------------------------------------------------- + +class TestSeek: + def test_seek_sets_frame_index(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + src.seek(10) + frame = src.read() + src.close() + assert frame.frame_index == 10 + + def test_seek_delivers_correct_content(self, synthetic_gray_mp4): + """Seeking to frame 20 should deliver a brighter frame than frame 5.""" + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + src.seek(5) + bright5 = src.read().img.mean() + src.seek(20) + bright20 = src.read().img.mean() + src.close() + assert bright20 > bright5 + + def test_seek_to_zero_restarts(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + # Advance to the middle + for _ in range(15): + src.read() + src.seek(0) + frame = src.read() + src.close() + assert frame.frame_index == 0 + + def test_consecutive_reads_after_seek(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + src.seek(15) + f1 = src.read() + f2 = src.read() + src.close() + assert f1.frame_index == 15 + assert f2.frame_index == 16 + + def test_eos_still_raised_after_seek_near_end(self, synthetic_gray_mp4): + src = OpenCVVideoFileSource(synthetic_gray_mp4, grayscale=True) + src.open() + src.seek(VIDEO_N - 1) + src.read() # last frame + with pytest.raises(EndOfStreamError): + src.read() + src.close() + diff --git a/tests/unit/test_opencv_webcam_source.py b/tests/unit/test_opencv_webcam_source.py new file mode 100644 index 0000000..59c639d --- /dev/null +++ b/tests/unit/test_opencv_webcam_source.py @@ -0,0 +1,435 @@ +""" +Unit tests for OpenCVWebcamSource — no hardware required. + +Fake objects +------------ +FakeCapture + Duck-type for cv2.VideoCapture. Stores a list of BGR frames and serves + them via read(); get() returns configured width/height/fps metadata. + +Factory pattern +--------------- +_make_factory() returns a spy factory and a 'calls' list. Each call to the +factory creates a fresh FakeCapture and appends {"device", "backend", +"capture"} to calls. + + calls[0] — probe capture (created and released() by _probe()) + calls[1] — live capture (created by open()) + +_make_source() is a convenience wrapper for tests that don't need to inspect +factory arguments directly. +""" + +from __future__ import annotations + +import cv2 +import numpy as np +import pytest + +from py3r.media.types import VideoFrame +from py3r.media.video.opencv_webcam_source import OpenCVWebcamSource, ReadFailedError + +W, H = 64, 48 +FPS = 30.0 +DEVICE = 7 # arbitrary integer device index +BACKEND = cv2.CAP_ANY + + +# --------------------------------------------------------------------------- +# Fake cv2.VideoCapture +# --------------------------------------------------------------------------- + +class FakeCapture: + """Minimal duck-type for cv2.VideoCapture.""" + + def __init__( + self, + frames: list[np.ndarray], + *, + width: int = W, + height: int = H, + fps: float = FPS, + opened: bool = True, + ): + self._frames = list(frames) + self._width = width + self._height = height + self._fps = fps + self._opened = opened + self.released = False + self.set_calls: list[tuple[int, float]] = [] + + def isOpened(self) -> bool: + return self._opened and not self.released + + def get(self, prop_id: int) -> float: + if prop_id == cv2.CAP_PROP_FRAME_WIDTH: return float(self._width) + if prop_id == cv2.CAP_PROP_FRAME_HEIGHT: return float(self._height) + if prop_id == cv2.CAP_PROP_FPS: return float(self._fps) + return 0.0 + + def set(self, prop_id: int, value: float) -> bool: + self.set_calls.append((prop_id, value)) + return True + + def read(self) -> tuple[bool, np.ndarray | None]: + if not self._frames: + return False, None + return True, self._frames.pop(0).copy() + + def release(self) -> None: + self.released = True + self._opened = False + + +# --------------------------------------------------------------------------- +# Factory helpers +# --------------------------------------------------------------------------- + +def _bgr_frame(val: int = 128) -> np.ndarray: + return np.full((H, W, 3), val, dtype=np.uint8) + + +def _bgr_frames(n: int, start: int = 0) -> list[np.ndarray]: + return [_bgr_frame((start + i) % 256) for i in range(n)] + + +def _make_factory( + frames: list[np.ndarray], + *, + width: int = W, + height: int = H, + fps: float = FPS, + opened: bool = True, +): + """Return (factory, calls). Each factory call creates a fresh FakeCapture.""" + calls: list[dict] = [] + + def factory(device, *, backend: int): + cap = FakeCapture(list(frames), width=width, height=height, + fps=fps, opened=opened) + calls.append({"device": device, "backend": backend, "capture": cap}) + return cap + + return factory, calls + + +def _make_source( + frames: list[np.ndarray], + *, + grayscale: bool = True, + width: int | None = None, + height: int | None = None, + fps: float | None = None, + backend: int = BACKEND, + cap_width: int = W, + cap_height: int = H, + cap_fps: float = FPS, +): + """Convenience wrapper. Returns (src, calls); calls[0]=probe, calls[1]=live.""" + factory, calls = _make_factory(frames, width=cap_width, height=cap_height, + fps=cap_fps) + src = OpenCVWebcamSource( + DEVICE, + grayscale=grayscale, + width=width, + height=height, + fps=fps, + backend=backend, + capture_factory=factory, + ) + return src, calls + + +# --------------------------------------------------------------------------- +# Helpers to get the live capture (index 1 after open()) +# --------------------------------------------------------------------------- + +def _live(calls: list[dict]) -> FakeCapture: + return calls[1]["capture"] + + +# --------------------------------------------------------------------------- +# TestCaptureFactoryArgs +# --------------------------------------------------------------------------- + +class TestCaptureFactoryArgs: + def test_device_passed_to_factory(self): + factory, calls = _make_factory(_bgr_frames(1)) + OpenCVWebcamSource(42, capture_factory=factory) + assert calls[0]["device"] == 42 + + def test_string_device_passed_to_factory(self): + factory, calls = _make_factory(_bgr_frames(1)) + OpenCVWebcamSource("/dev/video0", capture_factory=factory) + assert calls[0]["device"] == "/dev/video0" + + def test_backend_passed_to_factory(self): + factory, calls = _make_factory(_bgr_frames(1)) + OpenCVWebcamSource(0, backend=cv2.CAP_DSHOW, capture_factory=factory) + assert calls[0]["backend"] == cv2.CAP_DSHOW + + def test_default_backend_is_cap_any(self): + factory, calls = _make_factory(_bgr_frames(1)) + OpenCVWebcamSource(0, capture_factory=factory) + assert calls[0]["backend"] == cv2.CAP_ANY + + def test_factory_called_once_for_probe(self): + factory, calls = _make_factory(_bgr_frames(1)) + OpenCVWebcamSource(0, capture_factory=factory) + assert len(calls) == 1 + + def test_factory_called_again_for_open(self): + src, calls = _make_source(_bgr_frames(1)) + src.open() + assert len(calls) == 2 + + def test_probe_capture_is_released(self): + src, calls = _make_source(_bgr_frames(1)) + assert calls[0]["capture"].released + + def test_live_capture_not_released_while_open(self): + src, calls = _make_source(_bgr_frames(1)) + src.open() + assert not _live(calls).released + + def test_live_capture_released_after_close(self): + src, calls = _make_source(_bgr_frames(1)) + src.open() + src.close() + assert _live(calls).released + + +# --------------------------------------------------------------------------- +# TestProbe +# --------------------------------------------------------------------------- + +class TestProbe: + def test_size_populated_from_capture(self): + src, _ = _make_source([], cap_width=320, cap_height=240) + assert src.get_size() == (320, 240) + + def test_fps_populated_from_capture(self): + src, _ = _make_source([], cap_fps=60.0) + assert src.get_fps() == 60.0 + + def test_has_size_true_after_probe(self): + src, _ = _make_source([]) + assert src.has_size() + + def test_has_fps_true_after_probe(self): + src, _ = _make_source([]) + assert src.has_fps() + + def test_probe_soft_fails_when_factory_raises(self): + """Construction must not raise even if the factory fails.""" + def bad_factory(device, *, backend): + raise RuntimeError("no camera") + + src = OpenCVWebcamSource(0, capture_factory=bad_factory) + assert src.get_size() is None + assert not src.has_size() + + def test_probe_soft_fails_when_not_opened(self): + factory, _ = _make_factory([], opened=False) + src = OpenCVWebcamSource(0, capture_factory=factory) + assert src.get_size() is None + + def test_configure_called_with_requested_size(self): + factory, calls = _make_factory(_bgr_frames(1)) + OpenCVWebcamSource(0, width=W, height=H, capture_factory=factory) + probe_cap = calls[0]["capture"] + prop_ids = [p for p, _ in probe_cap.set_calls] + assert cv2.CAP_PROP_FRAME_WIDTH in prop_ids + assert cv2.CAP_PROP_FRAME_HEIGHT in prop_ids + + def test_configure_called_with_requested_fps(self): + factory, calls = _make_factory(_bgr_frames(1)) + OpenCVWebcamSource(0, fps=FPS, capture_factory=factory) + probe_cap = calls[0]["capture"] + prop_ids = [p for p, _ in probe_cap.set_calls] + assert cv2.CAP_PROP_FPS in prop_ids + + +# --------------------------------------------------------------------------- +# TestLifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_not_open_before_open(self): + src, _ = _make_source([]) + assert not src.is_open() + + def test_is_open_after_open(self): + src, _ = _make_source(_bgr_frames(1)) + src.open() + assert src.is_open() + + def test_not_open_after_close(self): + src, _ = _make_source(_bgr_frames(1)) + src.open() + src.close() + assert not src.is_open() + + def test_double_close_is_safe(self): + src, _ = _make_source(_bgr_frames(1)) + src.open() + src.close() + src.close() # must not raise + + def test_capability_flags(self): + src, _ = _make_source([]) + assert src.has_timing() + assert not src.has_num_frames() + assert not src.is_seekable() + + def test_num_channels_grayscale(self): + src, _ = _make_source([], grayscale=True) + assert src.get_num_channels() == 1 + + def test_num_channels_color(self): + src, _ = _make_source([], grayscale=False) + assert src.get_num_channels() == 3 + + +# --------------------------------------------------------------------------- +# TestFrameContent +# --------------------------------------------------------------------------- + +class TestFrameContent: + def test_read_returns_video_frame(self): + src, _ = _make_source(_bgr_frames(1)) + src.open() + frame = src.read() + assert isinstance(frame, VideoFrame) + + def test_grayscale_frame_is_2d(self): + src, _ = _make_source(_bgr_frames(1), grayscale=True) + src.open() + frame = src.read() + assert frame.img.ndim == 2 + + def test_grayscale_frame_dtype(self): + src, _ = _make_source(_bgr_frames(1), grayscale=True) + src.open() + assert src.read().img.dtype == np.uint8 + + def test_color_frame_is_3d(self): + src, _ = _make_source(_bgr_frames(1), grayscale=False) + src.open() + frame = src.read() + assert frame.img.ndim == 3 + assert frame.img.shape[2] == 3 + + def test_color_frame_preserves_values(self): + """BGR values should be passed through unchanged.""" + bgr = np.zeros((H, W, 3), dtype=np.uint8) + bgr[..., 0] = 10 # blue + bgr[..., 1] = 20 # green + bgr[..., 2] = 30 # red + factory, calls = _make_factory([bgr]) + src = OpenCVWebcamSource(DEVICE, grayscale=False, capture_factory=factory) + src.open() + out = src.read().img + np.testing.assert_array_equal(out, bgr) + + def test_frame_size_matches_capture_size(self): + src, _ = _make_source(_bgr_frames(1), grayscale=False) + src.open() + frame = src.read() + h, w = frame.img.shape[:2] + assert (w, h) == src.get_size() + + +# --------------------------------------------------------------------------- +# TestFrameIndex +# --------------------------------------------------------------------------- + +class TestFrameIndex: + def test_first_frame_index_is_zero(self): + src, _ = _make_source(_bgr_frames(3)) + src.open() + assert src.read().frame_index == 0 + + def test_frame_index_increments(self): + src, _ = _make_source(_bgr_frames(3)) + src.open() + indices = [src.read().frame_index for _ in range(3)] + assert indices == [0, 1, 2] + + def test_frame_index_resets_on_reopen(self): + factory, _ = _make_factory(_bgr_frames(3)) + src = OpenCVWebcamSource(DEVICE, capture_factory=factory) + src.open() + src.read() + src.close() + src.open() + assert src.read().frame_index == 0 + + +# --------------------------------------------------------------------------- +# TestTimestamps +# --------------------------------------------------------------------------- + +class TestTimestamps: + def test_timestamp_is_positive(self): + src, _ = _make_source(_bgr_frames(1)) + src.open() + assert src.read().timestamp > 0.0 + + def test_timestamps_non_decreasing(self): + src, _ = _make_source(_bgr_frames(5)) + src.open() + ts = [src.read().timestamp for _ in range(5)] + assert all(ts[i] <= ts[i + 1] for i in range(len(ts) - 1)) + + +# --------------------------------------------------------------------------- +# TestReadFailed +# --------------------------------------------------------------------------- + +class TestReadFailed: + def test_read_failed_raises_read_failed_error(self): + """FakeCapture with no frames returns ok=False → ReadFailedError.""" + src, _ = _make_source([]) # no frames + src.open() + with pytest.raises(ReadFailedError): + src.read() + + def test_read_failed_message_contains_device(self): + src, _ = _make_source([]) + src.open() + with pytest.raises(ReadFailedError, match=str(DEVICE)): + src.read() + + +# --------------------------------------------------------------------------- +# TestTimeout +# --------------------------------------------------------------------------- + +class TestTimeout: + def test_expired_deadline_raises_timeout_error(self): + """Pass timeout=0 so the deadline is already past before read().""" + src, _ = _make_source([]) + src.open() + with pytest.raises(TimeoutError): + src.read(timeout=0.0) + + +# --------------------------------------------------------------------------- +# TestNotOpen +# --------------------------------------------------------------------------- + +class TestNotOpen: + def test_read_before_open_raises_runtime_error(self): + src, _ = _make_source([]) + with pytest.raises(RuntimeError, match="not open"): + src.read() + + def test_read_after_close_raises_runtime_error(self): + src, _ = _make_source(_bgr_frames(1)) + src.open() + src.close() + with pytest.raises(RuntimeError, match="not open"): + src.read() + diff --git a/tests/unit/test_pyav_video_file_writer.py b/tests/unit/test_pyav_video_file_writer.py new file mode 100644 index 0000000..dec0ffa --- /dev/null +++ b/tests/unit/test_pyav_video_file_writer.py @@ -0,0 +1,337 @@ +""" +Unit tests for PyAVVideoFileWriter. + +Strategy +-------- +Write frames to a ``tmp_path`` file, then read them back with ``av.open`` +and compare. No mocking is needed — the output file is the natural injection +point for a file writer, exactly as a real input file is for the file source +tests. + +Round-trip fidelity +------------------- +* **Lossless** (libx264/libx264rgb with crf=0): pixel values are preserved + exactly — tests assert ``np.array_equal``. +* **Lossy** (libx264 + yuv420p): solid-fill images survive the YUV round-trip + exactly for the luma channel, so grey-value tests still use exact comparison. + Color lossy tests only check frame count and shape. +""" + +from __future__ import annotations + +from pathlib import Path + +import av +import numpy as np +import pytest + +from py3r.media.types import VideoFrame +from py3r.media.video.pyav_video_file_writer import PyAVVideoFileWriter + +W, H = 64, 48 +FPS = 30.0 +N = 10 # frames per test clip + + +# --------------------------------------------------------------------------- +# Frame-generation helpers +# --------------------------------------------------------------------------- + +def _gray(n: int = N, start: int = 0) -> list[np.ndarray]: + """Solid-fill grayscale frames with linearly increasing brightness.""" + return [np.full((H, W), (start + i * 8) % 256, dtype=np.uint8) for i in range(n)] + + +def _color(n: int = N, start: int = 0) -> list[np.ndarray]: + """Solid-fill BGR color frames (R=G=B for reliable YUV round-trip).""" + return [np.full((H, W, 3), (start + i * 8) % 256, dtype=np.uint8) for i in range(n)] + + +# --------------------------------------------------------------------------- +# Read-back helper +# --------------------------------------------------------------------------- + +def _read_frames(path: Path, fmt: str) -> list[np.ndarray]: + """Decode all frames from *path* and return them as ndarrays in *fmt*, + sorted by PTS so B-frame reordering doesn't affect comparisons.""" + frames: list[tuple[int | None, np.ndarray]] = [] + with av.open(str(path)) as container: + for frame in container.decode(video=0): + frames.append((frame.pts, frame.to_ndarray(format=fmt))) + frames.sort(key=lambda x: (x[0] is None, x[0])) + return [arr for _, arr in frames] + + +def _make_writer(path: Path, *, grayscale: bool = True, + quality: str = "medium", **kw) -> PyAVVideoFileWriter: + return PyAVVideoFileWriter(path, size=(W, H), fps=FPS, + grayscale=grayscale, quality=quality, **kw) + + +# --------------------------------------------------------------------------- +# TestLifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_not_open_before_open(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + assert not w.is_open + + def test_is_open_after_open(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + w.open() + assert w.is_open + w.close() + + def test_not_open_after_close(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + w.open() + w.close() + assert not w.is_open + + def test_double_open_is_no_op(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + w.open() + container_id = id(w._container) + w.open() # second call must not replace the container + assert id(w._container) == container_id + w.close() + + def test_double_close_is_safe(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + w.open() + w.close() + w.close() # must not raise + + def test_context_manager_opens_and_closes(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + with w: + assert w.is_open + assert not w.is_open + + def test_context_manager_closes_on_exception(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + with pytest.raises(ZeroDivisionError): + with w: + raise ZeroDivisionError + assert not w.is_open + + def test_file_is_created_after_close(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path) as w: + w.write(_gray(1)[0]) # at least one frame is required + assert path.exists() + + +# --------------------------------------------------------------------------- +# TestWrite +# --------------------------------------------------------------------------- + +class TestWrite: + def test_frame_count_increments(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + w.open() + for _ in range(5): + w.write(_gray(1)[0]) + assert w._frame_count == 5 + w.close() + + def test_correct_number_of_frames_in_file(self, tmp_path): + path = tmp_path / "out.mp4" + frames = _gray(N) + with _make_writer(path) as w: + for f in frames: + w.write(f) + decoded = _read_frames(path, "gray") + assert len(decoded) == N + + def test_gray_frame_shape_in_file(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path) as w: + w.write(_gray(1)[0]) + decoded = _read_frames(path, "gray") + assert decoded[0].shape == (H, W) + + def test_color_frame_shape_in_file(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path, grayscale=False) as w: + w.write(_color(1)[0]) + decoded = _read_frames(path, "bgr24") + assert decoded[0].shape == (H, W, 3) + + def test_accepts_has_image_object(self, tmp_path): + """write() must accept any object with a .img attribute (HasImage protocol).""" + path = tmp_path / "out.mp4" + img = _gray(1)[0] + vf = VideoFrame(img, 0, 0.0) # VideoFrame has .img + with _make_writer(path) as w: + w.write(vf) # must not raise + decoded = _read_frames(path, "gray") + assert len(decoded) == 1 + + def test_accepts_non_contiguous_array(self, tmp_path): + """Sliced (non-contiguous) arrays must be handled without error.""" + path = tmp_path / "out.mp4" + base = np.full((H * 2, W), 100, dtype=np.uint8) + non_contig = base[::2] # every other row → non-contiguous + with _make_writer(path) as w: + w.write(non_contig) + + +# --------------------------------------------------------------------------- +# TestRoundTrip — pixel-level fidelity +# --------------------------------------------------------------------------- + +class TestRoundTrip: + def test_lossless_gray_exact(self, tmp_path): + """All pixel values must survive the lossless encode/decode cycle exactly. + A constant fill is used so B-frame reordering doesn't affect comparison.""" + path = tmp_path / "out.mkv" + frame = np.full((H, W), 150, dtype=np.uint8) + with _make_writer(path, quality="lossless") as w: + for _ in range(5): + w.write(frame.copy()) + decoded = _read_frames(path, "gray") + assert len(decoded) == 5 + for dec in decoded: + np.testing.assert_array_equal(dec, frame) + + def test_lossless_gray_different_values_exact(self, tmp_path): + """Different pixel values must survive lossless encode/decode exactly. + B-frames are explicitly disabled so PTS-sorted order matches write order.""" + path = tmp_path / "out.mkv" + frames = _gray(5, start=50) # values 50, 58, 66, 74, 82 — well within valid range + with _make_writer(path, quality="lossless", + extra_options={"bf": "0"}) as w: + for f in frames: + w.write(f) + decoded = _read_frames(path, "gray") + assert len(decoded) == len(frames) + for orig, dec in zip(frames, decoded): + np.testing.assert_array_equal(orig, dec) + + def test_lossless_color_exact(self, tmp_path): + path = tmp_path / "out.mkv" + frames = _color(5) + with _make_writer(path, grayscale=False, quality="lossless") as w: + for f in frames: + w.write(f) + decoded = _read_frames(path, "bgr24") + for orig, dec in zip(frames, decoded): + np.testing.assert_array_equal(orig, dec) + + def test_lossy_gray_solid_fill_survives(self, tmp_path): + """Solid-fill luma values must survive yuv420p encode/decode within ±1 LSB + (YUV limited-range rounding may shift values by 1 count).""" + path = tmp_path / "out.mp4" + frames = _gray(5) + with _make_writer(path, quality="medium") as w: + for f in frames: + w.write(f) + decoded = _read_frames(path, "gray") + assert len(decoded) == len(frames) + for orig, dec in zip(frames, decoded): + np.testing.assert_allclose(dec.astype(int), orig.astype(int), atol=1) + + def test_hw_size_preserved(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path) as w: + w.write(_gray(1)[0]) + with av.open(str(path)) as container: + stream = container.streams.video[0] + assert stream.width == W + assert stream.height == H + + +# --------------------------------------------------------------------------- +# TestValidation +# --------------------------------------------------------------------------- + +class TestValidation: + def test_write_before_open_raises_runtime_error(self, tmp_path): + w = _make_writer(tmp_path / "out.mp4") + with pytest.raises(RuntimeError): + w.write(_gray(1)[0]) + + def test_wrong_dtype_raises_value_error(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path) as w: + bad = np.full((H, W), 0.5, dtype=np.float32) + with pytest.raises(ValueError, match="uint8"): + w.write(bad) + + def test_gray_mode_rejects_color_array(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path, grayscale=True) as w: + with pytest.raises(ValueError): + w.write(_color(1)[0]) # (H, W, 3) into gray writer + + def test_color_mode_rejects_gray_array(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path, grayscale=False) as w: + with pytest.raises(ValueError): + w.write(_gray(1)[0]) # (H, W) into color writer + + def test_wrong_height_raises_value_error(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path) as w: + wrong = np.full((H + 1, W), 0, dtype=np.uint8) + with pytest.raises(ValueError): + w.write(wrong) + + def test_wrong_width_raises_value_error(self, tmp_path): + path = tmp_path / "out.mp4" + with _make_writer(path) as w: + wrong = np.full((H, W + 1), 0, dtype=np.uint8) + with pytest.raises(ValueError): + w.write(wrong) + + def test_gray_hw1_shape_is_accepted(self, tmp_path): + """(H, W, 1) grayscale must be silently squeezed to (H, W).""" + path = tmp_path / "out.mp4" + with _make_writer(path, grayscale=True) as w: + frame = np.full((H, W, 1), 128, dtype=np.uint8) + w.write(frame) # must not raise + + +# --------------------------------------------------------------------------- +# TestQuality +# --------------------------------------------------------------------------- + +class TestQuality: + @pytest.mark.parametrize("quality", [ + "very_low", "low", "medium", "high", "very_high", + ]) + def test_lossy_quality_produces_valid_file(self, tmp_path, quality): + path = tmp_path / f"out_{quality}.mp4" + with _make_writer(path, quality=quality) as w: + w.write(_gray(1)[0]) + decoded = _read_frames(path, "gray") + assert len(decoded) == 1 + + def test_lossless_quality_produces_valid_gray_file(self, tmp_path): + path = tmp_path / "out_lossless.mkv" + with _make_writer(path, quality="lossless") as w: + w.write(_gray(1)[0]) + decoded = _read_frames(path, "gray") + assert len(decoded) == 1 + + def test_lossless_quality_produces_valid_color_file(self, tmp_path): + path = tmp_path / "out_lossless.mkv" + with _make_writer(path, grayscale=False, quality="lossless") as w: + w.write(_color(1)[0]) + decoded = _read_frames(path, "bgr24") + assert len(decoded) == 1 + + def test_extra_options_are_merged(self, tmp_path): + """extra_options must not break encoding — just verify file is valid.""" + path = tmp_path / "out.mp4" + with _make_writer(path, extra_options={"tune": "grain"}) as w: + w.write(_gray(1)[0]) + decoded = _read_frames(path, "gray") + assert len(decoded) == 1 + + + + + diff --git a/tests/unit/test_pyav_webcam_video_source.py b/tests/unit/test_pyav_webcam_video_source.py new file mode 100644 index 0000000..64bad65 --- /dev/null +++ b/tests/unit/test_pyav_webcam_video_source.py @@ -0,0 +1,493 @@ +""" +Unit tests for PyAVWebcamSource — no hardware required. + +Fake objects +------------ +FakeAVFrame + Minimal duck-type for av.VideoFrame. Only the three attributes used + by _reader_loop and _frame_timestamp_seconds are implemented. + +FakeContainer + Drives the reader thread with a list of frames (or blocks forever). + Uses MagicMock for .streams so that _select_video_stream works without + a separate FakeStream class. + +Factory pattern +--------------- +_make_factory() returns a spy factory callable and a 'calls' list. +Each call to the factory creates a fresh FakeContainer, records the +(file, format, mode, options) arguments, and appends the result to calls. + + calls[0] — probe container (opened and close()d by _probe()) + calls[1] — live container (created by open(); tests inspect this one) + +_make_source() is a convenience wrapper for tests that don't need to +inspect factory arguments directly. +""" + +from __future__ import annotations + +import sys +import threading +import time +from fractions import Fraction +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from py3r.media.video.pyav_webcam_source import PyAVWebcamSource + +W, H = 64, 48 +FPS = 30.0 +DEVICE = "MockCamera" +EXPECTED_FORMAT = "dshow" if sys.platform == "win32" else ( + "avfoundation" if sys.platform == "darwin" else "v4l2" +) + + +# --------------------------------------------------------------------------- +# Fake PyAV objects +# --------------------------------------------------------------------------- + +class FakeAVFrame: + """Duck-type for av.VideoFrame. Only the surface used by _reader_loop.""" + + def __init__(self, img: np.ndarray, *, time: float | None = 0.0, + pts: int | None = None, time_base: Fraction | None = None): + self.img = img + self.time = time + self.pts = pts + self.time_base = time_base + + def to_ndarray(self, format: str) -> np.ndarray: # noqa: A002 + return self.img.copy() + + +class FakeContainer: + """ + Produces frames from a list, optionally blocking or raising an error. + + .streams is a MagicMock list so _select_video_stream works without a + separate stream stub class. + """ + + def __init__(self, av_frames: list[FakeAVFrame], *, + error: Exception | None = None, + block_until_closed: bool = False): + self._frames = av_frames + self._error = error + self._block_until_closed = block_until_closed + self._closed = threading.Event() + self.all_frames_yielded = threading.Event() + + # MagicMock stream: attribute access/assignment works transparently. + mock_stream = MagicMock() + mock_stream.type = "video" + mock_stream.codec_context.width = W + mock_stream.codec_context.height = H + mock_stream.average_rate = FPS + self.streams = [mock_stream] + + def decode(self, stream): # noqa: ARG002 + if self._block_until_closed: + self._closed.wait() + return + yield from self._frames + self.all_frames_yielded.set() + if self._error is not None: + raise self._error + + def close(self) -> None: + self._closed.set() + + +# --------------------------------------------------------------------------- +# Factory helpers +# --------------------------------------------------------------------------- + +def _make_factory(av_frames: list[FakeAVFrame], *, + error: Exception | None = None, + block_until_closed: bool = False): + """ + Return (factory, calls). + + calls is a list of dicts: + {"file": str, "format": str, "mode": str, + "options": dict, "container": FakeContainer} + """ + calls: list[dict] = [] + + def factory(file: str, *, format: str, mode: str, options: dict): # noqa: A002 + container = FakeContainer(av_frames, error=error, + block_until_closed=block_until_closed) + calls.append({"file": file, "format": format, "mode": mode, + "options": dict(options), "container": container}) + return container + + return factory, calls + + +def _make_source(av_frames: list[FakeAVFrame], *, + grayscale: bool = True, queue_size: int = 8, + error: Exception | None = None, + block_until_closed: bool = False): + """Convenience wrapper. Returns (src, calls).""" + factory, calls = _make_factory(av_frames, error=error, + block_until_closed=block_until_closed) + src = PyAVWebcamSource( + DEVICE, + grayscale=grayscale, + width=W, height=H, fps=FPS, + queue_size=queue_size, + container_factory=factory, + ) + # calls[0] = probe container (already closed). calls[1] = after open(). + return src, calls + + +def _gray(val: int = 128) -> np.ndarray: + return np.full((H, W), val, dtype=np.uint8) + + +def _color(val: int = 128) -> np.ndarray: + img = np.zeros((H, W, 3), dtype=np.uint8) + img[:, :, 0] = val + return img + + +def _live(calls: list[dict]) -> FakeContainer: + """Return the container created by the most recent open() call.""" + return calls[-1]["container"] + + +# --------------------------------------------------------------------------- +# Container factory arguments +# --------------------------------------------------------------------------- + +class TestContainerFactoryArgs: + """Verify that _open_container passes the right arguments to av.open.""" + + def _args(self, **kwargs) -> dict: + """Construct source with given kwargs, return the probe call record.""" + factory, calls = _make_factory([]) + PyAVWebcamSource(DEVICE, container_factory=factory, **kwargs) + return calls[0] # probe call + + def test_file_is_device_name(self): + rec = self._args() + # dshow requires "video="; other platforms use the name directly. + if rec["format"] == "dshow": + assert rec["file"] == f"video={DEVICE}" + else: + assert rec["file"] == DEVICE + + def test_format_is_platform_default(self): + assert self._args()["format"] == EXPECTED_FORMAT + + def test_explicit_format_is_forwarded(self): + factory, calls = _make_factory([]) + PyAVWebcamSource(DEVICE, input_format="v4l2", container_factory=factory) + assert calls[0]["format"] == "v4l2" + + def test_mode_is_read(self): + assert self._args()["mode"] == "r" + + def test_options_video_size(self): + opts = self._args(width=1280, height=720)["options"] + assert opts["video_size"] == "1280x720" + + def test_options_framerate(self): + opts = self._args(fps=60.0)["options"] + assert opts["framerate"] == "60.0" + + def test_options_rtbufsize_on_dshow(self): + factory, calls = _make_factory([]) + PyAVWebcamSource(DEVICE, input_format="dshow", container_factory=factory) + assert calls[0]["options"].get("rtbufsize") == "500M" + + def test_no_rtbufsize_on_v4l2(self): + factory, calls = _make_factory([]) + PyAVWebcamSource(DEVICE, input_format="v4l2", container_factory=factory) + assert "rtbufsize" not in calls[0]["options"] + + def test_no_size_options_when_not_specified(self): + opts = self._args()["options"] + assert "video_size" not in opts + + def test_no_framerate_option_when_not_specified(self): + opts = self._args()["options"] + assert "framerate" not in opts + + def test_probe_and_open_receive_identical_args(self): + """Both probe and live session should pass identical av.open arguments.""" + factory, calls = _make_factory([], block_until_closed=True) + src = PyAVWebcamSource(DEVICE, container_factory=factory, + width=W, height=H, fps=FPS) + src.open() + src.close() + assert len(calls) == 2 + probe, live = calls[0], calls[1] + for key in ("file", "format", "mode", "options"): + assert probe[key] == live[key], f"Mismatch on {key!r}" + + def test_raises_without_device_name(self): + with pytest.raises(TypeError): + PyAVWebcamSource() # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- +# Frame delivery +# --------------------------------------------------------------------------- + +class TestFrameDelivery: + def test_frames_arrive_in_order(self): + av_frames = [FakeAVFrame(_gray(i * 50), time=i / FPS) for i in range(5)] + src, calls = _make_source(av_frames) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frames = [src.read(timeout=2.0) for _ in range(5)] + src.close() + assert [f.frame_index for f in frames] == list(range(5)) + + def test_frame_content_matches_input(self): + expected = _gray(200) + src, calls = _make_source([FakeAVFrame(expected, time=0.0)]) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frame = src.read(timeout=2.0) + src.close() + np.testing.assert_array_equal(frame.img, expected) + + def test_grayscale_shape_and_dtype(self): + src, calls = _make_source([FakeAVFrame(_gray(), time=0.0)]) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frame = src.read(timeout=2.0) + src.close() + assert frame.img.shape == (H, W) and frame.img.dtype == np.uint8 + + def test_color_shape_and_dtype(self): + src, calls = _make_source([FakeAVFrame(_color(), time=0.0)], grayscale=False) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frame = src.read(timeout=2.0) + src.close() + assert frame.img.shape == (H, W, 3) and frame.img.dtype == np.uint8 + + def test_timestamp_from_frame_time(self): + src, calls = _make_source([FakeAVFrame(_gray(), time=1.234)]) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frame = src.read(timeout=2.0) + src.close() + assert frame.timestamp == pytest.approx(1.234) + + def test_timestamp_pts_fallback(self): + """frame.time=None → pts * time_base is used.""" + av_frame = FakeAVFrame(_gray(), time=None, pts=45, time_base=Fraction(1, 30)) + src, calls = _make_source([av_frame]) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frame = src.read(timeout=2.0) + src.close() + assert frame.timestamp == pytest.approx(1.5, abs=0.01) + + +# --------------------------------------------------------------------------- +# End-of-stream +# --------------------------------------------------------------------------- + +class TestEndOfStream: + def test_eof_returns_none(self): + src, calls = _make_source([FakeAVFrame(_gray(), time=0.0)]) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + src.read(timeout=2.0) + time.sleep(0.1) + assert src.read(timeout=0.5) is None + src.close() + + def test_read_after_eof_keeps_returning_none(self): + src, calls = _make_source([FakeAVFrame(_gray(), time=0.0)]) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + src.read(timeout=2.0) + time.sleep(0.1) + assert src.read(timeout=0.2) is None + assert src.read(timeout=0.2) is None + src.close() + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + +class TestErrorHandling: + def test_reader_exception_raised_to_caller(self): + boom = ValueError("simulated av error") + src, calls = _make_source([], error=boom) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + time.sleep(0.1) + with pytest.raises(RuntimeError, match="reader failed"): + src.read(timeout=2.0) + src.close() + + def test_original_exception_is_chained(self): + boom = ValueError("original cause") + src, calls = _make_source([], error=boom) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + time.sleep(0.1) + with pytest.raises(RuntimeError) as exc_info: + src.read(timeout=2.0) + src.close() + assert exc_info.value.__cause__ is boom + + +# --------------------------------------------------------------------------- +# Timeout +# --------------------------------------------------------------------------- + +class TestTimeout: + def test_short_timeout_raises(self): + src, _ = _make_source([], block_until_closed=True) + src.open() + with pytest.raises(TimeoutError): + src.read(timeout=0.05) + src.close() + + def test_generous_timeout_does_not_raise_if_frame_arrives(self): + src, calls = _make_source([FakeAVFrame(_gray(), time=0.0)]) + src.open() + frame = src.read(timeout=3.0) + src.close() + assert frame is not None + + +# --------------------------------------------------------------------------- +# close() unblocks read() +# --------------------------------------------------------------------------- + +class TestCloseUnblocks: + def test_close_from_other_thread_unblocks_read(self): + src, _ = _make_source([], block_until_closed=True) + src.open() + + result_holder: list = [] + exc_holder: list = [] + + def _reader(): + try: + result_holder.append(src.read(timeout=10.0)) + except Exception as e: + exc_holder.append(e) + + t = threading.Thread(target=_reader) + t.start() + time.sleep(0.1) + src.close() + t.join(timeout=3.0) + + assert not t.is_alive(), "read() did not unblock after close()" + assert not exc_holder, f"Unexpected exception: {exc_holder}" + assert result_holder == [None] + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_not_open_before_open(self): + src, _ = _make_source([]) + assert not src.is_open() + + def test_is_open_after_open(self): + src, _ = _make_source([], block_until_closed=True) + src.open() + assert src.is_open() + src.close() + + def test_is_closed_after_close(self): + src, _ = _make_source([], block_until_closed=True) + src.open() + src.close() + assert not src.is_open() + + def test_double_close_is_safe(self): + src, _ = _make_source([], block_until_closed=True) + src.open() + src.close() + src.close() + + def test_frame_index_starts_at_zero(self): + src, calls = _make_source([FakeAVFrame(_gray(), time=0.0)]) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frame = src.read(timeout=2.0) + src.close() + assert frame.frame_index == 0 + + def test_frame_index_resets_on_reopen(self): + """After close() + open(), _idx must restart from 0.""" + factory, calls = _make_factory( + [FakeAVFrame(_gray(i * 80), time=i / FPS) for i in range(3)] + ) + src = PyAVWebcamSource(DEVICE, width=W, height=H, fps=FPS, + container_factory=factory) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + for _ in range(3): + src.read(timeout=2.0) + src.close() + + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=3.0) + frame = src.read(timeout=2.0) + src.close() + assert frame.frame_index == 0 + + def test_open_is_idempotent(self): + src, _ = _make_source([], block_until_closed=True) + src.open() + thread_before = src._reader_thread + src.open() + assert src._reader_thread is thread_before + src.close() + + +# --------------------------------------------------------------------------- +# Queue saturation +# --------------------------------------------------------------------------- + +class TestQueueSaturation: + def test_oldest_frames_dropped_when_full(self): + N, QUEUE_SIZE = 12, 3 + av_frames = [FakeAVFrame(_gray(i * 20), time=i / FPS) for i in range(N)] + src, calls = _make_source(av_frames, queue_size=QUEUE_SIZE) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=5.0) + time.sleep(0.05) + + received = [] + while True: + f = src.read(timeout=0.2) + if f is None: + break + received.append(f) + src.close() + + assert len(received) <= QUEUE_SIZE + assert received[-1].frame_index == N - 1 + + def test_no_deadlock_under_saturation(self): + av_frames = [FakeAVFrame(_gray(), time=i / FPS) for i in range(50)] + src, calls = _make_source(av_frames, queue_size=2) + src.open() + assert _live(calls).all_frames_yielded.wait(timeout=5.0) + src.close() + + + diff --git a/tests/unit/test_pylon_camera_source.py b/tests/unit/test_pylon_camera_source.py new file mode 100644 index 0000000..79e4787 --- /dev/null +++ b/tests/unit/test_pylon_camera_source.py @@ -0,0 +1,548 @@ +""" +Unit tests for PylonCameraSource — no hardware required. + +pypylon must be installed (for module-level constants and imports), but no +physical camera is needed. If pypylon is not installed all tests are skipped. + +Fake objects +------------ +FakeNode + Duck-type for a pypylon GenICam node (Width, Height, PixelFormat, …). + Supports GetValue() / SetValue(). + +FakeGrabResult + Duck-type for pylon.GrabResult. Controls GrabSucceeded(), Array, + BlockID, TimeStamp, GetErrorCode(), GetErrorDescription(), Release(). + +FakeCamera + Duck-type for pylon.InstantCamera. Serves FakeGrabResult objects from a + list via RetrieveResult(); tracks IsOpen / IsGrabbing state. + +Factory pattern +--------------- +_make_factory() returns a spy factory and a 'calls' list. Each call creates +a fresh FakeCamera and appends {"serial", "camera"} to calls. + + calls[0] — probe camera (Close()d by _probe()) + calls[1] — live camera (created by open()) + +_make_source() is a convenience wrapper. +""" + +from __future__ import annotations + +import time +from unittest.mock import MagicMock + +import numpy as np +import pytest + +# Skip the whole module if pypylon is not installed. +pytest.importorskip("pypylon") + +from py3r.media.types import VideoFrame +from py3r.media.video.pylon_camera_source import ( + GrabFailedError, + GrabTimeoutError, + PylonCameraSource, +) + +W, H = 64, 48 +FPS = 30.0 +SERIAL = "TEST0001" + + +# --------------------------------------------------------------------------- +# Fake pypylon objects +# --------------------------------------------------------------------------- + +class FakeNode: + """Duck-type for a GenICam node.""" + + def __init__(self, value): + self._value = value + + def GetValue(self): + return self._value + + def SetValue(self, value) -> None: + self._value = value + + +class FakeGrabResult: + """Duck-type for pylon.GrabResult.""" + + def __init__( + self, + img: np.ndarray, + *, + grab_succeeded: bool = True, + block_id: int | None = None, + timestamp_ns: int | None = None, + error_code: int = 0, + error_desc: str = "", + ): + self._img = img + self._succeeded = grab_succeeded + self._block_id = block_id + self._timestamp_ns = timestamp_ns + self._error_code = error_code + self._error_desc = error_desc + self.released = False + + @property + def Array(self) -> np.ndarray: + return self._img + + @property + def BlockID(self): + return self._block_id + + @property + def TimeStamp(self): + return self._timestamp_ns + + def GrabSucceeded(self) -> bool: + return self._succeeded + + def GetErrorCode(self) -> int: + return self._error_code + + def GetErrorDescription(self) -> str: + return self._error_desc + + def Release(self) -> None: + self.released = True + + +class FakeCamera: + """Duck-type for pylon.InstantCamera.""" + + def __init__( + self, + results: list[FakeGrabResult | None], + *, + width: int = W, + height: int = H, + fps: float = FPS, + pixel_format: str = "Mono8", + tick_frequency: int = 1_000_000_000, + ): + self._results = list(results) + self.Width = FakeNode(width) + self.Height = FakeNode(height) + self.AcquisitionFrameRateAbs = FakeNode(fps) + self.PixelFormat = FakeNode(pixel_format) + self.GevTimestampTickFrequency = FakeNode(tick_frequency) + self.MaxNumBuffer = 0 + self._opened = True # factory returns an already-opened camera + self._grabbing = False + self.start_grab_calls: list = [] + self.close_calls = 0 + + def IsOpen(self) -> bool: + return self._opened + + def IsGrabbing(self) -> bool: + return self._grabbing + + def Open(self) -> None: + self._opened = True + + def Close(self) -> None: + self._opened = False + self._grabbing = False + self.close_calls += 1 + + def StartGrabbing(self, strategy) -> None: + self._grabbing = True + self.start_grab_calls.append(strategy) + + def StopGrabbing(self) -> None: + self._grabbing = False + + def GetNodeMap(self): + return MagicMock() + + def RetrieveResult(self, timeout_ms: int, handling) -> FakeGrabResult | None: + if not self._results: + return None + return self._results.pop(0) + + +# --------------------------------------------------------------------------- +# Factory helpers +# --------------------------------------------------------------------------- + +def _gray_img(val: int = 128) -> np.ndarray: + return np.full((H, W), val, dtype=np.uint8) + + +def _make_result( + val: int = 128, + *, + block_id: int | None = None, + timestamp_ns: int | None = None, +) -> FakeGrabResult: + return FakeGrabResult( + _gray_img(val), + grab_succeeded=True, + block_id=block_id, + timestamp_ns=timestamp_ns, + ) + + +def _make_factory( + results: list[FakeGrabResult | None], + *, + width: int = W, + height: int = H, + fps: float = FPS, + pixel_format: str = "Mono8", + tick_frequency: int = 1_000_000_000, +): + """Return (factory, calls). Each factory call creates a fresh FakeCamera.""" + calls: list[dict] = [] + + def factory(serial: str): + cam = FakeCamera(list(results), width=width, height=height, + fps=fps, pixel_format=pixel_format, + tick_frequency=tick_frequency) + calls.append({"serial": serial, "camera": cam}) + return cam + + return factory, calls + + +def _make_source( + results: list[FakeGrabResult | None], + *, + width: int = W, + height: int = H, + fps: float = FPS, + pixel_format: str = "Mono8", + tick_frequency: int = 1_000_000_000, +): + """Convenience wrapper. Returns (src, calls); calls[0]=probe, calls[1]=live.""" + factory, calls = _make_factory(results, width=width, height=height, + fps=fps, pixel_format=pixel_format, + tick_frequency=tick_frequency) + src = PylonCameraSource(SERIAL, camera_factory=factory) + return src, calls + + +def _live(calls: list[dict]) -> FakeCamera: + return calls[1]["camera"] + + +# --------------------------------------------------------------------------- +# TestCameraFactoryArgs +# --------------------------------------------------------------------------- + +class TestCameraFactoryArgs: + def test_serial_passed_to_factory(self): + factory, calls = _make_factory([]) + PylonCameraSource("SN123", camera_factory=factory) + assert calls[0]["serial"] == "SN123" + + def test_factory_called_once_for_probe(self): + factory, calls = _make_factory([]) + PylonCameraSource(SERIAL, camera_factory=factory) + assert len(calls) == 1 + + def test_factory_called_again_for_open(self): + src, calls = _make_source([]) + src.open() + assert len(calls) == 2 + + def test_probe_camera_is_closed_after_probe(self): + src, calls = _make_source([]) + assert calls[0]["camera"].close_calls >= 1 + + def test_probe_camera_not_open_after_probe(self): + src, calls = _make_source([]) + assert not calls[0]["camera"].IsOpen() + + +# --------------------------------------------------------------------------- +# TestProbe +# --------------------------------------------------------------------------- + +class TestProbe: + def test_size_from_probe(self): + src, _ = _make_source([], width=320, height=240) + assert src.get_size() == (320, 240) + + def test_fps_from_probe(self): + src, _ = _make_source([], fps=60.0) + assert src.get_fps() == 60.0 + + def test_has_size_after_probe(self): + src, _ = _make_source([]) + assert src.has_size() + + def test_has_fps_after_probe(self): + src, _ = _make_source([]) + assert src.has_fps() + + def test_grayscale_when_mono8(self): + src, _ = _make_source([], pixel_format="Mono8") + assert src.get_num_channels() == 1 + + def test_color_when_not_mono8(self): + src, _ = _make_source([], pixel_format="BayerRG8") + assert src.get_num_channels() == 3 + + def test_probe_soft_fails_when_factory_raises(self): + """Construction must not raise even when the factory fails.""" + def bad_factory(serial: str): + raise RuntimeError("no camera") + + src = PylonCameraSource(SERIAL, camera_factory=bad_factory) + assert src.get_size() is None + assert not src.has_size() + + +# --------------------------------------------------------------------------- +# TestLifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_is_not_open_before_open(self): + src, _ = _make_source([]) + assert not src.is_open() + + def test_is_open_after_open(self): + src, _ = _make_source([]) + src.open() + assert src.is_open() + + def test_not_open_after_close(self): + src, _ = _make_source([]) + src.open() + src.close() + assert not src.is_open() + + def test_double_close_is_safe(self): + src, _ = _make_source([]) + src.open() + src.close() + src.close() + + def test_open_starts_grabbing(self): + src, calls = _make_source([]) + src.open() + assert _live(calls).IsGrabbing() + + def test_open_calls_start_grabbing_once(self): + src, calls = _make_source([]) + src.open() + assert len(_live(calls).start_grab_calls) == 1 + + def test_close_stops_grabbing(self): + src, calls = _make_source([]) + src.open() + src.close() + # The live camera is closed; IsGrabbing checks internal state. + assert not _live(calls).IsGrabbing() + + def test_capability_flags(self): + src, _ = _make_source([]) + assert src.has_timing() + assert not src.has_num_frames() + assert not src.is_seekable() + + +# --------------------------------------------------------------------------- +# TestRead +# --------------------------------------------------------------------------- + +class TestRead: + def test_read_returns_video_frame(self): + src, _ = _make_source([_make_result()]) + src.open() + frame = src.read() + assert isinstance(frame, VideoFrame) + + def test_frame_shape_matches_camera(self): + src, _ = _make_source([_make_result()]) + src.open() + frame = src.read() + assert frame.img.shape == (H, W) + + def test_frame_pixel_values(self): + src, _ = _make_source([_make_result(200)]) + src.open() + assert src.read().img[0, 0] == 200 + + def test_result_released_after_read(self): + result = _make_result() + src, _ = _make_source([result]) + src.open() + src.read() + assert result.released + + def test_frame_index_resets_on_open(self): + factory, _ = _make_factory([_make_result(), _make_result()]) + src = PylonCameraSource(SERIAL, camera_factory=factory) + src.open() + src.read() + assert src._idx == 1 + src.close() + src.open() + assert src._idx == 0 + + +# --------------------------------------------------------------------------- +# TestFrameIndex +# --------------------------------------------------------------------------- + +class TestFrameIndex: + def test_uses_block_id_when_present(self): + """BlockID from the SDK must be used as the frame_index.""" + result = _make_result(block_id=42) + src, _ = _make_source([result]) + src.open() + assert src.read().frame_index == 42 + + def test_falls_back_to_internal_counter_when_no_block_id(self): + result = _make_result(block_id=None) + src, _ = _make_source([result]) + src.open() + assert src.read().frame_index == 0 + + def test_internal_counter_increments_each_read(self): + results = [_make_result(block_id=None), _make_result(block_id=None)] + src, _ = _make_source(results) + src.open() + src.read() + assert src._idx == 1 + src.read() + assert src._idx == 2 + + def test_block_id_gap_is_preserved(self): + """If camera skips block IDs (dropped frames), frame_index must reflect that.""" + r1 = _make_result(block_id=10) + r2 = _make_result(block_id=15) # gap of 4 + src, _ = _make_source([r1, r2]) + src.open() + f1 = src.read() + f2 = src.read() + assert f2.frame_index - f1.frame_index == 5 + + +# --------------------------------------------------------------------------- +# TestTimestamps +# --------------------------------------------------------------------------- + +class TestTimestamps: + def test_timestamp_from_result_at_1ghz(self): + """Default tick frequency is 1 GHz (1e9 ticks/s) → ticks / 1e9 = seconds.""" + result = _make_result(timestamp_ns=2_000_000_000) # 2 billion ticks @ 1 GHz = 2 s + src, _ = _make_source([result]) + src.open() + assert src.read().timestamp == pytest.approx(2.0) + + def test_timestamp_uses_tick_frequency_from_probe(self): + """When GevTimestampTickFrequency is e.g. 125 MHz, ticks/125e6 = seconds.""" + tick_freq = 125_000_000 # 125 MHz — common Basler GigE value + ticks = 250_000_000 # 250 million ticks → 2 seconds + result = _make_result(timestamp_ns=ticks) + src, _ = _make_source([result], tick_frequency=tick_freq) + src.open() + assert src.read().timestamp == pytest.approx(2.0) + + def test_timestamp_zero_falls_back_to_perf_counter(self): + """TimeStamp == 0 (falsy) must fall back to time.perf_counter().""" + result = FakeGrabResult(_gray_img(), timestamp_ns=0) + src, _ = _make_source([result]) + src.open() + before = time.perf_counter() + ts = src.read().timestamp + after = time.perf_counter() + assert before <= ts <= after + + def test_timestamp_none_falls_back_to_perf_counter(self): + result = FakeGrabResult(_gray_img(), timestamp_ns=None) + src, _ = _make_source([result]) + src.open() + before = time.perf_counter() + ts = src.read().timestamp + after = time.perf_counter() + assert before <= ts <= after + + +# --------------------------------------------------------------------------- +# TestGrabFailed +# --------------------------------------------------------------------------- + +class TestGrabFailed: + def test_grab_failed_raises_grab_failed_error(self): + result = FakeGrabResult( + _gray_img(), + grab_succeeded=False, + error_code=0xE1000014, + error_desc="Buffer incomplete", + ) + src, _ = _make_source([result]) + src.open() + with pytest.raises(GrabFailedError): + src.read() + + def test_grab_failed_message_contains_error_code(self): + result = FakeGrabResult(_gray_img(), grab_succeeded=False, + error_code=0xDEAD, error_desc="") + src, _ = _make_source([result]) + src.open() + with pytest.raises(GrabFailedError, match="0x0000dead"): + src.read() + + def test_grab_failed_releases_result(self): + result = FakeGrabResult(_gray_img(), grab_succeeded=False) + src, _ = _make_source([result]) + src.open() + with pytest.raises(GrabFailedError): + src.read() + assert result.released + + +# --------------------------------------------------------------------------- +# TestGrabTimeout +# --------------------------------------------------------------------------- + +class TestGrabTimeout: + def test_retrieve_none_raises_grab_timeout(self): + """RetrieveResult returning None means no frame in time → GrabTimeoutError.""" + src, _ = _make_source([None]) + src.open() + with pytest.raises(GrabTimeoutError): + src.read() + + def test_grab_timeout_message_contains_duration(self): + src, _ = _make_source([None]) + src.open() + with pytest.raises(GrabTimeoutError, match="0.250"): + src.read(timeout=0.25) + + + +# --------------------------------------------------------------------------- +# TestNotOpen +# --------------------------------------------------------------------------- + +class TestNotOpen: + def test_read_before_open_raises_runtime_error(self): + src, _ = _make_source([]) + with pytest.raises(RuntimeError, match="not open"): + src.read() + + def test_read_after_close_raises_runtime_error(self): + src, _ = _make_source([]) + src.open() + src.close() + with pytest.raises(RuntimeError): + src.read() + + + + From 19cd57472771f1dd4fea795c75a8789c86e32521 Mon Sep 17 00:00:00 2001 From: Marcel Schmutz Date: Mon, 11 May 2026 14:47:52 +0200 Subject: [PATCH 5/6] fixup! AI generated test cases --- .github/workflows/unit-tests.yml | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..b3469a1 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,38 @@ +name: Unit Tests + +on: + push: + pull_request: + +jobs: + test: + name: Python ${{ matrix.python-version }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + # opencv-python needs libGL on headless Ubuntu runners. + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update -q && sudo apt-get install -y libgl1 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run unit tests + run: python -m pytest tests/unit/ -q --tb=short + From 35079cb82b2d8c27cd28e19c8bac41f243df0f81 Mon Sep 17 00:00:00 2001 From: Marcel Schmutz Date: Mon, 11 May 2026 14:54:41 +0200 Subject: [PATCH 6/6] fixup! fixup! AI generated test cases --- .github/workflows/unit-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b3469a1..a3a8ebc 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -31,6 +31,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install -e . pip install -r requirements-dev.txt - name: Run unit tests