From 0b01e1bf24daa9dd61d2418ecfd060312b679d80 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Tue, 21 Jul 2026 09:45:42 +0800 Subject: [PATCH] feat(v1): add secure configuration baseline --- .gitignore | 17 ++++ docs/03-tech-stack.md | 3 +- docs/06-tasks.md | 2 +- docs/07-v1-implementation-plan.md | 12 +-- docs/api.md | 4 +- docs/current-state.md | 16 ++-- init.ps1 | 4 +- progress.md | 9 ++ v1/__init__.py | 5 + v1/config.example.json | 21 +++++ v1/config.py | 146 ++++++++++++++++++++++++++++++ v1/requirements.txt | 5 + v1/tests/test_config.py | 56 ++++++++++++ 13 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 .gitignore create mode 100644 v1/__init__.py create mode 100644 v1/config.example.json create mode 100644 v1/config.py create mode 100644 v1/requirements.txt create mode 100644 v1/tests/test_config.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24ca2cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Local configuration and credentials +v1/config.local.json +*.local.json +.env +.env.* + +# Runtime artifacts and private evaluation materials +artifacts/ +testdata/private/ +testdata/videos/ +*.onnx +*.pt + +# Python tooling +.pytest_cache/ +__pycache__/ +*.py[cod] diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index bf2d1e8..3d95d19 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -42,7 +42,8 @@ | 基线检查 | `./init.ps1` | | 旧代码编译检查 | `python -m compileall -q demo` | | 旧 GUI 启动 | `Set-Location demo; python main.py` | -| V1 测试 | T-101 创建 `v1/requirements.txt` 和测试后写入真实 `pytest` 命令 | +| V1 依赖安装 | `python -m pip install -r v1/requirements.txt`(显式执行;`init.ps1` 不自动安装) | +| V1 单元测试 | `python -m pytest v1/tests -v` | | V2 测试 | T-302 创建 Go 模块后使用 `go test ./...` | ## 演进决策 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index f01c680..e4cf235 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -14,7 +14,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-101 | 创建 V1 包、依赖清单、示例配置和忽略规则 | T-000 | `v1/` 可导入;真实 RTSP 凭证被拒绝提交;`pytest` 能运行。 | DOING | +| T-101 | 创建 V1 包、依赖清单、示例配置和忽略规则 | T-000 | `v1/` 可导入;真实 RTSP 凭证被拒绝提交;`pytest` 能运行。 | DONE | | T-102 | 实现可重连的视频源与录像回放适配器 | T-101 | 有效本地录像可按时间戳产帧;无效源进入连接错误状态且不崩溃。 | TODO | | T-103 | 实现 Pose 适配器与模型来源校验 | T-102 | 输出 person box、17 点和置信度;错误模型或哈希不符时给出明确错误。 | TODO | | T-104 | 实现人员跟踪与姿态质量门控 | T-103 | 连续人员维持 ID;低质量、缺失膝踝或空帧不会产生倒地候选。 | TODO | diff --git a/docs/07-v1-implementation-plan.md b/docs/07-v1-implementation-plan.md index b5fed84..d56d47e 100644 --- a/docs/07-v1-implementation-plan.md +++ b/docs/07-v1-implementation-plan.md @@ -36,7 +36,7 @@ - Modify: `.gitignore` - Modify: `init.ps1`, `docs/03-tech-stack.md`, `docs/current-state.md` -- [ ] **Step 1: Write the failing configuration test** +- [x] **Step 1: Write the failing configuration test** ```python from v1.config import load_config @@ -55,12 +55,12 @@ def test_load_config_resolves_rtsp_environment_variable(tmp_path, monkeypatch): assert load_config(config_file).source_url == "rtsp://example" ``` -- [ ] **Step 2: Run the test to verify it fails** +- [x] **Step 2: Run the test to verify it fails** Run: `python -m pytest v1/tests/test_config.py -v` Expected: FAIL because `v1.config` does not exist. -- [ ] **Step 3: Implement the smallest configuration interface** +- [x] **Step 3: Implement the smallest configuration interface** ```python import json @@ -114,12 +114,12 @@ def load_config(path: Path) -> AppConfig: `config.local.json`, `artifacts/`, `testdata/private/`, `*.onnx` and camera credentials must be ignored; `config.example.json` contains only `rtsp_url_env`. -- [ ] **Step 4: Run baseline tests and update the standard command** +- [x] **Step 4: Run baseline tests and update the standard command** Run: `python -m pytest v1/tests -v` -Expected: PASS. Update `init.ps1` so it installs `v1/requirements.txt` and runs this command. +Expected: PASS. Update `init.ps1` so it checks the required runtime imports and runs this command; keep `v1/requirements.txt` as an explicit installation command rather than mutating the environment during verification. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```powershell git add .gitignore init.ps1 v1 docs/03-tech-stack.md docs/current-state.md docs/06-tasks.md progress.md diff --git a/docs/api.md b/docs/api.md index 168f5b9..aaeacd9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,7 +15,7 @@ }, "model": { "path": "models/best.pt", - "sha256": "填写受控模型哈希", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", "confidence_threshold": 0.25 }, "event": { @@ -32,7 +32,7 @@ ``` - `rtsp_url_env` 必填;应用从同名环境变量读取真实 URL。 -- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑。 +- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。 - 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。 ## 核心数据 diff --git a/docs/current-state.md b/docs/current-state.md index 5176c51..8fab14f 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,18 +4,18 @@ ## 当前快照 -- 日期:2026-07-20 -- 阶段:V1 工程化起步;T-101 进行中。 +- 日期:2026-07-21 +- 阶段:V1 工程化起步;T-101 已验收,等待 T-102。 - 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。 - 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。 -- V1 代码:`v1/` 目录存在但尚无实现。 +- V1 代码:已建立 `v1/__init__.py`、`v1/config.py`、安全的 `config.example.json`、固定依赖清单和配置单元测试;视频源、Pose、跟踪、证据、状态机和 GUI 尚未实现。 - V2 代码:`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 demo` 已通过;T-101 开始前 `v1/tests` 尚不存在,因此 `python -m pytest v1/tests -v` 以目录未找到退出,这是待建立的初始测试基线。 +- 测试:`python -m compileall -q demo` 已通过;`python -m pytest v1/tests -v` 当前有 2 个配置测试并已通过。`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:V1 依赖清单、示例配置、回归录像及事件标签尚未创建;真实海康 RTSP 流尚未接入。 +- 当前 blocker:回归录像及事件标签尚未创建;真实海康 RTSP 流尚未接入。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 @@ -32,9 +32,9 @@ ## 任务状态 -- 已完成:T-000(Harness 文档与旧基线快照)。 -- 正在进行:T-101(创建 V1 包、依赖清单、示例配置和忽略规则)。 -- 下一个可领取:完成 T-101 后为 T-102。 +- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)。 +- 正在进行:无。 +- 下一个可领取:T-102。 ## 当前可运行内容 diff --git a/init.ps1 b/init.ps1 index d1fb68a..e4bebec 100644 --- a/init.ps1 +++ b/init.ps1 @@ -1,13 +1,14 @@ #!/usr/bin/env pwsh # Silver Pose 的 Windows 统一启动与验证入口。 -# 当前阶段验证 demo 基线;T-101 创建 v1 后,应同步更新本文件和 docs/03-tech-stack.md。 +# V1 的 requirements.txt 是显式安装清单;本脚本只检查已安装依赖,避免修改当前环境。 $ErrorActionPreference = "Stop" 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" function Invoke-Checked { @@ -22,6 +23,7 @@ function Invoke-Checked { Write-Host "==> 当前目录: $($PWD.Path)" Invoke-Checked -Label "检查 Silver Pose Python 依赖" -Command $InstallCmd Invoke-Checked -Label "运行 demo 基线验证" -Command $VerifyCmd +Invoke-Checked -Label "运行 V1 单元测试" -Command $V1TestCmd Write-Host "==> 图形界面启动命令" Write-Host " $StartCmd" diff --git a/progress.md b/progress.md index fc57efb..9d8d368 100644 --- a/progress.md +++ b/progress.md @@ -80,3 +80,12 @@ - 阻塞:无。 - 决策:`init.ps1` 保持无副作用的依赖检查与基线验证,不自动安装包;`requirements.txt` 将作为显式、可复现的安装清单。 - 下一步:先写配置加载与敏感值拒绝的失败测试,再实现最小配置接口。 + +## 【2026-07-21】T-101 创建 V1 包、依赖清单、示例配置和忽略规则(完成) + +- 状态:DONE +- 变更:新增 `v1` 包、经验证的 `config.py`、仅含环境变量名的公开配置示例、与当前环境一致的 `requirements.txt` 和两项 pytest 配置测试;新增 `.gitignore`,忽略本地配置、事件工件、私有录像和模型导出物。`init.ps1` 现在运行 V1 测试但不自动安装依赖。 +- 验证:先运行 `python -m pytest v1/tests/test_config.py -v`,确认因缺少 `v1.config` 产生预期导入失败;实现后 `./init.ps1` 和 `python -m pytest v1/tests -v` 均通过(2 passed),`python -m compileall -q v1 demo` 通过。公开配置的地址/凭证扫描无匹配,`git check-ignore` 已确认本地配置、事件工件、`.pt` 与 `.onnx` 工件被忽略。 +- 阻塞:无。 +- 决策:配置只允许 `rtsp_url_env` 指向运行环境变量,拒绝内嵌来源地址;确认窗口限制为 1–3 秒。公开示例使用 64 位全零哈希占位,T-103 必须替换为受控模型真实哈希后才可加载模型。 +- 下一步:T-102,先建立录像回放和错误来源的失败测试。 diff --git a/v1/__init__.py b/v1/__init__.py new file mode 100644 index 0000000..a4bee09 --- /dev/null +++ b/v1/__init__.py @@ -0,0 +1,5 @@ +"""Silver Pose Python V1 package. + +V1 keeps configuration, video acquisition, pose inference, tracking, evidence, +and temporal event decisions in separate modules. +""" diff --git a/v1/config.example.json b/v1/config.example.json new file mode 100644 index 0000000..99dbfe6 --- /dev/null +++ b/v1/config.example.json @@ -0,0 +1,21 @@ +{ + "source": { + "id": "lobby-camera-01", + "rtsp_url_env": "SILVER_POSE_RTSP_URL" + }, + "model": { + "path": "models/best.pt", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "confidence_threshold": 0.25 + }, + "event": { + "keypoint_confidence_threshold": 0.4, + "suspect_window_seconds": 0.5, + "confirm_window_seconds": 1.8, + "recovery_window_seconds": 2.0, + "cooldown_seconds": 10.0 + }, + "artifacts": { + "event_dir": "../artifacts/events" + } +} diff --git a/v1/config.py b/v1/config.py new file mode 100644 index 0000000..7200582 --- /dev/null +++ b/v1/config.py @@ -0,0 +1,146 @@ +"""Validated, credential-safe configuration loading for Silver Pose V1.""" + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict + + +class ConfigError(ValueError): + """Raised when a configuration file cannot safely start V1.""" + + +@dataclass(frozen=True) +class EventConfig: + keypoint_confidence_threshold: float + suspect_window_seconds: float + confirm_window_seconds: float + recovery_window_seconds: float + cooldown_seconds: float + + +@dataclass(frozen=True) +class AppConfig: + source_id: str + source_url: str + model_path: Path + model_sha256: str + confidence_threshold: float + event: EventConfig + event_dir: Path + + +_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") + + +def _mapping(value: Any, field_name: str) -> Dict[str, Any]: + if not isinstance(value, dict): + raise ConfigError("{0} must be an object".format(field_name)) + return value + + +def _text(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ConfigError("{0} must be a non-empty string".format(field_name)) + return value.strip() + + +def _number(value: Any, field_name: str, minimum: float, maximum: float) -> float: + try: + result = float(value) + except (TypeError, ValueError): + raise ConfigError("{0} must be numeric".format(field_name)) + if not minimum <= result <= maximum: + raise ConfigError( + "{0} must be between {1} and {2}".format(field_name, minimum, maximum) + ) + return result + + +def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path: + raw_path = Path(_text(value, field_name)) + if raw_path.is_absolute(): + return raw_path + return (config_path.parent / raw_path).resolve() + + +def load_config(path: Path) -> AppConfig: + """Load one public/local configuration pair without persisting credentials. + + The config file can name an environment variable but must not embed a source + address. The resolved address remains in memory only. + """ + + config_path = Path(path).resolve() + try: + raw = json.loads(config_path.read_text(encoding="utf-8")) + except OSError as exc: + raise ConfigError("cannot read config: {0}".format(exc)) + except json.JSONDecodeError as exc: + raise ConfigError("invalid JSON config: {0}".format(exc)) + + root = _mapping(raw, "config") + source = _mapping(root.get("source"), "source") + if "url" in source or "rtsp_url" in source: + raise ConfigError("source must define rtsp_url_env, not an embedded address") + environment_name = _text(source.get("rtsp_url_env"), "source.rtsp_url_env") + if not _ENVIRONMENT_NAME.match(environment_name): + raise ConfigError("source.rtsp_url_env must be an environment variable name") + source_url = os.environ.get(environment_name) + if not source_url: + raise ConfigError("missing RTSP environment variable: {0}".format(environment_name)) + + model = _mapping(root.get("model"), "model") + model_sha256 = _text(model.get("sha256"), "model.sha256").lower() + if not _SHA256.match(model_sha256): + raise ConfigError("model.sha256 must be a 64-character SHA-256 value") + + event_raw = _mapping(root.get("event"), "event") + event = EventConfig( + keypoint_confidence_threshold=_number( + event_raw.get("keypoint_confidence_threshold"), + "event.keypoint_confidence_threshold", + 0.0, + 1.0, + ), + suspect_window_seconds=_number( + event_raw.get("suspect_window_seconds"), + "event.suspect_window_seconds", + 0.0, + 30.0, + ), + confirm_window_seconds=_number( + event_raw.get("confirm_window_seconds"), + "event.confirm_window_seconds", + 1.0, + 3.0, + ), + recovery_window_seconds=_number( + event_raw.get("recovery_window_seconds"), + "event.recovery_window_seconds", + 0.0, + 300.0, + ), + cooldown_seconds=_number( + event_raw.get("cooldown_seconds"), + "event.cooldown_seconds", + 0.0, + 3600.0, + ), + ) + + artifacts = _mapping(root.get("artifacts"), "artifacts") + return AppConfig( + source_id=_text(source.get("id"), "source.id"), + source_url=source_url, + model_path=_resolve_path(config_path, model.get("path"), "model.path"), + model_sha256=model_sha256, + confidence_threshold=_number( + model.get("confidence_threshold"), "model.confidence_threshold", 0.0, 1.0 + ), + event=event, + event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"), + ) diff --git a/v1/requirements.txt b/v1/requirements.txt new file mode 100644 index 0000000..9bf8e6e --- /dev/null +++ b/v1/requirements.txt @@ -0,0 +1,5 @@ +numpy==1.24.4 +opencv-python==4.6.0.66 +ultralytics==8.3.205 +PyQt5==5.15.9 +pytest==8.3.5 diff --git a/v1/tests/test_config.py b/v1/tests/test_config.py new file mode 100644 index 0000000..795127d --- /dev/null +++ b/v1/tests/test_config.py @@ -0,0 +1,56 @@ +import json + +import pytest + +from v1.config import ConfigError, load_config + + +def _write_config(path, source): + path.write_text( + json.dumps( + { + "source": source, + "model": { + "path": "models/best.pt", + "sha256": "a" * 64, + "confidence_threshold": 0.25, + }, + "event": { + "keypoint_confidence_threshold": 0.4, + "suspect_window_seconds": 0.5, + "confirm_window_seconds": 1.8, + "recovery_window_seconds": 2.0, + "cooldown_seconds": 10.0, + }, + "artifacts": {"event_dir": "artifacts/events"}, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + +def test_load_config_resolves_source_from_environment_variable(tmp_path, monkeypatch): + config_file = tmp_path / "config.json" + _write_config( + config_file, + {"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL"}, + ) + monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live") + + config = load_config(config_file) + + assert config.source_id == "lobby-camera-01" + assert config.source_url == "rtsp://demo.invalid/live" + assert config.event.confirm_window_seconds == 1.8 + + +def test_load_config_rejects_embedded_source_address(tmp_path): + config_file = tmp_path / "config.json" + _write_config( + config_file, + {"id": "lobby-camera-01", "url": "rtsp://demo.invalid/live"}, + ) + + with pytest.raises(ConfigError, match="rtsp_url_env"): + load_config(config_file)