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.
The GIF above is rendered through
cv-evidence-rendereritself โ synthetic scene, synthetic detections, real output. Regenerate any time withpython 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 itsVideoSinkiscv2.VideoWriterwithmp4vhard-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_clipwith multipleClipSourcesegments - Batch rendering:
render_clipswrites 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_formattercallable; default formatter exposed for composition - Playback speed control (
playback_speed) and output duration cap (max_duration_seconds) withtimelapseorframedropstrategy - libx264 CPU encoding via PyAV โ works on Mac, Linux, Windows with no GPU
- First-class interop with
supervision.Detectionsand Ultralytics YOLOResultsโ 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
subprocessto 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
- DeepStream sink integration (close the no-Python-binding gap)
- Overlapping recordings on the same stream (feature DeepStream explicitly doesn't support)
- Per-event metadata sidecar (JSON + MKV chapters)
- ONVIF event trigger input
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8e7d07cf4aa3d5582dda9dc690f279ca04d9a657d2be9d07c619eaf45da9376b
|
|
| MD5 |
cc2e68b2a3e9c1f39b238e2fcfc326a1
|
|
| BLAKE2b-256 |
731d21f0fd71e49bf7d00db89dc173398af809d12d7ce6aac151eb6d13a47b6d
|
File details
Details for the file cv_evidence_renderer-0.1.0-py3-none-any.whl.
File metadata
- Download URL: cv_evidence_renderer-0.1.0-py3-none-any.whl
- Upload date:
- Size: 25.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c29ba4866ba2d11a88cf5316713969847b8d64023a4c1f42289d311c804eeb37
|
|
| MD5 |
ba615742414fa7676f51d071f1c151b8
|
|
| BLAKE2b-256 |
9b959224b724d0616b66276f518e5be41b86116b92bbf0b1edaa1d83713ce125
|