feat(v2): add fall event regression engine

This commit is contained in:
ila
2026-07-22 16:18:16 +08:00
parent 258fa49259
commit 1298369054
22 changed files with 1214 additions and 92 deletions
+62
View File
@@ -0,0 +1,62 @@
package fall
import (
"math"
"silverpose/v2/internal/pose"
)
var torsoJoints = [...]int{5, 6, 11, 12}
var lowerJoints = [...]int{13, 14, 15, 16}
func assessPoseQuality(person pose.PersonPose, threshold float32, requireLowerBody bool) PoseQuality {
if threshold < 0 || threshold > 1 {
return PoseQuality{Reason: "invalid_threshold"}
}
visible := 0
for _, keypoint := range person.Keypoints {
if keypoint.Confidence >= threshold {
visible++
}
}
for _, index := range torsoJoints {
if person.Keypoints[index].Confidence < threshold {
return PoseQuality{Reason: "required_joint_low_confidence", VisibleJointCount: visible}
}
}
if requireLowerBody {
for _, index := range lowerJoints {
if person.Keypoints[index].Confidence < threshold {
return PoseQuality{Reason: "required_joint_low_confidence", VisibleJointCount: visible}
}
}
}
return PoseQuality{Accepted: true, Reason: "accepted", VisibleJointCount: visible}
}
func extractEvidence(person pose.PersonPose, quality PoseQuality, previous *PoseEvidence, horizontalAngleThreshold float32) PoseEvidence {
if !quality.Accepted {
return PoseEvidence{Reason: quality.Reason}
}
shoulderX, shoulderY := midpoint(person, 5, 6)
hipX, hipY := midpoint(person, 11, 12)
vectorX := hipX - shoulderX
vectorY := hipY - shoulderY
torsoLength := float32(math.Hypot(float64(vectorX), float64(vectorY)))
if torsoLength == 0 {
return PoseEvidence{HipCenterY: hipY, HasHipCenterY: true, TorsoLength: 0, Reason: "degenerate_torso"}
}
cosine := math.Max(-1, math.Min(1, math.Abs(float64(vectorX))/float64(torsoLength)))
angle := float32(math.Acos(cosine) * 180 / math.Pi)
rapid := previous != nil && previous.Accepted && previous.HasHipCenterY && hipY-previous.HipCenterY >= 0.5*torsoLength
return PoseEvidence{
Accepted: true, HorizontalPose: angle <= horizontalAngleThreshold,
RapidVerticalChange: rapid, HorizontalAngleDegree: angle, HasAngle: true,
HipCenterY: hipY, HasHipCenterY: true, TorsoLength: torsoLength, Reason: "accepted",
}
}
func midpoint(person pose.PersonPose, first, second int) (float32, float32) {
return (person.Keypoints[first].X + person.Keypoints[second].X) / 2,
(person.Keypoints[first].Y + person.Keypoints[second].Y) / 2
}
+94
View File
@@ -0,0 +1,94 @@
package fall
import "fmt"
type Engine struct {
config EngineConfig
tracker *tracker
policy *policy
stateMachine *stateMachine
previousEvidence map[string]PoseEvidence
activeTrackIDs map[string]bool
}
func NewEngine(config EngineConfig) (*Engine, error) {
if config.KeypointConfidenceThreshold < 0 || config.KeypointConfidenceThreshold > 1 {
return nil, fmt.Errorf("keypoint confidence threshold must be between 0 and 1")
}
if config.SuspectWindowSeconds < 0 {
return nil, fmt.Errorf("suspect window seconds must be non-negative")
}
if config.HorizontalAngleThresholdDegrees < 0 || config.HorizontalAngleThresholdDegrees > 90 {
return nil, fmt.Errorf("horizontal angle threshold degrees must be between 0 and 90")
}
tracker, err := newTracker(0.2, 2.0)
if err != nil {
return nil, err
}
stateMachine, err := newStateMachine(config)
if err != nil {
return nil, err
}
return &Engine{
config: config, tracker: tracker, policy: newPolicy(config.SuspectWindowSeconds, config.RequireRapidDrop),
stateMachine: stateMachine, previousEvidence: make(map[string]PoseEvidence), activeTrackIDs: make(map[string]bool),
}, nil
}
func (engine *Engine) StateOf(trackID string) State {
return engine.stateMachine.stateOf(trackID)
}
func (engine *Engine) Process(frame Frame) FrameResult {
tracked, err := engine.tracker.update(frame.Poses, frame.Timestamp, frame.Width, frame.Height)
if err != nil {
return FrameResult{}
}
currentIDs := make(map[string]bool, len(tracked))
for _, trackedPose := range tracked {
currentIDs[trackedPose.TrackID] = true
}
events := engine.rejectMissingTracks(currentIDs, frame.Timestamp)
people := make([]PersonAnalysis, 0, len(tracked))
for _, trackedPose := range tracked {
quality := assessPoseQuality(trackedPose.Pose, engine.config.KeypointConfidenceThreshold, engine.config.RequireLowerBody)
var previous *PoseEvidence
if candidate, found := engine.previousEvidence[trackedPose.TrackID]; found {
previous = &candidate
}
poseEvidence := extractEvidence(trackedPose.Pose, quality, previous, engine.config.HorizontalAngleThresholdDegrees)
stateBefore := engine.stateMachine.stateOf(trackedPose.TrackID)
evidence := engine.policy.evaluate(trackedPose.TrackID, poseEvidence, frame.Timestamp, stateBefore)
newEvents, err := engine.stateMachine.update(trackedPose.TrackID, evidence, frame.Timestamp)
if err == nil {
events = append(events, newEvents...)
}
if poseEvidence.Accepted {
engine.previousEvidence[trackedPose.TrackID] = poseEvidence
} else {
delete(engine.previousEvidence, trackedPose.TrackID)
}
people = append(people, PersonAnalysis{
TrackedPose: trackedPose, PoseEvidence: poseEvidence, Evidence: evidence,
State: engine.stateMachine.stateOf(trackedPose.TrackID),
})
}
engine.activeTrackIDs = currentIDs
return FrameResult{People: people, Events: events}
}
func (engine *Engine) rejectMissingTracks(currentIDs map[string]bool, now float64) []Event {
events := make([]Event, 0)
for trackID := range engine.activeTrackIDs {
if currentIDs[trackID] {
continue
}
delete(engine.previousEvidence, trackID)
engine.policy.evaluate(trackID, PoseEvidence{}, now, engine.stateMachine.stateOf(trackID))
newEvents, err := engine.stateMachine.update(trackID, Evidence{}, now)
if err == nil {
events = append(events, newEvents...)
}
}
return events
}
+105
View File
@@ -0,0 +1,105 @@
package fall
import (
"math"
"testing"
"silverpose/v2/internal/pose"
)
func TestEngineConfirmsPersistentHorizontalPoseAtV1Delay(t *testing.T) {
engine, err := NewEngine(EngineConfig{
KeypointConfidenceThreshold: 0.4,
SuspectWindowSeconds: 0.5,
ConfirmWindowSeconds: 1.8,
RecoveryWindowSeconds: 2.0,
CooldownSeconds: 10.0,
RequireRapidDrop: false,
RequireLowerBody: false,
HorizontalAngleThresholdDegrees: 45.0,
ConfigVersion: "cfg-v1-contract",
SessionID: "regression",
})
if err != nil {
t.Fatalf("NewEngine returned an error: %v", err)
}
engine.Process(Frame{Timestamp: 0.0, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(false)}})
engine.Process(Frame{Timestamp: 0.1, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}})
result := engine.Process(Frame{Timestamp: 1.91, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}})
if len(result.Events) != 1 {
t.Fatalf("events = %+v, want one confirmation", result.Events)
}
event := result.Events[0]
if event.TrackID != "P-0001" || math.Abs(event.LatencySeconds-1.81) > 1e-9 || event.ConfigVersion != "cfg-v1-contract" {
t.Fatalf("event = %+v", event)
}
if got := engine.StateOf("P-0001"); got != Confirmed {
t.Fatalf("state = %s, want CONFIRMED", got)
}
}
func TestEngineDoesNotConfirmWhenTrackIsMissing(t *testing.T) {
engine, err := NewEngine(testConfig())
if err != nil {
t.Fatalf("NewEngine returned an error: %v", err)
}
engine.Process(Frame{Timestamp: 0.0, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(false)}})
engine.Process(Frame{Timestamp: 0.1, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}})
engine.Process(Frame{Timestamp: 0.2, Width: 180, Height: 180})
result := engine.Process(Frame{Timestamp: 1.9, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}})
if len(result.Events) != 0 {
t.Fatalf("events = %+v, want none after a missing track", result.Events)
}
}
func TestEngineDoesNotAlarmForUprightSequence(t *testing.T) {
engine, err := NewEngine(testConfig())
if err != nil {
t.Fatalf("NewEngine returned an error: %v", err)
}
for _, timestamp := range []float64{0.0, 0.6, 1.2, 1.8, 2.4} {
result := engine.Process(Frame{Timestamp: timestamp, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(false)}})
if len(result.Events) != 0 {
t.Fatalf("timestamp %v emitted %+v for upright pose", timestamp, result.Events)
}
}
}
func testConfig() EngineConfig {
return EngineConfig{
KeypointConfidenceThreshold: 0.4,
SuspectWindowSeconds: 0.5,
ConfirmWindowSeconds: 1.8,
RecoveryWindowSeconds: 2.0,
CooldownSeconds: 10.0,
RequireRapidDrop: false,
RequireLowerBody: false,
HorizontalAngleThresholdDegrees: 45.0,
ConfigVersion: "cfg-v1-contract",
SessionID: "regression",
}
}
func testPose(horizontal bool) pose.PersonPose {
person := pose.PersonPose{Box: pose.Box{Left: 20, Top: 20, Right: 160, Bottom: 160}, Confidence: 0.9}
for index := range person.Keypoints {
person.Keypoints[index] = pose.Keypoint{X: float32(index), Y: float32(index), Confidence: 0.9}
}
if horizontal {
person.Keypoints[5] = pose.Keypoint{X: 20, Y: 80, Confidence: 0.9}
person.Keypoints[6] = pose.Keypoint{X: 30, Y: 80, Confidence: 0.9}
person.Keypoints[11] = pose.Keypoint{X: 70, Y: 100, Confidence: 0.9}
person.Keypoints[12] = pose.Keypoint{X: 80, Y: 100, Confidence: 0.9}
} else {
person.Keypoints[5] = pose.Keypoint{X: 30, Y: 10, Confidence: 0.9}
person.Keypoints[6] = pose.Keypoint{X: 40, Y: 10, Confidence: 0.9}
person.Keypoints[11] = pose.Keypoint{X: 30, Y: 30, Confidence: 0.9}
person.Keypoints[12] = pose.Keypoint{X: 40, Y: 30, Confidence: 0.9}
}
return person
}
+37
View File
@@ -0,0 +1,37 @@
package fall
type policy struct {
suspectWindowSeconds float64
requireRapidDrop bool
rapidDropAt map[string]float64
}
func newPolicy(suspectWindowSeconds float64, requireRapidDrop bool) *policy {
return &policy{
suspectWindowSeconds: suspectWindowSeconds,
requireRapidDrop: requireRapidDrop,
rapidDropAt: make(map[string]float64),
}
}
func (policy *policy) evaluate(trackID string, poseEvidence PoseEvidence, now float64, state State) Evidence {
if !poseEvidence.Accepted {
delete(policy.rapidDropAt, trackID)
return Evidence{}
}
if poseEvidence.RapidVerticalChange {
policy.rapidDropAt[trackID] = now
}
candidate := false
if state == Suspect {
candidate = poseEvidence.HorizontalPose
} else if poseEvidence.HorizontalPose {
if !policy.requireRapidDrop {
candidate = true
} else if droppedAt, found := policy.rapidDropAt[trackID]; found {
candidate = now-droppedAt <= policy.suspectWindowSeconds
}
}
recovery := (state == Confirmed || state == Recovering) && !poseEvidence.HorizontalPose && !poseEvidence.RapidVerticalChange
return Evidence{Accepted: true, IsFallCandidate: candidate, IsRecoveryCandidate: recovery}
}
+142
View File
@@ -0,0 +1,142 @@
package fall
import "fmt"
type stateRecord struct {
state State
suspectStartedAt float64
hasSuspectStartedAt bool
confirmedAt float64
hasConfirmedAt bool
recoveryStartedAt float64
hasRecoveryStartedAt bool
lastUpdatedAt float64
hasLastUpdatedAt bool
}
type stateMachine struct {
confirmWindowSeconds float64
recoveryWindowSeconds float64
cooldownSeconds float64
configVersion string
sessionID string
records map[string]*stateRecord
nextEventNumber int
}
func newStateMachine(config EngineConfig) (*stateMachine, error) {
if config.ConfirmWindowSeconds < 1 || config.ConfirmWindowSeconds > 3 {
return nil, fmt.Errorf("confirm window seconds must be between 1 and 3")
}
if config.RecoveryWindowSeconds <= 0 {
return nil, fmt.Errorf("recovery window seconds must be positive")
}
if config.CooldownSeconds < 0 {
return nil, fmt.Errorf("cooldown seconds must be non-negative")
}
if config.ConfigVersion == "" {
return nil, fmt.Errorf("config version must be non-empty")
}
return &stateMachine{
confirmWindowSeconds: config.ConfirmWindowSeconds, recoveryWindowSeconds: config.RecoveryWindowSeconds,
cooldownSeconds: config.CooldownSeconds, configVersion: config.ConfigVersion, sessionID: config.SessionID,
records: make(map[string]*stateRecord), nextEventNumber: 1,
}, nil
}
func (machine *stateMachine) stateOf(trackID string) State {
if record, found := machine.records[trackID]; found {
return record.state
}
return Normal
}
func (machine *stateMachine) update(trackID string, evidence Evidence, now float64) ([]Event, error) {
if trackID == "" {
return nil, fmt.Errorf("track ID must be non-empty")
}
record, found := machine.records[trackID]
if !found {
record = &stateRecord{state: Normal}
machine.records[trackID] = record
}
if record.hasLastUpdatedAt && now < record.lastUpdatedAt {
return nil, fmt.Errorf("timestamps must be monotonic per track")
}
record.lastUpdatedAt, record.hasLastUpdatedAt = now, true
if !evidence.Accepted {
machine.rejectEvidence(record)
return nil, nil
}
switch record.state {
case Normal:
if evidence.IsFallCandidate {
record.state, record.suspectStartedAt, record.hasSuspectStartedAt = Suspect, now, true
}
return nil, nil
case Suspect:
if !evidence.IsFallCandidate {
machine.setNormal(record)
return nil, nil
}
if now-record.suspectStartedAt >= machine.confirmWindowSeconds {
record.state, record.confirmedAt, record.hasConfirmedAt = Confirmed, now, true
event := machine.newEvent(trackID, record.suspectStartedAt, now)
record.hasSuspectStartedAt = false
return []Event{event}, nil
}
return nil, nil
case Confirmed:
if evidence.IsRecoveryCandidate && now-record.confirmedAt >= machine.cooldownSeconds {
record.state, record.recoveryStartedAt, record.hasRecoveryStartedAt = Recovering, now, true
}
return nil, nil
case Recovering:
if evidence.IsFallCandidate {
record.state, record.suspectStartedAt, record.hasSuspectStartedAt = Suspect, now, true
record.hasRecoveryStartedAt = false
return nil, nil
}
if !evidence.IsRecoveryCandidate {
record.state, record.hasRecoveryStartedAt = Confirmed, false
return nil, nil
}
if now-record.recoveryStartedAt >= machine.recoveryWindowSeconds {
machine.setNormal(record)
}
return nil, nil
default:
return nil, fmt.Errorf("unknown fall state")
}
}
func (machine *stateMachine) rejectEvidence(record *stateRecord) {
if record.state == Suspect {
machine.setNormal(record)
} else if record.state == Recovering {
record.state, record.hasRecoveryStartedAt = Confirmed, false
}
}
func (machine *stateMachine) setNormal(record *stateRecord) {
record.state = Normal
record.hasSuspectStartedAt = false
record.hasConfirmedAt = false
record.hasRecoveryStartedAt = false
}
func (machine *stateMachine) newEvent(trackID string, suspectedAt, confirmedAt float64) Event {
prefix := "FALL-"
if machine.sessionID != "" {
prefix += machine.sessionID + "-"
}
event := Event{
EventID: fmt.Sprintf("%s%06d", prefix, machine.nextEventNumber), TrackID: trackID,
ConfigVersion: machine.configVersion, SuspectedAtMonotonic: suspectedAt,
ConfirmedAtMonotonic: confirmedAt, LatencySeconds: confirmedAt - suspectedAt, State: Confirmed,
}
machine.nextEventNumber++
return event
}
+93
View File
@@ -0,0 +1,93 @@
package fall
import (
"fmt"
"math"
"sort"
"silverpose/v2/internal/pose"
)
type track struct {
centerX float32
centerY float32
lastSeenAt float64
}
type tracker struct {
maxMatchDistanceRatio float64
maxAgeSeconds float64
tracks map[string]track
nextTrackNumber int
}
func newTracker(maxMatchDistanceRatio, maxAgeSeconds float64) (*tracker, error) {
if maxMatchDistanceRatio <= 0 || maxMatchDistanceRatio > 1 {
return nil, fmt.Errorf("max match distance ratio must be in (0, 1]")
}
if maxAgeSeconds <= 0 {
return nil, fmt.Errorf("max age seconds must be positive")
}
return &tracker{
maxMatchDistanceRatio: maxMatchDistanceRatio,
maxAgeSeconds: maxAgeSeconds,
tracks: make(map[string]track),
nextTrackNumber: 1,
}, nil
}
func (tracker *tracker) update(poses []pose.PersonPose, now float64, width, height int) ([]TrackedPose, error) {
if width <= 0 || height <= 0 {
return nil, fmt.Errorf("frame size must contain positive width and height")
}
tracker.expire(now)
availableIDs := make(map[string]bool, len(tracker.tracks))
for trackID := range tracker.tracks {
availableIDs[trackID] = true
}
tracked := make([]TrackedPose, 0, len(poses))
for _, person := range poses {
centerX := (person.Box.Left + person.Box.Right) / 2
centerY := (person.Box.Top + person.Box.Bottom) / 2
trackID := tracker.nearestAvailable(centerX, centerY, availableIDs, width, height)
if trackID == "" {
trackID = fmt.Sprintf("P-%04d", tracker.nextTrackNumber)
tracker.nextTrackNumber++
} else {
delete(availableIDs, trackID)
}
tracker.tracks[trackID] = track{centerX: centerX, centerY: centerY, lastSeenAt: now}
tracked = append(tracked, TrackedPose{TrackID: trackID, Pose: person})
}
return tracked, nil
}
func (tracker *tracker) nearestAvailable(centerX, centerY float32, availableIDs map[string]bool, width, height int) string {
ids := make([]string, 0, len(availableIDs))
for trackID := range availableIDs {
ids = append(ids, trackID)
}
sort.Strings(ids)
closestID := ""
closestDistance := math.Inf(1)
for _, trackID := range ids {
previous := tracker.tracks[trackID]
distance := math.Hypot(
float64(centerX-previous.centerX)/float64(width),
float64(centerY-previous.centerY)/float64(height),
)
if distance <= tracker.maxMatchDistanceRatio && distance < closestDistance {
closestID = trackID
closestDistance = distance
}
}
return closestID
}
func (tracker *tracker) expire(now float64) {
for trackID, current := range tracker.tracks {
if now-current.lastSeenAt > tracker.maxAgeSeconds {
delete(tracker.tracks, trackID)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package fall
import "silverpose/v2/internal/pose"
type State string
const (
Normal State = "NORMAL"
Suspect State = "SUSPECT"
Confirmed State = "CONFIRMED"
Recovering State = "RECOVERING"
)
type Evidence struct {
Accepted bool
IsFallCandidate bool
IsRecoveryCandidate bool
}
type PoseQuality struct {
Accepted bool
Reason string
VisibleJointCount int
}
type PoseEvidence struct {
Accepted bool
HorizontalPose bool
RapidVerticalChange bool
HorizontalAngleDegree float32
HasAngle bool
HipCenterY float32
HasHipCenterY bool
TorsoLength float32
Reason string
}
type Event struct {
EventID string
TrackID string
ConfigVersion string
SuspectedAtMonotonic float64
ConfirmedAtMonotonic float64
LatencySeconds float64
State State
}
type Frame struct {
Timestamp float64
Width int
Height int
Poses []pose.PersonPose
}
type TrackedPose struct {
TrackID string
Pose pose.PersonPose
}
type PersonAnalysis struct {
TrackedPose TrackedPose
PoseEvidence PoseEvidence
Evidence Evidence
State State
}
type FrameResult struct {
People []PersonAnalysis
Events []Event
}
type EngineConfig struct {
KeypointConfidenceThreshold float32
SuspectWindowSeconds float64
ConfirmWindowSeconds float64
RecoveryWindowSeconds float64
CooldownSeconds float64
RequireRapidDrop bool
RequireLowerBody bool
HorizontalAngleThresholdDegrees float32
ConfigVersion string
SessionID string
}