Skip to content

Motion Detection

Motion detection uses the Ingenic ISP's own hardware-adjacent motion engine, IMP_IVS ("Intelligent Video System") "move" detection API — not a software frame-difference algorithm running on the CPU. Implemented in src/hal/imp_motion.c, configured under motion.* (see Configuration Reference), and surfaced live via /control's "motion" object and the /events motion SSE type (see HTTP /control API Reference).

Availability

Whether IMP_IVS move detection exists at all, and how large a grid it can support, is entirely determined by the vendor SDK headers the binary was built against (src/motion_caps.h, auto-detected via __has_include(<imp/imp_ivs_move.h>)):

  • MOTION_AVAILABLE — 1 if this build's SDK has the move API, 0 otherwise (feature fully compiled out as a no-op stub; GET /control reports "available":0).
  • MOTION_MAX_CELLS — taken directly from the SDK's IMP_IVS_MOVE_MAX_ROI_CNT constant, never hardcoded: 52 on most SDKs (T10/T20 3.12.0, T21, T23, T30, T31, T40, T41, C100), but only 4 on the old T10/T20 3.9.0 SDK. make sim simulates the common 52-cell case so the WebUI motion overlay can be exercised without hardware.

The grid model

Motion detection is a motion.cols × motion.rows grid of IVS move-ROIs, split evenly (by integer pixel boundaries) over the frame of motion.monitor_stream. Each cell reports motion independently. Cell index is row-major: index = row*cols + col — the same order IVS's own retRoi[] result array uses and the same order /control's "active" array and the motion SSE event use.

cols*rows is clamped to MOTION_MAX_CELLS; motion.cols/motion.rows each clamp against the current value of the other axis (never the reverse), so re-applying the same pair via /control is idempotent and doesn't trigger a spurious rebuild.

Sensitivity mapping: the UI-facing motion.sensitivity range is 0–255; IVS's native move-detection sensitivity is 0–4. The mapping is a simple linear scale, v*4/255, applied uniformly to every cell (there is no per-cell sensitivity). Because of this quantization, /control's change-detection additionally compares the mapped value, not just the raw one — a sensitivity change that maps to the same IVS level (e.g. 128 → 129) is treated as unchanged and skips the (expensive) IVS grid rebuild.

monitor_stream and FrameSource sharing

motion.monitor_stream selects which video stream's FrameSource feeds the IVS grid. The default is 1, the sub stream: IVS compares frames on the CPU on every SoC here, so its cost follows the pixel count (a 640x360 sub stream is about a ninth of 1080p). When video1 is not enabled the HAL falls back to stream 0. The cell grid is relative to the monitored frame, so zones and privacy masks need no rescaling. Set 0 to run detection on the main stream (finer detail for small, distant objects). The HAL pins that stream's FrameSource channel active for as long as motion detection is running (via the same refcounted fs_use()/fs_unuse() mechanism Architecture uses for on-demand video/JPEG start), specifically so the framesource's normal idle-stop debounce never disables the frames IVS needs — motion detection keeps working even with zero RTSP/HTTP clients connected to that stream.

Privacy-mask exclusion

Motion and an OSD privacy cover mask share the same zero-copy FrameSource buffer, and the cover is drawn in-place on that buffer. If a grid cell's rectangle intersects any enabled privacy<S>.<N> region on the monitored stream, that cell is excluded from the IVS ROI list entirely — otherwise an unmasked cell sitting over the cover would see raw pre-cover/fill-color flicker and report motion permanently. Excluded cells simply never get a retRoi slot and always read as inactive in status output.

Live rebuild behavior

