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.
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):
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:
- 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.
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)
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:
- the buffered GOP (the continuous footage right before the spike) is flushed to disk;
- 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.
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.
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
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(witharea_pct/ centroid /flood/ghostfields) for UI and automations; Prometheus exposesnvr_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.
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
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
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
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:
- Vision writes AI events back to the NVR (AI tags in the recordings list + events), visible during playback;
- 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"}'
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_downloadwhen Vision runs on a different machine;local_filerequires 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.Zline.
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
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):
- 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.
- 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
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.
- Repo: https://github.com/Mi-Bee-Studio/MiBeeNvr
- Docs: https://www.mlsbs.top/projects/mibeenvr
- My blog (mostly Chinese): https://blog.mickeyzzc.tech
- MiBeeVision Lite v0.1.0: see the repo's releases page
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)