feat(v2): implement operator monitoring window

This commit is contained in:
ila
2026-07-22 21:24:10 +08:00
parent 8e093acb9b
commit a98005e631
8 changed files with 335 additions and 58 deletions
+12 -44
View File
@@ -1,52 +1,20 @@
package main
import (
"silverpose/v2/internal/ui"
"log"
. "github.com/lxn/walk/declarative"
"silverpose/v2/internal/ui"
)
func main() {
spec := ui.MonitorWindowSpec()
MainWindow{
Title: spec.Title,
MinSize: Size{Width: spec.Width, Height: spec.Height},
Layout: VBox{},
Children: []Widget{
TabWidget{
Pages: []TabPage{
{
Title: "监控",
Layout: HBox{},
Children: []Widget{
GroupBox{
Title: "实时画面",
Layout: VBox{},
Children: []Widget{
TextLabel{Text: "视频帧将在此呈现"},
},
},
GroupBox{
Title: "当前状态",
Layout: VBox{},
Children: []Widget{
TextLabel{Text: "检测状态:NORMAL"},
TextLabel{Text: "事件:暂无"},
TextLabel{Text: "运行时:Go + ONNX Runtime"},
},
},
},
},
{
Title: "设置",
Layout: VBox{},
Children: []Widget{
TextLabel{Text: "设置将在 T-304 接入本机配置;不保存摄像头凭证。"},
},
},
},
},
},
}.Run()
window, err := ui.NewWindow(ui.Settings{
SourceEnvironment: "SILVER_POSE_RTSP_URL",
ModelSHA256: "fac42d41a2ae3108dc42b3a2097f299e27d7f4055cb6b26a319fad0b0a3f286e",
RuntimeSummary: "Go + ONNX Runtime + FFmpeg(技术 Spike)",
EventSummary: "确认窗 1.8 秒;仅确认摔倒使用红色",
}, ui.Commands{})
if err != nil {
log.Fatal(err)
}
window.Run()
}
+67 -2
View File
@@ -1,5 +1,12 @@
package ui
import (
"fmt"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/source"
)
// WindowSpec locks the visual baseline tested by the Windows UI Spike. The
// production V2 app will consume the same semantic colours and medium desktop
// layout after the decoding/inference pipeline is complete.
@@ -8,15 +15,73 @@ type WindowSpec struct {
Width int
Height int
BackgroundHex string
SurfaceHex string
AccentHex string
AlertHex string
}
func MonitorWindowSpec() WindowSpec {
return WindowSpec{
Title: "Silver Pose V2 · 技术 Spike",
Title: "Silver Pose V2",
Width: 1120,
Height: 720,
BackgroundHex: "#F3F6FA",
BackgroundHex: "#EAF1F8",
SurfaceHex: "#FFFFFF",
AccentHex: "#2563EB",
AlertHex: "#C62828",
}
}
// ViewModel is a UI-thread-neutral snapshot. It deliberately stores only a
// source environment variable name and status, never a resolved RTSP URL.
type ViewModel struct {
ConnectionText string
StateText string
EventText string
SettingsSummary string
Critical bool
}
func NewViewModel() ViewModel {
return ViewModel{
ConnectionText: "等待启动监控",
StateText: "检测状态:正常",
EventText: "事件:暂无",
}
}
func (model *ViewModel) Apply(state fall.State, status source.Status) {
model.Critical = state == fall.Confirmed
switch state {
case fall.Confirmed:
model.StateText = "检测状态:确认摔倒"
model.EventText = "事件:已确认,已保存证据"
case fall.Suspect:
model.StateText = "检测状态:疑似倒地"
model.EventText = "事件:正在持续确认"
case fall.Recovering:
model.StateText = "检测状态:恢复观察中"
model.EventText = "事件:等待恢复稳定"
default:
model.StateText = "检测状态:正常"
model.EventText = "事件:暂无"
}
switch status {
case source.Connected:
model.ConnectionText = "视频流:在线"
case source.Retrying:
model.ConnectionText = "视频流:正在重连,不触发摔倒报警"
case source.Stopped:
model.ConnectionText = "视频流:已停止"
default:
model.ConnectionText = "视频流:正在连接"
}
}
func (model *ViewModel) SetSourceReady(environmentName string, ready bool) {
state := "未就绪"
if ready {
state = "已就绪"
}
model.SettingsSummary = fmt.Sprintf("来源环境变量 %s:%s", environmentName, state)
}
+2 -2
View File
@@ -4,13 +4,13 @@ import "testing"
func TestMonitorWindowSpecUsesLightMonitoringLayout(t *testing.T) {
spec := MonitorWindowSpec()
if spec.Title != "Silver Pose V2 · 技术 Spike" {
if spec.Title != "Silver Pose V2" {
t.Fatalf("title = %q", spec.Title)
}
if spec.Width != 1120 || spec.Height != 720 {
t.Fatalf("size = %dx%d, want 1120x720", spec.Width, spec.Height)
}
if spec.BackgroundHex != "#F3F6FA" || spec.AlertHex != "#C62828" {
if spec.BackgroundHex != "#EAF1F8" || spec.AlertHex != "#C62828" {
t.Fatalf("unexpected colours: background=%s alert=%s", spec.BackgroundHex, spec.AlertHex)
}
}
+210
View File
@@ -0,0 +1,210 @@
package ui
import (
"fmt"
"strings"
"github.com/lxn/walk"
d "github.com/lxn/walk/declarative"
"github.com/lxn/win"
"silverpose/v2/internal/alert"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/monitor"
)
type Settings struct {
SourceEnvironment string
ModelSHA256 string
RuntimeSummary string
EventSummary string
}
type Commands struct {
Start func()
Stop func()
}
// Window is an observer of monitor updates. It does not own a stream, runtime,
// fall engine, or event artifact writer.
type Window struct {
main *walk.MainWindow
imageView *walk.ImageView
connectionLabel *walk.TextLabel
stateLabel *walk.TextLabel
eventLabel *walk.TextLabel
startButton *walk.PushButton
stopButton *walk.PushButton
model ViewModel
bitmap *walk.Bitmap
seenAlerts map[string]struct{}
commands Commands
}
func NewWindow(settings Settings, commands Commands) (*Window, error) {
spec := MonitorWindowSpec()
window := &Window{model: NewViewModel(), seenAlerts: make(map[string]struct{}), commands: commands}
window.model.SetSourceReady(settings.SourceEnvironment, settings.SourceEnvironment != "")
background := d.SolidColorBrush{Color: walk.RGB(234, 241, 248)}
surface := d.SolidColorBrush{Color: walk.RGB(255, 255, 255)}
if err := (d.MainWindow{
AssignTo: &window.main,
Title: spec.Title,
Size: d.Size{Width: spec.Width, Height: spec.Height},
MinSize: d.Size{Width: 960, Height: 640},
Background: background,
Layout: d.VBox{},
Children: []d.Widget{
d.TabWidget{
Pages: []d.TabPage{
{
Title: "监控",
Layout: d.HBox{},
Children: []d.Widget{
d.Composite{
Background: surface,
Layout: d.VBox{},
StretchFactor: 3,
Children: []d.Widget{
d.TextLabel{Text: "实时画面", ToolTipText: "显示完成推理与姿态叠加后的最新画面"},
d.ImageView{AssignTo: &window.imageView, Mode: d.ImageViewModeZoom, StretchFactor: 8, ToolTipText: "实时姿态画面"},
},
},
d.Composite{
Background: surface,
Layout: d.VBox{},
StretchFactor: 2,
Children: []d.Widget{
d.TextLabel{Text: "运行状态"},
d.TextLabel{AssignTo: &window.connectionLabel, Text: window.model.ConnectionText},
d.TextLabel{AssignTo: &window.stateLabel, Text: window.model.StateText},
d.TextLabel{AssignTo: &window.eventLabel, Text: window.model.EventText},
d.PushButton{AssignTo: &window.startButton, Text: "开始监控", ToolTipText: "使用当前配置启动单路监控", OnClicked: window.start},
d.PushButton{AssignTo: &window.stopButton, Text: "停止监控", Enabled: false, ToolTipText: "停止监控并释放本地运行资源", OnClicked: window.stop},
d.TextLabel{Text: "红色仅表示已确认摔倒。连接异常不会触发报警。"},
},
},
},
},
{
Title: "设置",
Layout: d.VBox{},
Children: []d.Widget{
d.TextLabel{Text: "来源与运行依赖(只读,不显示 RTSP 地址或密码)"},
d.TextLabel{Text: window.model.SettingsSummary},
d.TextLabel{Text: "模型 SHA-256:" + shortHash(settings.ModelSHA256)},
d.TextLabel{Text: "运行依赖:" + settings.RuntimeSummary},
d.TextLabel{Text: "事件参数:" + settings.EventSummary},
d.TextLabel{Text: "参数在配置文件中维护;修改后请停止并重新开始监控。"},
},
},
},
},
},
}).Create(); err != nil {
return nil, err
}
window.main.Closing().Attach(func(canceled *bool, reason walk.CloseReason) {
window.stop()
if window.bitmap != nil {
window.bitmap.Dispose()
window.bitmap = nil
}
})
return window, nil
}
func (window *Window) Run() int { return window.main.Run() }
func (window *Window) start() {
if window.commands.Start != nil {
window.commands.Start()
}
window.startButton.SetEnabled(false)
window.stopButton.SetEnabled(true)
}
func (window *Window) stop() {
if window.commands.Stop != nil {
window.commands.Stop()
}
if window.startButton != nil {
window.startButton.SetEnabled(true)
}
if window.stopButton != nil {
window.stopButton.SetEnabled(false)
}
}
// Present runs only the final state assignment on the UI thread.
func (window *Window) Present(update monitor.Update) {
window.main.Synchronize(func() {
state := highestState(update.Result)
window.model.Apply(state, update.SourceStatus)
if update.SourceMessage != "" {
window.model.ConnectionText = "视频流:" + update.SourceMessage
}
_ = window.connectionLabel.SetText(window.model.ConnectionText)
_ = window.stateLabel.SetText(window.model.StateText)
_ = window.eventLabel.SetText(window.model.EventText)
if update.Image == nil {
return
}
bitmap, err := walk.NewBitmapFromImage(update.Image)
if err != nil {
return
}
if err := window.imageView.SetImage(bitmap); err != nil {
bitmap.Dispose()
return
}
previous := window.bitmap
window.bitmap = bitmap
if previous != nil {
previous.Dispose()
}
})
}
// PresentAlert is called by a consumer of Monitor.Alerts. Event IDs are checked
// again at the UI boundary so a reconnect/repaint cannot replay sound or popup.
func (window *Window) PresentAlert(record alert.Record) {
if !record.Written {
return
}
window.main.Synchronize(func() {
if _, seen := window.seenAlerts[record.Event.EventID]; seen {
return
}
window.seenAlerts[record.Event.EventID] = struct{}{}
win.MessageBeep(win.MB_ICONHAND)
message := fmt.Sprintf("确认摔倒\n人员:%s\n延迟:%.2f 秒\n截图:%s", record.Event.TrackID, record.Event.LatencySeconds, record.ScreenshotPath)
walk.MsgBox(window.main, "Silver Pose V2 · 确认摔倒", message, walk.MsgBoxOK|walk.MsgBoxIconError)
})
}
func highestState(result fall.FrameResult) fall.State {
state := fall.Normal
for _, person := range result.People {
switch person.State {
case fall.Confirmed:
return fall.Confirmed
case fall.Suspect:
if state == fall.Normal || state == fall.Recovering {
state = fall.Suspect
}
case fall.Recovering:
if state == fall.Normal {
state = fall.Recovering
}
}
}
return state
}
func shortHash(value string) string {
if len(value) <= 12 {
return value
}
return strings.ToLower(value[:12]) + "…"
}
+36
View File
@@ -0,0 +1,36 @@
package ui
import (
"strings"
"testing"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/source"
)
func TestViewModelUsesCriticalOnlyForConfirmed(t *testing.T) {
view := NewViewModel()
view.Apply(fall.Normal, source.Connected)
if view.Critical {
t.Fatal("normal monitoring state is critical")
}
view.Apply(fall.Confirmed, source.Connected)
if !view.Critical || !strings.Contains(view.StateText, "确认摔倒") {
t.Fatalf("confirmed view = %#v", view)
}
view.Apply(fall.Normal, source.Retrying)
if view.Critical || !strings.Contains(view.ConnectionText, "重连") {
t.Fatalf("retrying view = %#v", view)
}
}
func TestSettingsNeverExposeResolvedRTSPURL(t *testing.T) {
view := NewViewModel()
view.SetSourceReady("SILVER_POSE_RTSP_URL", true)
if strings.Contains(view.SettingsSummary, "rtsp://") || strings.Contains(view.SettingsSummary, "secret") {
t.Fatalf("settings leaked source: %q", view.SettingsSummary)
}
if !strings.Contains(view.SettingsSummary, "SILVER_POSE_RTSP_URL") {
t.Fatalf("settings omitted source environment name: %q", view.SettingsSummary)
}
}