diff --git a/README.md b/README.md index a50f719..824dcd7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ silver_pose/ └── progress.md # 只追加执行流水 ``` -## 当前可验证基线 +## V1 启动与验证 在 Windows PowerShell、仓库根目录运行: @@ -26,14 +26,31 @@ silver_pose/ ./init.ps1 ``` -它会检查当前 Python 环境并编译 `demo/`。图形界面可用: +它会检查当前 Python 环境、编译旧 `demo/` 基线并运行 V1 测试。首次部署先安装锁定依赖: ```powershell -Set-Location demo -python main.py +python -m pip install -r v1/requirements.txt ``` -运行 GUI 或 RTSP 演示前,必须在本机准备已安装的 PyQt5、OpenCV、NumPy 与 Ultralytics 环境。真实 RTSP 凭证不得提交。 +将 `v1/config.release.example.json`(发布包中生成)或 `v1/config.example.json` 复制为未跟踪的 `v1/config.local.json`,仅在本机填写摄像头来源与凭证。然后启动 V1: + +```powershell +python -m v1.app +``` + +运行 GUI 或 RTSP 演示前,必须在本机准备已安装的 PyQt5、OpenCV、NumPy、Ultralytics 与 Pillow 环境。真实 RTSP 凭证、客户视频、截图和 JSONL 事件工件不得提交。 + +## 客户演示与发布包 + +客户演示步骤、证据复查和适用边界见 [docs/demo-runbook.md](docs/demo-runbook.md)。构建发布包时,模型必须提供受控 SHA-256;脚本只复制 V1 代码、公开示例、运行手册和锁定模型,不复制本地配置或事件工件: + +```powershell +$modelSha = (Get-FileHash -Algorithm SHA256 demo/best.pt).Hash.ToLower() +pwsh -File v1/scripts/build-v1-release.ps1 -ModelPath demo/best.pt -ModelSha256 $modelSha -Output dist/SilverPose-V1 +python -m v1.release verify --package dist/SilverPose-V1 +``` + +发布包和现场演示仅覆盖固定俯视、全身大部分可见、单路大厅或走廊;`best.pt` 是人体姿态模型,不是摔倒分类器,也不构成医疗级或全场景可靠性承诺。 ## 文档入口 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index b402665..eea8d14 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -29,7 +29,7 @@ | T-202 | 实现本地报警、弹窗、截图和 JSONL 日志 | T-201 | CONFIRMED 产生一次声音、一次弹窗、一张带标注截图和一条事件日志。 | DONE | | T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | DONE | | T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | DONE | -| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | DOING | +| T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | DONE | | T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE | | T-207 | 抓流低延迟参数进配置与设置页 | T-206 | 配置支持 transport(tcp/udp)/连接超时/低延迟,程序拼 `OPENCV_FFMPEG_CAPTURE_OPTIONS` 并在开流前注入;设置页用下拉/数字/勾选而非原始字符串;启动可不依赖 run 脚本设环境变量。 | DONE | | T-208 | 每帧摔倒判定诊断叠加 | T-201 | 管线暴露每人策略证据;实时画面角落叠加每人 `accepted/horizontal/角度/rapid/candidate/state`(拒绝时显示原因),便于定位真实摔倒未确认卡在哪一步;诊断字符串构建 Qt-free 可测。 | DONE | diff --git a/docs/current-state.md b/docs/current-state.md index 040d3ae..cb2a83e 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-22 -- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件、结构化摄像头配置/连接测试、抓流低延迟参数与每帧判定诊断叠加已建立;T-204 已验收,T-205(客户演示脚本与 V1 发布包)进行中。判定灵敏度默认已放宽(低位俯视:持续水平即可疑,膝踝可缺,水平阈值 45°),确认窗为主防误报闸。 +- 阶段:V1 客户演示闭环已验收;T-205 已完成,V2 只能在 T-301 的 ONNX 一致性门槛后开始。判定灵敏度默认已放宽(低位俯视:持续水平即可疑,膝踝可缺,水平阈值 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)。 @@ -15,7 +15,7 @@ - 模型:`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-205 尚未完成客户演示脚本、启动预检与发布包说明。T-204 已记录两条现场确认事件(1.84s、1.86s,均在目标区间)和走动、坐下、弯腰、蹲下捡物、站立停留均不报警的反例结果;现场录像和事件工件保留本机,不提交仓库。GUI 可视化、声音、弹窗与设置页测试连接的像素级观感仍应在 Windows 目视复核。 +- 当前 blocker:V2 尚未达到 T-301 的模型导出与一致性门槛;现场录像和事件工件保留本机,不提交仓库。GUI 可视化、声音、弹窗与设置页测试连接的像素级观感仍应在 Windows 目视复核。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 @@ -33,8 +33,8 @@ ## 任务状态 - 已完成: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-205(客户演示脚本与 V1 发布包)。 -- 下一个可领取:无;完成 T-205 后才可开始 V2 模型导出门槛 T-301。 +- 正在进行:无。 +- 下一个可领取:T-301(锁定 V1 模型并导出 ONNX 一致性工件)。 ## 当前可运行内容 diff --git a/docs/demo-runbook.md b/docs/demo-runbook.md new file mode 100644 index 0000000..2e15279 --- /dev/null +++ b/docs/demo-runbook.md @@ -0,0 +1,31 @@ +# Silver Pose V1 客户演示手册 + +## 启动前检查 + +1. 使用 Windows PowerShell 进入发布包根目录,运行 `./init.ps1`;依赖检查、旧基线编译和 V1 测试必须通过。 +2. 运行 `python -m v1.release verify --package .`,确认发布清单和 `v1/models/best.pt` 的 SHA-256 未被替换。 +3. 将 `v1/config.release.example.json` 复制为未跟踪的 `v1/config.local.json`,只在本机配置摄像头来源;不得将账号、密码、地址、客户录像或事件工件提交、发送或投屏。 +4. 在设置页确认摄像头连接测试可抓取预览帧;确认低延迟、确认窗口和灵敏度参数与本次现场记录一致。 +5. 确认演示区为固定俯视的单路大厅或走廊,人员头、躯干、膝盖和脚踝大部分可见;安全模拟必须由经同意成年人按现场安全措施完成。 + +## 现场演示步骤 + +1. 运行 `python -m v1.app`,在设置页确认来源已就绪后开始监控。 +2. 先演示走动、坐下、弯腰、蹲下捡物和站立停留;这些动作不应出现确认摔倒的红色状态、声音或弹窗。 +3. 由现场负责人安排安全模拟从站立或行走到持续倒地;观察同一人员的骨架、ID、疑似状态和确认后的红色画面。 +4. 确认事件发生后,向客户展示一次声音、一次弹窗、带姿态标注的截图和一条本地事件记录。 +5. 可选演示断流:停止来源后界面显示重连状态且不报警;恢复来源后继续预览与检测。 + +## 证据复查 + +1. 在演示结束后运行 `python -m v1.report artifacts/events`。 +2. 核对每个确认事件的人员 ID、确认时间、`latency_seconds` 和相对截图路径;本项目的现场目标为 1–3 秒。 +3. 每个事件仅应有一条 JSONL 记录和一张事件截图;正常活动期间不应产生新的确认事件。 +4. 截图和 JSONL 只保留在本机约定的工件目录,按客户现场的数据管理要求处理。 + +## 适用边界 + +- 本演示只覆盖固定俯视、单路、全身大部分可见的大厅或走廊。 +- `best.pt` 是人体姿态模型,不是摔倒分类器;系统结论来自姿态质量、人员连续性和时间状态机。 +- 系统不是医疗诊断产品,不承诺所有公共场所、所有视角、严重遮挡、半身人员或多人密集交叉时的可靠性。 +- 客户应以现场批准的正反例和事件记录评估本机位表现,不应将本演示结果外推为通用准确率承诺。 diff --git a/docs/superpowers/plans/2026-07-22-t205-v1-demo-release.md b/docs/superpowers/plans/2026-07-22-t205-v1-demo-release.md new file mode 100644 index 0000000..bf5152c --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-t205-v1-demo-release.md @@ -0,0 +1,173 @@ +# T-205 V1 Demo Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 生成不含客户数据的 V1 演示包,并让现场操作员能按固定脚本完成预检、演示、证据复查和适用边界说明。 + +**Architecture:** `v1/release.py` 负责可测试的发布包构建与校验;它只复制 V1 运行代码、公开示例、依赖、运行手册和经 SHA-256 校验的模型。`v1/scripts/build-v1-release.ps1` 只是 Windows 命令行入口。运行手册定义现场步骤,不保存或输出 RTSP 凭证。 + +**Tech Stack:** CPython 3.8、PowerShell、`hashlib`、`json`、`shutil`、pytest、PyQt5 V1 应用。 + +--- + +### Task 1: 可测试的发布包构建器 + +**Files:** + +- Create: `v1/release.py` +- Create: `v1/tests/test_release.py` + +- [ ] **Step 1: 写失败测试,定义受控模型和非敏感清单** + +```python +def test_build_release_copies_locked_model_and_excludes_local_credentials(tmp_path): + model = tmp_path / "best.pt" + model.write_bytes(b"locked-model") + output = tmp_path / "SilverPose-V1" + + manifest = build_release_package(ROOT, model, sha256_file(model), output) + + assert (output / "v1" / "models" / "best.pt").read_bytes() == b"locked-model" + assert manifest["model_sha256"] == sha256_file(model) + assert not list(output.rglob("config.local.json")) + assert not list(output.rglob("events.jsonl")) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `python -m pytest v1/tests/test_release.py -v` + +Expected: 因 `v1.release` 或 `build_release_package` 不存在而失败。 + +- [ ] **Step 3: 实现最小构建器** + +```python +def build_release_package(project_root, model_path, expected_sha256, output_dir): + _validate_model(model_path, expected_sha256) + _copy_tree(project_root / "v1", output_dir / "v1", ignored={"config.local.json", "__pycache__"}) + _copy_file(project_root / "README.md", output_dir / "README.md") + _copy_file(project_root / "docs" / "demo-runbook.md", output_dir / "docs" / "demo-runbook.md") + _copy_model(model_path, output_dir / "v1" / "models" / "best.pt") + return _write_manifest(output_dir, expected_sha256) +``` + +清单只包含格式版本、生成时间、模型 SHA-256、包内相对文件路径和文件 SHA-256;不得写入 RTSP URL、账号、密码、客户视频路径或事件工件路径。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `python -m pytest v1/tests/test_release.py -v` + +Expected: PASS。 + +### Task 2: Windows 发布入口和启动命令 + +**Files:** + +- Create: `v1/scripts/build-v1-release.ps1` +- Modify: `init.ps1` +- Modify: `README.md` + +- [ ] **Step 1: 写失败测试,定义发布清单校验** + +```python +def test_verify_release_rejects_a_tampered_model(tmp_path): + output = _build_test_release(tmp_path) + (output / "v1" / "models" / "best.pt").write_bytes(b"changed") + + with pytest.raises(ReleaseError, match="model SHA-256"): + verify_release_package(output) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `python -m pytest v1/tests/test_release.py::test_verify_release_rejects_a_tampered_model -v` + +Expected: `verify_release_package` 尚未定义而失败。 + +- [ ] **Step 3: 实现清单校验和 PowerShell 入口** + +```powershell +param( + [Parameter(Mandatory = $true)][string]$ModelPath, + [Parameter(Mandatory = $true)][string]$ModelSha256, + [string]$Output = "dist/SilverPose-V1" +) +python -m v1.release build --model-path $ModelPath --model-sha256 $ModelSha256 --output $Output +``` + +`verify_release_package` 必须重新计算包内模型和清单列出的每个文件 SHA-256。`init.ps1` 的 GUI 启动提示改为 `python -m v1.app`,但继续执行旧 `demo/` 的只读编译基线。README 列出依赖安装、无凭证配置方式、V1 启动命令和发布脚本命令。 + +- [ ] **Step 4: 运行针对性测试** + +Run: `python -m pytest v1/tests/test_release.py -v` + +Expected: PASS。 + +### Task 3: 客户演示与证据复查手册 + +**Files:** + +- Create: `docs/demo-runbook.md` +- Modify: `README.md` + +- [ ] **Step 1: 写手册静态守卫测试** + +```python +def test_demo_runbook_covers_preflight_evidence_and_scope(): + text = (ROOT / "docs" / "demo-runbook.md").read_text(encoding="utf-8") + for heading in ("启动前检查", "现场演示步骤", "证据复查", "适用边界"): + assert heading in text + assert "rtsp://" not in text +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `python -m pytest v1/tests/test_release.py::test_demo_runbook_covers_preflight_evidence_and_scope -v` + +Expected: 手册不存在而失败。 + +- [ ] **Step 3: 编写手册** + +手册必须给出:Windows/依赖/模型哈希/本地配置/摄像头连接测试的预检;演示正常活动和安全模拟摔倒的顺序;确认红色姿态、声音、弹窗和截图的观察点;`python -m v1.report artifacts/events` 的证据复查;断流不报警的观察;固定俯视、全身大部分可见、单路大厅或走廊的范围限制;以及不作医疗级、全场景或 Pose 模型即摔倒分类器的表述。 + +- [ ] **Step 4: 运行手册守卫测试** + +Run: `python -m pytest v1/tests/test_release.py -v` + +Expected: PASS。 + +### Task 4: 构建并复查无敏感工件的演示包 + +**Files:** + +- Modify: `docs/06-tasks.md` +- Modify: `docs/current-state.md` +- Modify: `progress.md` + +- [ ] **Step 1: 使用锁定模型构建临时发布包** + +Run: `$releaseRoot = Join-Path $env:TEMP "SilverPose-V1-Release"; $modelSha = (Get-FileHash -Algorithm SHA256 demo/best.pt).Hash.ToLower(); pwsh -File v1/scripts/build-v1-release.ps1 -ModelPath demo/best.pt -ModelSha256 $modelSha -Output $releaseRoot` + +Expected: 输出 `release-manifest.json`,模型、代码、公开示例和手册存在,且构建目录中无 `config.local.json`、`events.jsonl`、`artifacts/` 或 RTSP 地址。 + +- [ ] **Step 2: 校验发布包** + +Run: `$releaseRoot = Join-Path $env:TEMP "SilverPose-V1-Release"; $modelSha = (Get-FileHash -Algorithm SHA256 demo/best.pt).Hash.ToLower(); pwsh -File v1/scripts/build-v1-release.ps1 -ModelPath demo/best.pt -ModelSha256 $modelSha -Output $releaseRoot; python -m v1.release verify --package $releaseRoot` + +Expected: `release package verified`。 + +- [ ] **Step 3: 运行全量验证** + +Run: `./init.ps1`, `python -m pytest v1/tests -v`, `python -m compileall -q v1 demo`, `git diff --check`。 + +Expected: 所有测试通过,编译退出码 0,差异检查无输出。 + +- [ ] **Step 4: 更新任务与执行记录并提交** + +将 T-205 标为 DONE,记录实际构建/校验命令和“不包含客户工件”的结果;提交代码、测试、手册和任务文档。临时发布目录保持在忽略目录内,不提交。 + +## 自检 + +- 覆盖:发布包、模型哈希、篡改检测、Windows 入口、演示步骤、证据复查、适用边界和无敏感工件。 +- 任务没有未定义的函数或占位步骤;每项生产行为均先有失败测试。 +- 发布包验证不依赖真实 RTSP,也不打印或写入凭证。 diff --git a/init.ps1 b/init.ps1 index e4bebec..e1bbaa3 100644 --- a/init.ps1 +++ b/init.ps1 @@ -9,7 +9,7 @@ Set-Location -Path $PSScriptRoot $InstallCmd = 'python -c "import cv2, numpy, ultralytics, PyQt5; print(''required Python packages available'')"' $VerifyCmd = "python -m compileall -q demo" $V1TestCmd = "python -m pytest v1/tests -v" -$StartCmd = "Set-Location demo; python main.py" +$StartCmd = "python -m v1.app" function Invoke-Checked { param([string]$Label, [string]$Command) @@ -22,7 +22,11 @@ function Invoke-Checked { Write-Host "==> 当前目录: $($PWD.Path)" Invoke-Checked -Label "检查 Silver Pose Python 依赖" -Command $InstallCmd -Invoke-Checked -Label "运行 demo 基线验证" -Command $VerifyCmd +if (Test-Path -LiteralPath "demo") { + Invoke-Checked -Label "运行 demo 基线验证" -Command $VerifyCmd +} else { + Write-Host "==> 发布包未包含 demo/,跳过旧基线编译" +} Invoke-Checked -Label "运行 V1 单元测试" -Command $V1TestCmd Write-Host "==> 图形界面启动命令" diff --git a/progress.md b/progress.md index 891f14d..9662850 100644 --- a/progress.md +++ b/progress.md @@ -380,6 +380,15 @@ - 决策:演示脚本必须明确固定俯视、全身大部分可见、单路大厅/走廊的适用边界,不将 Pose 模型或现场结果表述为医疗级或全场景可靠性。 - 下一步:盘点当前启动/配置/报告工具,先为预检和演示清单写可测试契约,再实现发布说明。 +## 【2026-07-22】T-205 固化客户演示脚本与 V1 发布包(完成) + +- 状态:DONE +- 变更:新增 `docs/demo-runbook.md`、`v1/release.py` 与 `v1/scripts/build-v1-release.ps1`;README 与 init 启动提示改为 V1。发布包只包含 V1 代码、公开示例、手册和 SHA-256 锁定模型,不包含本地配置或事件工件。 +- 验证:临时发布包通过 `python -m v1.release verify`;包内 `./init.ps1` 为 88 passed、1 skipped,仓库 `./init.ps1`、`python -m pytest v1/tests -q`、`python -m compileall -q v1 demo` 和 `git diff --check` 均通过。 +- 阻塞:无。真实凭证、录像、截图和 JSONL 保持本机工件。 +- 决策:客户演示按运行手册执行并明确适用边界;V2 仍等待 T-301 ONNX 一致性验证。 +- 下一步:T-301。 + ## 【2026-07-21】T-211 检测灵敏度参数进设置页 - 状态:DOING diff --git a/v1/release.py b/v1/release.py new file mode 100644 index 0000000..c03d025 --- /dev/null +++ b/v1/release.py @@ -0,0 +1,150 @@ +"""Build and verify a credential-free Silver Pose V1 release directory.""" + +import argparse +import hashlib +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Iterable + + +class ReleaseError(ValueError): + """Raised when a release package cannot be safely created or verified.""" + + +_EXCLUDED_NAMES = {"config.local.json", "artifacts", "__pycache__", ".pytest_cache", "models"} + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_release_package( + project_root: Path, model_path: Path, expected_sha256: str, output_dir: Path +) -> Dict: + """Copy the V1 runtime and a locked model without local customer artifacts.""" + + root = Path(project_root).resolve() + source_model = Path(model_path).resolve() + output = Path(output_dir).resolve() + expected = _validate_model(source_model, expected_sha256) + if output.exists(): + raise ReleaseError("output directory already exists: {0}".format(output)) + + output.mkdir(parents=True) + _copy_runtime_tree(root / "v1", output / "v1") + _copy_required_file(root / "README.md", output / "README.md") + _copy_required_file(root / "init.ps1", output / "init.ps1") + _copy_required_file(root / "docs" / "demo-runbook.md", output / "docs" / "demo-runbook.md") + destination_model = output / "v1" / "models" / "best.pt" + destination_model.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(source_model), str(destination_model)) + _write_release_config(root / "v1" / "config.example.json", output / "v1", expected) + manifest = _manifest(output, expected) + (output / "release-manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8" + ) + return manifest + + +def verify_release_package(package_dir: Path) -> Dict: + package = Path(package_dir).resolve() + manifest_path = package / "release-manifest.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise ReleaseError("cannot read release manifest: {0}".format(exc)) + for relative_path, expected in manifest.get("files", {}).items(): + actual_path = package / relative_path + if not actual_path.is_file() or sha256_file(actual_path) != expected: + raise ReleaseError("release file SHA-256 does not match: {0}".format(relative_path)) + model = package / "v1" / "models" / "best.pt" + if not model.is_file() or sha256_file(model) != manifest.get("model_sha256"): + raise ReleaseError("model SHA-256 does not match release manifest") + return manifest + + +def _validate_model(model_path: Path, expected_sha256: str) -> str: + expected = str(expected_sha256).lower() + if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected): + raise ReleaseError("expected model SHA-256 must be 64 hexadecimal characters") + if not model_path.is_file(): + raise ReleaseError("model file does not exist: {0}".format(model_path)) + if sha256_file(model_path) != expected: + raise ReleaseError("model SHA-256 does not match expected value") + return expected + + +def _copy_runtime_tree(source: Path, destination: Path) -> None: + if not source.is_dir(): + raise ReleaseError("V1 source directory does not exist: {0}".format(source)) + shutil.copytree( + str(source), + str(destination), + ignore=shutil.ignore_patterns(*_EXCLUDED_NAMES), + ) + + +def _copy_required_file(source: Path, destination: Path) -> None: + if not source.is_file(): + raise ReleaseError("required release file does not exist: {0}".format(source)) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(source), str(destination)) + + +def _write_release_config(source: Path, destination_dir: Path, model_sha256: str) -> None: + raw = json.loads(source.read_text(encoding="utf-8")) + raw["model"]["sha256"] = model_sha256 + (destination_dir / "config.release.example.json").write_text( + json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def _manifest(package: Path, model_sha256: str) -> Dict: + files = {} + for path in _files(package): + files[path.relative_to(package).as_posix()] = sha256_file(path) + return { + "format": "silver-pose-v1-release-1", + "created_at_utc": datetime.now(timezone.utc).isoformat(), + "model_sha256": model_sha256, + "files": files, + } + + +def _files(root: Path) -> Iterable[Path]: + for path in sorted(root.rglob("*")): + if path.is_file() and path.name != "release-manifest.json": + yield path + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="Build or verify a Silver Pose V1 release") + commands = parser.add_subparsers(dest="command", required=True) + build = commands.add_parser("build") + build.add_argument("--project-root", default=".") + build.add_argument("--model-path", required=True) + build.add_argument("--model-sha256", required=True) + build.add_argument("--output", required=True) + verify = commands.add_parser("verify") + verify.add_argument("--package", required=True) + args = parser.parse_args(argv) + if args.command == "build": + manifest = build_release_package( + Path(args.project_root), Path(args.model_path), args.model_sha256, Path(args.output) + ) + print("release package built: {0}".format(args.output)) + print("model SHA-256: {0}".format(manifest["model_sha256"])) + return 0 + verify_release_package(Path(args.package)) + print("release package verified: {0}".format(args.package)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/v1/scripts/build-v1-release.ps1 b/v1/scripts/build-v1-release.ps1 new file mode 100644 index 0000000..7fdc9a3 --- /dev/null +++ b/v1/scripts/build-v1-release.ps1 @@ -0,0 +1,13 @@ +param( + [Parameter(Mandatory = $true)][string]$ModelPath, + [Parameter(Mandatory = $true)][string]$ModelSha256, + [string]$Output = "dist/SilverPose-V1" +) + +$ErrorActionPreference = "Stop" +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +Set-Location $projectRoot +python -m v1.release build --project-root $projectRoot --model-path $ModelPath --model-sha256 $ModelSha256 --output $Output +if ($LASTEXITCODE -ne 0) { + throw "V1 release package build failed with exit code $LASTEXITCODE" +} diff --git a/v1/tests/test_release.py b/v1/tests/test_release.py new file mode 100644 index 0000000..f32ef36 --- /dev/null +++ b/v1/tests/test_release.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from v1.release import build_release_package, main, sha256_file + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_build_release_copies_locked_model_and_excludes_local_credentials(tmp_path): + model = tmp_path / "best.pt" + model.write_bytes(b"locked-model") + output = tmp_path / "SilverPose-V1" + + manifest = build_release_package(ROOT, model, sha256_file(model), output) + + assert (output / "v1" / "models" / "best.pt").read_bytes() == b"locked-model" + assert manifest["model_sha256"] == sha256_file(model) + assert not list(output.rglob("config.local.json")) + assert not list(output.rglob("events.jsonl")) + + +def test_demo_runbook_covers_preflight_evidence_and_scope(): + text = (ROOT / "docs" / "demo-runbook.md").read_text(encoding="utf-8") + + for heading in ("启动前检查", "现场演示步骤", "证据复查", "适用边界"): + assert heading in text + assert "rtsp://" not in text + + +def test_release_command_verifies_a_built_package(tmp_path, capsys): + model = tmp_path / "best.pt" + model.write_bytes(b"locked-model") + output = tmp_path / "SilverPose-V1" + build_release_package(ROOT, model, sha256_file(model), output) + + assert main(["verify", "--package", str(output)]) == 0 + assert "release package verified" in capsys.readouterr().out