Files
go2rtc/pkg/hksv/motion.go
T
Sergey Krashevich c567831c91 feat: add motion detection feature with EMA-based P-frame size analysis
- Implemented MotionDetector for detecting motion based on H.264 P-frame sizes.
- Introduced adjustable sensitivity threshold for motion detection.
- Added tests for various scenarios including motion detection, hold time, cooldown, and baseline adaptation.
- Created hksvSession to manage HDS DataStream connections for HKSV recording.
- Updated schema.json to include a new speaker option for 2-way audio support.
2026-03-06 19:58:15 +03:00

244 lines
6.2 KiB
Go

// Author: Sergei "svk" Krashevich <svk@svk.su>
package hksv
import (
"io"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/pion/rtp"
"github.com/rs/zerolog"
)
const (
motionWarmupFrames = 30
defaultThreshold = 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
)
// MotionDetector implements core.Consumer for P-frame based motion detection.
// It analyzes H.264 P-frame sizes using an EMA baseline and triggers a callback
// when the frame size exceeds the baseline by the configured threshold.
type MotionDetector struct {
core.Connection
done chan struct{}
log zerolog.Logger
// 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) `json:"-"` // callback when motion state changes
}
// NewMotionDetector creates a new motion detector with the given threshold and callback.
// If threshold <= 0, the default of 2.0 is used.
// onMotion is called when motion state changes (true=detected, false=ended).
func NewMotionDetector(threshold float64, onMotion func(bool), log zerolog.Logger) *MotionDetector {
if threshold <= 0 {
threshold = defaultThreshold
}
medias := []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{
{Name: core.CodecH264},
},
},
}
return &MotionDetector{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "motion",
Protocol: "detect",
Medias: medias,
},
threshold: threshold,
done: make(chan struct{}),
now: time.Now,
OnMotion: onMotion,
log: log,
}
}
func (m *MotionDetector) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
m.log.Debug().Str("codec", track.Codec.Name).Msg("[hksv] 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) calibrate() {
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
m.log.Debug().
Float64("baseline", m.baseline).
Int("holdFrames", m.holdBudget).Int("cooldownFrames", m.cooldownBudget).
Msg("[hksv] 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
m.log.Debug().
Float64("ratio", float64(size)/m.baseline).
Msg("[hksv] 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
m.log.Debug().Msg("[hksv] 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
m.log.Trace().
Float64("baseline", m.baseline).Float64("ratio", float64(size)/m.baseline).
Bool("active", m.motionActive).Msg("[hksv] motion: status")
}
}
func (m *MotionDetector) setMotion(detected bool) {
if m.OnMotion != nil {
m.OnMotion(detected)
}
}
func (m *MotionDetector) String() string {
return "motion detector"
}
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
m.log.Debug().Msg("[hksv] motion: OFF (stop)")
m.setMotion(false)
}
close(m.done)
}
return m.Connection.Stop()
}