From 78ef8fc064aaebeabdc8bf9ed06e07bd7399ff06 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Thu, 5 Mar 2026 08:50:27 +0300 Subject: [PATCH] perf(homekit): optimize motion detector with frame-based timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/homekit/README.md | 21 +++- internal/homekit/motion.go | 115 ++++++++++++++-------- internal/homekit/motion_test.go | 168 +++++++++++++++++++------------- www/schema.json | 10 +- 4 files changed, 204 insertions(+), 110 deletions(-) diff --git a/internal/homekit/README.md b/internal/homekit/README.md index 91eb2a8e..f48a638a 100644 --- a/internal/homekit/README.md +++ b/internal/homekit/README.md @@ -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:** diff --git a/internal/homekit/motion.go b/internal/homekit/motion.go index d9141cb6..ae21ced1 100644 --- a/internal/homekit/motion.go +++ b/internal/homekit/motion.go @@ -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") } } diff --git a/internal/homekit/motion_test.go b/internal/homekit/motion_test.go index 06215686..832226bd 100644 --- a/internal/homekit/motion_test.go +++ b/internal/homekit/motion_test.go @@ -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)) diff --git a/www/schema.json b/www/schema.json index 4eaa96ff..2d488083 100644 --- a/www/schema.json +++ b/www/schema.json @@ -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 } } }