From 22aadf2b263c2f04707418f97d55e46379c94f91 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Wed, 22 Jul 2026 16:01:28 +0800 Subject: [PATCH] feat(v2): complete Go inference and UI spike --- docs/03-tech-stack.md | 13 ++-- docs/04-architecture.md | 12 ++++ docs/06-tasks.md | 2 +- docs/adr/2026-07-22-t302-go-spike-decision.md | 32 +++++++++ docs/current-state.md | 16 ++--- progress.md | 9 +++ v2/cmd/spike/main.go | 65 +++++++++++++++++++ v2/cmd/ui-spike/main.go | 52 +++++++++++++++ v2/go.mod | 14 ++++ v2/go.sum | 11 ++++ v2/internal/spike/ort.go | 63 ++++++++++++++++++ v2/internal/spike/ort_test.go | 16 +++++ v2/internal/spike/preprocess.go | 43 ++++++++++++ v2/internal/spike/preprocess_test.go | 38 +++++++++++ v2/internal/ui/spec.go | 22 +++++++ v2/internal/ui/spec_test.go | 16 +++++ 16 files changed, 411 insertions(+), 13 deletions(-) create mode 100644 docs/adr/2026-07-22-t302-go-spike-decision.md create mode 100644 v2/cmd/spike/main.go create mode 100644 v2/cmd/ui-spike/main.go create mode 100644 v2/go.mod create mode 100644 v2/go.sum create mode 100644 v2/internal/spike/ort.go create mode 100644 v2/internal/spike/ort_test.go create mode 100644 v2/internal/spike/preprocess.go create mode 100644 v2/internal/spike/preprocess_test.go create mode 100644 v2/internal/ui/spec.go create mode 100644 v2/internal/ui/spec_test.go diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 97fcea4..c29b59c 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -23,9 +23,10 @@ | --- | --- | --- | --- | | 语言 | Go | 已定 | 面向长时间 RTSP 运行、并发控制和单一交付物。 | | 模型工件 | 固定输入尺寸的 ONNX Pose 模型 | 已定 | Go 不直接加载 `.pt`;模型由已验收 V1 权重导出并记录哈希。 | -| 推理运行时 | ONNX Runtime 或经 Spike 验证的等价 Go 运行时 | 待 T-302 决定 | 选择标准是 Windows 打包、Pose 输出、CPU/GPU 与持续运行稳定性。 | -| 视频输入与绘制 | Go 方案待 T-302 Spike 决定 | 待定 | 必须读取 RTSP、保留 BGR/RGB 与 letterbox 一致性,并满足现场显示。 | -| 演示 UI | 本地网页壳或轻量桌面壳待 T-302 Spike 决定 | 待定 | 不在未验证视频帧展示能力前锁定 UI 框架。 | +| 推理运行时 | `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。 | +| 视频输入 | 受控的 `ffmpeg.exe` 子进程输出 `bgr24` 原始帧 | Spike 已验证 | 已读取 848×480 本地录像首帧;避免未配置的 GoCV/OpenCV 开发绑定。T-304 必须随应用锁定 FFmpeg/FFprobe 的版本、哈希与 RTSP 重连行为。 | +| 图像预处理 | Go 实现的 BGR→RGB、CHW、640 letterbox | Spike 已验证,待一致性回归 | 可生成 ONNX 所需 1,228,800 个 float;当前是最小 nearest-neighbour Spike,不得直接当作生产预处理,T-303 必须与 V1 缩放、补边、NMS 和坐标还原逐项对齐。 | +| 演示 UI | `github.com/lxn/walk`(Windows 原生控件) | Spike 已验证 | 双 Tab 骨架可在 `CGO_ENABLED=0` 编译,避免 Fyne/OpenGL 首次编译的高复杂度;正式版仍须实现浅灰蓝底、白色卡片,红色仅表示确认摔倒。 | | 测试 | `go test ./...` + V1 回归录像 | 已定 | V2 行为必须与 V1 基线对照。 | ## 关键选型纪律 @@ -33,6 +34,8 @@ - V1 不引入新模型、数据库、云服务或重量级告警依赖。 - RTSP 凭证仅来自环境变量或未跟踪本地文件;示例配置只含变量名。 - V2 的 ONNX 预处理、NMS、关键点解析、坐标还原和阈值必须与 V1 录像基线一致。 +- `onnxruntime.dll`、`ffmpeg.exe` 与 `ffprobe.exe` 是 V2 发布依赖,必须以明确版本、来源和 SHA-256 随发布清单锁定;不得把开发机 Python 包、Go 模块缓存或系统 PATH 当成交付依赖。 +- V2 的 ONNX Runtime 包装器依赖 CGO;构建机必须固定 Go、MinGW 和 CGO 环境,运行端只加载已锁定的 DLL。CPU 是当前唯一已验证执行路径。 - 如果 V2 需要 GPU 专用引擎,必须记录显卡、驱动、运行时版本和回退 CPU 路径;不能将硬件绑定伪装成通用稳定性。 ## 当前可运行命令 @@ -44,7 +47,9 @@ | 旧 GUI 启动 | `Set-Location demo; python main.py` | | V1 依赖安装 | `python -m pip install -r v1/requirements.txt`(显式执行;`init.ps1` 不自动安装) | | V1 单元测试 | `python -m pytest v1/tests -v` | -| V2 测试 | T-302 创建 Go 模块后使用 `go test ./...` | +| V2 单元测试 | `Set-Location v2; $env:CGO_ENABLED='1'; go test ./...` | +| 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 Walk UI 编译 Spike | `Set-Location v2; $env:CGO_ENABLED='0'; go build ./cmd/ui-spike` | ## 演进决策 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 573d461..75b8408 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -26,6 +26,8 @@ Pose 推理适配器(person box + 17 keypoints) V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一配置、事件字段和录像集,但将 Pose 推理替换为 ONNX 适配器;不得改变事件语义。 +T-302 的实现路线为:受控 `ffmpeg.exe` 负责解码为 BGR 原始帧,Go 预处理模块生成固定 `1×3×640×640` RGB/CHW tensor,`onnxruntime_go` 以显式 DLL 路径执行 Pose ONNX,Windows Walk UI 只接收已完成渲染的数据。解码、推理和事件计算都不得在 UI 消息线程运行;UI 只显示最新完成帧和事件状态。T-303 必须先补齐与 V1 一致的 letterbox、NMS、17 点解析、坐标还原、跟踪和状态机,才可以接入实时 UI。 + ## 模块职责 | 模块 | V1 位置 | 职责 | 不负责 | @@ -46,6 +48,16 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一 | 应用装配 | `v1/app.py` | 装配配置、Pose、管线线程与窗口;`FrameWorker` 只发出已判定的 `FrameAnalysis` | 事件判定、渲染细节 | | 回归工具 | `v1/tests/` 与 `v1/scripts/` | 回放录像、断言事件和延迟 | 生产 UI | +V2 对应模块将在 T-303/T-304 建立,职责预先固定如下: + +| 模块 | V2 目标位置 | 职责 | 不负责 | +| --- | --- | --- | --- | +| 解码器 | `v2/internal/video` | 启动受控 FFmpeg、提供最新 BGR 帧与显式来源状态 | Pose、事件判定、UI 绘制 | +| ONNX Pose | `v2/internal/pose` | 校验模型/DLL 清单,完成精确预处理、推理、NMS、关键点及坐标还原 | 人员 ID、摔倒结论 | +| 事件引擎 | `v2/internal/fall` | 复现 V1 的跟踪、证据、四态状态机和 `FallEvent` | 视频解码、声音、文件 | +| Windows UI | `v2/internal/ui` | Walk 顶部“监控/设置”Tab、渲染最新帧和已计算状态 | 直接读 RTSP、执行 ONNX 或事件规则 | +| 应用装配 | `v2/cmd/silver-pose` | 管理 worker 生命周期、取消、最新帧投递和依赖注入 | 重写领域规则 | + ## UI 导航与配置生效生命周期 PyQt 主窗口只包含实时监控和设置两个顶部 Tab。实时监控 Tab 保持视频画面优先;设置 Tab 不能直接调用视频源、Pose 或状态机。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index c8a92a9..16d25e7 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -44,7 +44,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-301 | 锁定 V1 模型并导出 ONNX 一致性工件 | T-205 | 记录 `.pt` 与 ONNX 哈希、输入尺寸、导出命令;同一帧关键点与 Python 在约定容差内。 | DONE | -| T-302 | 完成 Go 推理与视频/UI 技术 Spike | T-301 | Windows 上可读取录像和 ONNX Pose;选定运行时与 UI 方案并记录取舍。 | DOING | +| T-302 | 完成 Go 推理与视频/UI 技术 Spike | T-301 | Windows 上可读取录像和 ONNX Pose;选定运行时与 UI 方案并记录取舍。 | DONE | | T-303 | 实现 Go 的事件引擎与 V1 回归对比 | T-302 | 同一录像的确认事件、无报警案例和延迟满足 V1 基线。 | TODO | | T-304 | 完成 Go 实时演示、报警和打包 | T-303 | 实时 RTSP、红色画面、声音、弹窗和截图可运行;新环境按文档启动。 | TODO | diff --git a/docs/adr/2026-07-22-t302-go-spike-decision.md b/docs/adr/2026-07-22-t302-go-spike-decision.md new file mode 100644 index 0000000..6f2e0ec --- /dev/null +++ b/docs/adr/2026-07-22-t302-go-spike-decision.md @@ -0,0 +1,32 @@ +# T-302 · Go 推理、视频与 Windows UI Spike 决策 + +- 状态:已接受,作为 T-303/T-304 的实施前提 +- 日期:2026-07-22 +- 范围:仅验证 Windows 开发环境的单帧推理、录像解码和 UI 技术路线;不等于 V2 行为验收或客户演示验收。 + +## 结论 + +1. Pose 运行时采用 `github.com/yalue/onnxruntime_go` v1.31.0,CPU 路径,且启动时必须显式指定 `onnxruntime.dll`。包装器当前使用 ONNX Runtime C API 26,构建机须固定 Go、`CGO_ENABLED=1` 和 MinGW;运行端不依赖 Python,但必须随发布物携带版本、来源和 SHA-256 都已锁定的匹配 DLL。 +2. 视频接入采用受控 `ffmpeg.exe` 子进程。它解码录像或 RTSP 为 `bgr24` 原始帧;T-304 将锁定 `ffmpeg.exe`/`ffprobe.exe` 的版本和哈希,并实现来源状态、超时、取消与重连。当前不采用 GoCV,因为此机没有可用的 OpenCV/GoCV 开发绑定。 +3. Windows UI 采用 `github.com/lxn/walk`。它提供原生窗口、顶部 Tab 和控件,UI Spike 在 `CGO_ENABLED=0` 下可编译。正式 UI 继续使用文档中的浅灰蓝底、白色卡片和“红色只表示 CONFIRMED”的语义;Walk 只在 UI 线程渲染,不能调用解码、ONNX 或状态机。 + +## 验证证据 + +命令行 Spike 使用 `demo/1.mp4` 的第一帧(848×480),从 FFmpeg 获取准确的 1,221,120 字节 BGR 帧,转为 1×3×640×640 的 1,228,800 个 `float32` 输入;锁定 `v2/assets/best.onnx` 输出为 1×56×8400 的 470,400 个值。实际运行输出: + +```text +SPIKE_OK frame=848x480 input=1228800 output=470400 max=685.7795 +``` + +`go test ./...` 在 `CGO_ENABLED=1` 下通过,且 `go build ./cmd/ui-spike` 在 `CGO_ENABLED=0` 下通过。最小测试覆盖 BGR 尺寸拒绝、letterbox 后 RGB/CHW 布局、Pose 输入长度拒绝和 UI 视觉令牌。 + +开发机的 ONNX Spike 暂时使用 Go 模块缓存内的 `test_data/onnxruntime.dll`,仅用于证明接口可执行;它不是允许交付的 DLL 来源,也未被拷入仓库。 + +## 拒绝的路线 + +- **GoCV/OpenCV**:未配置 Windows 开发库、`pkg-config` 或 CGO 绑定,无法据实声称可用于 V2。 +- **Fyne**:初次 Windows 构建进入 OpenGL/CGO 编译并在 120 秒内未完成,且遗留编译进程;对于当前“稳定现场演示”的目标,引入的 UI 图形链复杂度不合适。随后已停止这些精确的临时进程,没有修改用户文件。 + +## 不在本任务中的内容 + +当前 Go letterbox 是最小 Spike(nearest-neighbour)。它尚未完成或证明与 V1 等价的缩放/补边、NMS、关键点解析、坐标还原、人员跟踪、事件状态机、RTSP 断流、声音、弹窗、截图和打包。T-303 必须以同一录像的关键点、事件数量、非报警案例和确认延迟证明 V1/V2 等价;T-304 才可形成部署与现场演示闭环。 diff --git a/docs/current-state.md b/docs/current-state.md index dbad92f..1af45a2 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,17 +5,17 @@ ## 当前快照 - 日期:2026-07-22 -- 阶段:V1 客户演示闭环和 T-301 模型导出一致性已验收;T-302 Go 推理/UI 技术 Spike 进行中。判定灵敏度默认已放宽(低位俯视:持续水平即可疑,膝踝可缺,水平阈值 45°),确认窗为主防误报闸。 +- 阶段:V1 客户演示闭环、T-301 模型导出一致性与 T-302 Go 技术 Spike 已验收;下一个任务是 T-303(复现 V1 事件引擎并做录像回归)。判定灵敏度默认已放宽(低位俯视:持续水平即可疑,膝踝可缺,水平阈值 45°),确认窗为主防误报闸。 - 已验证环境: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 代码:`v2/` 目录存在但尚无实现。 +- V2 代码:已创建 Go 模块和受控 Spike。`cmd/spike` 用 FFmpeg 读取本地 BGR 帧、将其预处理为 640 tensor,并用 `onnxruntime_go` 对锁定 ONNX 执行一次推理;`cmd/ui-spike` 是可编译的 Windows Walk 双 Tab 外壳。它们不含 NMS/关键点解析、跟踪、状态机、RTSP、报警或发布打包,不能作为客户演示版。 - 非代码设计工件: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` 语法);`python -m pytest v1/tests -v` 当前有 86 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串、每帧诊断、灵敏度放宽、截图 ASCII 标注、断流重连、事件汇总、灵敏度写回、最新帧丢弃和截图中文标注测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 +- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`./init.ps1` 当前运行 V1 测试为 88 passed、1 skipped,覆盖配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串、每帧诊断、灵敏度放宽、截图标注、断流重连、事件汇总、灵敏度写回和最新帧丢弃。T-302 的 `CGO_ENABLED=1 go test ./...` 通过;Go Spike 对 848×480 本地录像首帧生成 1,228,800 元素输入并获得 470,400 元素 ONNX 输出;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-301 尚未锁定 ONNX 工件并完成 Python/ONNX 关键点一致性记录;现场录像和事件工件保留本机,不提交仓库。GUI 可视化、声音、弹窗与设置页测试连接的像素级观感仍应在 Windows 目视复核。 +- 当前 blocker:没有 T-303 的代码阻塞;但 Go 尚未验证与 V1 的预后处理、NMS、关键点、跟踪、状态机和事件延迟一致,不能把 T-302 的单帧推理表述为可演示的 V2。ONNX Runtime DLL、FFmpeg/FFprobe 仍须在 T-304 以版本、来源和哈希纳入发布包;现场录像和事件工件保持本机不提交。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 @@ -24,17 +24,17 @@ | 路径 | 状态 | 说明 | | --- | --- | --- | | `demo/` | 已有 | 旧 Python GUI、Pose 模型与样例视频;当前单帧规则不适合作为正式事件逻辑。 | -| `v1/` | 待开发 | 新 Python V1 的正式实现目录。 | -| `v2/` | 待开发 | Go V2 目录;V1 验收前不实现功能。 | +| `v1/` | 已验收 | Python V1 正式实现、现场验收记录与发布工具。 | +| `v2/` | Spike 已完成 | Go 模块、ONNX/视频命令行 Spike 与 Windows Walk UI 外壳;尚不是完整 V2。 | | `docs/` | 已有 | Harness Coding 文档、任务和计划。 | | `tests/` | 不适用 | V1 测试将在 `v1/tests/` 下创建。 | | `artifacts/` | 待创建 | 本地事件截图和 JSONL,不提交。 | ## 任务状态 -- 已完成:T-000、T-101~T-106(V1 地基与事件管线)、T-201(PyQt 双 Tab)、T-202(本地报警/截图/JSONL)、T-203(海康 RTSP 与断流恢复)、T-204(正反例录像与现场验收)、T-206(结构化摄像头配置与连接测试)、T-207(抓流低延迟参数)、T-208(每帧诊断叠加)、T-209(放宽俯视灵敏度)、T-210(截图 ASCII 标注)、T-211(灵敏度设置页)、T-212(实时流最新帧)、T-213(截图中文标注)。 +- 已完成:T-000、T-101~T-106(V1 地基与事件管线)、T-201(PyQt 双 Tab)、T-202(本地报警/截图/JSONL)、T-203(海康 RTSP 与断流恢复)、T-204(正反例录像与现场验收)、T-205(客户演示脚本与发布包)、T-206(结构化摄像头配置与连接测试)、T-207(抓流低延迟参数)、T-208(每帧诊断叠加)、T-209(放宽俯视灵敏度)、T-210(截图 ASCII 标注)、T-211(灵敏度设置页)、T-212(实时流最新帧)、T-213(截图中文标注)、T-301(ONNX 一致性)、T-302(Go 推理、视频/UI Spike)。 - 正在进行:无。 -- 下一个可领取:T-301(锁定 V1 模型并导出 ONNX 一致性工件)。 +- 下一个可领取:T-303(实现 Go 事件引擎与 V1 回归对比)。 ## 当前可运行内容 diff --git a/progress.md b/progress.md index bd3e3ed..76e510a 100644 --- a/progress.md +++ b/progress.md @@ -469,3 +469,12 @@ - 阻塞:真实中文观感需 Windows(自动用 C:/Windows/Fonts/msyh.ttc)。 - 决策:auto 字体候选只放 CJK 字体,找不到就回退 ASCII 而非渲染豆腐块;无需配置,Windows 自动中文、其它环境安全回退。 - 下一步:本批"别的"三项(T-211/212/213)完成;用户 Windows 冒烟中文标注、低延迟与灵敏度设置页。 + +## 【2026-07-22】T-302 Go 推理与视频/UI 技术 Spike(完成) + +- 状态:DONE +- 变更:新增 `v2/` Go 模块、`internal/spike` 的 BGR→RGB/CHW/640 letterbox 与 ONNX Runtime 单次推理、`cmd/spike` 的本地录像验证命令,以及 `cmd/ui-spike` 的 Windows Walk 顶部双 Tab 外壳;新增 `docs/adr/2026-07-22-t302-go-spike-decision.md`,并同步技术栈、架构和当前状态。未提交 ONNX、DLL、FFmpeg、录像、凭证或事件工件。 +- 验证:`./init.ps1` 通过(V1 为 88 passed、1 skipped)。在 `v2` 下,`CGO_ENABLED=1 go test ./...` 通过;`CGO_ENABLED=0 go build ./cmd/ui-spike` 通过。以 FFmpeg 解码 `demo/1.mp4` 首帧(848×480)并对锁定 ONNX 运行:`SPIKE_OK frame=848x480 input=1228800 output=470400 max=685.7795`。`git diff --check` 通过。 +- 决策:CPU Pose 使用 `onnxruntime_go` v1.31.0 加显式、发布时锁定的 ONNX Runtime DLL;视频解码使用受控 FFmpeg 子进程;UI 选择 Walk。Fyne 因 OpenGL/CGO 首次构建超时而拒绝,GoCV 因本机没有开发绑定而不选。当前缓存 DLL 只用于 Spike,绝不作为发布依赖。 +- 阻塞:无任务阻塞;但 Go 尚未复现 V1 的预后处理、NMS、关键点解析、跟踪、状态机、延迟回归、RTSP 和报警,不能作为客户演示版。 +- 下一步:T-303,按 V1 合约实现事件引擎并在同一录像上比较关键点、确认事件、无报警案例和延迟。 diff --git a/v2/cmd/spike/main.go b/v2/cmd/spike/main.go new file mode 100644 index 0000000..b327a47 --- /dev/null +++ b/v2/cmd/spike/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "time" + + "silverpose/v2/internal/spike" +) + +func main() { + ffmpegPath := flag.String("ffmpeg", "ffmpeg", "path to ffmpeg.exe") + videoPath := flag.String("video", "..\\demo\\1.mp4", "local non-sensitive validation video") + width := flag.Int("width", 848, "source video frame width") + height := flag.Int("height", 480, "source video frame height") + modelPath := flag.String("onnx", "assets\\best.onnx", "locked ONNX pose model path") + runtimeDLLPath := flag.String("ort-dll", "", "absolute onnxruntime.dll path") + flag.Parse() + + if *runtimeDLLPath == "" { + fail("--ort-dll is required") + } + if *width <= 0 || *height <= 0 { + fail("--width and --height must be positive") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + raw, err := exec.CommandContext(ctx, *ffmpegPath, + "-v", "error", "-i", *videoPath, + "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "bgr24", "-", + ).Output() + if err != nil { + fail("decode first BGR frame: %v", err) + } + expectedBytes := *width * *height * 3 + if len(raw) != expectedBytes { + fail("decoded BGR frame has %d bytes, want %d; verify --width/--height", len(raw), expectedBytes) + } + + input, err := spike.LetterboxBGRToNCHW(raw, *width, *height, 640) + if err != nil { + fail("preprocess frame: %v", err) + } + output, err := spike.RunPose(*modelPath, *runtimeDLLPath, input) + if err != nil { + fail("run ONNX Pose: %v", err) + } + + max := float32(0) + for _, value := range output { + if value > max { + max = value + } + } + fmt.Printf("SPIKE_OK frame=%dx%d input=%d output=%d max=%.4f\n", *width, *height, len(input), len(output), max) +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "spike: "+format+"\n", args...) + os.Exit(1) +} diff --git a/v2/cmd/ui-spike/main.go b/v2/cmd/ui-spike/main.go new file mode 100644 index 0000000..182cc5a --- /dev/null +++ b/v2/cmd/ui-spike/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "silverpose/v2/internal/ui" + + . "github.com/lxn/walk/declarative" +) + +func main() { + spec := ui.MonitorWindowSpec() + + MainWindow{ + Title: spec.Title, + MinSize: Size{Width: spec.Width, Height: spec.Height}, + Layout: VBox{}, + Children: []Widget{ + TabWidget{ + Pages: []TabPage{ + { + Title: "监控", + Layout: HBox{}, + Children: []Widget{ + GroupBox{ + Title: "实时画面", + Layout: VBox{}, + Children: []Widget{ + TextLabel{Text: "视频帧将在此呈现"}, + }, + }, + GroupBox{ + Title: "当前状态", + Layout: VBox{}, + Children: []Widget{ + TextLabel{Text: "检测状态:NORMAL"}, + TextLabel{Text: "事件:暂无"}, + TextLabel{Text: "运行时:Go + ONNX Runtime"}, + }, + }, + }, + }, + { + Title: "设置", + Layout: VBox{}, + Children: []Widget{ + TextLabel{Text: "设置将在 T-304 接入本机配置;不保存摄像头凭证。"}, + }, + }, + }, + }, + }, + }.Run() +} diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..c72f126 --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,14 @@ +module silverpose/v2 + +go 1.24.0 + +require ( + github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 + github.com/yalue/onnxruntime_go v1.31.0 +) + +require ( + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect + golang.org/x/sys v0.30.0 // indirect + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect +) diff --git a/v2/go.sum b/v2/go.sum new file mode 100644 index 0000000..be093b4 --- /dev/null +++ b/v2/go.sum @@ -0,0 +1,11 @@ +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA= +github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/v2/internal/spike/ort.go b/v2/internal/spike/ort.go new file mode 100644 index 0000000..cad8e83 --- /dev/null +++ b/v2/internal/spike/ort.go @@ -0,0 +1,63 @@ +package spike + +import ( + "fmt" + + ort "github.com/yalue/onnxruntime_go" +) + +const ( + poseInputSize = 640 + poseInputCount = 1 * 3 * poseInputSize * poseInputSize + poseOutputCount = 1 * 56 * 8400 +) + +// RunPose executes the locked YOLO pose ONNX graph once using the CPU runtime. +// The caller supplies absolute model and DLL paths so no camera credential or +// machine-specific path is retained in V2 configuration or source. +func RunPose(modelPath, runtimeDLLPath string, input []float32) ([]float32, error) { + if len(input) != poseInputCount { + return nil, fmt.Errorf("pose input length = %d, want %d", len(input), poseInputCount) + } + + ort.SetSharedLibraryPath(runtimeDLLPath) + if err := ort.InitializeEnvironment(); err != nil { + return nil, fmt.Errorf("initialize ONNX Runtime: %w", err) + } + defer func() { _ = ort.DestroyEnvironment() }() + + inputTensor, err := ort.NewTensor(ort.NewShape(1, 3, poseInputSize, poseInputSize), input) + if err != nil { + return nil, fmt.Errorf("create pose input tensor: %w", err) + } + defer func() { _ = inputTensor.Destroy() }() + + outputTensor, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 56, 8400)) + if err != nil { + return nil, fmt.Errorf("create pose output tensor: %w", err) + } + defer func() { _ = outputTensor.Destroy() }() + + session, err := ort.NewAdvancedSession( + modelPath, + []string{"images"}, + []string{"output0"}, + []ort.Value{inputTensor}, + []ort.Value{outputTensor}, + nil, + ) + if err != nil { + return nil, fmt.Errorf("open pose ONNX session: %w", err) + } + defer func() { _ = session.Destroy() }() + + if err := session.Run(); err != nil { + return nil, fmt.Errorf("run pose ONNX session: %w", err) + } + + output := outputTensor.GetData() + if len(output) != poseOutputCount { + return nil, fmt.Errorf("pose output length = %d, want %d", len(output), poseOutputCount) + } + return append([]float32(nil), output...), nil +} diff --git a/v2/internal/spike/ort_test.go b/v2/internal/spike/ort_test.go new file mode 100644 index 0000000..1c87741 --- /dev/null +++ b/v2/internal/spike/ort_test.go @@ -0,0 +1,16 @@ +package spike + +import ( + "strings" + "testing" +) + +func TestRunPoseRejectsWrongInputLengthBeforeLoadingRuntime(t *testing.T) { + _, err := RunPose("missing.onnx", "missing.dll", []float32{0}) + if err == nil { + t.Fatal("RunPose accepted an input that is not 1x3x640x640") + } + if !strings.Contains(err.Error(), "input length") { + t.Fatalf("RunPose error = %q, want input length error", err) + } +} diff --git a/v2/internal/spike/preprocess.go b/v2/internal/spike/preprocess.go new file mode 100644 index 0000000..77431dd --- /dev/null +++ b/v2/internal/spike/preprocess.go @@ -0,0 +1,43 @@ +package spike + +import "fmt" + +// LetterboxBGRToNCHW converts one packed BGR frame into the RGB, CHW, +// float32 tensor expected by the locked 640-pixel YOLO pose ONNX model. +// +// It intentionally uses nearest-neighbour scaling for this Spike. T-303 +// must replace or validate this preprocessing against the V1 implementation +// before it becomes the V2 production preprocessing path. +func LetterboxBGRToNCHW(frame []byte, width, height, target int) ([]float32, error) { + if width <= 0 || height <= 0 || target <= 0 { + return nil, fmt.Errorf("frame width, height and target must be positive") + } + if len(frame) != width*height*3 { + return nil, fmt.Errorf("BGR frame length = %d, want %d", len(frame), width*height*3) + } + + scaleWidth := target + scaleHeight := height * target / width + if scaleHeight > target { + scaleHeight = target + scaleWidth = width * target / height + } + padX := (target - scaleWidth) / 2 + padY := (target - scaleHeight) / 2 + plane := target * target + input := make([]float32, 3*plane) + + for y := 0; y < scaleHeight; y++ { + sourceY := y * height / scaleHeight + for x := 0; x < scaleWidth; x++ { + sourceX := x * width / scaleWidth + source := (sourceY*width + sourceX) * 3 + destination := (padY+y)*target + padX + x + input[destination] = float32(frame[source+2]) / 255.0 + input[plane+destination] = float32(frame[source+1]) / 255.0 + input[2*plane+destination] = float32(frame[source]) / 255.0 + } + } + + return input, nil +} diff --git a/v2/internal/spike/preprocess_test.go b/v2/internal/spike/preprocess_test.go new file mode 100644 index 0000000..255fdeb --- /dev/null +++ b/v2/internal/spike/preprocess_test.go @@ -0,0 +1,38 @@ +package spike + +import "testing" + +func TestLetterboxBGRToNCHWPlacesPixelInScaledImage(t *testing.T) { + // One bright BGR pixel in a 2:1 frame should be resized into the centred + // 640x320 image area. The remaining top/bottom area must stay black. + frame := []byte{0, 0, 0, 255, 0, 0} + + input, err := LetterboxBGRToNCHW(frame, 2, 1, 640) + if err != nil { + t.Fatalf("LetterboxBGRToNCHW returned an error: %v", err) + } + + const plane = 640 * 640 + if got := len(input); got != 3*plane { + t.Fatalf("input length = %d, want %d", got, 3*plane) + } + if got := input[2*plane+159*640+320]; got != 0 { + t.Fatalf("top padding blue channel = %v, want 0", got) + } + if got := input[0*plane+320*640+320]; got != 0 { + t.Fatalf("red channel = %v, want 0", got) + } + if got := input[1*plane+320*640+320]; got != 0 { + t.Fatalf("green channel = %v, want 0", got) + } + if got := input[2*plane+320*640+320]; got != 1 { + t.Fatalf("blue channel = %v, want 1", got) + } +} + +func TestLetterboxBGRToNCHWRejectsIncorrectFrameSize(t *testing.T) { + _, err := LetterboxBGRToNCHW([]byte{0}, 2, 1, 640) + if err == nil { + t.Fatal("LetterboxBGRToNCHW accepted a truncated BGR frame") + } +} diff --git a/v2/internal/ui/spec.go b/v2/internal/ui/spec.go new file mode 100644 index 0000000..a2e01c7 --- /dev/null +++ b/v2/internal/ui/spec.go @@ -0,0 +1,22 @@ +package ui + +// WindowSpec locks the visual baseline tested by the Windows UI Spike. The +// production V2 app will consume the same semantic colours and medium desktop +// layout after the decoding/inference pipeline is complete. +type WindowSpec struct { + Title string + Width int + Height int + BackgroundHex string + AlertHex string +} + +func MonitorWindowSpec() WindowSpec { + return WindowSpec{ + Title: "Silver Pose V2 · 技术 Spike", + Width: 1120, + Height: 720, + BackgroundHex: "#F3F6FA", + AlertHex: "#C62828", + } +} diff --git a/v2/internal/ui/spec_test.go b/v2/internal/ui/spec_test.go new file mode 100644 index 0000000..295a8d3 --- /dev/null +++ b/v2/internal/ui/spec_test.go @@ -0,0 +1,16 @@ +package ui + +import "testing" + +func TestMonitorWindowSpecUsesLightMonitoringLayout(t *testing.T) { + spec := MonitorWindowSpec() + if spec.Title != "Silver Pose V2 · 技术 Spike" { + t.Fatalf("title = %q", spec.Title) + } + if spec.Width != 1120 || spec.Height != 720 { + t.Fatalf("size = %dx%d, want 1120x720", spec.Width, spec.Height) + } + if spec.BackgroundHex != "#F3F6FA" || spec.AlertHex != "#C62828" { + t.Fatalf("unexpected colours: background=%s alert=%s", spec.BackgroundHex, spec.AlertHex) + } +}