fix(v1): wire model confidence, explicit source mode, unique event ids
A: PoseAdapter.set_confidence_threshold is applied on start, so the
settings model-confidence field actually affects inference.
B: config source.mode ('stream'|'replay') is explicit; app no longer
guesses the source type from the URL prefix.
C: FallStateMachine takes a session_id and from_config generates a
unique one per run, so event ids never collide across restarts
(no screenshot overwrite or duplicate JSONL identity in a day).
51 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"source": {
|
||||
"id": "lobby-camera-01",
|
||||
"rtsp_url_env": "SILVER_POSE_RTSP_URL",
|
||||
"mode": "stream",
|
||||
"roi_normalized": [0.0, 0.0, 1.0, 1.0]
|
||||
},
|
||||
"model": {
|
||||
@@ -32,6 +33,8 @@
|
||||
```
|
||||
|
||||
- `rtsp_url_env` 必填;应用从同名环境变量读取真实 URL。
|
||||
- `source.mode` 显式声明来源类型,取值 `stream`(默认,实时 RTSP,允许有界重连)或 `replay`(本地录像,EOF 不重放);由配置决定,不再按 URL 前缀猜测。
|
||||
- `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。
|
||||
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
|
||||
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
|
||||
|
||||
@@ -76,6 +79,8 @@ FallEvent = {
|
||||
|
||||
`FallEvent` 是状态机的纯内存确认事件,只在状态首次进入 `CONFIRMED` 时创建一次。连续帧更新 UI 状态,但不重复创建事件。`config_version` 是由运行配置快照计算的非敏感版本标识;T-202 的 `alerts` 会在不改变事件幂等语义的前提下,为截图/JSONL 记录补充来源、UTC 时间和证据。
|
||||
|
||||
`event_id` 带每次监控运行的会话前缀(`FALL-<session>-NNNNNN`),跨监控重启全局唯一;因此同一天目录内的截图不会被覆盖,`events.jsonl` 也不会出现同 id 不同内容的记录。
|
||||
|
||||
T-202 的 `alerts` 按 `event_id` 去重,对首次 CONFIRMED 只触发一次副作用(声音、弹窗、截图、JSONL 一行)。JSONL 记录如下,文件名与字段不含 RTSP 地址、凭证或客户姓名:
|
||||
|
||||
```text
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 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` 提供声音与一次性弹窗)触发一次声音/弹窗。真实海康 RTSP 接入(T-203)尚未实现。
|
||||
- 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 v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py` 语法);`python -m pytest v1/tests -v` 当前有 44 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型和报警工件测试并已通过。`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` 语法);`python -m pytest v1/tests -v` 当前有 51 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型和报警工件测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一的复核修复)。`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 测试,但不会安装软件包。
|
||||
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
||||
- 当前标准启动:`./init.ps1`。
|
||||
- 当前标准验证:`python -m compileall -q demo`。
|
||||
|
||||
@@ -226,3 +226,12 @@
|
||||
- 阻塞:现场海康摄像头、网络与真实 RTSP 凭证;经同意成年人的摔倒/反例录像与现场安全措施。决策人:用户/现场负责人。
|
||||
- 决策:不以合成数据冒充现场 RTSP 预览或摔倒/反例录像验收(AGENTS.md:任务完成的证据是可运行命令与可观察结果,安全模拟摔倒必须使用经同意成年人与现场安全措施)。代码侧的重连、状态与幂等逻辑已就绪,解除阻塞后可直接进入现场验证。
|
||||
- 下一步:待用户提供现场流/录像后领取 T-203;期间可按需在 Windows 执行 T-201/T-202 的 GUI、声音与弹窗可视化冒烟。
|
||||
|
||||
## 【2026-07-21】FIX T-201/T-202 复核问题 A/B/C
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:修复三处复核问题。A:`PoseAdapter` 新增 `set_confidence_threshold`/`confidence_threshold`,`app.py` 在开始监控时按运行配置调用,使设置里的模型检测置信度真正生效(此前 adapter 置信度构造后固定、草稿字段为死旋钮)。B:`config.py` 新增显式 `source.mode`(`stream` 默认/`replay`,校验取值),`AppConfig.source_mode` 由配置决定,`app.py` 不再按 `source_url.startswith("rtsp")` 猜测来源模式;`config.example.json` 与 `docs/api.md` 同步。C:`FallStateMachine` 新增 `session_id`,`event_id` 变为 `FALL-<session>-NNNNNN`;`FallPipeline.from_config` 每次运行经 `new_session_id()` 生成进程内唯一会话,避免同一天内重启监控时截图被覆盖、JSONL 出现同 id 不同内容。
|
||||
- 验证:新增/更新测试——config 三项(默认 stream、解析 replay、非法 mode 被拒)、pose 两项(置信度转发到推理、越界被拒)、fall_state 一项(不同会话 event_id 唯一)、pipeline 一项(两次 from_config 的确认事件 id 不同);`python3 -m pytest v1/tests -q` 为 51 passed;`python3 -m compileall -q v1 demo` 退出码 0。
|
||||
- 阻塞:无。声音/弹窗/GUI 可视化冒烟仍需 Windows。
|
||||
- 决策:来源模式由配置显式声明而非猜测;模型置信度经适配器方法在下次启动生效,保持“运行配置快照”语义;事件身份以会话前缀保证跨轮唯一,dedup 基于该持久标识。
|
||||
- 下一步:T-203(受阻,等现场流)。
|
||||
|
||||
@@ -34,7 +34,7 @@ class FrameWorker(QtCore.QThread):
|
||||
self._stop = False
|
||||
|
||||
def run(self) -> None:
|
||||
mode = SourceMode.STREAM if self._config.source_url.startswith("rtsp") else SourceMode.REPLAY
|
||||
mode = SourceMode.STREAM if self._config.source_mode == "stream" else SourceMode.REPLAY
|
||||
source = VideoSource(self._config.source_url, mode=mode)
|
||||
pipeline = FallPipeline.from_config(self._config, self._pose_adapter)
|
||||
try:
|
||||
@@ -103,6 +103,7 @@ class ApplicationController:
|
||||
|
||||
self._draft.start_monitoring()
|
||||
running = _running_config(self._config, self._draft)
|
||||
self._pose_adapter.set_confidence_threshold(running.confidence_threshold)
|
||||
writer = EventArtifactWriter(running.event_dir, running.source_id)
|
||||
self._dispatcher = AlertDispatcher(writer, QtAlertSink(self.window))
|
||||
worker = FrameWorker(running, self._pose_adapter)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"source": {
|
||||
"id": "lobby-camera-01",
|
||||
"rtsp_url_env": "SILVER_POSE_RTSP_URL"
|
||||
"rtsp_url_env": "SILVER_POSE_RTSP_URL",
|
||||
"mode": "stream"
|
||||
},
|
||||
"model": {
|
||||
"path": "models/best.pt",
|
||||
|
||||
@@ -31,6 +31,7 @@ class AppConfig:
|
||||
confidence_threshold: float
|
||||
event: EventConfig
|
||||
event_dir: Path
|
||||
source_mode: str = "stream"
|
||||
|
||||
@property
|
||||
def runtime_config_version(self) -> str:
|
||||
@@ -113,6 +114,10 @@ def load_config(path: Path) -> AppConfig:
|
||||
if not source_url:
|
||||
raise ConfigError("missing RTSP environment variable: {0}".format(environment_name))
|
||||
|
||||
source_mode = source.get("mode", "stream")
|
||||
if source_mode not in ("replay", "stream"):
|
||||
raise ConfigError("source.mode must be 'replay' or 'stream'")
|
||||
|
||||
model = _mapping(root.get("model"), "model")
|
||||
model_sha256 = _text(model.get("sha256"), "model.sha256").lower()
|
||||
if not _SHA256.match(model_sha256):
|
||||
@@ -163,4 +168,5 @@ def load_config(path: Path) -> AppConfig:
|
||||
),
|
||||
event=event,
|
||||
event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"),
|
||||
source_mode=source_mode,
|
||||
)
|
||||
|
||||
+4
-1
@@ -48,6 +48,7 @@ class FallStateMachine:
|
||||
recovery_window_seconds: float,
|
||||
config_version: str,
|
||||
cooldown_seconds: float = 0.0,
|
||||
session_id: str = "",
|
||||
) -> None:
|
||||
if not 1.0 <= confirm_window_seconds <= 3.0:
|
||||
raise ValueError("confirm_window_seconds must be between 1 and 3 seconds")
|
||||
@@ -61,6 +62,7 @@ class FallStateMachine:
|
||||
self._recovery_window_seconds = float(recovery_window_seconds)
|
||||
self._cooldown_seconds = float(cooldown_seconds)
|
||||
self._config_version = config_version.strip()
|
||||
self._session_id = str(session_id).strip()
|
||||
self._records: Dict[str, _Record] = {}
|
||||
self._next_event_number = 1
|
||||
|
||||
@@ -141,8 +143,9 @@ class FallStateMachine:
|
||||
def _new_event(
|
||||
self, track_id: str, suspected_at: float, confirmed_at: float
|
||||
) -> FallEvent:
|
||||
prefix = "FALL-{0}-".format(self._session_id) if self._session_id else "FALL-"
|
||||
event = FallEvent(
|
||||
event_id="FALL-{0:06d}".format(self._next_event_number),
|
||||
event_id="{0}{1:06d}".format(prefix, self._next_event_number),
|
||||
track_id=track_id,
|
||||
config_version=self._config_version,
|
||||
suspected_at_monotonic=suspected_at,
|
||||
|
||||
+24
-3
@@ -1,7 +1,9 @@
|
||||
"""Compose Pose, tracking, evidence policy, and temporal fall state."""
|
||||
|
||||
import itertools
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Sequence, Tuple
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, Sequence, Tuple
|
||||
|
||||
from v1.config import AppConfig
|
||||
from v1.evidence import PoseEvidence, assess_pose_quality, extract_evidence
|
||||
@@ -11,6 +13,17 @@ from v1.tracking import PersonTracker, TrackedPersonPose
|
||||
from v1.video_source import FramePacket, SourceStatus
|
||||
|
||||
|
||||
_SESSION_COUNTER = itertools.count(1)
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
"""Return a process-unique, human-readable run id for event traceability."""
|
||||
|
||||
return "{0}-{1:03d}".format(
|
||||
datetime.now().strftime("%Y%m%d-%H%M%S"), next(_SESSION_COUNTER)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonAnalysis:
|
||||
tracked_pose: TrackedPersonPose
|
||||
@@ -47,8 +60,15 @@ class FallPipeline:
|
||||
self._active_track_ids = set()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: AppConfig, pose_adapter) -> "FallPipeline":
|
||||
"""Create one immutable runtime decision flow from validated config."""
|
||||
def from_config(
|
||||
cls, config: AppConfig, pose_adapter, session_id: Optional[str] = None
|
||||
) -> "FallPipeline":
|
||||
"""Create one immutable runtime decision flow from validated config.
|
||||
|
||||
Each run gets a unique ``session_id`` so confirmed-event IDs never collide
|
||||
across monitoring restarts within the same day (no screenshot overwrite or
|
||||
duplicate JSONL identity).
|
||||
"""
|
||||
|
||||
return cls(
|
||||
pose_adapter=pose_adapter,
|
||||
@@ -59,6 +79,7 @@ class FallPipeline:
|
||||
recovery_window_seconds=config.event.recovery_window_seconds,
|
||||
cooldown_seconds=config.event.cooldown_seconds,
|
||||
config_version=config.runtime_config_version,
|
||||
session_id=session_id or new_session_id(),
|
||||
),
|
||||
keypoint_confidence_threshold=config.event.keypoint_confidence_threshold,
|
||||
)
|
||||
|
||||
+12
@@ -66,6 +66,18 @@ class PoseAdapter:
|
||||
def model_path(self) -> Path:
|
||||
return self._model_path
|
||||
|
||||
@property
|
||||
def confidence_threshold(self) -> float:
|
||||
return self._confidence
|
||||
|
||||
def set_confidence_threshold(self, value: float) -> None:
|
||||
"""Update the inference confidence so settings changes take effect."""
|
||||
|
||||
confidence = float(value)
|
||||
if not 0.0 <= confidence <= 1.0:
|
||||
raise ModelValidationError("confidence threshold must be between 0 and 1")
|
||||
self._confidence = confidence
|
||||
|
||||
def infer(self, image: np.ndarray) -> Sequence[PersonPose]:
|
||||
results = self._model(image, conf=self._confidence, verbose=False)
|
||||
return self.from_results(results, self._person_class_ids)
|
||||
|
||||
@@ -56,6 +56,40 @@ def test_load_config_rejects_embedded_source_address(tmp_path):
|
||||
load_config(config_file)
|
||||
|
||||
|
||||
def test_source_mode_defaults_to_stream(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")
|
||||
|
||||
assert load_config(config_file).source_mode == "stream"
|
||||
|
||||
|
||||
def test_source_mode_replay_is_parsed(tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
config_file,
|
||||
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", "mode": "replay"},
|
||||
)
|
||||
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live")
|
||||
|
||||
assert load_config(config_file).source_mode == "replay"
|
||||
|
||||
|
||||
def test_invalid_source_mode_is_rejected(tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
config_file,
|
||||
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", "mode": "loop"},
|
||||
)
|
||||
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live")
|
||||
|
||||
with pytest.raises(ConfigError, match="mode"):
|
||||
load_config(config_file)
|
||||
|
||||
|
||||
def test_runtime_config_version_is_stable_and_excludes_rtsp_address(tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
|
||||
@@ -82,6 +82,25 @@ def test_confirmation_window_must_remain_within_customer_target():
|
||||
)
|
||||
|
||||
|
||||
def test_session_id_makes_event_ids_unique_across_runs():
|
||||
run_a = FallStateMachine(
|
||||
confirm_window_seconds=1.0, recovery_window_seconds=2.0,
|
||||
config_version="cfg", session_id="run-a",
|
||||
)
|
||||
run_b = FallStateMachine(
|
||||
confirm_window_seconds=1.0, recovery_window_seconds=2.0,
|
||||
config_version="cfg", session_id="run-b",
|
||||
)
|
||||
run_a.update("P-0001", Evidence(True, True), now=0.0)
|
||||
event_a = run_a.update("P-0001", Evidence(True, True), now=1.0)
|
||||
run_b.update("P-0001", Evidence(True, True), now=0.0)
|
||||
event_b = run_b.update("P-0001", Evidence(True, True), now=1.0)
|
||||
|
||||
assert event_a[0].event_id != event_b[0].event_id
|
||||
assert "run-a" in event_a[0].event_id
|
||||
assert "run-b" in event_b[0].event_id
|
||||
|
||||
|
||||
def test_each_track_has_an_independent_confirmation_window():
|
||||
machine = FallStateMachine(
|
||||
confirm_window_seconds=1.0,
|
||||
|
||||
@@ -119,3 +119,18 @@ def test_pipeline_from_config_uses_runtime_version_for_confirmed_event():
|
||||
result = pipeline.process(_packet(1.1))
|
||||
|
||||
assert result.events[0].config_version == config.runtime_config_version
|
||||
|
||||
|
||||
def test_from_config_gives_each_run_a_unique_event_id():
|
||||
config = _config()
|
||||
|
||||
def confirm(pipeline):
|
||||
pipeline.process(_packet(0.0))
|
||||
pipeline.process(_packet(0.1))
|
||||
return pipeline.process(_packet(1.1)).events[0].event_id
|
||||
|
||||
frames = [(_pose(),), (_pose(horizontal=True),), (_pose(horizontal=True),)]
|
||||
first = confirm(FallPipeline.from_config(config, _SequencePoseAdapter(list(frames))))
|
||||
second = confirm(FallPipeline.from_config(config, _SequencePoseAdapter(list(frames))))
|
||||
|
||||
assert first != second
|
||||
|
||||
@@ -46,6 +46,61 @@ def test_from_results_extracts_person_box_confidence_and_seventeen_keypoints():
|
||||
assert poses[0].keypoints[5].confidence == 0.9
|
||||
|
||||
|
||||
class _RecordingPoseModel:
|
||||
task = "pose"
|
||||
names = {0: "person"}
|
||||
|
||||
class model:
|
||||
kpt_shape = (17, 3)
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, image, conf, verbose):
|
||||
self.calls.append(conf)
|
||||
|
||||
class _Empty:
|
||||
names = {0: "person"}
|
||||
|
||||
class boxes:
|
||||
xyxy = []
|
||||
conf = []
|
||||
cls = []
|
||||
|
||||
class keypoints:
|
||||
data = []
|
||||
|
||||
return _Empty()
|
||||
|
||||
|
||||
def test_set_confidence_threshold_is_forwarded_to_inference(tmp_path):
|
||||
weights = tmp_path / "pose.pt"
|
||||
weights.write_bytes(b"fake-weights")
|
||||
expected_sha256 = hashlib.sha256(weights.read_bytes()).hexdigest()
|
||||
model = _RecordingPoseModel()
|
||||
adapter = PoseAdapter(
|
||||
weights, expected_sha256, confidence_threshold=0.25, model_factory=lambda _p: model
|
||||
)
|
||||
|
||||
adapter.set_confidence_threshold(0.6)
|
||||
adapter.infer(np.zeros((10, 10, 3), dtype=np.uint8))
|
||||
|
||||
assert adapter.confidence_threshold == 0.6
|
||||
assert model.calls == [0.6]
|
||||
|
||||
|
||||
def test_set_confidence_threshold_rejects_out_of_range(tmp_path):
|
||||
weights = tmp_path / "pose.pt"
|
||||
weights.write_bytes(b"fake-weights")
|
||||
expected_sha256 = hashlib.sha256(weights.read_bytes()).hexdigest()
|
||||
adapter = PoseAdapter(
|
||||
weights, expected_sha256, model_factory=lambda _p: _RecordingPoseModel()
|
||||
)
|
||||
|
||||
with pytest.raises(ModelValidationError):
|
||||
adapter.set_confidence_threshold(1.5)
|
||||
|
||||
|
||||
def test_pose_adapter_rejects_non_pose_model_after_hash_validation(tmp_path):
|
||||
model_path = tmp_path / "model.pt"
|
||||
model_path.write_bytes(b"model bytes")
|
||||
|
||||
Reference in New Issue
Block a user