Skip to main content

The missing evidence-clip layer between your detector and storage. Trim, bbox burn-in, NVENC, pure Python.

Project description

๐ŸŽฌ cv-evidence-renderer

The missing evidence-clip layer between your detector and storage. Point it at a video + a detection JSONL โ†’ get a trimmed MP4 with bounding boxes already burned in, encoded in pure Python โ€” no DeepStream required.

demo

The GIF above is rendered through cv-evidence-renderer itself โ€” synthetic scene, synthetic detections, real output. Regenerate any time with python scripts/make_demo_gif.py.

Status: v0.1 โ€” offline mode works end-to-end with cross-file concat, batch rendering, playback speed, duration cap, and per-clip caption customisation. Live RTSP recording, ring buffer, and NVENC encoding are designed but not yet implemented (see Roadmap).


Why this project

Every CV team shipping to production hits the same wall: the detector fires, and now you need a short MP4 clip of the event with bounding boxes drawn on it, to attach to an alert, archive for compliance, or replay during QA. The existing options are all incomplete:

  • supervision (39k โญ) owns Python drawing โ€” but its VideoSink is cv2.VideoWriter with mp4v hard-coded. No NVENC, no event window, no pre/post buffer.
  • DeepStream's Smart Record is the official NVIDIA answer โ€” but it has no Python bindings (NVIDIA staff confirmed), and bbox burn-in has been broken since 6.4.
  • The canonical PyImageSearch KeyClipWriter ring-buffer pattern is detection-agnostic, OpenCV-only, and isn't a library.

So every team hand-rolls OpenCV + an FFmpeg subprocess, ships the bug to prod, and writes it again on the next project. This repo is the library version of that pattern โ€” done once, done right, with GPU encoding included.


