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:
Sergey Krashevich
2026-03-05 08:50:27 +03:00
parent a591186da6
commit 78ef8fc064
4 changed files with 204 additions and 110 deletions
+75 -40
View File
@@ -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")
}
}