Adds a 测试连接摄像头 button next to save; it builds the RTSP URL from the current form fields and runs ffprobe (8s timeout) off the UI thread, then shows a result popup (success WxH@fps, or a redacted failure). Exports config.BuildRTSPURL and source.Probe/Redact for it. Cross-compiles for Windows; config/source tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1010 lines
33 KiB
Go
1010 lines
33 KiB
Go
package ui
|
||
|
||
import (
|
||
"context"
|
||
"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"
|
||
"silverpose/v2/internal/source"
|
||
)
|
||
|
||
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
|
||
FFprobePath 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
|
||
pendingAt time.Time
|
||
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
|
||
testConn widget.Clickable
|
||
testClose widget.Clickable
|
||
settingsStatus string
|
||
settingsList widget.List
|
||
cameraID string
|
||
ffprobePath string
|
||
testMessage string
|
||
showTest bool
|
||
testing bool
|
||
}
|
||
|
||
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")
|
||
window.ffprobePath = settings.FFprobePath
|
||
|
||
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.pendingAt = time.Now()
|
||
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
|
||
pendingAt := window.pendingAt
|
||
incoming := window.incoming
|
||
running := window.running
|
||
showTest := window.showTest
|
||
testMessage := window.testMessage
|
||
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
|
||
}
|
||
if window.testClose.Clicked(gtx) {
|
||
window.mu.Lock()
|
||
window.showTest = false
|
||
window.mu.Unlock()
|
||
showTest = false
|
||
}
|
||
// 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
|
||
}
|
||
// The confirm popup auto-closes 5 seconds after it opens.
|
||
if pending != nil {
|
||
if time.Since(pendingAt) >= 5*time.Second {
|
||
window.mu.Lock()
|
||
window.pending = nil
|
||
window.mu.Unlock()
|
||
pending = nil
|
||
} else {
|
||
gtx.Execute(op.InvalidateCmd{At: pendingAt.Add(5 * time.Second)})
|
||
}
|
||
}
|
||
|
||
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 showTest {
|
||
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.layoutTestDialog(gtx, testMessage)
|
||
})
|
||
}),
|
||
)
|
||
}
|
||
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()
|
||
}
|
||
if window.testConn.Clicked(gtx) {
|
||
window.onTestConnection()
|
||
}
|
||
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.cameraButtonsRow(),
|
||
)
|
||
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 (window *Window) cameraButtonsRow() layout.Widget {
|
||
return func(gtx layout.Context) layout.Dimensions {
|
||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||
return smallButton(gtx, window.theme, &window.saveCamera, "保存摄像头参数")
|
||
}),
|
||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||
return smallButton(gtx, window.theme, &window.testConn, "测试连接摄像头")
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
|
||
func (window *Window) onTestConnection() {
|
||
window.mu.Lock()
|
||
if window.testing {
|
||
window.mu.Unlock()
|
||
return
|
||
}
|
||
window.mu.Unlock()
|
||
|
||
host := strings.TrimSpace(window.editHost.Text())
|
||
username := strings.TrimSpace(window.editUsername.Text())
|
||
password := window.editPassword.Text()
|
||
channel := strings.TrimSpace(window.editChannel.Text())
|
||
transport := strings.ToLower(strings.TrimSpace(window.editTransport.Text()))
|
||
port, portErr := strconv.Atoi(strings.TrimSpace(window.editPort.Text()))
|
||
timeout, _ := strconv.ParseFloat(strings.TrimSpace(window.editTimeout.Text()), 64)
|
||
if host == "" || username == "" || password == "" {
|
||
window.setTestResult("请先填写主机、账号和密码", false)
|
||
return
|
||
}
|
||
if portErr != nil || port < 1 || port > 65535 {
|
||
window.setTestResult("端口无效", false)
|
||
return
|
||
}
|
||
if timeout <= 0 {
|
||
timeout = 5
|
||
}
|
||
window.setTestResult("正在测试连接…", true)
|
||
go func() {
|
||
streamURL := config.BuildRTSPURL(host, port, username, password, channel)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||
defer cancel()
|
||
metadata, err := source.Probe(ctx, source.Config{
|
||
SourceURL: streamURL, FFprobePath: window.ffprobePath, Transport: transport,
|
||
Timeout: time.Duration(timeout * float64(time.Second)),
|
||
})
|
||
message := fmt.Sprintf("连接成功:%dx%d @ %.1f fps", metadata.Width, metadata.Height, metadata.FPS)
|
||
if err != nil {
|
||
message = "连接失败:" + source.Redact(err.Error(), streamURL)
|
||
}
|
||
window.setTestResult(message, false)
|
||
}()
|
||
}
|
||
|
||
func (window *Window) setTestResult(message string, testing bool) {
|
||
window.mu.Lock()
|
||
window.testMessage = message
|
||
window.showTest = true
|
||
window.testing = testing
|
||
window.mu.Unlock()
|
||
window.appWindow.Invalidate()
|
||
}
|
||
|
||
func (window *Window) layoutTestDialog(gtx layout.Context, message string) layout.Dimensions {
|
||
width := gtx.Dp(440)
|
||
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(material.H6(window.theme, "测试连接摄像头").Layout),
|
||
layout.Rigid(material.Body1(window.theme, message).Layout),
|
||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||
return smallButton(gtx, window.theme, &window.testClose, "关闭")
|
||
}),
|
||
)
|
||
})
|
||
}),
|
||
)
|
||
}
|
||
|
||
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]) + "…"
|
||
}
|