What it does (and doesn't)

โœ… Working today (v0.1):

  • Offline mode: re-render evidence from a saved video + detections JSONL with event-window trim
  • Multi-source events: one event window spanning several source files (NVR / CCTV segmented recordings cut at hour boundaries) โ€” see render_clip with multiple ClipSource segments
  • Batch rendering: render_clips writes N evidence files in one call; clips that share a source video are decoded once, dispatched to each clip's encoder
  • Bounding-box / label burn-in before encode (so the evidence file is the annotated version)
  • Per-clip caption customisation via label_formatter callable; default formatter exposed for composition
  • Playback speed control (playback_speed) and output duration cap (max_duration_seconds) with timelapse or framedrop strategy
  • libx264 CPU encoding via PyAV โ€” works on Mac, Linux, Windows with no GPU
  • First-class interop with supervision.Detections and Ultralytics YOLO Results โ€” also accepts raw JSONL
  • Python library + Typer CLI (cv-evidence render ... with --playback-speed, --max-duration-seconds, --duration-strategy)

๐Ÿšง Designed, not yet implemented (see Roadmap):

  • NVENC GPU encoding (H.264 / H.265) โ€” v0.2
  • Live mode: threaded RTSP reader โ†’ ring buffer โ†’ trigger_event() flushes evidence โ€” v0.2
  • Multi-stream parallel via shared encoder pool โ€” v0.3
  • Plugin overlays for custom lines, points, anchors, distance vectors, zone polygons โ€” v0.3

๐Ÿšซ Does not (by design):

  • Detection / tracking โ€” bring your own (YOLO, Detectron2, anything that produces bboxes)
  • Live video streaming output โ€” output is an MP4 file, not an RTSP relay
  • Alerting / Telegram / email โ€” pair with realtime-object-detection-alert for that
  • Web UI โ€” CLI + Python library only

Quick start

pip install cv-evidence-renderer

# Optional: install with supervision interop
pip install cv-evidence-renderer[supervision]

Use case A: offline batch from JSONL (working today)

from cv_evidence_renderer import render_from_jsonl

render_from_jsonl(
    video="incidents/raw_001.mp4",
    detections_jsonl="incidents/raw_001.detections.jsonl",
    event_start=12.5,            # seconds
    event_end=22.0,
    output="evidence/event_001.mp4",
    encoder="libx264",           # NVENC ships in v0.2
)

Use case B: CLI (working today)

cv-evidence render \
  --input street.mp4 \
  --detections detections.jsonl \
  --event-start 12.5 --event-end 22.0 \
  --output evidence.mp4 \
  --encoder libx264

Use case C: route detections from Ultralytics YOLO or supervision

import supervision as sv
from ultralytics import YOLO
from cv_evidence_renderer.adapters import from_yolo_results, from_supervision

model = YOLO("yolov8n.pt")
results = model("incidents/raw_001.mp4")

# Either: stream Ultralytics Results directly
frame_detections = [
    from_yolo_results(r, frame_idx=i) for i, r in enumerate(results)
]

# Or: pre-converted supervision.Detections
det = sv.Detections.from_ultralytics(results[0])
frame_detections = [from_supervision(det, frame_idx=0)]

Both adapters require the optional [supervision] extra (pip install cv-evidence-renderer[supervision]).

Use case D: one event spanning two NVR files (cross-file concat)

from cv_evidence_renderer import ClipSource, render_clip

# A violation at 22:59:30 lives across two hour-segmented recordings.
render_clip(
    sources=[
        ClipSource(video="cam01_22-00.mp4", detections="cam01_22-00.jsonl",
                   from_seconds=1770, to_seconds=1800),  # last 30 s of file A
        ClipSource(video="cam01_23-00.mp4", detections="cam01_23-00.jsonl",
                   from_seconds=0, to_seconds=90),       # first 90 s of file B
    ],
    output="evidence/violation_cross_file.mp4",
    label_formatter=lambda d: f"{d.label.upper()} #{d.track_id}",
)

All sources must share width, height, and (within 1%) fps; otherwise the call raises a clear ValueError. The output is one continuous MP4 with detections overlaid on each segment from its own JSONL.

Use case E: batch render with playback speed and duration cap

from cv_evidence_renderer import Clip, ClipSource, render_clips

# Ten events from the same 4-hour recording. The source is decoded once.
events = [(60, 75), (340, 360), (812, 830), ...]  # (start, end) per event

render_clips(
    clips=[
        Clip(
            sources=[ClipSource(video="day_03.mp4", detections="day_03.jsonl",
                                from_seconds=start, to_seconds=end)],
            output=f"evidence/event_{i:03d}.mp4",
            playback_speed=1.0,
            max_duration_seconds=15,        # cap each clip at 15 s
            duration_strategy="timelapse",  # auto fast-forward if longer
        )
        for i, (start, end) in enumerate(events)
    ],
)

Use case F: live RTSP recorder (v0.2 โ€” not yet implemented)

# This is the planned API. EvidenceRecorder currently raises NotImplementedError.
from cv_evidence_renderer import EvidenceRecorder

recorder = EvidenceRecorder(
    source="rtsp://camera.local/stream",
    pre_buffer_seconds=5,
    post_buffer_seconds=10,
    encoder="nvenc_h264",  # NVENC also v0.2
)
recorder.start()
# ... see SPEC.md for the full design.

Benchmark โ€” Apple M4 (CPU libx264 baseline)

5-second source video, 30 fps, two detections burned in every frame, full decode โ†’ overlay โ†’ encode pipeline through render_from_jsonl. Reproduce with python scripts/benchmark.py.

Resolution Render time Throughput ร— realtime Output
480p (854ร—480) 0.53 s 282 fps 9.4ร— 0.42 MB
720p (1280ร—720) 0.89 s 168 fps 5.6ร— 0.70 MB
1080p (1920ร—1080) 1.70 s 88 fps 2.95ร— 1.34 MB

CPU-only libx264 on M4 already runs faster than realtime up to 1080p; NVENC on a discrete GPU (v0.2) will be added to this table side by side once the encoder lands.


Architecture

                                โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                โ”‚  Your detector loop                  โ”‚
                                โ”‚  (YOLO / Detectron2 / anything)      โ”‚
                                โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                               โ”‚ frame_idx, sv.Detections
                                               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Video source โ”‚ โ†’ โ”‚ Threaded reader  โ”‚ โ†’ โ”‚ Ring buffer      โ”‚ โ†’ โ”‚ NVENC encoderโ”‚ โ†’ evidence.mp4
โ”‚ RTSP / MP4   โ”‚    โ”‚ (auto-reconnect) โ”‚    โ”‚ (pre-buffer N s) โ”‚    โ”‚ (PyAV)       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                                     โ”‚
                                          on trigger_event():
                                          flush pre-buffer +
                                          record N more seconds
                                          with bbox burn-in

Code layout

File Responsibility
src/cv_evidence_renderer/recorder.py EvidenceRecorder โ€” live RTSP + ring buffer + trigger API
src/cv_evidence_renderer/offline.py render_from_jsonl() โ€” re-render from saved video
src/cv_evidence_renderer/buffer.py Ring buffer with keyframe-aware seek
src/cv_evidence_renderer/encoder/nvenc.py PyAV NVENC wrapper
src/cv_evidence_renderer/encoder/libx264.py Fallback CPU encode
src/cv_evidence_renderer/overlay.py Bbox burn-in (cv2; supervision-compatible)
src/cv_evidence_renderer/io/rtsp.py Threaded RTSP reader, auto-reconnect
src/cv_evidence_renderer/adapters.py sv.Detections โ†” internal format โ†” raw JSONL
src/cv_evidence_renderer/pool.py Multi-stream encoder pool
src/cv_evidence_renderer/cli.py Typer entrypoint

Design notes

  • Why a ring buffer instead of just trimming after the fact? Because in live mode you don't have the future โ€” when an event fires at frame N, you need to have already been keeping frames N-150 to N. A naive trim-after-event approach only works for saved video files (see "use case B"), and most teams hit the live case first.
  • Why bbox burn-in instead of metadata sidecar? Evidence clips get sent to non-technical operators (compliance, ops) who open them in QuickTime / VLC. A sidecar JSON they can't read is useless. The burn-in is the point.
  • Why PyAV instead of subprocess to FFmpeg? Race conditions when multiple streams write to the same FFmpeg subprocess pool. PyAV gives a clean Python object per stream and re-uses the libav encoder context.
  • Why interop with supervision instead of re-implementing drawing? Because supervision (39.5k โญ) does it better than we ever will, and "ride the ecosystem" is faster than "compete for drawing API mindshare". Our moat is the event-clip pipeline, not the rectangle drawing.
  • Why keyframe-aware seek for pre-buffer? Because seeking to a non-keyframe in libav gives you garbage frames until the next IDR. The buffer indexes keyframes and snaps pre-buffer start to the nearest one.

Roadmap


Comparison to similar tools

See COMPETITORS.md for the full research write-up.

cv-evidence-renderer supervision DeepStream Smart Record KeyClipWriter
Python-only install โœ… โœ… โŒ (needs DeepStream SDK) โœ…
Event-window trim (offline) โœ… โŒ โœ… (C only) โœ…
Cross-file event concat (NVR-style split files) โœ… โŒ โŒ โŒ
Decode-once batch for N events on one source โœ… โŒ โŒ โŒ
Output duration cap (timelapse / framedrop) โœ… โŒ โŒ โŒ
Bbox burn-in into evidence โœ… โœ… (excellent) โš ๏ธ (bug since 6.4) โŒ
supervision interop โœ… โ€” โŒ โŒ
Ultralytics YOLO adapter โœ… โœ… (via from_ultralytics) โŒ โŒ
NVENC encode ๐Ÿšง v0.2 โŒ โœ… โŒ
Live RTSP ring buffer ๐Ÿšง v0.2 โŒ โœ… (C only) โœ…
Multi-stream pool ๐Ÿšง v0.3 โŒ โœ… โŒ

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cv_evidence_renderer-0.1.0.tar.gz (693.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cv_evidence_renderer-0.1.0-py3-none-any.whl (25.3 kB view details)

Uploaded Python 3

File details

Details for the file cv_evidence_renderer-0.1.0.tar.gz.

File metadata

  • Download URL: cv_evidence_renderer-0.1.0.tar.gz
  • Upload date:
  • Size: 693.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for cv_evidence_renderer-0.1.0.tar.gz
Algorithm Hash digest
SHA256 8e7d07cf4aa3d5582dda9dc690f279ca04d9a657d2be9d07c619eaf45da9376b
MD5 cc2e68b2a3e9c1f39b238e2fcfc326a1
BLAKE2b-256 731d21f0fd71e49bf7d00db89dc173398af809d12d7ce6aac151eb6d13a47b6d

See more details on using hashes here.

File details

Details for the file cv_evidence_renderer-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for cv_evidence_renderer-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c29ba4866ba2d11a88cf5316713969847b8d64023a4c1f42289d311c804eeb37
MD5 ba615742414fa7676f51d071f1c151b8
BLAKE2b-256 9b959224b724d0616b66276f518e5be41b86116b92bbf0b1edaa1d83713ce125

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page