Files
silver_pose/v2/internal/ui/window.go
T
ilaandClaude Opus 4.8 58fd92aa80 feat(v2): T-308 group monitor/settings into white cards for visual separation
Adds a card() surface (white rounded panel, 1px #C8D4E3 stroke, 8px radius)
matching the light-Windows spec, and groups the monitor (video / status+
controls / gallery) and settings (camera / params / deps) into cards so the
gray-blue page shows through the gutters. Components were previously flat on
one background with no separation. Cross-compiles for Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:40:18 +08:00

871 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"fmt"
"image"
"image/color"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/io/key"
"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/config"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/monitor"
)
const (
galleryColumns = 2
galleryRows = 3
galleryCapacity = galleryColumns * galleryRows // 2 columns x 3 rows
)
// 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}
colorStroke = color.NRGBA{R: 0xC8, G: 0xD4, B: 0xE3, A: 0xFF}
colorScrim = color.NRGBA{A: 0xB0}
)
type Settings struct {
SourceEnvironment string
ModelSHA256 string
RuntimeSummary string
EventSummary string
ConfigPath string
Camera CameraFields
Params ParamFields
}
// CameraFields pre-fill the editable camera connection form. Credentials live
// only in the untracked local config; the form saves them back there.
type CameraFields struct {
ID string
Host string
Port int
Channel string
Username string
Password string
Transport string
TimeoutSeconds float64
LowLatency bool
}
// ParamFields pre-fill the editable common detection parameters.
type ParamFields struct {
KeypointConfidence float64
ConfirmWindowSeconds float64
HorizontalAngleDegrees float64
ModelConfidence float64
RequireRapidDrop bool
RequireLowerBody bool
}
type Commands struct {
Start func()
Stop func()
}
// galleryIncoming is a confirmed screenshot queued off the UI thread. The Gio
// op is built once on the UI thread when it is promoted into galleryItem.
type galleryIncoming struct {
caption string
image *image.RGBA
}
type galleryItem struct {
caption string
op paint.ImageOp
click widget.Clickable
lastClick time.Time
}
// Window observes monitor updates. Background goroutines call Present/
// PresentAlert/ReportIssue, which only mutate shared state under a lock and
// Invalidate; the Gio event loop reads that state and renders.
type Window struct {
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{}
incoming []galleryIncoming
seenEvents map[string]struct{}
running bool
configPath string
// 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
gallery []*galleryItem
viewing *galleryItem
viewerClose widget.Clickable
// Settings form (event-loop goroutine only).
editHost widget.Editor
editPort widget.Editor
editChannel widget.Editor
editUsername widget.Editor
editPassword widget.Editor
editTransport widget.Editor
editTimeout widget.Editor
editKeypoint widget.Editor
editConfirm widget.Editor
editAngle widget.Editor
editModelConf widget.Editor
lowLatency widget.Bool
requireRapid widget.Bool
requireLower widget.Bool
saveCamera widget.Clickable
saveParams widget.Clickable
settingsStatus string
settingsList widget.List
cameraID string
}
func setEditor(editor *widget.Editor, value string) {
editor.SingleLine = true
editor.SetText(value)
}
func NewWindow(settings Settings, commands Commands) (*Window, error) {
spec := MonitorWindowSpec()
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 != "")
width, height := spec.Width, spec.Height
if screenWidth, screenHeight := screenSize(); screenWidth > 0 && screenHeight > 0 {
width = screenWidth * 80 / 100
height = screenHeight * 80 / 100
}
appWindow := new(app.Window)
appWindow.Option(
app.Title(spec.Title),
app.Size(unit.Dp(width), unit.Dp(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{}),
seenEvents: make(map[string]struct{}),
tabs: []string{"监控", "设置"},
}
window.tabButton = make([]widget.Clickable, len(window.tabs))
window.configPath = settings.ConfigPath
camera, params := settings.Camera, settings.Params
setEditor(&window.editHost, camera.Host)
setEditor(&window.editPort, strconv.Itoa(defaultPort(camera.Port)))
setEditor(&window.editChannel, defaultString(camera.Channel, "102"))
setEditor(&window.editUsername, camera.Username)
setEditor(&window.editPassword, camera.Password)
window.editPassword.Mask = '•'
setEditor(&window.editTransport, defaultString(camera.Transport, "tcp"))
setEditor(&window.editTimeout, formatFloat(defaultFloat(camera.TimeoutSeconds, 5)))
window.lowLatency.Value = camera.LowLatency
setEditor(&window.editKeypoint, formatFloat(defaultFloat(params.KeypointConfidence, 0.4)))
setEditor(&window.editConfirm, formatFloat(defaultFloat(params.ConfirmWindowSeconds, 1.8)))
setEditor(&window.editAngle, formatFloat(defaultFloat(params.HorizontalAngleDegrees, 45)))
setEditor(&window.editModelConf, formatFloat(defaultFloat(params.ModelConfidence, 0.25)))
window.requireRapid.Value = params.RequireRapidDrop
window.requireLower.Value = params.RequireLowerBody
window.settingsList.Axis = layout.Vertical
window.cameraID = defaultString(camera.ID, "ip-camera")
return window, nil
}
func defaultPort(port int) int {
if port <= 0 {
return 554
}
return port
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func defaultFloat(value, fallback float64) float64 {
if value == 0 {
return fallback
}
return value
}
func formatFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
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() {
window.mu.Lock()
window.running = true
window.mu.Unlock()
if window.commands.Start != nil {
window.commands.Start()
}
}
func (window *Window) stop() {
window.mu.Lock()
window.running = false
window.mu.Unlock()
if window.commands.Stop != nil {
window.commands.Stop()
}
}
// Present stores the newest analysed frame and queues any confirmed screenshot.
func (window *Window) Present(update monitor.Update) {
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++
}
if update.Image != nil {
for _, record := range update.Events {
id := record.Event.EventID
if id == "" {
continue
}
if _, seen := window.seenEvents[id]; seen {
continue
}
window.seenEvents[id] = struct{}{}
window.incoming = append(window.incoming, galleryIncoming{
caption: fmt.Sprintf("%s %s", record.Event.TrackID, time.Now().Format("15:04:05")),
image: update.Image,
})
}
}
window.mu.Unlock()
window.appWindow.Invalidate()
}
// PresentAlert queues one confirm popup per event ID and plays the alert sound.
func (window *Window) PresentAlert(record alert.Record) {
if !record.Written {
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.running = false
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
frame := window.latestImage
gen := window.imageGen
pending := window.pending
incoming := window.incoming
running := window.running
window.incoming = nil
window.mu.Unlock()
for i := range incoming {
item := &galleryItem{caption: incoming[i].caption, op: paint.NewImageOp(incoming[i].image)}
window.gallery = append([]*galleryItem{item}, window.gallery...)
}
if len(window.gallery) > galleryCapacity {
window.gallery = window.gallery[:galleryCapacity]
}
hasFrame := frame != nil
if hasFrame && gen != window.imageShownGen {
window.imageOp = paint.NewImageOp(frame)
window.imageShownGen = gen
}
now := time.Now()
for _, item := range window.gallery {
if item.click.Clicked(gtx) {
if !item.lastClick.IsZero() && now.Sub(item.lastClick) < 350*time.Millisecond {
window.viewing = item
}
item.lastClick = now
}
}
if window.viewerClose.Clicked(gtx) {
window.viewing = nil
}
// Esc closes the full-image viewer.
for {
event, ok := gtx.Event(key.Filter{Focus: window, Name: key.NameEscape})
if !ok {
break
}
if pressed, ok := event.(key.Event); ok && pressed.State == key.Press {
window.viewing = nil
}
}
if window.viewing != nil {
gtx.Execute(key.FocusCmd{Tag: window})
}
if window.ackButton.Clicked(gtx) {
window.mu.Lock()
window.pending = nil
window.mu.Unlock()
pending = nil
}
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, hasFrame, running)
}
return window.layoutSettings(gtx, model)
}),
)
}
children := []layout.StackChild{layout.Expanded(content)}
if window.viewing != nil {
children = append(children, layout.Expanded(scrim), layout.Expanded(window.layoutViewer))
}
if pending != nil {
record := *pending
children = append(children,
layout.Expanded(scrim),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return window.layoutDialog(gtx, record)
})
}),
)
}
if len(children) == 1 {
return content(gtx)
}
return layout.Stack{}.Layout(gtx, children...)
}
func scrim(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, colorScrim, clip.Rect{Max: gtx.Constraints.Min}.Op())
return layout.Dimensions{Size: gtx.Constraints.Min}
}
func (window *Window) layoutTabs(gtx layout.Context) layout.Dimensions {
for i := range window.tabButton {
if window.tabButton[i].Clicked(gtx) {
window.active = i
}
}
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, hasFrame, running bool) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(7, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions {
if !hasFrame {
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(3, func(gtx layout.Context) layout.Dimensions {
return window.layoutRightPanel(gtx, model, running)
}),
)
}
func (window *Window) layoutRightPanel(gtx layout.Context, model ViewModel, running bool) layout.Dimensions {
if !running && window.startButton.Clicked(gtx) {
window.start()
}
if running && window.stopButton.Clicked(gtx) {
window.stop()
}
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return window.statusControlsGroup(gtx, model, running)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return card(gtx, window.galleryGroup)
}),
)
})
}
func (window *Window) statusControlsGroup(gtx layout.Context, model ViewModel, running bool) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.Body1(window.theme, model.ConnectionText).Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return stateButton(gtx, window.theme, &window.startButton, "开始监控", !running)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return stateButton(gtx, window.theme, &window.stopButton, "停止监控", running)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.Body1(window.theme, model.EventText).Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
note := material.Body2(window.theme, "红色=确认摔倒")
note.Color = colorSubtle
return note.Layout(gtx)
}),
)
}),
)
}
func (window *Window) galleryGroup(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(window.theme, "最近确认截图(双击看原图)").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Flexed(1, window.layoutGallery),
)
}
// card wraps content in a white rounded surface with a subtle stroke, so the
// gray-blue page shows through the gutters between components (per the UI spec).
func card(gtx layout.Context, inner layout.Widget) layout.Dimensions {
border := widget.Border{Color: colorStroke, Width: unit.Dp(1), CornerRadius: unit.Dp(8)}
return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
macro := op.Record(gtx.Ops)
dims := layout.UniformInset(unit.Dp(10)).Layout(gtx, inner)
content := macro.Stop()
defer clip.UniformRRect(image.Rectangle{Max: dims.Size}, gtx.Dp(8)).Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, colorSurface)
content.Add(gtx.Ops)
return dims
})
}
func smallButton(gtx layout.Context, theme *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
return stateButton(gtx, theme, click, label, true)
}
// stateButton renders a compact button; when disabled it is greyed and, via
// gtx.Disabled(), drops pointer input so the click never registers.
func stateButton(gtx layout.Context, theme *material.Theme, click *widget.Clickable, label string, enabled bool) layout.Dimensions {
button := material.Button(theme, click, label)
button.Inset = layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4), Left: unit.Dp(10), Right: unit.Dp(10)}
button.TextSize = unit.Sp(13)
if !enabled {
button.Background = color.NRGBA{R: 0xCF, G: 0xD8, B: 0xE3, A: 0xFF}
button.Color = color.NRGBA{R: 0x94, G: 0xA3, B: 0xB8, A: 0xFF}
gtx = gtx.Disabled()
}
return button.Layout(gtx)
}
func (window *Window) layoutGallery(gtx layout.Context) layout.Dimensions {
rows := make([]layout.FlexChild, 0, galleryRows)
for r := 0; r < galleryRows; r++ {
r := r
rows = append(rows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
cols := make([]layout.FlexChild, 0, galleryColumns)
for c := 0; c < galleryColumns; c++ {
index := r*galleryColumns + c
cols = append(cols, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if index < len(window.gallery) {
return window.layoutCell(gtx, window.gallery[index])
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}))
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, cols...)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...)
}
func (window *Window) layoutCell(gtx layout.Context, item *galleryItem) layout.Dimensions {
return item.click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(3)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return widget.Image{Src: item.op, Fit: widget.Contain, Position: layout.Center}.Layout(gtx)
})
})
}
func (window *Window) layoutViewer(gtx layout.Context) layout.Dimensions {
item := window.viewing
if item == nil {
return layout.Dimensions{Size: gtx.Constraints.Min}
}
return layout.UniformInset(unit.Dp(24)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
caption := material.Body1(window.theme, item.caption)
caption.Color = colorSurface
return caption.Layout(gtx)
}),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return smallButton(gtx, window.theme, &window.viewerClose, "关闭")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return widget.Image{Src: item.op, Fit: widget.Contain, Position: layout.Center}.Layout(gtx)
})
}),
)
})
}
func (window *Window) layoutDialog(gtx layout.Context, record alert.Record) layout.Dimensions {
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 (window *Window) layoutSettings(gtx layout.Context, model ViewModel) layout.Dimensions {
if window.saveCamera.Clicked(gtx) {
window.onSaveCamera()
}
if window.saveParams.Clicked(gtx) {
window.onSaveParams()
}
cameraCard := verticalRows(
window.heading("摄像头连接(账号密码仅存本地配置,下次启动生效)"),
window.editorRow("IP / 主机", &window.editHost),
window.editorRow("端口", &window.editPort),
window.editorRow("通道(101 主 / 102 子)", &window.editChannel),
window.editorRow("账号", &window.editUsername),
window.editorRow("密码", &window.editPassword),
window.editorRow("传输 (tcp/udp)", &window.editTransport),
window.editorRow("连接超时 (秒)", &window.editTimeout),
window.checkRow("低延迟", &window.lowLatency),
window.buttonRow(&window.saveCamera, "保存摄像头"),
)
paramsCard := verticalRows(
window.heading("常用检测参数(下次启动生效)"),
window.editorRow("关键点置信度", &window.editKeypoint),
window.editorRow("确认窗口 (秒, 1-3)", &window.editConfirm),
window.editorRow("水平角度阈值 (°)", &window.editAngle),
window.editorRow("模型置信度", &window.editModelConf),
window.checkRow("要求先快速下移", &window.requireRapid),
window.checkRow("要求膝踝清晰", &window.requireLower),
window.buttonRow(&window.saveParams, "保存参数"),
window.labelRow(window.settingsStatus),
)
depsCard := verticalRows(
window.heading("运行依赖(只读)"),
window.labelRow("模型 SHA-256:"+shortHash(window.settings.ModelSHA256)),
window.labelRow("运行依赖:"+window.settings.RuntimeSummary),
)
sections := []layout.Widget{
func(gtx layout.Context) layout.Dimensions { return card(gtx, cameraCard) },
func(gtx layout.Context) layout.Dimensions { return card(gtx, paramsCard) },
func(gtx layout.Context) layout.Dimensions { return card(gtx, depsCard) },
}
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return material.List(window.theme, &window.settingsList).Layout(gtx, len(sections), func(gtx layout.Context, index int) layout.Dimensions {
return layout.UniformInset(unit.Dp(6)).Layout(gtx, sections[index])
})
})
}
// verticalRows stacks row widgets with a small gap between them.
func verticalRows(rows ...layout.Widget) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
children := make([]layout.FlexChild, 0, len(rows)*2)
for i := range rows {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(rows[i]))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
}
func (window *Window) heading(text string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return material.Body1(window.theme, text).Layout(gtx)
}
}
func (window *Window) labelRow(text string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return material.Body2(window.theme, text).Layout(gtx)
}
}
func (window *Window) spacerRow() layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}
}
func (window *Window) editorRow(label string, editor *widget.Editor) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Dp(150)
return material.Body2(window.theme, label).Layout(gtx)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
border := widget.Border{Color: colorStroke, Width: unit.Dp(1), CornerRadius: unit.Dp(4)}
return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(6)).Layout(gtx, material.Editor(window.theme, editor, "").Layout)
})
}),
)
}
}
func (window *Window) checkRow(label string, value *widget.Bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(window.theme, value, label).Layout(gtx)
}
}
func (window *Window) buttonRow(click *widget.Clickable, label string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return smallButton(gtx, window.theme, click, label)
}
}
func (window *Window) onSaveCamera() {
if window.configPath == "" {
window.settingsStatus = "未指定本地配置路径,无法保存"
return
}
port, err := strconv.Atoi(strings.TrimSpace(window.editPort.Text()))
if err != nil || port < 1 || port > 65535 {
window.settingsStatus = "端口无效"
return
}
timeout, err := strconv.ParseFloat(strings.TrimSpace(window.editTimeout.Text()), 64)
if err != nil {
window.settingsStatus = "连接超时无效"
return
}
transport := strings.ToLower(strings.TrimSpace(window.editTransport.Text()))
if transport != "tcp" && transport != "udp" {
window.settingsStatus = "传输须为 tcp 或 udp"
return
}
if err := config.WriteLocalCameraSource(window.configPath, window.cameraID,
strings.TrimSpace(window.editHost.Text()), port, strings.TrimSpace(window.editChannel.Text()),
strings.TrimSpace(window.editUsername.Text()), window.editPassword.Text(),
transport, timeout, window.lowLatency.Value); err != nil {
window.settingsStatus = "保存失败:" + err.Error()
return
}
window.settingsStatus = "摄像头已保存,将在下次开始监控时生效"
}
func (window *Window) onSaveParams() {
if window.configPath == "" {
window.settingsStatus = "未指定本地配置路径,无法保存"
return
}
keypoint, keypointErr := strconv.ParseFloat(strings.TrimSpace(window.editKeypoint.Text()), 64)
confirm, confirmErr := strconv.ParseFloat(strings.TrimSpace(window.editConfirm.Text()), 64)
angle, angleErr := strconv.ParseFloat(strings.TrimSpace(window.editAngle.Text()), 64)
modelConfidence, modelErr := strconv.ParseFloat(strings.TrimSpace(window.editModelConf.Text()), 64)
if keypointErr != nil || confirmErr != nil || angleErr != nil || modelErr != nil {
window.settingsStatus = "参数须为数字"
return
}
if err := config.WriteLocalEventTuning(window.configPath, keypoint, confirm, angle, modelConfidence,
window.requireRapid.Value, window.requireLower.Value); err != nil {
window.settingsStatus = "保存失败:" + err.Error()
return
}
window.settingsStatus = "参数已保存,将在下次开始监控时生效"
}
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]) + "…"
}