feat(v2): T-308C editable settings form (camera + params) applied on start

Settings tab is now an editable, scrollable form: camera connection
(host/port/channel/user/masked password/transport/timeout/low-latency) and
common detection params, each with a save button that writes the untracked
local config. cmd/silver-pose pre-fills from the local file, passes the
config path, and the controller reloads config on each Start so saved edits
apply next run. UI cross-compiles for Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-23 21:30:23 +08:00
co-authored by Claude Opus 4.8
parent 38b0013355
commit 03d3f83f1d
3 changed files with 321 additions and 31 deletions
+236 -8
View File
@@ -6,6 +6,7 @@ import (
"image/color"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -23,6 +24,7 @@ import (
"gioui.org/widget/material"
"silverpose/v2/internal/alert"
"silverpose/v2/internal/config"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/monitor"
)
@@ -41,6 +43,7 @@ var (
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}
)
@@ -49,6 +52,33 @@ type Settings struct {
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 {
@@ -89,6 +119,7 @@ type Window struct {
incoming []galleryIncoming
seenEvents map[string]struct{}
running bool
configPath string
// Touched only by the event-loop goroutine.
imageOp paint.ImageOp
@@ -102,6 +133,32 @@ type Window struct {
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) {
@@ -139,9 +196,55 @@ func NewWindow(settings Settings, commands Commands) (*Window, error) {
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()
@@ -551,18 +654,143 @@ func (window *Window) layoutDialog(gtx layout.Context, record alert.Record) layo
}
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()
}
rows := []layout.Widget{
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, "保存摄像头"),
window.spacerRow(),
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),
window.spacerRow(),
window.heading("运行依赖(只读)"),
window.labelRow("模型 SHA-256:" + shortHash(window.settings.ModelSHA256)),
window.labelRow("运行依赖:" + window.settings.RuntimeSummary),
}
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),
)
return material.List(window.theme, &window.settingsList).Layout(gtx, len(rows), func(gtx layout.Context, index int) layout.Dimensions {
return layout.UniformInset(unit.Dp(4)).Layout(gtx, rows[index])
})
})
}
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 {