feat(v2): implement operator monitoring window
This commit is contained in:
+12
-44
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user