motion.enabled, sensitivity, cols, rows, and monitor_stream are all live-applicable via /control. enabled/cols/rows/ monitor_stream are creation-time attributes in the SDK — changing any of them requires tearing down and rebuilding the entire IVS group and move interface. To avoid rebuilding once per key in a multi-key POST (e.g. the WebUI's motion settings page saving cols/rows/sensitivity together), the HAL registers a batch-commit callback with the hub (hub_set_control_commit_cb): these keys just flag "needs rebuild," and the actual stop/destroy/recreate cycle runs at most once, after /control has applied every key in the request.

sensitivity alone is the one exception and takes a cheaper fast path: IMP_IVS_MoveParam.sense[] is a runtime-updatable per-ROI parameter on all 9 platforms, so imp_motion_set_sensitivity() reads the running channel's move params via IMP_IVS_GetParam, rewrites every configured ROI's sense[] entry to the newly-mapped 0–4 level, and pushes it back via IMP_IVS_SetParam — no stop/destroy/recreate at all. If a geometry/monitor_stream/enabled change arrives in the same request, the full rebuild supersedes this fast path and re-applies sensitivity as part of it; and if the fast path itself fails for any reason (channel not actually running, or the SDK Get/SetParam calls report an error), the code falls back to the full rebuild rather than leaving the channel in a half-updated state.

cooldown_ms, hold_ms, and skip_frames

These three are related but distinct. hold_ms and skip_frames are POST-able over /control and applied live (they ride the deferred IVS grid re-sync that runs once at the end of the request); cooldown_ms is config-file-only by design, as the floor that bounds how often the on_motion hook can be re-exec'd:

  • motion.skip_frames (default 5, floor 1) maps directly to IMP_IVS_MoveParam.skipFrameCnt — an IVS-native "analyze every Nth frame" knob. Higher values are cheaper but slower to detect; lower values are snappier but cost more CPU.
  • motion.hold_ms (default 800, 0 = off) is a status latch, not a detection parameter. IVS only flags a cell true on the exact frame it moved and clears it on the very next processed frame, so an asynchronous reader (an /events subscriber, a /control poller) usually races the clear and observes all-zero even during genuine motion. Each cell that fires is held "active" for hold_ms after its last hit so any reader reliably sees it — every transition is also pushed into a small snapshot queue for /events, since level-sampling alone would still miss two transitions landing between two samples.
  • motion.cooldown_ms (default 5000, floor 250) rate-limits how often the on_motion hook script itself can fire, independent of hold_ms. The cooldown clock is kept at file scope (not reset by an IVS rebuild that a config change triggers), so a live /control edit right after a real detection can't accidentally let the hook re-fire before the configured cooldown has actually elapsed.

The on_motion hook

motion.on_motion (default "" = disabled, config-file-only, and — unlike almost every other config key — not even readable back via GET /control) names a program to run when any cell trips. It is invoked via posix_spawn(), never system() — the hook has no shell and takes no arguments, so a malicious or malformed on_motion value can only fail to exec, never inject shell metacharacters. Because posix_spawn() execs through execve, there is no PATH search: give it an absolute path. It replaced the old double fork() (which copied the whole daemon's address space twice per event and caused ENOMEM on low-RAM boards) and hands back a direct child that the detection thread reaps without blocking on the hook's runtime.

The hook receives context via environment variables (avoiding the need for the script to poll /control and race the hold_ms decay window):

Variable Meaning
MOTION_COLS Grid columns in effect.
MOTION_ROWS Grid rows in effect.
MOTION_CELLS A decimal unsigned long long bitmask — bit N set means row-major cell N fired on this trigger (capped to the low 64 bits).
MOTION_TIME Unix timestamp (seconds) of the trigger.

All four values are daemon-computed numerics with no attacker-controlled content, so passing them via setenv() introduces no injection surface.

Motion on rotated streams

Every 90/270 path rotates inside the framesource (IMP_FrameSource_SetChnRotate on T31 and on OpenIMP T10/T20/T21/T23/T30, the I2D block on T40/T41). IVS is bound downstream of the framesource, so it sees the already-rotated frame, and the grid is laid out over the post-rotation ("effective") dimensions (ms_vstream_eff_dims()), the same space the WebUI overlay and privacy masks use. A rotation refused at start leaves the stream and the grid unrotated.

Up to v1.11.1 T23 had a timps-own software rotate where IVS saw the unrotated frame and each grid cell was inverse-mapped into sensor coordinates. That path and its mapping were removed; T23 now rotates through OpenIMP like T10/T20/T21.

See Platform & SDK Support and docs/rotation.md for the rest of the rotation feature's platform-by- platform behavior.