Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <codec>_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
Expand Down
33 changes: 21 additions & 12 deletions redactcam/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import cv2
import numpy as np

from .frames import iter_frames

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -729,6 +730,7 @@ def detect_regions(
)
frame_idx += 1
finally:
frames.close()
cap.release()

return results
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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=<name>``
and repeated on the ``detect: done`` line (both only while progress logging is on). A checkpoint resume decodes with cv2.
"""
from .track import TrackManager

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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/
Expand All @@ -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
Loading
Loading