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
+10
View File
@@ -612,3 +612,13 @@
- T-308 落 DOING:分三段——A 界面四项(80%窗口/2×3画廊/Esc关弹窗/按钮灰显)、B V2 配置支持结构化摄像头(账号密码,百分号编码,仅未跟踪本地配置)、C Gio 可编辑设置表单(摄像头+常用参数,保存下次启动生效)。用户明确要求内网演示要账号密码框;合规做法=凭证仅入未跟踪 config.local.json,公开示例禁凭证、凭证不进 config_version/日志/事件(V2 已脱敏 ffmpeg 日志、版本已排除 URL)。顺带解决手动 %40 编码痛点。
- 验证:每段 `GOOS=windows CGO_ENABLED=0 go build -mod=vendor` 交叉编译;配置层本地 go test;窗口目视需 Windows。
- 下一步:先做 A。
## 【2026-07-23】T-308 A/B/C 代码完成(交叉编译验证)
- 状态:DOING(三段代码完成 + Windows 交叉编译/本地 go test 通过;窗口目视需 Windows)
- A(界面四项):80% 屏幕开窗(GetSystemMetrics)、画廊 2×3 且右栏缩小、Esc 关全图弹窗(key focus+filter)、开始/停止按运行状态灰显禁用(gtx.Disabled;硬失败 ReportIssue 复位 running)。`GOOS=windows` 交叉编译通过。
- B(V2 结构化摄像头):`config` 接受 host/port/channel/username/password(仅本地)或 rtsp_url_env,`net/url` 自动百分号编码(修掉手动 %40);新增 `WriteLocalCameraSource`/`WriteLocalEventTuning`;护栏保留(拒绝整段 url/rtsp_url、凭证不进 config_version、公开示例无凭证测试)。本地 `go test ./internal/config` 通过。
- C(Gio 可编辑设置表单):设置页改为可编辑——摄像头(host/端口/通道/账号/密码掩码/传输/超时/低延迟)与常用参数(关键点/确认窗/水平角/模型置信度/快速下移/膝踝),两个保存按钮写回本地配置;`cmd/silver-pose` 预填(读本地文件)、传 ConfigPath,控制器在每次开始监控时 `config.Load` 重载使保存生效。UI 交叉编译通过、gofmt 干净。
- 阻塞:`cmd/silver-pose` 全量构建需 Windows+CGo(onnxruntime)+Gio;窗口目视/表单交互需 Windows。
- 决策:账号密码按用户要求进设置(内网演示),但仅存未跟踪 config.local.json、UI 掩码、不入日志/版本/事件;source.id 用固定/原值避免把内网 IP 写进 JSONL。设置改动"下次开始监控生效"经控制器重载 config 实现(对齐 V1 草稿→运行快照语义)。
- 下一步:用户 Windows `go run ./cmd/silver-pose` 目视——80% 窗口、2×3 画廊、Esc 关图、按钮灰显、设置页可编辑并保存后重启监控生效。
+70 -18
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"crypto/sha256"
"encoding/json"
"flag"
"fmt"
"io"
@@ -32,8 +33,8 @@ func main() {
if err := validateStartup(cfg); err != nil {
log.Fatal("启动预检失败:", err)
}
controller := &controller{config: cfg}
window, err := ui.NewWindow(settingsFor(cfg), ui.Commands{Start: controller.Start, Stop: controller.Stop})
controller := &controller{config: cfg, configPath: *configPath}
window, err := ui.NewWindow(settingsFor(cfg, *configPath), ui.Commands{Start: controller.Start, Stop: controller.Stop})
if err != nil {
log.Fatal("创建操作窗口失败:", err)
}
@@ -44,6 +45,7 @@ func main() {
type controller struct {
config config.Config
configPath string
window *ui.Window
mu sync.Mutex
cancel func()
@@ -83,22 +85,27 @@ func (controller *controller) run(ctx context.Context, cancel func(), runID uint
controller.mu.Unlock()
cancel()
}()
runtime, err := spike.OpenRuntime(controller.config.Model.ONNXPath, controller.config.Model.ONNXRuntimeDLLPath)
cfg, err := config.Load(controller.configPath)
if err != nil {
controller.window.ReportIssue("配置无效,无法开始监控")
return
}
runtime, err := spike.OpenRuntime(cfg.Model.ONNXPath, cfg.Model.ONNXRuntimeDLLPath)
if err != nil {
log.Printf("初始化 ONNX Runtime 失败:%v", err)
controller.window.ReportIssue("无法初始化 ONNX Runtime")
return
}
engine, err := fall.NewEngine(fall.EngineConfig{
KeypointConfidenceThreshold: controller.config.Event.KeypointConfidenceThreshold,
SuspectWindowSeconds: controller.config.Event.SuspectWindowSeconds,
ConfirmWindowSeconds: controller.config.Event.ConfirmWindowSeconds,
RecoveryWindowSeconds: controller.config.Event.RecoveryWindowSeconds,
CooldownSeconds: controller.config.Event.CooldownSeconds,
RequireRapidDrop: controller.config.Event.RequireRapidDrop,
RequireLowerBody: controller.config.Event.RequireLowerBody,
HorizontalAngleThresholdDegrees: controller.config.Event.HorizontalAngleThresholdDegrees,
ConfigVersion: controller.config.RuntimeConfigVersion,
KeypointConfidenceThreshold: cfg.Event.KeypointConfidenceThreshold,
SuspectWindowSeconds: cfg.Event.SuspectWindowSeconds,
ConfirmWindowSeconds: cfg.Event.ConfirmWindowSeconds,
RecoveryWindowSeconds: cfg.Event.RecoveryWindowSeconds,
CooldownSeconds: cfg.Event.CooldownSeconds,
RequireRapidDrop: cfg.Event.RequireRapidDrop,
RequireLowerBody: cfg.Event.RequireLowerBody,
HorizontalAngleThresholdDegrees: cfg.Event.HorizontalAngleThresholdDegrees,
ConfigVersion: cfg.RuntimeConfigVersion,
SessionID: time.Now().UTC().Format("20060102-150405"),
})
if err != nil {
@@ -108,13 +115,13 @@ func (controller *controller) run(ctx context.Context, cancel func(), runID uint
return
}
stream := source.Start(ctx, source.Config{
SourceURL: controller.config.Source.URL, FFmpegPath: controller.config.Tools.FFmpegPath,
FFprobePath: controller.config.Tools.FFprobePath, Transport: controller.config.Source.Transport,
Timeout: controller.config.Source.Timeout, LowLatency: controller.config.Source.LowLatency,
SourceURL: cfg.Source.URL, FFmpegPath: cfg.Tools.FFmpegPath,
FFprobePath: cfg.Tools.FFprobePath, Transport: cfg.Source.Transport,
Timeout: cfg.Source.Timeout, LowLatency: cfg.Source.LowLatency,
}, source.Dependencies{})
monitored, err := monitor.New(
stream, runtime, engine, controller.config.Model.ConfidenceThreshold,
alert.NewDispatcher(controller.config.Artifacts.EventDirectory, controller.config.Source.ID), time.Now,
stream, runtime, engine, cfg.Model.ConfidenceThreshold,
alert.NewDispatcher(cfg.Artifacts.EventDirectory, cfg.Source.ID), time.Now,
)
if err != nil {
log.Printf("启动监控管线失败:%v", err)
@@ -172,7 +179,7 @@ func sha256File(path string) (string, error) {
return fmt.Sprintf("%x", digest.Sum(nil)), nil
}
func settingsFor(cfg config.Config) ui.Settings {
func settingsFor(cfg config.Config, configPath string) ui.Settings {
return ui.Settings{
SourceEnvironment: cfg.Source.RTSPURLEnv,
ModelSHA256: cfg.Model.SHA256,
@@ -180,5 +187,50 @@ func settingsFor(cfg config.Config) ui.Settings {
EventSummary: fmt.Sprintf("确认 %.1f 秒;水平角 %.0f°;快速下移=%t;下肢=%t",
cfg.Event.ConfirmWindowSeconds, cfg.Event.HorizontalAngleThresholdDegrees,
cfg.Event.RequireRapidDrop, cfg.Event.RequireLowerBody),
ConfigPath: configPath,
Camera: cameraFieldsFrom(configPath),
Params: ui.ParamFields{
KeypointConfidence: float64(cfg.Event.KeypointConfidenceThreshold),
ConfirmWindowSeconds: cfg.Event.ConfirmWindowSeconds,
HorizontalAngleDegrees: float64(cfg.Event.HorizontalAngleThresholdDegrees),
ModelConfidence: float64(cfg.Model.ConfidenceThreshold),
RequireRapidDrop: cfg.Event.RequireRapidDrop,
RequireLowerBody: cfg.Event.RequireLowerBody,
},
}
}
// cameraFieldsFrom pre-fills the settings form from the untracked local config's
// source block. It reads credentials only to display them in the (masked) form;
// they are never logged.
func cameraFieldsFrom(configPath string) ui.CameraFields {
data, err := os.ReadFile(configPath)
if err != nil {
return ui.CameraFields{}
}
var document struct {
Source struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
Channel string `json:"channel"`
Username string `json:"username"`
Password string `json:"password"`
Transport string `json:"transport"`
TimeoutSeconds float64 `json:"timeout_seconds"`
LowLatency *bool `json:"low_latency"`
} `json:"source"`
}
if err := json.Unmarshal(data, &document); err != nil {
return ui.CameraFields{}
}
lowLatency := true
if document.Source.LowLatency != nil {
lowLatency = *document.Source.LowLatency
}
return ui.CameraFields{
ID: document.Source.ID, Host: document.Source.Host, Port: document.Source.Port,
Channel: document.Source.Channel, Username: document.Source.Username, Password: document.Source.Password,
Transport: document.Source.Transport, TimeoutSeconds: document.Source.TimeoutSeconds, LowLatency: lowLatency,
}
}
+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,16 +654,141 @@ 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 {