diff --git a/progress.md b/progress.md index 5ca39ec..6d5b1ef 100644 --- a/progress.md +++ b/progress.md @@ -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 关图、按钮灰显、设置页可编辑并保存后重启监控生效。 diff --git a/v2/cmd/silver-pose/main.go b/v2/cmd/silver-pose/main.go index 43f3876..35f855e 100644 --- a/v2/cmd/silver-pose/main.go +++ b/v2/cmd/silver-pose/main.go @@ -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) } @@ -43,11 +44,12 @@ func main() { } type controller struct { - config config.Config - window *ui.Window - mu sync.Mutex - cancel func() - runID uint64 + config config.Config + configPath string + window *ui.Window + mu sync.Mutex + cancel func() + runID uint64 } func (controller *controller) Start() { @@ -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, } } diff --git a/v2/internal/ui/window.go b/v2/internal/ui/window.go index 1ef2b1b..10f1ca7 100644 --- a/v2/internal/ui/window.go +++ b/v2/internal/ui/window.go @@ -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 {