DEV Community

ZhengZhiCong
ZhengZhiCong

Posted on

How My NVR Cuts Recording Storage by 75% — Without AI, and Without Dropped Frames

A few third-party write-ups about MiBeeNvr — review sites, aggregator posts — describe its adaptive recording as "motion-aware, saves disk" and stop there. I'm the author. This post is the full mechanism: what signal decides when to record, why the switch drops zero frames, which cameras break it, and where AI fits (and deliberately doesn't).

If you run Frigate, Blue Iris, Surveillance Station or just a camera on a Raspberry Pi, the design trade-offs below should map onto things you already know.

TL;DR

95% of the time, nothing happens in front of your cameras — but continuous recording writes full-rate video anyway. MiBeeNvr's adaptive recording:

  • drops to time-lapse-grade sparse writes (one keyframe every 30s) when the scene is quiet;
  • snaps back to full frame rate the instant activity is detected, with zero dropped frames at the switch;
  • measures -75% disk on a static 2K/H.265 scene (~2700 → ~688 MB/h), and ~34× less during fully-empty periods;
  • decides "is something happening?" from P-frame sizes in the compressed domain — no decoding, no model, near-zero CPU;
  • keeps AI out of the core on purpose, and puts it where the hardware is (MiBeeVision, released today).

Background: why "activity" is visible in the byte count

You don't need a video-codec background for this section, but it's the foundation for everything else.

A camera's H.264/H.265 stream is cut into GOPs. Each GOP starts with an I-frame (a complete picture, independently decodable), followed by P-frames that only store the difference from the previous frame.

GOP structure: an I-frame followed by P-frames, then the next I-frame

The key property: the quieter the scene, the smaller the P-frames. An empty corridor produces P-frames of a few KB. Someone walks through, and the encoder has to describe their outline, motion and shadows — the same P-frame balloons to hundreds of KB. So the sequence of P-frame sizes is already an activity curve. You can see it without decoding a single frame.

Measured on a 2K H.265 camera pointed at a mostly-static studio (documented, not estimated):

Disk writes per hour: continuous 2700 MB, adaptive 688 MB, empty scene \~81 MB

The third bar is derived: a sparse segment of 5.6 MB / 248 s ≈ 81 MB/h, versus ~190 MB of full-rate video for the same wall-clock window — about 34× less.

The one enemy of this trick

If the camera's bitrate is flattened — roughly the same bytes per second whether or not anyone is there — P-frame sizes stop correlating with activity. Some smart-encoder cameras behave exactly like that. That's the blind spot of compressed-domain gating; my fallbacks are pixgate and tiered recording.

The hardware constraint, and why AI stays out of the core

MiBeeNvr's design baseline is a 1 GB low-end ARM board (Raspberry Pi 3B), with a 512 MB process budget, a single static binary and zero runtime dependencies. Every feature has to fit that budget.

A resident inference model doesn't fit. It would either eat the memory or force you onto Coral/GPU hardware — which would break the premise of "an old device that runs today". So the core recording path deliberately contains no model. That's a trade-off, not a capability gap.

Where does AI go instead? Two places, both outside the budget line:

Three options for placing compute: server-side (rejected), browser-side (supported), external Vision (supported)

  • Browser-side: detection runs on the viewer's device (ONNX Runtime Web + WebGPU). Many viewers share the load; the server never pays. The cost is that it isn't a 7×24 unattended pipeline.
  • External Vision: a separate process on hardware you actually have room for. The NVR stays light and dumb; the AI runs where the silicon is; conclusions come back over an API. Full architecture in the MiBeeVision section.

