diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index e4d37b4..71fadb1 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -23,10 +23,10 @@ | --- | --- | --- | --- | | 语言 | Go | 已定 | 面向长时间 RTSP 运行、并发控制和单一交付物。 | | 模型工件 | 固定输入尺寸的 ONNX Pose 模型 | 已定 | Go 不直接加载 `.pt`;模型由已验收 V1 权重导出并记录哈希。 | -| 推理运行时 | `github.com/yalue/onnxruntime_go` v1.31.0 + ONNX Runtime CPU DLL | Spike 已验证 | Windows 上以显式 DLL 路径加载锁定的 1×3×640×640 Pose ONNX,已完成一次真实录像首帧推理;该包装器的 C API 为 26,V2 构建必须启用 CGO/MinGW,并在发布包锁定匹配 DLL 的来源与 SHA-256。 | +| 推理运行时 | `github.com/yalue/onnxruntime_go` v1.31.0 + ONNX Runtime CPU DLL | 本机发布目录已验证 | Windows 上以显式 DLL 路径加载锁定的 1×3×640×640 Pose ONNX,已完成一次真实录像首帧推理;该包装器的 C API 为 26,V2 构建必须启用 CGO/MinGW。`build-v2-release.ps1` 已从显式本机 DLL 生成 SHA-256 清单;现场 RTSP 推理仍待验收。 | | 视频输入 | 受控的 `ffmpeg.exe` 子进程输出 `bgr24` 原始帧 | 回放已验证;RTSP 源单元测试已覆盖 | Go 回归命令已完整读取 317 帧本地录像;`internal/source` 已实现 FFprobe 元数据探测、FFmpeg 解码、有界重连、取消与最新帧投递,且错误状态不含 RTSP URL。避免未配置的 GoCV/OpenCV 开发绑定。T-304 仍须随应用锁定 FFmpeg/FFprobe 的版本、哈希,并在现场验证真实流重连。 | | 图像预处理与 Pose 解析 | Go 实现的 BGR→RGB、CHW、640 letterbox、NMS、17 点及坐标还原 | 合成契约与本机漏检回放已验证 | 采用 Ultralytics 的 114 补边、双线性缩放后转 uint8 的语义;Go 首帧输出两个人,与 V1 对应结果的 box 坐标差在已记录的 6 像素容差内。`demo/1.mp4` 是真实摔倒但在现有 V1 配置下漏检,不能称为“无报警反例”;仍需经同意、符合固定俯视大厅/走廊边界的正例录像完成事件级确认。 | -| 演示 UI | `github.com/lxn/walk`(Windows 原生控件) | Spike 已验证 | 双 Tab 骨架可在 `CGO_ENABLED=0` 编译,避免 Fyne/OpenGL 首次编译的高复杂度;正式版仍须实现浅灰蓝底、白色卡片,红色仅表示确认摔倒。 | +| 演示 UI | `github.com/lxn/walk`(Windows 原生控件) | 操作窗口已编译 | 浅灰蓝双 Tab、最新帧、来源/事件状态、脱敏设置、开始/停止、确认声音与弹窗入口已实现;避免 Fyne/OpenGL 首次编译的高复杂度。现场需验证实际键盘、缩放、声音与弹窗。 | | 测试 | `go test ./...` + V1 回归录像 | 已定 | V2 行为必须与 V1 基线对照。 | ## 关键选型纪律 @@ -51,6 +51,8 @@ | V2 ONNX/视频 Spike | `Set-Location v2; $env:CGO_ENABLED='1'; go run ./cmd/spike --ffmpeg --video --width --height --onnx assets\best.onnx --ort-dll ` | | V2 录像事件回归 | `Set-Location v2; $env:CGO_ENABLED='1'; go run ./cmd/regression --ffmpeg --video --width --height --fps --onnx assets\best.onnx --ort-dll ` | | V2 Walk UI 编译 Spike | `Set-Location v2; $env:CGO_ENABLED='0'; go build ./cmd/ui-spike` | +| V2 正式应用 | `Set-Location v2; $env:CGO_ENABLED='1'; go run ./cmd/silver-pose --config ` | +| V2 发布目录 | `Set-Location v2; ./scripts/build-v2-release.ps1 -OnnxPath -OrtDllPath -FfmpegPath -FfprobePath -OutputDirectory ` | ## 演进决策 diff --git a/docs/current-state.md b/docs/current-state.md index a32ec9c..1c52159 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -9,13 +9,13 @@ - 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。 - 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。 - V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。 -- V2 代码:T-303 已落地 `internal/pose`(Ultralytics 114 letterbox、BGR→RGB/CHW、YOLO Pose NMS、17 点及坐标还原)与 `internal/fall`(中心点跟踪、质量/几何证据、策略、四态状态机和事件 ID);`cmd/regression` 使用 FFmpeg 顺序回放本机录像并将 ONNX Pose 接入事件引擎。用户已手动确认 T-303 完成,但其缺少部署边界内真实正例录像的事实仍保留在诊断记录中。T-304 已新增安全 V2 配置、FFprobe/FFmpeg 有界重连源、后台监控装配、叠加 PNG/JSONL 证据和浅色 Walk 双 Tab 操作窗口;窗口只接收完成帧和可靠告警队列。正式 `cmd/silver-pose`、发布包及现场 RTSP/声音/弹窗目视验收尚未完成,V2 仍不能作为客户演示版。 +- V2 代码:T-303 已落地 `internal/pose`(Ultralytics 114 letterbox、BGR→RGB/CHW、YOLO Pose NMS、17 点及坐标还原)与 `internal/fall`(中心点跟踪、质量/几何证据、策略、四态状态机和事件 ID);`cmd/regression` 使用 FFmpeg 顺序回放本机录像并将 ONNX Pose 接入事件引擎。用户已手动确认 T-303 完成,但其缺少部署边界内真实正例录像的事实仍保留在诊断记录中。T-304 已新增安全 V2 配置、FFprobe/FFmpeg 有界重连源、后台监控装配、叠加 PNG/JSONL 证据、浅色 Walk 双 Tab 操作窗口和 `cmd/silver-pose` 正式装配命令;窗口只接收完成帧和可靠告警队列。`v2/scripts/build-v2-release.ps1` 已在本机从显式 ONNX/DLL/FFmpeg/FFprobe 输入生成临时发布目录与哈希清单。现场 RTSP、声音、弹窗、截图和安全模拟摔倒的目视验收尚未完成,V2 仍不能作为客户演示版。 - 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。 - 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`./init.ps1` 当前运行 V1 测试为 88 passed、1 skipped。T-303 的 `CGO_ENABLED=1 go test ./...` 通过。对同一 317 帧本机录像,V1 回放得到 253 个有人帧、263 人次、0 个 CONFIRMED;Go 回放得到 255 个有人帧、265 人次、0 个 CONFIRMED。该录像经用户确认是摔倒正例,二者的 0 事件是漏检,不是“无报警反例”通过:V1 在 2.400–2.467 秒仅有 3 帧横向候选,随后倒地人体的 Pose 置信度降至 0.012,并在 3.000 秒帧以 0.01 推理仍无任何人体姿态。它是室外斜视且有字幕遮挡,超出当前固定俯视大厅/走廊验收边界;降低检测阈值至 0.10 仍为 0 事件。人次差异来自 OpenCV/FFmpeg 解码像素不同,不能据此声称 Go 已完成真实正例一致性。Go 合成正例契约和 V1 状态机均在 1.81 秒时产生一个 `CONFIRMED`。Walk UI Spike 以 `CGO_ENABLED=0 go build ./cmd/ui-spike` 编译通过。这些不表示摔倒识别准确率或完整 Go 客户演示已验收。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 - 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。 - 当前标准启动:`./init.ps1`。 - 当前标准验证:`python -m compileall -q demo`。 -- 当前 blocker:T-303 已由用户手动确认关闭,但仍缺少一段经同意、无敏感信息且符合固定俯视大厅/走廊、全身大部分可见边界的本机正例录像及其 V1 确认事件/延迟基线;因此不能将其表述为真实正例录像级一致性证明。`demo/1.mp4` 虽是真实摔倒,但已诊断为 Pose 漏检,不能作为此项验收正例。T-304 还须锁定 ONNX Runtime DLL、FFmpeg/FFprobe 的版本、来源和哈希;现场 RTSP 验收仍需要本机未跟踪凭证和现场网络,二者均不得提交。 +- 当前 blocker:T-303 已由用户手动确认关闭,但仍缺少一段经同意、无敏感信息且符合固定俯视大厅/走廊、全身大部分可见边界的本机正例录像及其 V1 确认事件/延迟基线;因此不能将其表述为真实正例录像级一致性证明。`demo/1.mp4` 虽是真实摔倒,但已诊断为 Pose 漏检,不能作为此项验收正例。T-304 的本机发布清单已锁定 ONNX Runtime DLL、FFmpeg/FFprobe 和 ONNX 哈希;最终现场 RTSP、声音、弹窗、事件截图/JSONL、断流恢复与安全摔倒验收仍需要未跟踪凭证、现场网络与现场人员,均不得提交。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 @@ -46,6 +46,9 @@ python main.py Set-Location ..\v2 $env:CGO_ENABLED='1' go run ./cmd/regression --help + +# V2 正式应用(需未提交 config.local.json 与环境变量 RTSP) +go run ./cmd/silver-pose --config config.local.json ``` ## 已知事实和限制 diff --git a/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md b/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md index b4d2ab4..1208359 100644 --- a/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md +++ b/docs/superpowers/plans/2026-07-22-t304-go-demo-delivery.md @@ -319,7 +319,7 @@ git commit -m "feat(v2): implement operator monitoring window" - Modify: `docs/06-tasks.md` - Modify: `progress.md` -- [ ] **Step 1: Write the command preflight test** +- [x] **Step 1: Write the command preflight test** ```go func TestValidateStartupRejectsMissingRuntimeWithoutLeakingSource(t *testing.T) { @@ -328,12 +328,12 @@ func TestValidateStartupRejectsMissingRuntimeWithoutLeakingSource(t *testing.T) } ``` -- [ ] **Step 2: Verify it fails before adding the command package** +- [x] **Step 2: Verify it fails before adding the command package** Run: `Set-Location v2; go test ./cmd/silver-pose -run TestValidateStartup -v` Expected: FAIL because the command package is absent. -- [ ] **Step 3: Implement preflight, lifecycle and release assembly** +- [x] **Step 3: Implement preflight, lifecycle and release assembly** ```go func main() { @@ -347,7 +347,7 @@ func main() { `build-v2-release.ps1` must require `-OnnxPath`, `-OrtDllPath`, `-FfmpegPath`, `-FfprobePath` and `-OutputDirectory`; it must reject a non-empty target directory, build `silver-pose.exe` with CGO/MinGW, copy the four explicit binaries plus `config.example.json`, write `runtime-manifest.json` with SHA-256 and tool version output, and fail when a required input is missing. The runbook must document a local ignored `config.local.json`, setting the RTSP environment variable outside the repository, normal/reconnect/confirmed demonstrations, where event evidence is written, a 1–3 second stated target, and the fixed-view scope limitation. -- [ ] **Step 4: Run automated and release-folder smoke verification** +- [x] **Step 4: Run automated and release-folder smoke verification** Run: `Set-Location v2; $env:CGO_ENABLED='1'; go test ./...; go build ./cmd/silver-pose` Expected: PASS. @@ -363,11 +363,11 @@ Expected: PASS. Run: launch the release executable with a local configuration and approved live RTSP stream. Expected: live frame and skeleton/ID; disconnect causes reconnect text without fall alert; one approved fall produces red overlay, one sound, one acknowledgment popup, one PNG and one JSONL line; normal activity does not create a confirmed event. -- [ ] **Step 6: Commit delivery evidence** +- [x] **Step 6: Commit command and release-tooling slice** ```powershell git add v2/cmd/silver-pose v2/scripts docs/03-tech-stack.md docs/04-architecture.md docs/current-state.md docs/06-tasks.md docs/v2-demo-runbook.md progress.md -git commit -m "feat(v2): deliver realtime demo and release tooling" +git commit -m "feat(v2): add realtime command and release tooling" ``` ## Plan self-review diff --git a/docs/v2-demo-runbook.md b/docs/v2-demo-runbook.md new file mode 100644 index 0000000..86c3167 --- /dev/null +++ b/docs/v2-demo-runbook.md @@ -0,0 +1,47 @@ +# Silver Pose V2 现场演示与发布手册 + +## 适用范围 + +本手册只适用于单路、固定机位、向下俯视且人员全身大部分可见的大厅或走廊。系统用 Pose 关键点和持续倒地事件规则触发报警,不是医疗诊断,也不承诺室外斜视、强遮挡、字幕视频或任意公共场景的效果。 + +## 发布前构建 + +在 `v2/` 目录执行下列命令,所有路径均为本机可信发布输入。脚本要求目标目录不存在或为空,并生成 `runtime-manifest.json` 记录实际 SHA-256 和工具版本: + +```powershell +./scripts/build-v2-release.ps1 ` + -OnnxPath ` + -OrtDllPath ` + -FfmpegPath ` + -FfprobePath ` + -OutputDirectory +``` + +发布目录包含 `silver-pose.exe`、`runtime/`、公开 `config.example.json`、本手册和运行时哈希清单。不要把真实摄像头地址、密码、客户录像、事件截图或本机 `config.local.json` 放进发布目录。 + +## 本机安全配置 + +1. 从 `config.example.json` 复制为未提交的 `config.local.json`。 +2. 使用发布目录中的 `runtime-manifest.json` 把 `model.sha256` 填为 `runtime/best.onnx` 的实际 SHA-256。 +3. 在当前 PowerShell 会话设置 RTSP 环境变量;不要把命令、地址或密码复制进仓库、截图或 JSONL。 + +```powershell +$env:SILVER_POSE_RTSP_URL = '<本机安全 RTSP 地址>' +.\silver-pose.exe --config .\config.local.json +``` + +设置页只显示环境变量名称和是否就绪,不显示解析后的地址或密码。开始监控后,配置快照固定;若要修改事件参数,请停止监控、修改本地文件,再重新开始。 + +## 客户演示顺序 + +1. 启动后确认“视频流:在线”,实时画面出现人员骨架和 ID;正常状态不是红色。 +2. 演示走动、坐下、弯腰、捡物和短暂停留,确认没有 `CONFIRMED`、声音、弹窗或新证据。 +3. 在现场负责人同意与安全防护下,安排成年人在画面中从站立/行走转为持续倒地。目标确认延迟是 1–3 秒;确认后应出现红色姿态和边框、一次 Windows 声音、一次确认弹窗,以及一张 PNG 和一条 JSONL。 +4. 保持倒地画面,确认同一 `event_id` 不重复声音、弹窗、截图或 JSONL。恢复稳定站立后才允许新的事件。 +5. 暂停流或断开网络,确认界面显示正在重连且没有红色摔倒告警;恢复流后回到在线。 + +事件证据位于本机 `artifacts/events/YYYYMMDD/`:PNG 文件名仅含事件 ID,`events.jsonl` 可核查来源 ID、配置版本、确认时间和延迟。它们是本地工件,不提交。 + +## 交付验收记录 + +记录发布目录的 `runtime-manifest.json`、Windows/Go 版本、摄像头安装高度与角度、正反例动作结果、确认延迟、断流恢复结果和操作者。不要记录 RTSP URL、账号、密码、序列号或客户个人信息。 diff --git a/progress.md b/progress.md index f077a6d..01451a3 100644 --- a/progress.md +++ b/progress.md @@ -514,3 +514,12 @@ - 阻塞:T-304 实现阶段无代码 blocker;最终现场验收仍需要未跟踪的本机 RTSP 凭证、网络和现场视频。发布依赖的 ONNX Runtime DLL、FFmpeg/FFprobe 还需锁定来源、版本与 SHA-256。 - 决策:T-304 继续使用 Go、Walk、受控 FFmpeg 和 ONNX Runtime;保持单路固定机位边界、浅色 Windows UI,红色仅用于 CONFIRMED 与关联报警证据。 - 下一步:审查 V2 当前 Spike、V1 报警契约和 UI 规格,编写并执行 T-304 的可测试实施计划。 + +## 【2026-07-22】T-304 Go 实时演示、报警和打包(实现部分完成) + +- 状态:DOING(代码与本机发布目录已完成;现场演示验收待执行)。 +- 变更:新增 `v2/internal/config`(仅环境变量 RTSP、脱敏配置版本)、`internal/source`(FFprobe/FFmpeg、有界重连、最新帧)、`internal/render`/`internal/alert`(姿态叠加、一次 PNG/JSONL)、`internal/monitor`(后台 Pose→事件→证据,画面与告警分离队列)、`internal/ui` 的浅色 Walk 双 Tab 窗口、`cmd/silver-pose`、发布脚本与 V2 现场手册。红色只由 `CONFIRMED` 使用;来源错误不进入事件引擎且不含 URL。 +- 验证:`CGO_ENABLED=1 go test ./...` 通过;`CGO_ENABLED=1 go build ./cmd/silver-pose` 通过;缺少 `SILVER_POSE_RTSP_URL` 的命令冒烟以非零退出且只显示环境变量名。`build-v2-release.ps1` 已从本机显式输入生成忽略的 `artifacts/v2-release-check`,包含 `silver-pose.exe`、ONNX、ONNX Runtime DLL、FFmpeg、FFprobe、公开配置、手册和 `runtime-manifest.json`。清单记录 ONNX SHA-256 `fac42d41a2ae3108dc42b3a2097f299e27d7f4055cb6b26a319fad0b0a3f286e`、DLL/工具哈希与 FFmpeg 版本。 +- 阻塞:最终验收需现场未跟踪 RTSP 凭证、可用海康流、现场网络与经同意的安全模拟动作;当前不能伪造实时画面、声音、弹窗、截图或 1–3 秒正例延迟。`demo/1.mp4` 继续只作为域外漏检风险样本。 +- 决策:发布脚本不从 PATH、Python 包或 Go 缓存隐式取运行依赖,全部由显式参数输入并记录哈希;事件队列不随最新帧丢弃,确保 UI 不因慢刷新遗漏确认告警。 +- 下一步:用本机 `config.local.json` 与现场 RTSP 启动发布目录程序,按 `docs/v2-demo-runbook.md` 逐项验证在线/反例/确认/断流恢复,再决定是否将 T-304 置 DONE。 diff --git a/v2/cmd/silver-pose/main.go b/v2/cmd/silver-pose/main.go new file mode 100644 index 0000000..31e2b3a --- /dev/null +++ b/v2/cmd/silver-pose/main.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "crypto/sha256" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "silverpose/v2/internal/alert" + "silverpose/v2/internal/config" + "silverpose/v2/internal/fall" + "silverpose/v2/internal/monitor" + "silverpose/v2/internal/source" + "silverpose/v2/internal/spike" + "silverpose/v2/internal/ui" +) + +func main() { + configPath := flag.String("config", "config.local.json", "local V2 JSON configuration without RTSP credentials") + flag.Parse() + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatal("配置无效:", err) + } + 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}) + if err != nil { + log.Fatal("创建操作窗口失败:", err) + } + controller.window = window + window.Run() + controller.Stop() +} + +type controller struct { + config config.Config + window *ui.Window + mu sync.Mutex + cancel func() + runID uint64 +} + +func (controller *controller) Start() { + controller.mu.Lock() + if controller.cancel != nil { + controller.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(context.Background()) + controller.cancel = cancel + controller.runID++ + runID := controller.runID + controller.mu.Unlock() + go controller.run(ctx, cancel, runID) +} + +func (controller *controller) Stop() { + controller.mu.Lock() + cancel := controller.cancel + controller.cancel = nil + controller.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (controller *controller) run(ctx context.Context, cancel func(), runID uint64) { + defer func() { + controller.mu.Lock() + if controller.runID == runID { + controller.cancel = nil + } + controller.mu.Unlock() + cancel() + }() + runtime, err := spike.OpenRuntime(controller.config.Model.ONNXPath, controller.config.Model.ONNXRuntimeDLLPath) + if err != nil { + 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, + SessionID: time.Now().UTC().Format("20060102-150405"), + }) + if err != nil { + runtime.Close() + controller.window.ReportIssue("无法初始化摔倒事件引擎") + 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, + }, 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, + ) + if err != nil { + runtime.Close() + controller.window.ReportIssue("无法启动监控管线") + return + } + go monitored.Run(ctx) + updates, alerts := monitored.Updates(), monitored.Alerts() + for updates != nil || alerts != nil { + select { + case update, open := <-updates: + if !open { + updates = nil + continue + } + controller.window.Present(update) + case record, open := <-alerts: + if !open { + alerts = nil + continue + } + controller.window.PresentAlert(record) + } + } +} + +func validateStartup(cfg config.Config) error { + for _, path := range []string{cfg.Model.ONNXPath, cfg.Model.ONNXRuntimeDLLPath, cfg.Tools.FFmpegPath, cfg.Tools.FFprobePath} { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return fmt.Errorf("必需运行文件缺失或不可读") + } + } + actual, err := sha256File(cfg.Model.ONNXPath) + if err != nil { + return fmt.Errorf("无法校验 ONNX 模型") + } + if !strings.EqualFold(actual, cfg.Model.SHA256) { + return fmt.Errorf("ONNX 模型哈希与配置不匹配") + } + return nil +} + +func sha256File(path string) (string, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return "", err + } + defer file.Close() + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + return "", err + } + return fmt.Sprintf("%x", digest.Sum(nil)), nil +} + +func settingsFor(cfg config.Config) ui.Settings { + return ui.Settings{ + SourceEnvironment: cfg.Source.RTSPURLEnv, + ModelSHA256: cfg.Model.SHA256, + RuntimeSummary: "ONNX Runtime、FFmpeg 和 FFprobe 已通过启动预检", + EventSummary: fmt.Sprintf("确认 %.1f 秒;水平角 %.0f°;快速下移=%t;下肢=%t", + cfg.Event.ConfirmWindowSeconds, cfg.Event.HorizontalAngleThresholdDegrees, + cfg.Event.RequireRapidDrop, cfg.Event.RequireLowerBody), + } +} diff --git a/v2/cmd/silver-pose/main_test.go b/v2/cmd/silver-pose/main_test.go new file mode 100644 index 0000000..e670e99 --- /dev/null +++ b/v2/cmd/silver-pose/main_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "strings" + "testing" + + "silverpose/v2/internal/config" +) + +func TestValidateStartupRejectsMissingRuntimeWithoutLeakingSource(t *testing.T) { + err := validateStartup(config.Config{ + Source: config.SourceConfig{URL: "rtsp://operator:secret@example/Streaming/Channels/101"}, + Model: config.ModelConfig{ONNXPath: "missing-model.onnx", SHA256: strings.Repeat("a", 64), ONNXRuntimeDLLPath: "missing-runtime.dll"}, + Tools: config.ToolConfig{FFmpegPath: "missing-ffmpeg.exe", FFprobePath: "missing-ffprobe.exe"}, + }) + if err == nil { + t.Fatal("expected missing runtime error") + } + if strings.Contains(err.Error(), "rtsp://") || strings.Contains(err.Error(), "secret") { + t.Fatalf("startup error leaked source: %v", err) + } +} diff --git a/v2/internal/ui/window.go b/v2/internal/ui/window.go index 3f28f5a..ddb7995 100644 --- a/v2/internal/ui/window.go +++ b/v2/internal/ui/window.go @@ -183,6 +183,17 @@ func (window *Window) PresentAlert(record alert.Record) { }) } +// ReportIssue communicates a recoverable startup/worker issue without using +// critical-red fall language or exposing configuration values. +func (window *Window) ReportIssue(message string) { + window.main.Synchronize(func() { + window.model.ConnectionText = "视频流:" + message + window.model.Critical = false + _ = window.connectionLabel.SetText(window.model.ConnectionText) + _ = window.stateLabel.SetText("检测状态:正常") + }) +} + func highestState(result fall.FrameResult) fall.State { state := fall.Normal for _, person := range result.People { diff --git a/v2/scripts/build-v2-release.ps1 b/v2/scripts/build-v2-release.ps1 new file mode 100644 index 0000000..0414983 --- /dev/null +++ b/v2/scripts/build-v2-release.ps1 @@ -0,0 +1,82 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string] $OnnxPath, + [Parameter(Mandatory = $true)] [string] $OrtDllPath, + [Parameter(Mandatory = $true)] [string] $FfmpegPath, + [Parameter(Mandatory = $true)] [string] $FfprobePath, + [Parameter(Mandatory = $true)] [string] $OutputDirectory +) + +$ErrorActionPreference = 'Stop' + +function Resolve-RequiredFile([string] $Path, [string] $Name) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "$Name does not exist or is not a file: $Path" + } + return (Resolve-Path -LiteralPath $Path).Path +} + +function Get-ReleaseFileInfo([string] $Path, [string] $Name) { + $item = Get-Item -LiteralPath $Path + return [ordered]@{ + name = $Name + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + bytes = $item.Length + } +} + +$onnx = Resolve-RequiredFile $OnnxPath 'ONNX model' +$ort = Resolve-RequiredFile $OrtDllPath 'ONNX Runtime DLL' +$ffmpeg = Resolve-RequiredFile $FfmpegPath 'FFmpeg executable' +$ffprobe = Resolve-RequiredFile $FfprobePath 'FFprobe executable' +$target = [System.IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $target) { + $existing = Get-ChildItem -LiteralPath $target -Force + if ($existing.Count -gt 0) { + throw "OutputDirectory must be empty: $target" + } +} else { + New-Item -ItemType Directory -Path $target | Out-Null +} + +$scriptRoot = Split-Path -Parent $PSCommandPath +$v2Root = Split-Path -Parent $scriptRoot +$runtime = Join-Path $target 'runtime' +New-Item -ItemType Directory -Path $runtime | Out-Null + +$oldCgo = $env:CGO_ENABLED +$env:CGO_ENABLED = '1' +try { + Push-Location $v2Root + go build -trimpath -ldflags '-s -w' -o (Join-Path $target 'silver-pose.exe') ./cmd/silver-pose +} finally { + Pop-Location + $env:CGO_ENABLED = $oldCgo +} + +Copy-Item -LiteralPath $onnx -Destination (Join-Path $runtime 'best.onnx') +Copy-Item -LiteralPath $ort -Destination (Join-Path $runtime 'onnxruntime.dll') +Copy-Item -LiteralPath $ffmpeg -Destination (Join-Path $runtime 'ffmpeg.exe') +Copy-Item -LiteralPath $ffprobe -Destination (Join-Path $runtime 'ffprobe.exe') +Copy-Item -LiteralPath (Join-Path $v2Root 'config.example.json') -Destination (Join-Path $target 'config.example.json') +Copy-Item -LiteralPath (Join-Path (Split-Path -Parent $v2Root) 'docs\v2-demo-runbook.md') -Destination (Join-Path $target 'v2-demo-runbook.md') + +$ffmpegVersion = (& (Join-Path $runtime 'ffmpeg.exe') -version | Select-Object -First 1) +$ffprobeVersion = (& (Join-Path $runtime 'ffprobe.exe') -version | Select-Object -First 1) +$manifest = [ordered]@{ + format = 'silver-pose-v2-runtime-1' + generated_at_utc = [DateTime]::UtcNow.ToString('o') + go_version = (& go version) + ffmpeg_version = $ffmpegVersion + ffprobe_version = $ffprobeVersion + files = @( + (Get-ReleaseFileInfo (Join-Path $target 'silver-pose.exe') 'silver-pose.exe'), + (Get-ReleaseFileInfo (Join-Path $runtime 'best.onnx') 'runtime/best.onnx'), + (Get-ReleaseFileInfo (Join-Path $runtime 'onnxruntime.dll') 'runtime/onnxruntime.dll'), + (Get-ReleaseFileInfo (Join-Path $runtime 'ffmpeg.exe') 'runtime/ffmpeg.exe'), + (Get-ReleaseFileInfo (Join-Path $runtime 'ffprobe.exe') 'runtime/ffprobe.exe') + ) +} +$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $target 'runtime-manifest.json') -Encoding UTF8 + +Write-Output "V2 release created: $target"