Files
go2rtc/internal/homekit/motion.go
T
Sergey Krashevich 78ef8fc064 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.
2026-03-05 08:50:27 +03:00

244 lines
6.0 KiB
Go

package homekit
import (
"io"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/pion/rtp"
)
const (
motionWarmupFrames = 30
motionThreshold = 2.0
motionAlphaFast = 0.1
motionAlphaSlow = 0.02
motionHoldTime = 30 * time.Second
motionCooldown = 5 * time.Second
motionDefaultFPS = 30.0
// recalibrate FPS and emit trace log every N frames (~5s at 30fps)
motionTraceFrames = 150
)
type motionDetector struct {
core.Connection
server *server
done chan struct{}
// algorithm state (accessed only from Sender goroutine — no mutex needed)
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
// periodic FPS recalibration
lastFPSCheck time.Time
lastFPSFrame int
// for testing: injectable time and callback
now func() time.Time
onMotion func(bool)
}
func newMotionDetector(srv *server) *motionDetector {
medias := []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{
{Name: core.CodecH264},
},
},
}
threshold := motionThreshold
if srv != nil && srv.motionThreshold > 0 {
threshold = srv.motionThreshold
}
return &motionDetector{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "motion",
Protocol: "detect",
Medias: medias,
},
server: srv,
threshold: threshold,
done: make(chan struct{}),
now: time.Now,
}
}
func (m *motionDetector) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
log.Debug().Str("stream", m.streamName()).Str("codec", track.Codec.Name).Msg("[homekit] motion: add track")
codec := track.Codec.Clone()
sender := core.NewSender(media, codec)
sender.Handler = func(packet *rtp.Packet) {
m.handlePacket(packet)
}
if track.Codec.IsRTP() {
sender.Handler = h264.RTPDepay(track.Codec, sender.Handler)
} else {
sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler)
}
sender.HandleRTP(track)
m.Senders = append(m.Senders, sender)
return nil
}
func (m *motionDetector) streamName() string {
if m.server != nil {
return m.server.stream
}
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 {
return
}
// skip keyframes — always large, not informative for motion
if h264.IsKeyframe(payload) {
return
}
size := len(payload)
m.frameCount++
if m.frameCount <= motionWarmupFrames {
fsize := float64(size)
if !m.initialized {
m.baseline = fsize
m.initialized = true
} else {
m.baseline += motionAlphaFast * (fsize - m.baseline)
}
if m.frameCount == motionWarmupFrames {
m.calibrate()
}
return
}
if m.triggerLevel <= 0 {
return
}
// integer comparison — no float division needed
triggered := size > m.triggerLevel
if !m.motionActive {
// 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 {
fsize := float64(size)
m.baseline += motionAlphaSlow * (fsize - m.baseline)
m.triggerLevel = int(m.baseline * m.threshold)
}
} else {
// active motion path: pure integer arithmetic, zero time.Now() calls
if triggered {
m.remainingHold = m.holdBudget
} else {
m.remainingHold--
if m.remainingHold <= 0 {
m.motionActive = false
m.remainingCooldown = m.cooldownBudget
log.Debug().Str("stream", m.streamName()).Msg("[homekit] motion: OFF (hold expired)")
m.setMotion(false)
}
}
}
// 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", float64(size)/m.baseline).
Bool("active", m.motionActive).Msg("[homekit] motion: status")
}
}
func (m *motionDetector) setMotion(detected bool) {
if m.onMotion != nil {
m.onMotion(detected)
return
}
if m.server != nil {
m.server.SetMotionDetected(detected)
}
}
func (m *motionDetector) WriteTo(io.Writer) (int64, error) {
<-m.done
return 0, nil
}
func (m *motionDetector) Stop() error {
select {
case <-m.done:
default:
if m.motionActive {
m.motionActive = false
log.Debug().Str("stream", m.streamName()).Msg("[homekit] motion: OFF (stop)")
m.setMotion(false)
}
close(m.done)
}
return m.Connection.Stop()
}