feat(v2): compose realtime monitor pipeline
This commit is contained in:
@@ -58,6 +58,7 @@ V2 对应模块的当前落实与 T-304 目标如下:
|
||||
| ONNX Pose | `v2/internal/pose` | 114 letterbox、RGB/CHW、NMS、关键点及坐标还原 | 人员 ID、摔倒结论 |
|
||||
| 事件引擎 | `v2/internal/fall` | 复现 V1 的跟踪、证据、四态状态机和 `FallEvent` | 视频解码、声音、文件 |
|
||||
| 画面与证据 | `v2/internal/render`、`v2/internal/alert` | 叠加人员骨架/状态,红色仅表示 CONFIRMED;按事件 ID 保存一次 PNG 和 JSONL | 读取 RTSP、重跑事件规则、直接操作 Walk 控件 |
|
||||
| 实时装配 | `v2/internal/monitor` | 在后台串联帧→Pose→事件→渲染→证据;画面只保留最新完成帧,确认事件经独立队列送 UI | 直接操作 Walk 控件、保存凭证 |
|
||||
| Windows UI | `v2/internal/ui` | Walk 顶部“监控/设置”Tab、渲染最新帧和已计算状态 | 直接读 RTSP、执行 ONNX 或事件规则 |
|
||||
| 应用装配 | `v2/cmd/silver-pose`(T-304) | 管理 worker 生命周期、取消、最新帧投递和依赖注入 | 重写领域规则 |
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ git commit -m "feat(v2): add annotated evidence and alert dispatch"
|
||||
- Create: `v2/internal/monitor/monitor_test.go`
|
||||
- Modify: `v2/internal/spike/ort.go` only if an interface adapter is needed
|
||||
|
||||
- [ ] **Step 1: Write failing orchestration tests using fakes**
|
||||
- [x] **Step 1: Write failing orchestration tests using fakes**
|
||||
|
||||
```go
|
||||
func TestMonitorPublishesLatestCompletedFrameAndOneAlert(t *testing.T) {
|
||||
@@ -227,12 +227,12 @@ func TestMonitorDoesNotCreateFallEvidenceOnSourceFailure(t *testing.T) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the monitor tests fail**
|
||||
- [x] **Step 2: Verify the monitor tests fail**
|
||||
|
||||
Run: `Set-Location v2; go test ./internal/monitor -v`
|
||||
Expected: FAIL because `internal/monitor` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the composition boundary**
|
||||
- [x] **Step 3: Implement the composition boundary**
|
||||
|
||||
```go
|
||||
type PoseRuntime interface { Run([]float32) ([]float32, error); Close() }
|
||||
@@ -244,12 +244,12 @@ func (m *Monitor) Run(ctx context.Context) { /* source frame -> pose -> fall ->
|
||||
|
||||
Create/open one ONNX Runtime instance before consuming frames and close it only when monitoring stops. Treat Pose/preprocess errors as non-fall status updates and continue/reconnect according to source state; do not terminate the Walk message loop. Only completed rendered frames enter the one-slot `Updates` channel, so a slow UI never queues stale video frames.
|
||||
|
||||
- [ ] **Step 4: Run monitor, fall and full Go tests**
|
||||
- [x] **Step 4: Run monitor, fall and full Go tests**
|
||||
|
||||
Run: `Set-Location v2; $env:CGO_ENABLED='1'; go test ./internal/monitor ./internal/fall ./...`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit the monitor slice**
|
||||
- [x] **Step 5: Commit the monitor slice**
|
||||
|
||||
```powershell
|
||||
git add v2/internal/monitor v2/internal/spike/ort.go
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Package monitor composes source, pose, fall and evidence work off the UI thread.
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silverpose/v2/internal/alert"
|
||||
"silverpose/v2/internal/fall"
|
||||
"silverpose/v2/internal/pose"
|
||||
"silverpose/v2/internal/render"
|
||||
"silverpose/v2/internal/source"
|
||||
)
|
||||
|
||||
type FrameSource interface {
|
||||
Frames() <-chan source.Frame
|
||||
Statuses() <-chan source.Update
|
||||
}
|
||||
|
||||
type PoseRuntime interface {
|
||||
Run([]float32) ([]float32, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
type EventProcessor interface {
|
||||
Process(fall.Frame) fall.FrameResult
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
Image *image.RGBA
|
||||
Result fall.FrameResult
|
||||
Events []alert.Record
|
||||
SourceStatus source.Status
|
||||
SourceMessage string
|
||||
Sequence uint64
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
source FrameSource
|
||||
runtime PoseRuntime
|
||||
processor EventProcessor
|
||||
confidence float32
|
||||
dispatcher *alert.Dispatcher
|
||||
clock func() time.Time
|
||||
updates chan Update
|
||||
alerts chan alert.Record
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func New(
|
||||
stream FrameSource,
|
||||
runtime PoseRuntime,
|
||||
processor EventProcessor,
|
||||
confidence float32,
|
||||
dispatcher *alert.Dispatcher,
|
||||
clock func() time.Time,
|
||||
) (*Monitor, error) {
|
||||
if stream == nil || runtime == nil || processor == nil || dispatcher == nil {
|
||||
return nil, fmt.Errorf("monitor dependencies must be non-nil")
|
||||
}
|
||||
if confidence < 0 || confidence > 1 {
|
||||
return nil, fmt.Errorf("pose confidence must be between 0 and 1")
|
||||
}
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
return &Monitor{
|
||||
source: stream, runtime: runtime, processor: processor, confidence: confidence,
|
||||
dispatcher: dispatcher, clock: clock, updates: make(chan Update, 1), alerts: make(chan alert.Record, 16), done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (monitor *Monitor) Updates() <-chan Update { return monitor.updates }
|
||||
func (monitor *Monitor) Done() <-chan struct{} { return monitor.done }
|
||||
func (monitor *Monitor) Alerts() <-chan alert.Record { return monitor.alerts }
|
||||
|
||||
// Run blocks until the source closes or the supplied context is cancelled.
|
||||
// The caller owns the goroutine; all expensive work stays out of Walk's UI thread.
|
||||
func (monitor *Monitor) Run(ctx context.Context) {
|
||||
defer monitor.close()
|
||||
defer monitor.runtime.Close()
|
||||
frames := monitor.source.Frames()
|
||||
statuses := monitor.source.Statuses()
|
||||
currentStatus := source.Connecting
|
||||
currentMessage := "正在连接视频流"
|
||||
for frames != nil || statuses != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case status, open := <-statuses:
|
||||
if !open {
|
||||
statuses = nil
|
||||
continue
|
||||
}
|
||||
currentStatus, currentMessage = status.Status, status.Message
|
||||
monitor.publish(Update{SourceStatus: currentStatus, SourceMessage: currentMessage})
|
||||
case frame, open := <-frames:
|
||||
if !open {
|
||||
frames = nil
|
||||
continue
|
||||
}
|
||||
statuses, currentStatus, currentMessage = monitor.drainStatuses(statuses, currentStatus, currentMessage)
|
||||
monitor.processFrame(frame, currentStatus, currentMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (monitor *Monitor) drainStatuses(statuses <-chan source.Update, current source.Status, message string) (<-chan source.Update, source.Status, string) {
|
||||
for statuses != nil {
|
||||
select {
|
||||
case status, open := <-statuses:
|
||||
if !open {
|
||||
return nil, current, message
|
||||
}
|
||||
current, message = status.Status, status.Message
|
||||
default:
|
||||
return statuses, current, message
|
||||
}
|
||||
}
|
||||
return nil, current, message
|
||||
}
|
||||
|
||||
func (monitor *Monitor) processFrame(frame source.Frame, status source.Status, message string) {
|
||||
input, transform, err := pose.PreprocessBGR(frame.BGR, frame.Width, frame.Height, 640)
|
||||
if err != nil {
|
||||
monitor.publish(Update{SourceStatus: status, SourceMessage: "视频帧格式无效", Sequence: frame.Sequence})
|
||||
return
|
||||
}
|
||||
output, err := monitor.runtime.Run(input)
|
||||
if err != nil {
|
||||
monitor.publish(Update{SourceStatus: status, SourceMessage: "姿态推理失败", Sequence: frame.Sequence})
|
||||
return
|
||||
}
|
||||
people, err := pose.ParseYOLOv8Pose(output, transform, monitor.confidence, 0.70)
|
||||
if err != nil {
|
||||
monitor.publish(Update{SourceStatus: status, SourceMessage: "姿态结果无效", Sequence: frame.Sequence})
|
||||
return
|
||||
}
|
||||
result := monitor.processor.Process(fall.Frame{
|
||||
Timestamp: frame.Timestamp.Seconds(), Width: frame.Width, Height: frame.Height, Poses: people,
|
||||
})
|
||||
capturedAt := monitor.clock().UTC()
|
||||
canvas, err := render.Render(frame.BGR, frame.Width, frame.Height, result, capturedAt)
|
||||
if err != nil {
|
||||
monitor.publish(Update{Result: result, SourceStatus: status, SourceMessage: "画面渲染失败", Sequence: frame.Sequence})
|
||||
return
|
||||
}
|
||||
records := make([]alert.Record, 0, len(result.Events))
|
||||
for _, event := range result.Events {
|
||||
record, err := monitor.dispatcher.Dispatch(event, canvas, capturedAt)
|
||||
if err != nil {
|
||||
monitor.publish(Update{Image: canvas, Result: result, SourceStatus: status, SourceMessage: "事件证据保存失败", Sequence: frame.Sequence})
|
||||
return
|
||||
}
|
||||
if record.Written {
|
||||
records = append(records, record)
|
||||
monitor.alerts <- record
|
||||
}
|
||||
}
|
||||
monitor.publish(Update{
|
||||
Image: canvas, Result: result, Events: records, SourceStatus: status, SourceMessage: message, Sequence: frame.Sequence,
|
||||
})
|
||||
}
|
||||
|
||||
func (monitor *Monitor) publish(update Update) {
|
||||
select {
|
||||
case monitor.updates <- update:
|
||||
return
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-monitor.updates:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case monitor.updates <- update:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (monitor *Monitor) close() {
|
||||
monitor.closeOnce.Do(func() {
|
||||
close(monitor.updates)
|
||||
close(monitor.alerts)
|
||||
close(monitor.done)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"silverpose/v2/internal/alert"
|
||||
"silverpose/v2/internal/fall"
|
||||
"silverpose/v2/internal/pose"
|
||||
"silverpose/v2/internal/source"
|
||||
)
|
||||
|
||||
func TestMonitorPublishesLatestCompletedFrameAndOneAlert(t *testing.T) {
|
||||
frames := make(chan source.Frame, 2)
|
||||
frames <- source.Frame{BGR: []byte{1, 1, 1, 1, 1, 1}, Width: 2, Height: 1, Sequence: 1}
|
||||
frames <- source.Frame{BGR: []byte{2, 2, 2, 2, 2, 2}, Width: 2, Height: 1, Sequence: 2}
|
||||
close(frames)
|
||||
statuses := make(chan source.Update, 1)
|
||||
statuses <- source.Update{Status: source.Connected, Message: "视频流已连接"}
|
||||
close(statuses)
|
||||
processor := &fakeProcessor{result: fall.FrameResult{
|
||||
People: []fall.PersonAnalysis{{State: fall.Confirmed}},
|
||||
Events: []fall.Event{{EventID: "FALL-run-000001", TrackID: "P-0001", ConfigVersion: "cfg-safe", State: fall.Confirmed}},
|
||||
}}
|
||||
runtime := &fakeRuntime{output: make([]float32, pose.YOLOPoseOutputValues)}
|
||||
monitor, err := New(fakeSource{frames: frames, statuses: statuses}, runtime, processor, 0.25, alert.NewDispatcher(t.TempDir(), "lobby"), func() time.Time {
|
||||
return time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go monitor.Run(context.Background())
|
||||
<-monitor.Done()
|
||||
|
||||
update, ok := <-monitor.Updates()
|
||||
if !ok {
|
||||
t.Fatal("monitor emitted no update")
|
||||
}
|
||||
if update.Sequence != 2 || update.Image == nil || update.SourceStatus != source.Connected {
|
||||
t.Fatalf("update = %#v", update)
|
||||
}
|
||||
record, ok := <-monitor.Alerts()
|
||||
if !ok || !record.Written || record.Event.EventID != "FALL-run-000001" {
|
||||
t.Fatalf("alert record = %#v, open = %t", record, ok)
|
||||
}
|
||||
if !runtime.closed {
|
||||
t.Fatal("runtime was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorDoesNotCreateFallEvidenceOnSourceFailure(t *testing.T) {
|
||||
frames := make(chan source.Frame)
|
||||
close(frames)
|
||||
statuses := make(chan source.Update, 1)
|
||||
statuses <- source.Update{Status: source.Retrying, Message: "视频流已断开,正在重连"}
|
||||
close(statuses)
|
||||
processor := &fakeProcessor{}
|
||||
monitor, err := New(fakeSource{frames: frames, statuses: statuses}, &fakeRuntime{}, processor, 0.25, alert.NewDispatcher(t.TempDir(), "lobby"), time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go monitor.Run(context.Background())
|
||||
<-monitor.Done()
|
||||
|
||||
update := <-monitor.Updates()
|
||||
if update.SourceStatus != source.Retrying || len(update.Events) != 0 || processor.calls != 0 {
|
||||
t.Fatalf("update = %#v, process calls = %d", update, processor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSource struct {
|
||||
frames <-chan source.Frame
|
||||
statuses <-chan source.Update
|
||||
}
|
||||
|
||||
func (source fakeSource) Frames() <-chan source.Frame { return source.frames }
|
||||
func (source fakeSource) Statuses() <-chan source.Update { return source.statuses }
|
||||
|
||||
type fakeRuntime struct {
|
||||
output []float32
|
||||
err error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (runtime *fakeRuntime) Run(input []float32) ([]float32, error) {
|
||||
if len(input) == 0 {
|
||||
return nil, errors.New("missing input")
|
||||
}
|
||||
return runtime.output, runtime.err
|
||||
}
|
||||
func (runtime *fakeRuntime) Close() { runtime.closed = true }
|
||||
|
||||
type fakeProcessor struct {
|
||||
result fall.FrameResult
|
||||
calls int
|
||||
}
|
||||
|
||||
func (processor *fakeProcessor) Process(frame fall.Frame) fall.FrameResult {
|
||||
processor.calls++
|
||||
return processor.result
|
||||
}
|
||||
Reference in New Issue
Block a user