mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 15:47:06 +08:00
perf(homekit): optimize motion detector with frame-based timing
Replace time.Now() calls in hot path with frame-based timing: - Pre-compute triggerLevel (integer comparison instead of float division) - Calibrate hold/cooldown budgets from FPS (default 30fps) - Periodic FPS recalibration every 150 frames for accuracy - Active motion path: 47ns → 3.6ns (13x faster) Update schema.json with detect mode and motion_threshold. Add threshold tuning guide to README.
This commit is contained in:
@@ -145,7 +145,26 @@ homekit:
|
||||
motion_threshold: 1.0 # P-frame size / baseline ratio to trigger motion (default: 2.0)
|
||||
```
|
||||
|
||||
The `motion_threshold` controls sensitivity. Lower values = more sensitive. Typical values: 1.5 (high sensitivity) to 3.0 (low sensitivity). Default 2.0 works well for most real cameras with static scenes.
|
||||
The `motion_threshold` controls sensitivity — it's the ratio of P-frame size to the adaptive baseline. When a P-frame exceeds `baseline × threshold`, motion is triggered.
|
||||
|
||||
| Scenario | threshold | Notes |
|
||||
|---|---|---|
|
||||
| Quiet indoor scene | 1.3–1.5 | Low noise, stable baseline, even small motion is visible |
|
||||
| Standard camera (yard, hallway) | 2.0 (default) | Good balance between sensitivity and false positives |
|
||||
| Outdoor with trees/shadows/wind | 2.5–3.0 | Wind and shadows produce medium P-frames, need margin |
|
||||
| Busy street / complex scene | 3.0–5.0 | Lots of background motion, react only to large events |
|
||||
|
||||
Values below 1.0 are meaningless (triggers on every frame). Values above 5.0 require very large motion (person filling half the frame).
|
||||
|
||||
**How to tune:** set `log.level: trace` and watch `motion: status` lines — they show current `ratio`. Walk in front of the camera and note the ratio values:
|
||||
|
||||
```
|
||||
motion: status baseline=5000 ratio=0.95 ← quiet
|
||||
motion: status baseline=5000 ratio=3.21 ← person walked by
|
||||
motion: status baseline=5000 ratio=1.40 ← shadow/wind
|
||||
```
|
||||
|
||||
Set threshold between "noise" and "real motion". In this example, 2.0 is a good choice (ignores 1.4, catches 3.2).
|
||||
|
||||
**Motion API:**
|
||||
|
||||
|
||||
+75
-40
@@ -16,10 +16,9 @@ const (
|
||||
motionAlphaSlow = 0.02
|
||||
motionHoldTime = 30 * time.Second
|
||||
motionCooldown = 5 * time.Second
|
||||
motionDefaultFPS = 30.0
|
||||
|
||||
// check hold time expiry every N frames during active motion (~270ms at 30fps)
|
||||
motionHoldCheckFrames = 8
|
||||
// trace log every N frames (~5s at 30fps)
|
||||
// recalibrate FPS and emit trace log every N frames (~5s at 30fps)
|
||||
motionTraceFrames = 150
|
||||
)
|
||||
|
||||
@@ -29,15 +28,24 @@ type motionDetector struct {
|
||||
done chan struct{}
|
||||
|
||||
// algorithm state (accessed only from Sender goroutine — no mutex needed)
|
||||
threshold float64
|
||||
baseline float64
|
||||
initialized bool
|
||||
frameCount int
|
||||
threshold float64
|
||||
triggerLevel int // pre-computed: int(baseline * threshold)
|
||||
baseline float64
|
||||
initialized bool
|
||||
frameCount int
|
||||
|
||||
// frame-based timing (calibrated periodically, no time.Now() in per-frame hot path)
|
||||
holdBudget int // motionHoldTime converted to frames
|
||||
cooldownBudget int // motionCooldown converted to frames
|
||||
remainingHold int // frames left until hold expires (active motion)
|
||||
remainingCooldown int // frames left until cooldown expires (after OFF)
|
||||
|
||||
// motion state
|
||||
motionActive bool
|
||||
lastMotion time.Time
|
||||
lastOff time.Time
|
||||
|
||||
// periodic FPS recalibration
|
||||
lastFPSCheck time.Time
|
||||
lastFPSFrame int
|
||||
|
||||
// for testing: injectable time and callback
|
||||
now func() time.Time
|
||||
@@ -100,6 +108,20 @@ func (m *motionDetector) streamName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *motionDetector) calibrate() {
|
||||
// use default FPS — real FPS calibrated after first periodic check
|
||||
m.holdBudget = int(motionHoldTime.Seconds() * motionDefaultFPS)
|
||||
m.cooldownBudget = int(motionCooldown.Seconds() * motionDefaultFPS)
|
||||
m.triggerLevel = int(m.baseline * m.threshold)
|
||||
m.lastFPSCheck = m.now()
|
||||
m.lastFPSFrame = m.frameCount
|
||||
|
||||
log.Debug().Str("stream", m.streamName()).
|
||||
Float64("baseline", m.baseline).
|
||||
Int("holdFrames", m.holdBudget).Int("cooldownFrames", m.cooldownBudget).
|
||||
Msg("[homekit] motion: warmup complete")
|
||||
}
|
||||
|
||||
func (m *motionDetector) handlePacket(packet *rtp.Packet) {
|
||||
payload := packet.Payload
|
||||
if len(payload) < 5 {
|
||||
@@ -111,69 +133,82 @@ func (m *motionDetector) handlePacket(packet *rtp.Packet) {
|
||||
return
|
||||
}
|
||||
|
||||
size := float64(len(payload))
|
||||
size := len(payload)
|
||||
m.frameCount++
|
||||
|
||||
if m.frameCount <= motionWarmupFrames {
|
||||
// warmup: build baseline with fast EMA
|
||||
fsize := float64(size)
|
||||
if !m.initialized {
|
||||
m.baseline = size
|
||||
m.baseline = fsize
|
||||
m.initialized = true
|
||||
} else {
|
||||
m.baseline += motionAlphaFast * (size - m.baseline)
|
||||
m.baseline += motionAlphaFast * (fsize - m.baseline)
|
||||
}
|
||||
if m.frameCount == motionWarmupFrames {
|
||||
log.Debug().Str("stream", m.streamName()).Float64("baseline", m.baseline).Msg("[homekit] motion: warmup complete")
|
||||
m.calibrate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if m.baseline <= 0 {
|
||||
if m.triggerLevel <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ratio := size / m.baseline
|
||||
triggered := ratio > m.threshold
|
||||
// integer comparison — no float division needed
|
||||
triggered := size > m.triggerLevel
|
||||
|
||||
if !m.motionActive {
|
||||
// idle path: check for trigger first, then update baseline
|
||||
if triggered {
|
||||
// only call time.Now() when threshold exceeded
|
||||
now := m.now()
|
||||
if now.Sub(m.lastOff) >= motionCooldown {
|
||||
m.motionActive = true
|
||||
m.lastMotion = now
|
||||
log.Debug().Str("stream", m.streamName()).Float64("ratio", ratio).Msg("[homekit] motion: ON")
|
||||
m.setMotion(true)
|
||||
} else {
|
||||
log.Debug().Str("stream", m.streamName()).Float64("ratio", ratio).
|
||||
Dur("cooldown_left", motionCooldown-now.Sub(m.lastOff)).Msg("[homekit] motion: blocked by cooldown")
|
||||
}
|
||||
// idle path: decrement cooldown, check for trigger, update baseline
|
||||
if m.remainingCooldown > 0 {
|
||||
m.remainingCooldown--
|
||||
}
|
||||
|
||||
if triggered && m.remainingCooldown <= 0 {
|
||||
m.motionActive = true
|
||||
m.remainingHold = m.holdBudget
|
||||
log.Debug().Str("stream", m.streamName()).
|
||||
Float64("ratio", float64(size)/m.baseline).
|
||||
Msg("[homekit] motion: ON")
|
||||
m.setMotion(true)
|
||||
}
|
||||
|
||||
// update baseline only if still idle (trigger frame doesn't pollute baseline)
|
||||
if !m.motionActive {
|
||||
m.baseline += motionAlphaSlow * (size - m.baseline)
|
||||
fsize := float64(size)
|
||||
m.baseline += motionAlphaSlow * (fsize - m.baseline)
|
||||
m.triggerLevel = int(m.baseline * m.threshold)
|
||||
}
|
||||
} else {
|
||||
// active motion path
|
||||
// active motion path: pure integer arithmetic, zero time.Now() calls
|
||||
if triggered {
|
||||
m.lastMotion = m.now()
|
||||
} else if m.frameCount%motionHoldCheckFrames == 0 {
|
||||
// check hold time expiry periodically, not every frame
|
||||
now := m.now()
|
||||
if now.Sub(m.lastMotion) >= motionHoldTime {
|
||||
m.remainingHold = m.holdBudget
|
||||
} else {
|
||||
m.remainingHold--
|
||||
if m.remainingHold <= 0 {
|
||||
m.motionActive = false
|
||||
m.lastOff = now
|
||||
m.remainingCooldown = m.cooldownBudget
|
||||
log.Debug().Str("stream", m.streamName()).Msg("[homekit] motion: OFF (hold expired)")
|
||||
m.setMotion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// periodic trace using frame counter instead of time check
|
||||
// periodic: recalibrate FPS and emit trace log
|
||||
if m.frameCount%motionTraceFrames == 0 {
|
||||
now := m.now()
|
||||
frames := m.frameCount - m.lastFPSFrame
|
||||
if frames > 0 {
|
||||
if elapsed := now.Sub(m.lastFPSCheck); elapsed > time.Millisecond {
|
||||
fps := float64(frames) / elapsed.Seconds()
|
||||
m.holdBudget = int(motionHoldTime.Seconds() * fps)
|
||||
m.cooldownBudget = int(motionCooldown.Seconds() * fps)
|
||||
}
|
||||
}
|
||||
m.lastFPSCheck = now
|
||||
m.lastFPSFrame = m.frameCount
|
||||
|
||||
log.Trace().Str("stream", m.streamName()).
|
||||
Float64("baseline", m.baseline).Float64("ratio", ratio).
|
||||
Float64("baseline", m.baseline).Float64("ratio", float64(size)/m.baseline).
|
||||
Bool("active", m.motionActive).Msg("[homekit] motion: status")
|
||||
}
|
||||
}
|
||||
|
||||
+101
-67
@@ -10,7 +10,6 @@ import (
|
||||
)
|
||||
|
||||
// makeAVCC creates a fake AVCC packet with the given NAL type and total size.
|
||||
// Format: 4-byte big-endian length + NAL header + padding.
|
||||
func makeAVCC(nalType byte, totalSize int) []byte {
|
||||
if totalSize < 5 {
|
||||
totalSize = 5
|
||||
@@ -69,6 +68,13 @@ func warmup(det *motionDetector, clock *mockClock, size int) {
|
||||
}
|
||||
}
|
||||
|
||||
// warmupWithBudgets performs warmup then sets test-friendly hold/cooldown budgets.
|
||||
func warmupWithBudgets(det *motionDetector, clock *mockClock, size, hold, cooldown int) {
|
||||
warmup(det, clock, size)
|
||||
det.holdBudget = hold
|
||||
det.cooldownBudget = cooldown
|
||||
}
|
||||
|
||||
func TestMotionDetector_NoMotion(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
@@ -77,7 +83,6 @@ func TestMotionDetector_NoMotion(t *testing.T) {
|
||||
// feed same-size P-frames — no motion
|
||||
for i := 0; i < 100; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
}
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
@@ -92,7 +97,6 @@ func TestMotionDetector_MotionDetected(t *testing.T) {
|
||||
|
||||
// large P-frame triggers motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
|
||||
last, ok := rec.lastCall()
|
||||
if !ok || !last {
|
||||
@@ -103,50 +107,43 @@ func TestMotionDetector_MotionDetected(t *testing.T) {
|
||||
func TestMotionDetector_HoldTime(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
// trigger motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatal("expected motion ON")
|
||||
}
|
||||
|
||||
// advance 20s with small frames — still active (< holdTime)
|
||||
for i := 0; i < 60; i++ {
|
||||
clock.advance(333 * time.Millisecond)
|
||||
// send 20 non-triggered frames — still active (< holdBudget=30)
|
||||
for i := 0; i < 20; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// no OFF call yet
|
||||
if len(rec.calls) != 1 {
|
||||
t.Fatalf("expected only ON call during hold, got %v", rec.calls)
|
||||
}
|
||||
|
||||
// advance past holdTime (30s total)
|
||||
for i := 0; i < 40; i++ {
|
||||
clock.advance(333 * time.Millisecond)
|
||||
// send 15 more (total 35 > holdBudget=30) — should turn OFF
|
||||
for i := 0; i < 15; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// now should have OFF
|
||||
last, _ := rec.lastCall()
|
||||
if last {
|
||||
t.Fatal("expected motion OFF after hold time")
|
||||
t.Fatal("expected motion OFF after hold budget exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_Cooldown(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
// trigger and expire motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(motionHoldTime + time.Second)
|
||||
// feed enough small frames to hit a hold check interval
|
||||
for i := 0; i < motionHoldCheckFrames+1; i++ {
|
||||
for i := 0; i < 30; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
if len(rec.calls) != 2 || rec.calls[1] != false {
|
||||
@@ -159,8 +156,12 @@ func TestMotionDetector_Cooldown(t *testing.T) {
|
||||
t.Fatalf("expected cooldown to block re-trigger, got %v", rec.calls)
|
||||
}
|
||||
|
||||
// advance past cooldown
|
||||
clock.advance(motionCooldown + time.Second)
|
||||
// send frames to expire cooldown (blocked trigger consumed 1 decrement)
|
||||
for i := 0; i < 5; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// now re-trigger should work
|
||||
det.handlePacket(makePFrame(5000))
|
||||
if len(rec.calls) != 3 || !rec.calls[2] {
|
||||
t.Fatalf("expected motion ON after cooldown, got %v", rec.calls)
|
||||
@@ -174,13 +175,12 @@ func TestMotionDetector_SkipsKeyframes(t *testing.T) {
|
||||
|
||||
// huge keyframe should not trigger motion
|
||||
det.handlePacket(makeIFrame(50000))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatal("keyframes should not trigger motion")
|
||||
}
|
||||
|
||||
// verify baseline didn't change by checking small P-frame doesn't trigger
|
||||
// verify baseline didn't change
|
||||
det.handlePacket(makePFrame(500))
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatal("baseline should be unaffected by keyframes")
|
||||
@@ -209,7 +209,6 @@ func TestMotionDetector_BaselineFreeze(t *testing.T) {
|
||||
|
||||
// trigger motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatal("expected motion ON")
|
||||
@@ -218,7 +217,6 @@ func TestMotionDetector_BaselineFreeze(t *testing.T) {
|
||||
// feed large frames during motion — baseline should not change
|
||||
for i := 0; i < 50; i++ {
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
if det.baseline != baselineBefore {
|
||||
@@ -228,13 +226,12 @@ func TestMotionDetector_BaselineFreeze(t *testing.T) {
|
||||
|
||||
func TestMotionDetector_CustomThreshold(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
det.threshold = 1.5 // lower threshold
|
||||
det.threshold = 1.5
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// 1.6x — below default 2.0 but above custom 1.5
|
||||
det.handlePacket(makePFrame(800))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatalf("expected motion ON with custom threshold 1.5, got %v", rec.calls)
|
||||
@@ -243,13 +240,12 @@ func TestMotionDetector_CustomThreshold(t *testing.T) {
|
||||
|
||||
func TestMotionDetector_CustomThresholdNoFalsePositive(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
det.threshold = 3.0 // high threshold
|
||||
det.threshold = 3.0
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// 2.5x — above default 2.0 but below custom 3.0
|
||||
det.handlePacket(makePFrame(1250))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatalf("expected no motion with high threshold 3.0, got %v", rec.calls)
|
||||
@@ -259,35 +255,35 @@ func TestMotionDetector_CustomThresholdNoFalsePositive(t *testing.T) {
|
||||
func TestMotionDetector_HoldTimeExtended(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
// trigger motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatal("expected motion ON")
|
||||
}
|
||||
|
||||
// advance 25s, then re-trigger — hold timer resets
|
||||
clock.advance(25 * time.Second)
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
// advance another 25s (50s from first trigger, but only 25s from last)
|
||||
for i := 0; i < 75; i++ {
|
||||
clock.advance(333 * time.Millisecond)
|
||||
// send 25 non-triggered frames (remainingHold 30→5)
|
||||
for i := 0; i < 25; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// should still be ON — hold timer was reset by second trigger
|
||||
// re-trigger — remainingHold resets to 30
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
// send 25 more non-triggered (remainingHold 30→5)
|
||||
for i := 0; i < 25; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// should still be ON
|
||||
if len(rec.calls) != 1 {
|
||||
t.Fatalf("expected hold time to be extended by re-trigger, got %v", rec.calls)
|
||||
}
|
||||
|
||||
// advance past hold time from last trigger
|
||||
clock.advance(6 * time.Second)
|
||||
// feed enough frames to guarantee hitting hold check interval
|
||||
for i := 0; i < motionHoldCheckFrames+1; i++ {
|
||||
// send 10 more to exhaust hold
|
||||
for i := 0; i < 10; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
@@ -302,7 +298,6 @@ func TestMotionDetector_SmallPayloadIgnored(t *testing.T) {
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// payloads < 5 bytes should be silently ignored
|
||||
det.handlePacket(&rtp.Packet{Payload: []byte{1, 2, 3, 4}})
|
||||
det.handlePacket(&rtp.Packet{Payload: nil})
|
||||
det.handlePacket(&rtp.Packet{Payload: []byte{}})
|
||||
@@ -318,10 +313,9 @@ func TestMotionDetector_BaselineAdapts(t *testing.T) {
|
||||
warmup(det, clock, 500)
|
||||
baselineAfterWarmup := det.baseline
|
||||
|
||||
// feed gradually larger frames (no motion active) — baseline should drift up
|
||||
// feed gradually larger frames — baseline should drift up
|
||||
for i := 0; i < 200; i++ {
|
||||
det.handlePacket(makePFrame(700))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
}
|
||||
|
||||
if det.baseline <= baselineAfterWarmup {
|
||||
@@ -338,7 +332,7 @@ func TestMotionDetector_DoubleStopSafe(t *testing.T) {
|
||||
_ = det.Stop()
|
||||
_ = det.Stop() // second stop should not panic
|
||||
|
||||
if len(rec.calls) != 2 { // ON + OFF from first Stop
|
||||
if len(rec.calls) != 2 {
|
||||
t.Fatalf("expected ON+OFF, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
@@ -348,7 +342,6 @@ func TestMotionDetector_StopWithoutMotion(t *testing.T) {
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// stop without ever triggering motion — should not call onMotion
|
||||
rec := &motionRecorder{}
|
||||
det.onMotion = rec.onMotion
|
||||
_ = det.Stop()
|
||||
@@ -378,51 +371,94 @@ func TestMotionDetector_StopClearsMotion(t *testing.T) {
|
||||
func TestMotionDetector_WarmupBaseline(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
// feed varying sizes during warmup
|
||||
for i := 0; i < motionWarmupFrames; i++ {
|
||||
size := 400 + (i%5)*50 // 400-600 range
|
||||
size := 400 + (i%5)*50
|
||||
det.handlePacket(makePFrame(size))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
}
|
||||
|
||||
// baseline should be a reasonable average, not zero or the last value
|
||||
if det.baseline < 400 || det.baseline > 600 {
|
||||
t.Fatalf("baseline should be in 400-600 range after varied warmup, got %f", det.baseline)
|
||||
t.Fatalf("baseline should be in 400-600 range, got %f", det.baseline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_MultipleCycles(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
// 3 full motion cycles: ON → hold → OFF → cooldown → ON ...
|
||||
for cycle := 0; cycle < 3; cycle++ {
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(motionHoldTime + time.Second)
|
||||
// feed enough frames to hit hold check interval
|
||||
for i := 0; i < motionHoldCheckFrames+1; i++ {
|
||||
det.handlePacket(makePFrame(5000)) // trigger ON
|
||||
for i := 0; i < 30; i++ { // expire hold
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
for i := 0; i < 6; i++ { // expire cooldown
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
clock.advance(motionCooldown + time.Second)
|
||||
}
|
||||
|
||||
// expect 3 ON + 3 OFF = 6 calls
|
||||
if len(rec.calls) != 6 {
|
||||
t.Fatalf("expected 6 calls (3 cycles), got %d: %v", len(rec.calls), rec.calls)
|
||||
}
|
||||
for i, v := range rec.calls {
|
||||
expected := i%2 == 0 // ON at 0,2,4; OFF at 1,3,5
|
||||
expected := i%2 == 0
|
||||
if v != expected {
|
||||
t.Fatalf("call[%d] = %v, expected %v", i, v, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_TriggerLevel(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
expected := int(det.baseline * det.threshold)
|
||||
if det.triggerLevel != expected {
|
||||
t.Fatalf("triggerLevel = %d, expected %d", det.triggerLevel, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_DefaultFPSCalibration(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// calibrate uses default 30fps
|
||||
expectedHold := int(motionHoldTime.Seconds() * motionDefaultFPS)
|
||||
expectedCooldown := int(motionCooldown.Seconds() * motionDefaultFPS)
|
||||
if det.holdBudget != expectedHold {
|
||||
t.Fatalf("holdBudget = %d, expected %d", det.holdBudget, expectedHold)
|
||||
}
|
||||
if det.cooldownBudget != expectedCooldown {
|
||||
t.Fatalf("cooldownBudget = %d, expected %d", det.cooldownBudget, expectedCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_FPSRecalibration(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// initial budgets use default 30fps
|
||||
initialHold := det.holdBudget
|
||||
|
||||
// send motionTraceFrames frames with 100ms intervals → FPS=10
|
||||
for i := 0; i < motionTraceFrames; i++ {
|
||||
clock.advance(100 * time.Millisecond)
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// after recalibration, holdBudget should reflect ~10fps (±5% due to warmup tail)
|
||||
expectedHold := int(motionHoldTime.Seconds() * 10.0) // ~300
|
||||
if det.holdBudget < expectedHold-20 || det.holdBudget > expectedHold+20 {
|
||||
t.Fatalf("holdBudget after recalibration = %d, expected ~%d (was %d)", det.holdBudget, expectedHold, initialHold)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMotionDetector_HandlePacket(b *testing.B) {
|
||||
det, _, _ := newTestDetector()
|
||||
warmup(det, &mockClock{t: time.Now()}, 500)
|
||||
det.now = time.Now
|
||||
det, clock, _ := newTestDetector()
|
||||
warmup(det, clock, 500)
|
||||
|
||||
pkt := makePFrame(600)
|
||||
b.ResetTimer()
|
||||
@@ -432,9 +468,8 @@ func BenchmarkMotionDetector_HandlePacket(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkMotionDetector_WithKeyframes(b *testing.B) {
|
||||
det, _, _ := newTestDetector()
|
||||
warmup(det, &mockClock{t: time.Now()}, 500)
|
||||
det.now = time.Now
|
||||
det, clock, _ := newTestDetector()
|
||||
warmup(det, clock, 500)
|
||||
|
||||
pFrame := makePFrame(600)
|
||||
iFrame := makeIFrame(10000)
|
||||
@@ -451,7 +486,6 @@ func BenchmarkMotionDetector_WithKeyframes(b *testing.B) {
|
||||
func BenchmarkMotionDetector_MotionActive(b *testing.B) {
|
||||
det, clock, _ := newTestDetector()
|
||||
warmup(det, clock, 500)
|
||||
det.now = time.Now
|
||||
|
||||
// trigger motion and keep it active
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
+8
-2
@@ -338,13 +338,19 @@
|
||||
"default": false
|
||||
},
|
||||
"motion": {
|
||||
"description": "Motion detection mode for HKSV: `api` (triggered via HTTP API) or `continuous` (always report motion)",
|
||||
"description": "Motion detection mode for HKSV: `api` (triggered via HTTP API), `continuous` (always report motion), or `detect` (automatic detection based on P-frame size analysis)",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"api",
|
||||
"continuous"
|
||||
"continuous",
|
||||
"detect"
|
||||
],
|
||||
"default": "api"
|
||||
},
|
||||
"motion_threshold": {
|
||||
"description": "Motion detection sensitivity threshold for `detect` mode. Lower values = more sensitive. Uses EMA-based P-frame size analysis.",
|
||||
"type": "number",
|
||||
"default": 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user