This is also why the storage design works at all: the cheaper the activity decision, the more often you can afford to make it — and the denser you can make "nothing is happening" writes. Three layers, cheapest first: compressed-domain signals (near-zero cost), pixgate classic CV (under 2% of one core), external Vision (someone else's hardware).

Core mechanism: gating in the compressed domain

The NVR never decodes the picture. It watches each P-frame's size against a rolling baseline — the typical P-frame size of that stream recently, maintained with MAD (median absolute deviation, a statistic that resists jitter). A P-frame suddenly several times the baseline (default 5×) means "activity": switch back to full frame rate immediately.

Conversely, if the scene stays quiet for 60 seconds, drop into sparse mode: one keyframe every 30 seconds, audio paused.

State machine: NORMAL and TIMELAPSE with transitions

In config terms, that's all there is (every value below is the default):

cameras:
  - id: "studio"
    recording_mode: "adaptive"    # default "continuous" = full frame rate
    adaptive:
      calm_threshold: "60s"       # how long quiet must last before downgrading (10s–30m)
      timelapse_interval: "30s"   # keyframe spacing in sparse mode (5s–10m)
      spike_factor: 5.0           # activity sensitivity (1.5–20, lower = more sensitive)
      gop_buffer_bytes: 33554432  # GOP pre-buffer ceiling (1–64MB)
Enter fullscreen mode Exit fullscreen mode

In the web UI it's Camera → Edit → Recording mode: Adaptive (motion-aware).

Five signals, any one of which restores full frame rate

Signal What it is Requirement
Video spike P-frame size deviates from baseline by more than spike_factor× Any H.264/H.265 camera; the default path
Audio trigger Loudness (dBFS) over a 1-second window — breaking glass, shouting, alarms G.711 audio from the camera
pixgate Sampled sub-stream decoded and run through classic CV: "is there real, persistent foreground motion?" ffmpeg + reachable sub-stream
Camera-side ONVIF event Subscribe to the camera's MotionAlarm; the camera itself says something moved Camera support, enabled
External semantic trigger Any external system POSTs to an API — e.g. "your AI backend saw a person" An external system

The GOP ring buffer: why the switch drops zero frames

The biggest risk of a "don't write everything" strategy isn't saving too little — it's the first frames of an event missing from disk. Here's the insurance.

While in sparse mode, a 32 MB ring buffer in memory always holds the most recent complete GOP. The moment an activity spike appears:

  1. the buffered GOP (the continuous footage right before the spike) is flushed to disk;
  2. then the recorder switches back to full frame rate, and everything after lands on disk as usual.

The switch point contains no gap. Playback doesn't artifact, doesn't skip.

Sequence diagram: camera streams P-frames into the ring buffer, a spike triggers flush then full-rate recording

Blue Iris has a pre-trigger buffer, Synology has 5–300 s pre-recording — same family of idea: get the seconds before the trigger back onto disk. The difference is only the source: their pre-roll comes from deciding to record in advance; mine comes from what the buffer happens to be holding. No hierarchy, just different constraints.

What sparse mode writes, and what playback looks like

Empty periods are stored as compressed time. During merge, dwell samples longer than 2 s are squeezed to 0.1 s frame spacing. A 122-second sparse segment becomes 0.4 seconds in the file — playback fast-forwards through empty stretches and runs real-time through active ones, automatically, inside the same file.

The database row keeps the wall-clock axis, and the playback timeline's seek goes through a timeline_map to land at the right offset inside the product.

Wall-clock axis in the database maps to the compressed merged output on disk

Synology's Smart Time Lapse and Blue Iris' Periodic mode do the same family of thing — fewer bytes for longer empty time. I folded it into the everyday recording stream instead of shipping it as a separate switch.

pixgate: the pixel-domain gate

Why it exists

Compressed-domain gating has a blind spot that is principled, not a bug: rain hitting a puddle, leaves in the wind, a person walking past — all are real pixel motion, all inflate P-frames. The bitrate domain cannot tell "rain" from "person".

A second class of cameras is worse: smart encoders flatten the bitrate, so P-frame sizes decouple from activity entirely. Compressed-domain gating simply stops working on those streams.

So there's a pixel-domain layer: sample, decode, and use classic computer vision to ask "is there a coherent, persistent foreground object?" This layer isn't novel — ZoneMinder's zones, Shinobi's regions and Blue Iris' motion zones are all this idea. Here's my implementation.

The pipeline, and what each stage fixes

pixgate pipeline: sub-stream sampling, ffmpeg decode, seven CV stages, then GOP flush

Stage by stage, in plain language:

Stage The failure it fixes
① Background model (EMA) "A person standing still for a few minutes gets absorbed into the background" — foreground learns at 1/5 the background rate, so a stationary person is still recognized
② Adaptive diff threshold Night gain produces full-frame grain noise — the threshold tracks the sensor-noise median, so grain isn't read as foreground
③ Illumination-step suppression Headlights sweeping a wall, IR-cut flips — a single step is suppressed; only "the light really stayed on" rebuilds the background
④ Connected-component min area Rain vs. person: rain is many small, short-lived blobs; a person is one large, persistent blob (a mid-distance person on a 480p sub-stream covers ~2–8% of the grid)
⑤ ROI mask Sky, water, streets — areas that "always flicker" — are excluded with normalized polygons
⑥ Ghost absorption Static new foreground (lights on, a parked car, a water droplet on the lens) merges into the background after 5 minutes and stops triggering. Moving objects keep drifting centroids and are never absorbed
⑦ Persistence hysteresis 2 consecutive active samples to confirm, 3 consecutive quiet samples to release — single-frame noise can't trigger

Costs and prerequisites, stated plainly

  • Requires ffmpeg (the same optional dependency transcoding uses). If missing, the gate disables itself with one warning; nothing else is affected.
  • Requires a reachable sub-stream (rtsp / onvif / gb28181 cameras — GB/T 28181 is a Chinese national standard, relevant only if you have such devices; not applicable to srt / rtmp push cameras).
  • Cost: 480p-class sub-stream at 1 fps, roughly 10–20 ms/frame of software decode per camera — under 2% of one core.
  • While a PTZ camera is moving, the gate goes blind (anti-false-trigger) and rebuilds the background afterwards.
  • Decisions are published on the event bus as pixgate.activity (with area_pct / centroid / flood / ghost fields) for UI and automations; Prometheus exposes nvr_pixgate_samples_total / nvr_pixgate_triggers_total.

Its designed partner: video_exit: false

With video_exit: false, video spikes never exit sparse mode — rain can pour and the camera stays in time-lapse. Only pixgate, audio, camera events or the external API restore full rate. For doorways and corridors where "no human, no full frame rate" is the rule.

Tiered recording: a safety net for flattened bitrates

For cameras whose bitrate is flattened, single-layer adaptive may never downgrade. Tiered recording walks on two legs:

  • the sub-stream records 24/7 as a layer=1 baseline — 480p-class, 60-second segments, low bitrate, never misses anything;
  • the main stream keeps its adaptive density — paired with video_exit: false + pixgate, the main stream records events only, at full quality.

Tiered recording: sub-stream baseline, adaptive main stream, external Vision gating, trigger API

Sub-layer segments live in the same directory tree with a sub_ prefix, are born terminal (merge_status='sublayer'), never merge, and follow ordinary retention. The recordings API takes ?layer=1 / ?layer=0 to toggle visibility.

Blue Iris' "sub-stream continuous + main on trigger" (direct-to-disc BVR, sub-stream upsampled on playback) and Synology's Advanced Continuous Recording (low-res continuous, high-res on event) are the same family. Their trigger comes from configured rules; mine comes from automatic compressed-domain/pixgate decisions — plus sub-layer segments can feed external AI directly (next section).

Noise engineering: the three knobs

All three exist because of real user reports:

Setting What it treats
noise_floor_bytes At night the encoder can starve the bitrate down to a few hundred bytes/frame, and the relative-multiple metric catches jitter as activity — "the log says activity, the playback shows nothing". An absolute byte floor below which a P-frame never counts as a spike
auto_noise_floor (default on) Self-calibrating from time-lapse dwell frames: sparse-mode frames are static by construction, so their p99 size × 1.25 is this camera's own noise ceiling. Only engages on bitrate-starved streams; healthy streams behave identically
video_exit: false Permanent time-lapse: video spikes never exit sparse mode; only pixgate / audio / camera events / external API escalate. Treats rain, water, foliage

Explicit floor and self-calibrated floor take the larger value — the explicit value sets a hard minimum, the self-calibration raises it further on starved streams.

Activity scores, heat timelines, content-aware cleanup

Every segment gets an activity score from the compressed-domain analyzer, queryable in the recordings library: filter by "has activity / quiet / scene change", set a minimum score, or color the timeline by activity (green = quiet → red = busy).

Two anti-distortion measures since v0.13:

  • Absolute-bitrate confidence: purely relative metrics fabricate high scores on bitrate-starved streams. Confidence is anchored to the segment's median P-frame bytes in absolute terms (~400B → 0, ~1200B → full trust, linear between). Rankings and cleanup read "activity score × confidence".
  • Pixel-first routing: with pixgate enabled, if the pixel-domain foreground series covers a segment sufficiently, the score is synthesized from pixels (0.8 × foreground duty cycle + 0.2 × area amplitude), immune to night-gain noise and encoder refresh frames.

When the disk hits its watermark (default 85%), cleanup deletes the lowest-activity quiet segments first — the same disk holds more "something happened" footage. Frigate's retain ladder decides by type what to keep; I order deletions by content within a type. Same idea, two directions.

eval-replay: tuning without touching production

The gate is a pure function of "P-frame size sequence + config", so a finished recording can answer "what would this parameter set have done?" — no camera, no decoding:

# scorer replay: activity score + confidence per segment
mibee-nvr eval-replay --corpus corpus.json

# gate replay: default vs candidate parameters, side by side
mibee-nvr eval-replay --corpus corpus.json --gate --videoexit=false
mibee-nvr eval-replay --corpus corpus.json --gate --spike 8 --noisefloor-bytes 600
Enter fullscreen mode Exit fullscreen mode

A corpus is a JSON array whose label field is conventionally rain / lowbitrate / static / active, aggregated into per-label means.

MiBeeVision: AI where the hardware is

Architecture

Vision architecture: NVR pushes segments to external Vision instances, events come back, trigger API pulls the main stream

How data flows

The NVR watches segment-completed events, checks the Vision consumer's heartbeat, and pushes the segment in the window after it lands on disk but before merge (push_mode: notify lets the consumer pull; upload pushes bytes).

Why push at all: early Vision polled the NVR for recordings, and 78% of those requests hit 404 — the segment had already been merged or cleaned up before Vision arrived. With push, the video is in Vision's hands before the merge process touches it.

Heartbeat timeout (default 60 s) pauses pushing; on recovery, missed segments are re-pushed with bounds — a 2-hour window, max 500 segments, 200 ms pacing — so a recovery burst can't hammer the consumer or the disk.

Multi-instance routing

vision.instances supports several consumers at once, each with its own address, identity and on/off switch; cameras select instances via vision_targets (empty = all). Different instances can run different models, routed by scenario:

vision:
  enabled: true
  url: "http://192.168.1.20:9091"      # single-instance shorthand
  heartbeat_timeout_secs: 60
  push_mode: "notify"                   # notify = consumer pulls
  skip_cameras: ["cam-mjpeg-1"]         # pushing MJPEG cameras is pointless
  sub_layer_cameras: ["cam-4k-yard"]    # on-demand sub-stream analysis segments (not in the recordings library)
  tiered_cameras: ["cam-smart-codec"]   # layer=1 segments for semantic gating

cameras:
  - id: "cam-gate"
    vision_targets: ["jetson-yolo", "coral-tpu"]   # multi-instance routing
Enter fullscreen mode Exit fullscreen mode

Feeding AI low-resolution segments: decode cost cut to 1/4–1/16

Two low-res channels (shipped in v0.12.0, issues #512–#537):

  • Sub-layer analysis (sub_layer_cameras): the NVR pulls the sub-stream on demand and records short-retention analysis segments — outside the recordings library, never merged, deleted once consumed;
  • Tiered sub-layer segments (tiered_cameras): reuse the formal library's layer=1 rows; main-stream segments step aside and are no longer pushed.

4K main-stream recording is untouched. For the consumer, judging a 480p segment is an order of magnitude cheaper than judging 4K.

How conclusions come back

Two paths, both ending in the same action — pulling the main stream back to full frame rate:

  1. Vision writes AI events back to the NVR (AI tags in the recordings list + events), visible during playback;
  2. any external system (Vision itself, home automation, your scripts) calls the trigger API:
curl -u admin:password -X POST \
  http://192.168.1.50:9090/api/cameras/cam-gate/adaptive/trigger \
  -H "Content-Type: application/json" \
  -d '{"source": "vision", "hold": "30s"}'
Enter fullscreen mode Exit fullscreen mode

hold is how long full frame rate is maintained; each new confirmation restarts the clock.

MiBeeVision Lite v0.1.0 — released today (2026-10-18)

  • What it is: MiBeeNvr's AI detection component — object detection and tracking over NVR-recorded video, event generation from area / line-crossing / loitering rules, results written back to the NVR.
  • Shape: single Linux binary (x86_64 / aarch64) with YOLOv8n and ONNX Runtime 1.17.1 (CPU) built in. Lite = single camera, CPU inference.
  • Deploy: web UI on port 9092 — enter the NVR address, paste an API key, pick a camera, draw rule zones.
  • Auth: API keys are mbv_-prefixed; the web session token (mbs_) returns 401 on write-back.
  • Transport: http_download when Vision runs on a different machine; local_file requires a shared recordings directory.
  • License: closed-source proprietary, © Mi-Bee Studio, distributed free with MiBeeNvr; the bundled YOLOv8n is Ultralytics AGPL-3.0.
  • Versioning: independent of MiBeeNvr's vX.Y.Z line.

Lite is the first footprint on this path: one camera, CPU. It changes none of the NVR's budgets — it runs on your other machine, and the NVR pays at most some push bandwidth.

Same table, different bets

A framework: six places to save storage

Six categories of storage saving from camera encoder to disk

F is the load-bearing one: an NVR's storage ceiling is set first by which signal it uses to decide "record now".

Where each project spends its effort

Project Quiet-period behavior Detection domain Signature move
MiBeeNvr sparse keyframes (30s/frame) compressed + pixel + audio + ONVIF + external GOP ring buffer; activity-score cleanup
Frigate event segments (10s granularity, no motion = discarded) semantic (object detection) + pixel motion mask retention ladder by type; review UX
Blue Iris sub-stream continuous, or nothing pixel zones + optional AI + DIO sub-continuous + main-on-trigger in one BVR
Synology SS low-res stream, or nothing camera-side or SS-side pixel + person/vehicle Advanced Continuous Recording; Smart Time Lapse
ZoneMinder nothing (Modect writes events only) pixel zones (five zone types) event maturity; external triggers (Nodect)
Shinobi nothing (Watch-Only + Trigger Record) pixel regions + optional OpenCV/YOLO per-user storage quotas; flexible event config

In prose, because the table flattens things:

  • Frigate has the strongest server-side AI event recording in the category — object detection driving retention, the review interface, Home Assistant integration depth. It asks for Coral/GPU and Docker; that's the cost of the route, not a flaw. On "AI decides, then store the event", I'm clearly behind it.
  • Blue Iris ships "sub-stream continuous + main on trigger" as a mature feature (direct-to-disc BVR + pre-trigger buffer), works out of the box on Windows, and has a rich trigger ecosystem. Its storage measurement (1 hour: 3.90 GB → 120 MB) is the most persuasive community data point I've cited.
  • Synology Surveillance Station is the NAS-native surveillance hub — Advanced Continuous Recording, Dual Recording, Smart Time Lapse, edge recording, plus its licensing model. If you already run a Synology, it's the lowest-friction path.
  • ZoneMinder is one of the oldest open-source NVRs; its event system is mature and its zone types (Active/Inclusive/Exclusive/Preclusive/Inactive) were my reference for ROI masks. The LAMP stack is heavy on low-end hardware — again, route cost.
  • Shinobi is Node.js with flexible event-driven recording (Trigger Record + Buffer Time) and multi-tenant storage quotas as a standout. Pure event recording simply isn't in the same lane as mine on continuity.

Camera-side smart codecs

H.265+ (Hikvision), Smart H.265+ (Dahua), Ultra H.265 (Uniview), Zipstream (Axis), WiseStream (Hanwha) cut baseline bitrate with background modeling, dynamic GOP and ROI encoding. Vendor claims: 20–40% below standard H.265 at equal quality, more in favorable scenes. They complement NVR-side gating; they don't replace it.

Two traps I've written about (in Chinese):

  1. Smart codecs are proprietary extensions, not standards — cross-brand they usually degrade to standard H.265. Don't count their savings in cross-brand capacity planning.
  2. A flattened smart-encoder bitrate breaks every compressed-domain activity signal — not just mine. For those cameras, go tiered + pixgate.

My honest limits

# Limit Consequence Mitigation
1 Sparse-mode playback is compressed time Slow changes during empty periods are gone; the product file is far shorter than wall-clock and can look "corrupt" Documented behavior; a real trade-off against real-time sub-stream continuity (Blue Iris / Synology route)
2 No server-side AI chain Can't answer "how many cats yesterday?" unattended like Frigate MiBeeVision + external trigger API
3 Compressed domain fails on flattened bitrates May never downgrade tiered + pixgate
4 Push cameras (RTMP/SRT) can't use tiering or pixgate Single-layer adaptive only Video spike + audio + external trigger still apply
5 MJPEG has no compressed-domain signal; AAC/Opus can't trigger audio Adaptive degrades on these cameras MJPEG gets dark-frame filtering + AVI containers; audio needs G.711
6 GOP pre-buffer is 32 MB resident per camera Many cameras = redo the memory math 1–64 MB configurable; must hold one full GOP
7 Single-maintainer project Long-term maintenance risk is a real selection variable Docs and engineering depth — judge for yourself

Try it

# one-line install: downloads the binary, creates the nvr system user,
# generates a config, installs a systemd service and starts it
curl -fsSL https://raw.githubusercontent.com/Mi-Bee-Studio/MiBeeNvr/main/install.sh | sudo bash
# Web UI on http://localhost:9090, data in /var/lib/mibee-nvr

# or Docker (recordings on an external drive: edit the volume mount + NVR_DATA_DIR)
docker compose --project-directory . -f deploy/docker/docker-compose.yml up -d
Enter fullscreen mode Exit fullscreen mode

Then set any camera to Recording mode: Adaptive and watch the storage trend on the dashboard. eval-replay lets you tune parameters against recordings you already have.

Glossary

Term Plain meaning
I-frame / keyframe A frame decodable on its own; starts a GOP
P-frame Stores only the difference from the previous frame; the quieter the scene, the smaller it is
GOP The run of frames between two I-frames; 1–30 s is typical in surveillance
Bitrate Bytes per second; bitrate × time ≈ disk usage
VBR / CBR Variable vs. constant bitrate; VBR saves when quiet, CBR is predictable
Compressed domain Reading frame sizes and types without decoding
Pixel domain Decoding and comparing pixels (background differencing, connected components)
Semantic domain AI models deciding "that's a person" — most expensive, most accurate
Pre-buffer Memory holding recent video so the trigger's lead-in isn't lost
Sub-stream The camera's second, lower-resolution output
MAD Median absolute deviation — a statistic resistant to outlier jitter
EMA Exponential moving average — a "slowly learning" background model
Connected component Adjacent foreground pixels merged into a blob, judged by area and lifetime
ROI Region of interest; a mask is the opposite — an excluded region
Ghost A static new foreground (parked car, lens droplet) being absorbed into the background
Hysteresis N consecutive active samples to confirm, M consecutive quiet samples to release
timeline_map The wall-clock ↔ offset-in-product lookup that makes seek land correctly

Mechanisms described against v0.13.0 docs and source comments; comparison data from each project's public docs and community measurements as of 2026-10; storage figures marked as measured are from my own documented runs, the rest are estimates or vendor claims. Diagrams are the SVG files in the diagrams/ folder next to this markdown — upload them to your publishing platform (dev.to: drag each into the editor) or host them on your own domain and update the paths.

Top comments (0)