@@ -2,17 +2,40 @@ package ui
import (
"fmt"
"image"
"image/color"
"log"
"os"
"strings"
"sync"
"github.com/lxn/walk "
d "github.com/lxn/walk/declarative "
"github.com/lxn/win "
"gioui.org/app "
"gioui.org/font/gofont "
"gioui.org/layout "
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"silverpose/v2/internal/alert"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/monitor"
)
// Light Windows theme tokens (see docs/ui/silver-pose-ui-ux-spec.md).
var (
colorBg = color . NRGBA { R : 0xEA , G : 0xF1 , B : 0xF8 , A : 0xFF }
colorSurface = color . NRGBA { R : 0xFF , G : 0xFF , B : 0xFF , A : 0xFF }
colorText = color . NRGBA { R : 0x1E , G : 0x29 , B : 0x3B , A : 0xFF }
colorSubtle = color . NRGBA { R : 0x52 , G : 0x65 , B : 0x7C , A : 0xFF }
colorAccent = color . NRGBA { R : 0x25 , G : 0x63 , B : 0xEB , A : 0xFF }
colorAlert = color . NRGBA { R : 0xC6 , G : 0x28 , B : 0x28 , A : 0xFF }
colorScrim = color . NRGBA { A : 0x8C }
)
type Settings struct {
SourceEnvironment string
ModelSHA256 string
@@ -25,175 +48,322 @@ type Commands struct {
Stop func ( )
}
// Window is an observer of monitor updates. It doe s not own a stream, runtime,
// fall engine, or event artifact writer.
// Window is an observer of monitor updates. It own s no stream, runtime, fall
// engine or artifact writer. Background goroutines call Present/PresentAlert/
// ReportIssue, which only mutate shared state under a lock and Invalidate; the
// Gio event loop (its own goroutine) reads that state and renders the newest
// frame. This replaces Walk's main.Synchronize and its comctl/manifest coupling.
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
appWindow * app . Window
theme * material . Theme
settings Settings
commands Commands
spec WindowSpec
mu sync . Mutex
model ViewModel
latestImage * image . RGBA
imageGen uint64
pending * alert . Record
seenAlerts map [ string ] struct { }
// Touched only by the event-loop goroutine.
imageOp paint . ImageOp
imageShownGen uint64
tabs [ ] string
tabButton [ ] widget . Clickable
active int
startButton widget . Clickable
stopButton widget . Clickable
ackButton widget . Clickable
running bool
}
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
theme := material . NewTheme ( )
theme . Shaper = text . NewShaper ( text . WithCollection ( gofont . Collection ( ) ) )
theme . Palette . Bg = colorBg
theme . Palette . Fg = colorText
theme . Palette . ContrastBg = colorAccent
theme . Palette . ContrastFg = color . NRGBA { R : 0xFF , G : 0xFF , B : 0xFF , A : 0xFF }
model := NewViewModel ( )
model . SetSourceReady ( settings . SourceEnvironment , settings . SourceEnvironment != "" )
appWindow := new ( app . Window )
appWindow . Option (
app . Title ( spec . Title ) ,
app . Size ( unit . Dp ( spec . Width ) , unit . Dp ( spec . Height ) ) ,
app . MinSize ( unit . Dp ( 960 ) , unit . Dp ( 640 ) ) ,
)
window := & Window {
appWindow : appWindow ,
theme : theme ,
settings : settings ,
commands : commands ,
spec : spec ,
model : model ,
seenAlerts : make ( map [ string ] struct { } ) ,
tabs : [ ] string { "监控" , "设置 "} ,
}
window . main . Closing ( ) . Attach ( func ( canceled * bool , reason walk . CloseReason ) {
window . stop ( )
if window . bitmap != nil {
window . bitmap . Dispose ( )
window . bitmap = nil
}
} )
window . tabButton = make ( [ ] widget . Clickable , len ( window . tabs ) )
return window , nil
}
func ( window * Window ) Run ( ) int { return window . main . Run ( ) }
// Run drives the platform event loop on the calling (main) goroutine and the
// window loop on a separate goroutine. It does not return; the process exits
// after the window is closed and Stop cleanup has run.
func ( window * Window ) Run ( ) int {
go func ( ) {
err := window . loop ( )
window . stop ( )
if err != nil {
log . Print ( err )
}
os . Exit ( 0 )
} ( )
app . Main ( )
return 0
}
func ( window * Window ) loop ( ) error {
var ops op . Ops
for {
switch event := window . appWindow . Event ( ) . ( type ) {
case app . DestroyEvent :
return event . Err
case app . FrameEvent :
gtx := app . NewContext ( & ops , event )
window . layout ( gtx )
event . Frame ( gtx . Ops )
}
}
}
func ( window * Window ) start ( ) {
if window . running {
return
}
window . running = true
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 )
}
window . running = false
}
// Present runs only the final state assignment on the UI thread .
// Present stores the newest analysed frame and state, then requests a redraw .
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 ( )
}
} )
window . mu . Lock ( )
state := highestState ( update . Result )
window . model . Apply ( state , update . SourceStatus )
if update . SourceMessage != "" {
window . model . ConnectionText = "视频流:" + update . SourceMessage
}
if update . Image != nil {
window . latestImage = update . Image
window . imageGen ++
}
window . mu . Unlock ( )
window . appWindow . Invalidate ( )
}
// PresentAlert is called by a consumer of Monitor.Alerts. E vent IDs are checked
// again at the UI boundary so a reconnect/repaint cannot replay sound or popup.
// PresentAlert queues one confirm popup per e vent ID and plays the alert sound.
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 . mu . Lock ( )
if _ , seen := window . seenAlerts [ record . Event . EventID ] ; seen {
window . mu . Unlock ( )
return
}
window . seenAlerts [ record . Event . EventID ] = struct { } { }
queued := record
window . pending = & queued
window . mu . Unlock ( )
playAlertSound ( )
window . appWindow . Invalidate ( )
}
// ReportIssue surfaces a recoverable startup/worker issue without red fall
// language or exposing configuration values.
func ( window * Window ) ReportIssue ( message string ) {
window . mu . Lock ( )
window . model . ConnectionText = "视频流:" + message
window . model . Critical = false
window . model . StateText = "检测状态:正常"
window . mu . Unlock ( )
window . appWindow . Invalidate ( )
}
func ( window * Window ) layout ( gtx layout . Context ) layout . Dimensions {
paint . Fill ( gtx . Ops , colorBg )
window . mu . Lock ( )
model := window . model
image := window . latestImage
gen := window . imageGen
pending := window . pending
window . mu . Unlock ( )
hasImage := image != nil
if hasImage && gen != window . imageShownGen {
window . imageOp = paint . NewImageOp ( image )
window . imageShownGen = gen
}
content := func ( gtx layout . Context ) layout . Dimensions {
return layout . Flex { Axis : layout . Vertical } . Layout ( gtx ,
layout . Rigid ( window . layoutTabs ) ,
layout . Flexed ( 1 , func ( gtx layout . Context ) layout . Dimensions {
if window . active == 0 {
return window . layoutMonitor ( gtx , model , hasImage )
}
return window . layoutSettings ( gtx , model )
} ) ,
)
}
if pending == nil {
return content ( gtx )
}
return layout . Stack { } . Layout ( gtx ,
layout . Expanded ( content ) ,
layout . Expanded ( func ( gtx layout . Context ) layout . Dimensions {
paint . FillShape ( gtx . Ops , colorScrim , clip . Rect { Max : gtx . Constraints . Min } . Op ( ) )
return layout . Dimensions { Size : gtx . Constraints . Min }
} ) ,
layout . Stacked ( func ( gtx layout . Context ) layout . Dimensions {
return layout . Center . Layout ( gtx , func ( gtx layout . Context ) layout . Dimensions {
return window . layoutDialog ( gtx , * pending )
} )
} ) ,
)
}
func ( window * Window ) layoutTabs ( gtx layout . Context ) layout . Dimensions {
for i := range window . tabButton {
if window . tabButton [ i ] . Clicked ( gtx ) {
window . active = i
}
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 )
}
children := make ( [ ] layout . FlexChild , 0 , len ( window . tabs ) )
for i := range window . tabs {
i := i
children = append ( children , layout . Rigid ( func ( gtx layout . Context ) layout . Dimensions {
button := material . Button ( window . theme , & window . tabButton [ i ] , window . tabs [ i ] )
if i != window . active {
button . Background = colorSurface
button . Color = colorSubtle
}
return layout . UniformInset ( unit . Dp ( 6 ) ) . Layout ( gtx , button . Layout )
} ) )
}
return layout . Flex { } . Layout ( gtx , children ... )
}
func ( window * Window ) layoutMonitor ( gtx layout . Context , model ViewModel , hasImage bool ) layout . Dimensions {
return layout . Flex { Axis : layout . Horizontal } . Layout ( gtx ,
layout . Flexed ( 3 , func ( gtx layout . Context ) layout . Dimensions {
return layout . UniformInset ( unit . Dp ( 8 ) ) . Layout ( gtx , func ( gtx layout . Context ) layout . Dimensions {
if ! hasImage {
return layout . Center . Layout ( gtx , material . Body1 ( window . theme , model . ConnectionText ) . Layout )
}
return widget . Image { Src : window . imageOp , Fit : widget . Contain , Position : layout . Center } . Layout ( gtx )
} )
} ) ,
layout . Flexed ( 2 , func ( gtx layout . Context ) layout . Dimensions {
return window . layoutStatus ( gtx , model )
} ) ,
)
}
func ( window * Window ) layoutStatus ( gtx layout . Context , model ViewModel ) layout . Dimensions {
if window . startButton . Clicked ( gtx ) {
window . start ( )
}
if window . stopButton . Clicked ( gtx ) {
window . stop ( )
}
return layout . UniformInset ( unit . Dp ( 12 ) ) . Layout ( gtx , func ( gtx layout . Context ) layout . Dimensions {
return layout . Flex { Axis : layout . Vertical } . Layout ( gtx ,
layout . Rigid ( material . H6 ( window . theme , "运行状态" ) . Layout ) ,
layout . Rigid ( material . Body1 ( window . theme , model . ConnectionText ) . Layout ) ,
layout . Rigid ( func ( gtx layout . Context ) layout . Dimensions {
label := material . Body1 ( window . theme , model . StateText )
if model . Critical {
label . Color = colorAlert
}
return label . Layout ( gtx )
} ) ,
layout . Rigid ( material . Body1 ( window . theme , model . EventText ) . Layout ) ,
layout . Rigid ( layout . Spacer { Height : unit . Dp ( 8 ) } . Layout ) ,
layout . Rigid ( material . Button ( window . theme , & window . startButton , "开始监控" ) . Layout ) ,
layout . Rigid ( layout . Spacer { Height : unit . Dp ( 6 ) } . Layout ) ,
layout . Rigid ( material . Button ( window . theme , & window . stopButton , "停止监控" ) . Layout ) ,
layout . Rigid ( layout . Spacer { Height : unit . Dp ( 8 ) } . Layout ) ,
layout . Rigid ( material . Body2 ( window . theme , "红色仅表示已确认摔倒。连接异常不会触发报警。" ) . Layout ) ,
)
} )
}
// ReportIssue communicates a recoverable startup/worker issue without using
// critical-red fall language or exposing configuration values.
func ( window * Window ) ReportIssue ( message string ) {
window . main . Synchronize ( func ( ) {
window . model . ConnectionText = "视频流:" + message
window . model . Critical = false
_ = window . connectionLabel . SetText ( window . model . ConnectionText )
_ = window . stateLabel . SetText ( "检测状态:正常" )
func ( window * Window ) layoutSettings ( gtx layout . Context , model ViewModel ) layout . Dimensions {
return layout . UniformInset ( unit . Dp ( 12 ) ) . Layout ( gtx , func ( gtx layout . Context ) layout . Dimensions {
return layout . Flex { Axis : layout . Vertical } . Layout ( gtx ,
layout . Rigid ( material . H6 ( window . theme , "来源与运行依赖(只读,不显示 RTSP 地址或密码)" ) . Layout ) ,
layout . Rigid ( material . Body1 ( window . theme , model . SettingsSummary ) . Layout ) ,
layout . Rigid ( material . Body1 ( window . theme , "模型 SHA-256: " + shortHash ( window . settings . ModelSHA256 ) ) . Layout ) ,
layout . Rigid ( material . Body1 ( window . theme , "运行依赖:" + window . settings . RuntimeSummary ) . Layout ) ,
layout . Rigid ( material . Body1 ( window . theme , "事件参数:" + window . settings . EventSummary ) . Layout ) ,
layout . Rigid ( material . Body2 ( window . theme , "参数在配置文件中维护;修改后请停止并重新开始监控。" ) . Layout ) ,
)
} )
}
func ( window * Window ) layoutDialog ( gtx layout . Context , record alert . Record ) layout . Dimensions {
if window . ackButton . Clicked ( gtx ) {
window . mu . Lock ( )
window . pending = nil
window . mu . Unlock ( )
}
width := gtx . Dp ( 420 )
if width > gtx . Constraints . Max . X {
width = gtx . Constraints . Max . X
}
gtx . Constraints . Min . X = width
gtx . Constraints . Max . X = width
return layout . Stack { } . Layout ( gtx ,
layout . Expanded ( func ( gtx layout . Context ) layout . Dimensions {
rect := image . Rectangle { Max : gtx . Constraints . Min }
paint . FillShape ( gtx . Ops , colorSurface , clip . UniformRRect ( rect , gtx . Dp ( 8 ) ) . Op ( gtx . Ops ) )
return layout . Dimensions { Size : gtx . Constraints . Min }
} ) ,
layout . Stacked ( func ( gtx layout . Context ) layout . Dimensions {
return layout . UniformInset ( unit . Dp ( 16 ) ) . Layout ( gtx , func ( gtx layout . Context ) layout . Dimensions {
return layout . Flex { Axis : layout . Vertical } . Layout ( gtx ,
layout . Rigid ( func ( gtx layout . Context ) layout . Dimensions {
title := material . H6 ( window . theme , "确认摔倒" )
title . Color = colorAlert
return title . Layout ( gtx )
} ) ,
layout . Rigid ( material . Body1 ( window . theme , "人员:" + record . Event . TrackID ) . Layout ) ,
layout . Rigid ( material . Body1 ( window . theme , fmt . Sprintf ( "延迟:%.2f 秒" , record . Event . LatencySeconds ) ) . Layout ) ,
layout . Rigid ( material . Body2 ( window . theme , "截图:" + record . ScreenshotPath ) . Layout ) ,
layout . Rigid ( layout . Spacer { Height : unit . Dp ( 8 ) } . Layout ) ,
layout . Rigid ( material . Button ( window . theme , & window . ackButton , "我已知晓" ) . Layout ) ,
)
} )
} ) ,
)
}
func highestState ( result fall . FrameResult ) fall . State {
state := fall . Normal
for _ , person := range result . People {