From 58a0109e79007bfef89d4578d1227230153d0d39 Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Mon, 9 Nov 2026 08:14:31 -0500 Subject: [PATCH 1/2] feat(detect): NVDEC frame source via ffmpeg pipe with cv2 fallback --- CHANGELOG.md | 7 + README.md | 14 ++ redactcam/detect.py | 33 +++-- redactcam/frames.py | 315 +++++++++++++++++++++++++++++++++++++++++++ tests/test_frames.py | 242 +++++++++++++++++++++++++++++++++ 5 files changed, 599 insertions(+), 12 deletions(-) create mode 100644 redactcam/frames.py create mode 100644 tests/test_frames.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b3f2b..87ce7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to this project are documented here. The format follows ### Added +- **NVDEC frame source.** `redactcam.frames.iter_frames` feeds `detect_and_track` and + `detect_regions` from either OpenCV or `ffmpeg -hwaccel cuda -c:v *_cuvid` over a + full-frame pipe reader (`decode=` argument, `REDACTCAM_DECODE=cv2|nvdec|auto`). + `auto` falls back to cv2 unless ffmpeg has cuda, the matching cuvid decoder and a + working 1-frame probe; rotated clips, unsupported codecs and checkpoint resumes + use cv2. A mid-stream decoder failure raises instead of truncating. Frame indices, + sampling and results are identical; the backend is logged as `decode=`. - **Detection progress heartbeat.** `detect_and_track(progress_every_s=60.0)` logs frame, percentage, detection count and fps about once a minute (wall clock) plus a final `detect: done` line; `0` disables it. A 54 197-frame pass was silent for 4.5 h. diff --git a/README.md b/README.md index ef55369..c5ddb12 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,20 @@ produced it, so for those runs the receipt identifies the code that built the ma and render, not necessarily the code that detected. Use `--fresh` when the detection identity matters. +### Decode backend (NVDEC) + +Stage 1 reads every frame and is decode-bound on 4K HEVC. `detect_and_track` / +`detect_regions` take `decode="auto"|"cv2"|"nvdec"`; the environment variable +`REDACTCAM_DECODE=cv2|nvdec|auto` overrides it. `nvdec` runs +`ffmpeg -hwaccel cuda -c:v _cuvid ... -pix_fmt bgr24` through a pipe (measured +on a 4K60 HEVC clip: cv2 23 fps, CPU ffmpeg 22 fps, NVDEC 40 fps). `auto` uses it only +when ffmpeg reports `cuda`, has the `hevc_cuvid`/`h264_cuvid`/`av1_cuvid` decoder for +the stream, and decodes one probe frame; otherwise it logs why at INFO and uses cv2. +Clips with rotation metadata, other codecs and checkpoint resumes use cv2. Frame +indices, sampling and tracking are identical for both. A decoder that dies or ends +early mid-clip raises `FrameSourceError` rather than truncating the video. The +detection log shows `detect: decode=nvdec`. + ### Python ```python diff --git a/redactcam/detect.py b/redactcam/detect.py index 5d7f804..2def3d3 100644 --- a/redactcam/detect.py +++ b/redactcam/detect.py @@ -24,6 +24,8 @@ import cv2 import numpy as np +from .frames import iter_frames + logger = logging.getLogger(__name__) @@ -663,6 +665,7 @@ def detect_regions( plate_max_aspect: float = 6.0, plate_max_area_frac: float = 0.01, plate_threads: int = 0, + decode: str = "auto", ) -> list[FrameRegions]: """Detect face ∪ plate regions across a video, one ``FrameRegions`` per sampled frame (empty ``boxes`` is normal for clean frames). @@ -699,7 +702,8 @@ def detect_regions( plate_threads, ) - fps = cap.get(cv2.CAP_PROP_FPS) or 0.0 + meta, frames = iter_frames(path, decode=decode, capture=cap) + fps = meta.fps # Native frames to advance between samples; fall back to every frame when # fps is zero/garbage (some containers report 0). step = max(1, round(fps / sample_fps)) if fps > 0 and sample_fps > 0 else 1 @@ -708,10 +712,7 @@ def detect_regions( frame_idx = 0 tiles: list[tuple[int, int, int, int]] | None = None try: - while True: - ret, frame = cap.read() - if not ret: - break + for frame in frames: if frame_idx % step == 0: if tiles is None: # frame size is constant — derive the grid once tiles = frame_tiles(frame.shape[1], frame.shape[0], tile_px, tile_overlap) @@ -729,6 +730,7 @@ def detect_regions( ) frame_idx += 1 finally: + frames.close() cap.release() return results @@ -886,6 +888,7 @@ def detect_and_track( checkpoint_every_frames: int = 0, checkpoint_overlap_frames: int = CHECKPOINT_OVERLAP_FRAMES, progress_every_s: float = 60.0, + decode: str = "auto", ) -> tuple[dict[int, list[Box]], int, int, int]: """One decode pass producing DENSE per-frame privacy boxes. @@ -996,6 +999,11 @@ def detect_and_track( ``detect: done`` line, so a multi-hour pass is distinguishable from a hung one. TOTAL and P are omitted when the container reports no frame count. Logging only; the returned boxes are unchanged. + + ``decode`` (``auto`` | ``cv2`` | ``nvdec``; env ``REDACTCAM_DECODE`` wins) picks the + frame source, see ``redactcam.frames``. Frame indices, sampling and tracking are + identical for every backend; the chosen one is logged as ``detect: decode=`` + and repeated on the ``detect: done`` line (both only while progress logging is on). A checkpoint resume decodes with cv2. """ from .track import TrackManager @@ -1054,14 +1062,16 @@ def detect_and_track( if restored is not None: resumed, frames_done = restored frame_idx = max(0, frames_done - max(0, checkpoint_overlap_frames)) - cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) logger.info( "Resuming detection at frame %d (%d frames checkpointed)", frame_idx, frames_done, ) last_checkpoint = frame_idx - total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + meta, frames = iter_frames(path, decode=decode, start_frame=frame_idx, capture=cap) + total_frames = meta.frame_count + if progress_every_s > 0: + logger.info("detect: decode=%s", meta.backend) t_start = last_progress = time.monotonic() progress_start_idx = last_progress_idx = frame_idx # Adaptive plate cadence: between sparse samples a plate accelerating through a @@ -1072,10 +1082,7 @@ def detect_and_track( # self-clears when the plate retires (``max_speed`` → 0). boost_run = 0 try: - while True: - ret, frame = cap.read() - if not ret: - break + for frame in frames: if tracker is None: # geometry is constant — build the grid + tracker once h, w = frame.shape[:2] tiles = frame_tiles(w, h, tile_px, tile_overlap) @@ -1205,6 +1212,7 @@ def detect_and_track( ) last_checkpoint = frame_idx finally: + frames.close() cap.release() # Temporal confirmation drops unconfirmed FACE tracks (one-detection foliage/ @@ -1223,8 +1231,9 @@ def detect_and_track( frame_boxes = _union_boxes(resumed, frame_boxes) if progress_every_s > 0: logger.info( - "detect: done, %d frames in %.0fs", + "detect: done, %d frames in %.0fs, decode=%s", frame_idx - progress_start_idx, time.monotonic() - t_start, + meta.backend, ) return frame_boxes, frame_idx, n_face, n_plate diff --git a/redactcam/frames.py b/redactcam/frames.py new file mode 100644 index 0000000..4950836 --- /dev/null +++ b/redactcam/frames.py @@ -0,0 +1,315 @@ +"""Frame source for the per-frame loops: OpenCV (``cv2``) or NVIDIA NVDEC (``nvdec``). + +Stage 1 (``detect_and_track``) reads every frame of a 4K clip and is decode-bound: +on a measured 4K60 HEVC master OpenCV and a CPU ``ffmpeg`` pipe both top out near +22 fps, while ``ffmpeg -hwaccel cuda -c:v hevc_cuvid`` piped as ``bgr24`` reaches +~40 fps. ``iter_frames`` hides which one runs; both yield identical BGR ``uint8`` +``HxWx3`` arrays in the same order, so frame indices, sampling and tracking do not +depend on the backend. + +``REDACTCAM_DECODE=cv2|nvdec|auto`` overrides the ``decode`` argument. ``auto`` uses +NVDEC only when ffmpeg reports ``-hwaccel cuda``, has the matching ``*_cuvid`` +decoder, and decodes one frame of the input; anything else uses ``cv2``. A rotated +clip (display-matrix / rotate tag) always uses ``cv2``, which applies the rotation +the raw pipe would not. +""" + +from __future__ import annotations + +import functools +import json +import logging +import os +import shutil +import subprocess +import tempfile +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + +ENV_DECODE = "REDACTCAM_DECODE" +DECODE_MODES = ("auto", "cv2", "nvdec") +_CUVID = {"hevc": "hevc_cuvid", "h264": "h264_cuvid", "av1": "av1_cuvid"} +_PROBE_TIMEOUT_S = 60 +_WAIT_TIMEOUT_S = 10 +# A clean ffmpeg exit with fewer frames than the container promised is tolerated up +# to this fraction (container frame counts are estimates); more is a truncated read. +_SHORTFALL_FRAC = 0.01 + + +@dataclass(frozen=True) +class FrameMeta: + """Stream facts derived exactly as ``cv2.VideoCapture`` reports them.""" + + fps: float + width: int + height: int + frame_count: int + backend: str = "cv2" + + +class FrameSourceError(RuntimeError): + """The decoder died or truncated the stream before the video ended.""" + + +def resolve_mode(decode: str) -> str: + mode = (os.environ.get(ENV_DECODE) or decode or "auto").strip().lower() + if mode not in DECODE_MODES: + raise ValueError(f"decode must be one of {DECODE_MODES}, got {mode!r}") + return mode + + +def read_full(stream, size: int) -> bytearray | None: + """Read exactly ``size`` bytes from ``stream`` (looping ``readinto``). + + ``None`` is a clean end of stream (zero bytes). A pipe may return any number of + bytes per read, so a short read is NOT end of stream; ending partway through a + frame raises ``FrameSourceError`` rather than yielding or dropping a torn frame. + """ + buf = bytearray(size) + view = memoryview(buf) + got = 0 + while got < size: + n = stream.readinto(view[got:]) + if not n: + break + got += n + if got == 0: + return None + if got < size: + raise FrameSourceError(f"partial frame at end of stream: {got} of {size} bytes") + return buf + + +@functools.lru_cache(maxsize=4) +def _ffmpeg_caps(ffmpeg: str) -> tuple[bool, frozenset[str]]: + """(``-hwaccel cuda`` reported, the ``*_cuvid`` decoders this ffmpeg has).""" + + def run(*args: str) -> str: + try: + r = subprocess.run( + [ffmpeg, "-hide_banner", *args], + capture_output=True, + text=True, + timeout=_PROBE_TIMEOUT_S, + ) + except (OSError, subprocess.SubprocessError): + return "" + return r.stdout + + hw = any(line.strip() == "cuda" for line in run("-hwaccels").splitlines()) + decoders = frozenset( + parts[1] + for line in run("-decoders").splitlines() + if len(parts := line.split()) >= 2 and parts[1].endswith("_cuvid") + ) + return hw, decoders + + +def probe_stream(path: Path) -> dict | None: + """First video stream via ``ffprobe -show_streams`` (None if unavailable).""" + ffprobe = shutil.which("ffprobe") + if not ffprobe: + return None + try: + r = subprocess.run( + [ffprobe, "-v", "error", "-select_streams", "v:0", "-show_streams", + "-of", "json", str(path)], + capture_output=True, + text=True, + timeout=_PROBE_TIMEOUT_S, + ) + streams = json.loads(r.stdout or "{}").get("streams") or [] + except (OSError, subprocess.SubprocessError, ValueError): + return None + return streams[0] if streams else None + + +def is_rotated(stream: dict) -> bool: + """True if the stream carries rotation metadata (rotate tag or display matrix). + + cv2 applies it; the raw ffmpeg pipe is not proven to under ``-hwaccel cuda``, + so such clips stay on cv2.""" + if (stream.get("tags") or {}).get("rotate") not in (None, "", "0"): + return True + for sd in stream.get("side_data_list") or []: + kind = str(sd.get("side_data_type", "")).lower() + if "display" in kind or sd.get("rotation") not in (None, 0, "0"): + return True + return False + + +def _nvdec_command(ffmpeg: str, decoder: str, path: Path) -> list[str]: + return [ + ffmpeg, "-v", "error", "-hwaccel", "cuda", "-c:v", decoder, + "-i", str(path), "-f", "rawvideo", "-pix_fmt", "bgr24", "-", + ] # fmt: skip + + +def _nvdec_plan(path: Path, meta: FrameMeta) -> tuple[str, str] | str: + """(ffmpeg, decoder) if NVDEC can serve ``path``, else the reason it cannot.""" + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + return "ffmpeg not found" + stream = probe_stream(path) + if stream is None: + return "ffprobe could not read the stream" + decoder = _CUVID.get(str(stream.get("codec_name"))) + if decoder is None: + return f"no cuvid decoder for codec {stream.get('codec_name')!r}" + if is_rotated(stream): + return "stream has rotation metadata (cv2 applies it)" + if (int(stream.get("width") or 0), int(stream.get("height") or 0)) != (meta.width, meta.height): + return "ffprobe and cv2 disagree on frame size" + hw, decoders = _ffmpeg_caps(ffmpeg) + if not hw: + return "ffmpeg lacks -hwaccel cuda" + if decoder not in decoders: + return f"ffmpeg lacks the {decoder} decoder" + try: + r = subprocess.run( + [ffmpeg, "-v", "error", "-hwaccel", "cuda", "-c:v", decoder, "-i", str(path), + "-frames:v", "1", "-f", "null", "-"], + capture_output=True, + timeout=_PROBE_TIMEOUT_S, + ) + except (OSError, subprocess.SubprocessError) as exc: + return f"probe decode failed to run: {exc}" + if r.returncode != 0: + return "probe decode of 1 frame failed" + return ffmpeg, decoder + + +def _open_nvdec(cmd: list[str], meta: FrameMeta) -> Iterator[np.ndarray]: + """Start the pipe and read frame 0 eagerly so a dead start falls back to cv2. + + Raises ``FrameSourceError`` if it cannot start or yields no first frame.""" + size = meta.width * meta.height * 3 + err = tempfile.TemporaryFile() + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=err, bufsize=0) + except OSError as exc: + err.close() + raise FrameSourceError(f"could not start ffmpeg: {exc}") from exc + try: + first = read_full(proc.stdout, size) + except FrameSourceError: + first = None + if first is None: + _reap(proc) + msg = _stderr(err) + err.close() + raise FrameSourceError(f"nvdec pipe produced no frame: {msg}") + + def gen() -> Iterator[np.ndarray]: + delivered = 0 + shape = (meta.height, meta.width, 3) + buf: bytearray | None = first + try: + while buf is not None: + yield np.frombuffer(buf, dtype=np.uint8).reshape(shape) + delivered += 1 + buf = read_full(proc.stdout, size) + rc = _reap(proc) + if rc != 0: + raise FrameSourceError( + f"ffmpeg nvdec exited rc={rc} after {delivered} frames: {_stderr(err)}" + ) + if meta.frame_count and delivered < meta.frame_count * (1 - _SHORTFALL_FRAC): + raise FrameSourceError( + f"nvdec stream ended after {delivered} of {meta.frame_count} frames" + ) + finally: + _reap(proc) + err.close() + + return gen() + + +def _reap(proc: subprocess.Popen) -> int | None: + """Close the pipe, wait briefly, terminate (then kill) on timeout.""" + try: + if proc.stdout: + proc.stdout.close() + except OSError: + pass + try: + return proc.wait(timeout=_WAIT_TIMEOUT_S) + except subprocess.TimeoutExpired: + proc.terminate() + try: + return proc.wait(timeout=_WAIT_TIMEOUT_S) + except subprocess.TimeoutExpired: + proc.kill() + return proc.wait() + + +def _stderr(err) -> str: + try: + err.seek(0) + return err.read().decode("utf-8", "replace").strip()[-500:] + except (OSError, ValueError): + return "" + + +def _cv2_frames(cap) -> Iterator[np.ndarray]: + try: + while True: + ret, frame = cap.read() + if not ret: + break + yield frame + finally: + cap.release() + + +def iter_frames( + path: str | Path, + *, + decode: str = "auto", + start_frame: int = 0, + capture=None, +) -> tuple[FrameMeta, Iterator[np.ndarray]]: + """Open ``path`` and return ``(meta, frames)``; ``meta.backend`` names the decoder. + + Metadata always comes from ``cv2.VideoCapture`` (``capture`` lets the caller pass + one it already opened). ``start_frame`` > 0 (checkpoint resume) is served by cv2, + which can seek; NVDEC starts at frame 0. Close the iterator (``.close()`` / + ``contextlib.closing``) to stop ffmpeg and release the file.""" + path = Path(path) + mode = resolve_mode(decode) + cap = capture if capture is not None else cv2.VideoCapture(str(path)) + if not cap.isOpened(): + raise ValueError(f"Could not open video: {path}") + meta = FrameMeta( + fps=cap.get(cv2.CAP_PROP_FPS) or 0.0, + width=int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0), + height=int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0), + frame_count=int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0), + ) + if mode != "cv2": + reason = "resume from a checkpoint seeks with cv2" if start_frame > 0 else None + plan = reason or _nvdec_plan(path, meta) + if isinstance(plan, tuple): + ffmpeg, decoder = plan + try: + frames = _open_nvdec( + _nvdec_command(ffmpeg, decoder, path), + FrameMeta(meta.fps, meta.width, meta.height, meta.frame_count, "nvdec"), + ) + except FrameSourceError as exc: + plan = str(exc) + else: + cap.release() + return FrameMeta( + meta.fps, meta.width, meta.height, meta.frame_count, "nvdec" + ), frames + logger.info("decode: nvdec unavailable (%s); using cv2", plan) + if start_frame > 0: + cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) + return meta, _cv2_frames(cap) diff --git a/tests/test_frames.py b/tests/test_frames.py new file mode 100644 index 0000000..d392f21 --- /dev/null +++ b/tests/test_frames.py @@ -0,0 +1,242 @@ +"""Frame source: cv2 vs NVDEC parity, the pipe reader, and backend selection. + +Clips are generated with ffmpeg in ``tmp_path`` (no fixtures on disk). The NVDEC +parity tests SKIP, with the reason, on a box whose ffmpeg lacks cuda/cuvid or whose +1-frame probe decode fails; they never fail for lack of a GPU. +""" + +import io +import logging +import shutil +import subprocess +from contextlib import closing + +import numpy as np +import pytest + +from redactcam import detect, frames +from redactcam.frames import FrameSourceError, iter_frames, read_full + + +@pytest.fixture(autouse=True) +def _no_ambient_decode_override(monkeypatch): + """The suite may run with REDACTCAM_DECODE=cv2; these tests choose their own.""" + monkeypatch.delenv("REDACTCAM_DECODE", raising=False) + + +needs_ffmpeg = pytest.mark.skipif( + not (shutil.which("ffmpeg") and shutil.which("ffprobe")), reason="ffmpeg/ffprobe missing" +) + + +def _encode(path, codec, extra=()): + subprocess.run( + ["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", + "testsrc2=size=320x240:rate=30:duration=1", "-pix_fmt", "yuv420p", + "-c:v", codec, *extra, str(path)], + check=True, + ) # fmt: skip + + +@pytest.fixture(scope="module") +def h264_clip(tmp_path_factory): + if not shutil.which("ffmpeg"): + pytest.skip("ffmpeg missing") + p = tmp_path_factory.mktemp("clip") / "a.mp4" + _encode(p, "libx264") + return p + + +@pytest.fixture(scope="module") +def hevc_clip(tmp_path_factory): + if not shutil.which("ffmpeg"): + pytest.skip("ffmpeg missing") + p = tmp_path_factory.mktemp("clip") / "b.mp4" + try: + _encode(p, "libx265", ("-x265-params", "log-level=error")) + except subprocess.CalledProcessError: + pytest.skip("libx265 unavailable") + return p + + +def _nvdec_or_skip(clip): + meta, it = iter_frames(clip, decode="nvdec") + it.close() + if meta.backend != "nvdec": + pytest.skip("nvdec unavailable on this box (cuda/cuvid/probe)") + + +def _all(clip, mode): + meta, it = iter_frames(clip, decode=mode) + with closing(it): + return meta, list(it) + + +class _Trickle: + """Fake pipe returning one byte per ``readinto``.""" + + def __init__(self, data): + self._b = io.BytesIO(data) + + def readinto(self, view): + return self._b.readinto(view[:1]) + + +class TestReadFull: + def test_short_reads_are_not_end_of_stream(self): + data = bytes(range(12)) + stream = _Trickle(data * 2) + assert bytes(read_full(stream, 12)) == data + assert bytes(read_full(stream, 12)) == data + assert read_full(stream, 12) is None + + def test_partial_final_frame_raises(self): + with pytest.raises(FrameSourceError, match="partial frame"): + read_full(_Trickle(b"x" * 7), 12) + + +@needs_ffmpeg +class TestParity: + @pytest.mark.parametrize("clipname", ["h264_clip", "hevc_clip"]) + def test_nvdec_matches_cv2(self, clipname, request): + clip = request.getfixturevalue(clipname) + _nvdec_or_skip(clip) + m_cv, f_cv = _all(clip, "cv2") + m_nv, f_nv = _all(clip, "nvdec") + assert m_nv.backend == "nvdec" and m_cv.backend == "cv2" + assert (m_nv.fps, m_nv.width, m_nv.height, m_nv.frame_count) == ( + m_cv.fps, m_cv.width, m_cv.height, m_cv.frame_count, + ) # fmt: skip + assert len(f_nv) == len(f_cv) == m_cv.frame_count + for a, b in zip(f_cv, f_nv, strict=True): + assert a.shape == b.shape == (m_cv.height, m_cv.width, 3) + assert a.dtype == b.dtype == np.uint8 + assert np.abs(a.astype(int) - b.astype(int)).mean() < 2 + + def test_cv2_backend_metadata(self, h264_clip): + meta, fr = _all(h264_clip, "cv2") + assert (meta.width, meta.height, meta.frame_count) == (320, 240, 30) + assert len(fr) == 30 + + +@needs_ffmpeg +class TestSelection: + def test_auto_falls_back_when_ffmpeg_missing(self, h264_clip, monkeypatch, caplog): + monkeypatch.setattr(frames.shutil, "which", lambda name: None) + with caplog.at_level(logging.INFO, logger="redactcam.frames"): + meta, it = iter_frames(h264_clip, decode="auto") + it.close() + assert meta.backend == "cv2" + assert any("using cv2" in r.getMessage() for r in caplog.records) + + def test_auto_falls_back_when_probe_fails(self, h264_clip, monkeypatch): + monkeypatch.setattr(frames, "_nvdec_plan", lambda *a: "probe decode of 1 frame failed") + meta, it = iter_frames(h264_clip, decode="auto") + it.close() + assert meta.backend == "cv2" + + def test_dead_pipe_start_falls_back(self, h264_clip, monkeypatch): + monkeypatch.setattr(frames, "_nvdec_plan", lambda *a: ("/bin/false", "h264_cuvid")) + monkeypatch.setattr(frames, "_nvdec_command", lambda ff, dec, p: ["/bin/false"]) + meta, it = iter_frames(h264_clip, decode="nvdec") + with closing(it): + assert meta.backend == "cv2" and len(list(it)) == 30 + + def test_env_override_cv2_beats_argument(self, h264_clip, monkeypatch): + monkeypatch.setenv("REDACTCAM_DECODE", "cv2") + monkeypatch.setattr( + frames, "_nvdec_plan", lambda *a: pytest.fail("nvdec planned despite env=cv2") + ) + meta, it = iter_frames(h264_clip, decode="nvdec") + it.close() + assert meta.backend == "cv2" + + def test_env_override_nvdec_is_honoured(self, h264_clip, monkeypatch): + monkeypatch.setenv("REDACTCAM_DECODE", "nvdec") + seen = [] + monkeypatch.setattr(frames, "_nvdec_plan", lambda *a: seen.append(1) or "stub") + meta, it = iter_frames(h264_clip, decode="cv2") + it.close() + assert seen and meta.backend == "cv2" + + def test_bad_mode_rejected(self, h264_clip): + with pytest.raises(ValueError, match="decode must be"): + iter_frames(h264_clip, decode="gpu") + + def test_rotation_metadata_stays_on_cv2(self): + assert frames.is_rotated({"side_data_list": [{"side_data_type": "Display Matrix"}]}) + assert frames.is_rotated({"tags": {"rotate": "90"}}) + assert not frames.is_rotated({"tags": {}, "side_data_list": []}) + + def test_resume_start_uses_cv2(self, h264_clip): + meta, it = iter_frames(h264_clip, decode="auto", start_frame=10) + with closing(it): + assert meta.backend == "cv2" and len(list(it)) == 20 + + +class _FakeProc: + """Pipe that yields N whole frames then dies with rc=1.""" + + def __init__(self, nframes, size): + self.stdout = io.BytesIO(b"\x01" * (nframes * size)) + self.returncode = 1 + + def wait(self, timeout=None): + return 1 + + def terminate(self): + pass + + kill = terminate + + +class TestMidStreamFailure: + def test_dead_pipe_raises_instead_of_truncating(self, monkeypatch): + meta = frames.FrameMeta(30.0, 4, 3, 10, "nvdec") + monkeypatch.setattr( + frames.subprocess, "Popen", lambda *a, **k: _FakeProc(3, 4 * 3 * 3) + ) + it = frames._open_nvdec(["ffmpeg"], meta) + got = [] + with pytest.raises(FrameSourceError, match="rc=1 after 3 frames"): + for f in it: + got.append(f.shape) + assert got == [(3, 4, 3)] * 3 + + +@needs_ffmpeg +class TestDetectParity: + def test_sampled_indices_identical_across_backends(self, hevc_clip, monkeypatch): + _nvdec_or_skip(hevc_clip) + from redactcam import track + + calls = [] + real = track.TrackManager.step + + def spy(self, idx, gray, dets): + calls.append((idx, dets is not None)) + return real(self, idx, gray, dets) + + monkeypatch.setattr(track.TrackManager, "step", spy) + monkeypatch.setattr(detect, "detect_tiled", lambda *a, **k: []) + kw = dict(sample_fps=5.0, plate_model_path=None, tile_px=9999, track_downscale=1, + track_lk_win=15, track_lk_levels=2) # fmt: skip + out = {} + for mode in ("cv2", "nvdec"): + calls.clear() + _, n, _, _ = detect.detect_and_track(hevc_clip, decode=mode, **kw) + out[mode] = (n, list(calls)) + assert out["cv2"] == out["nvdec"] + assert out["cv2"][0] == 30 + assert any(sampled for _, sampled in out["cv2"][1]) + + def test_decode_backend_is_logged(self, h264_clip, monkeypatch, caplog): + monkeypatch.setattr(detect, "detect_tiled", lambda *a, **k: []) + with caplog.at_level(logging.INFO, logger="redactcam.detect"): + detect.detect_and_track( + h264_clip, decode="cv2", sample_fps=5.0, plate_model_path=None, + tile_px=9999, track_downscale=1, track_lk_win=15, track_lk_levels=2, + ) # fmt: skip + msgs = [r.getMessage() for r in caplog.records] + assert "detect: decode=cv2" in msgs + assert any(m.startswith("detect: done") and "decode=cv2" in m for m in msgs) From 362218673d9a260af4ccb2bb6d235ce2b4b5e4bb Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Mon, 9 Nov 2026 08:20:58 -0500 Subject: [PATCH 2/2] test(frames): stub the detector builder so parity tests need no model CI has neither the face ONNX nor the centerface extra; the logging test ran detect_and_track with a real detector and failed on ModuleNotFoundError (deface). The nvdec parity test had the same gap, hidden by its skip. --- tests/test_frames.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_frames.py b/tests/test_frames.py index d392f21..e38fb23 100644 --- a/tests/test_frames.py +++ b/tests/test_frames.py @@ -218,6 +218,7 @@ def spy(self, idx, gray, dets): return real(self, idx, gray, dets) monkeypatch.setattr(track.TrackManager, "step", spy) + monkeypatch.setattr(detect, "build_detectors", lambda *a, **k: (None, None)) monkeypatch.setattr(detect, "detect_tiled", lambda *a, **k: []) kw = dict(sample_fps=5.0, plate_model_path=None, tile_px=9999, track_downscale=1, track_lk_win=15, track_lk_levels=2) # fmt: skip @@ -231,6 +232,8 @@ def spy(self, idx, gray, dets): assert any(sampled for _, sampled in out["cv2"][1]) def test_decode_backend_is_logged(self, h264_clip, monkeypatch, caplog): + # No real model: CI has neither the face ONNX nor the centerface extra. + monkeypatch.setattr(detect, "build_detectors", lambda *a, **k: (None, None)) monkeypatch.setattr(detect, "detect_tiled", lambda *a, **k: []) with caplog.at_level(logging.INFO, logger="redactcam.detect"): detect.detect_and_track(