From bf643de93d4709f80a6336d5da7a529f76cec333 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 21:13:57 +0800 Subject: [PATCH] =?UTF-8?q?docs(studio-3d):=20=E9=98=B6=E6=AE=B5=206=20?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E8=AF=B4=E6=98=8E=EF=BC=88=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E6=80=A7=E4=B8=8E=E5=A4=B1=E6=95=88=E7=8A=B6=E6=80=81=EF=BC=89?= =?UTF-8?q?+=20=E6=8E=A5=E5=8F=A3=E9=AA=A8=E6=9E=B6=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - What: 1. `docs/architecture/studio-3d-stages.md` 新增「阶段 6:运行正确性与失效状态(批次 A)—— 已完成」: 做(epoch/字段级来源/设备几何身份/加载请求号/工具与重编包)、验收表格(含两份探针的 before/after 与全量计数)、"这一阶段抓到的" 7 条、兼容说明 6 条;文首进度改成阶段 6 已完成、 7/8/9 未开始;依赖关系图扩展为 6→7/8→9。 2. `docs/architecture/studio-3d-contracts.pyi` 与本阶段的契约改动同步:`SceneState` 加 `'stale'`; `SurfaceFieldFrame`/`RenderFrame` 加 `computed_frame_id`;`ExpectedGeometry` 加 `source_geometry_id` 并注明米制 id 何时为空;`SceneAssets` 加 `source_geometry_id`/`compatible_sensor_model`/ `compatible_hardware_revisions`;`ThreeDCoordinator.__init__` 加 `clock`/`stale_grace_s`,新增 `note_field_failure()`/`show_rest()`/`epoch`/`last_presented()`。 - Why: 规格 §13 要求每阶段同时交出实现、测试、输入样例、失败样例、兼容说明与平台诊断报告;阶段 6 的 契约改动(`SceneState`、两个 `computed_frame_id`、`ExpectedGeometry`/`SceneAssets` 的新身份字段、 协调器新参数)必须先落到接口骨架里,否则下一阶段会照着过期文档实现。审查报告本身不修改 (`_review_scene3d_20260914/` 保持原样),结论与反号证据写进本仓文档。 - Verification: · 阶段末全量复核(与本提交同一工作树):apps/studio 234 项 0 失败(14 跳过 = offscreen 下的真 GL 用例, 用 `QT_QPA_PLATFORM=windows` 单独跑为 14 项 0 失败 0 跳过);shared 316 项 0 失败 1 跳过; `ui_check` 587 项 0 失败;`locale_check` 0 缺失(zh/en 各 398 键,仅有键集合相同); `validate_scene.py` 对重编发布包 `source geometry recorded 46a03a5fbac1eace` 等全项 ok。 · 复现证据:`.repro/probe_stage6_r1.py`(R1/R7-b 反号)、`.repro/probe_stage6_device.py` (R3:同尺寸不同映射被拒、表面不同被拒、设备不声明 → unverified),输出 JSON 与扫描件留在 `.repro/`。 - Not in this change: 不改代码(本提交只动文档与接口骨架);阶段 7/8/9 的计划条目(二维后端统一、 回放入口收口、能力控件、限速与真实统计、保护包密钥注入、ARM 与 2 小时门禁)尚未开始; 真机上"算法链失败时 `VRAW` 是否也过期"这一条仍待实测(风险项已写进阶段 6 小节)。 --- docs/architecture/studio-3d-contracts.pyi | 20 +++- docs/architecture/studio-3d-stages.md | 126 ++++++++++++++++++++-- 2 files changed, 136 insertions(+), 10 deletions(-) diff --git a/docs/architecture/studio-3d-contracts.pyi b/docs/architecture/studio-3d-contracts.pyi index e09c0a2..848745b 100644 --- a/docs/architecture/studio-3d-contracts.pyi +++ b/docs/architecture/studio-3d-contracts.pyi @@ -8,7 +8,7 @@ by NDArray. See studio-3d-design.md for threading and validation requirements. """ from dataclasses import dataclass from pathlib import Path -from typing import Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar +from typing import Callable, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar from concurrent.futures import Executor import numpy as np @@ -28,7 +28,7 @@ Role: TypeAlias = Literal['sensing', 'housing', 'reference'] Quality: TypeAlias = Literal['calibrated', 'approximate', 'display_only'] Profile: TypeAlias = Literal['desktop33', 'gles30'] OfferResult: TypeAlias = Literal['accepted', 'replaced', 'stale', 'disabled', 'invalid'] -SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable'] +SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable', 'stale'] @dataclass(frozen=True) @@ -75,6 +75,7 @@ class SurfaceFieldFrame: displacement_sensor_m: F32 | None # [H,W,3]; base displacement BEFORE display gain. displacement_quality: Quality | None diagnostic: Diagnostic | None + computed_frame_id: int | None # Frame the array was computed for; None = unstated. @dataclass(frozen=True) @@ -118,9 +119,10 @@ class CameraPreset: class ExpectedGeometry: sensor_model: str hardware_revision: str - geometry_id: str + geometry_id: str # Metre-scaled shape identity; '' when the device cannot state it. field_domain_id: str shape_hw: tuple[int, int] + source_geometry_id: str # Unit-free surface identity a device CAN reproduce. @dataclass(frozen=True) @@ -133,6 +135,9 @@ class SceneAssets: binding: SurfaceBinding # v1 supports exactly one sensing part. camera_presets: tuple[CameraPreset, ...] bounds_sensor_m: F32 # [2,3]: min, max. + source_geometry_id: str # Unit-free source geometry this asset was built from. + compatible_sensor_model: str + compatible_hardware_revisions: tuple[str, ...] # '*' means any revision. @dataclass(frozen=True) @@ -172,6 +177,7 @@ class RenderFrame: valid: Mask # [N]. scalar_descriptor: FieldDescriptor diagnostics: tuple[Diagnostic, ...] + computed_frame_id: int | None # Carried through so the display side re-checks it. @dataclass(frozen=True) @@ -345,17 +351,23 @@ class Sensor3DWidget(QOpenGLWidget): class ThreeDCoordinator: def __init__(self, widget: Sensor3DWidget, composer: SceneComposer, - executor: Executor, max_fps: int = 30) -> None: ... + executor: Executor, max_fps: int = 30, *, + clock: Callable[[], float] = ..., stale_grace_s: float = 1.0) -> None: ... def activate(self, assets: SceneAssets, source_id: str) -> int: ... def submit(self, frame: SurfaceFieldFrame) -> OfferResult: ... + def note_field_failure(self, item: Diagnostic | None = None) -> None: ... def tick(self) -> None: ... def set_visible(self, visible: bool) -> None: ... def set_settings(self, settings: DisplaySettings) -> None: ... + def show_rest(self) -> None: ... def close(self) -> None: ... @property def state(self) -> SceneState: ... @property def generation(self) -> int: ... + @property + def epoch(self) -> int: ... + def last_presented(self) -> tuple[FrameIdentity, float] | None: ... class ThreeDPanel(QWidget): diff --git a/docs/architecture/studio-3d-stages.md b/docs/architecture/studio-3d-stages.md index a0daa07..1e65d7a 100644 --- a/docs/architecture/studio-3d-stages.md +++ b/docs/architecture/studio-3d-stages.md @@ -3,8 +3,11 @@ 配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。 本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。 -阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节)。 -唯一按用户要求暂缓的是 ARM 板卡上的能力报告与长时门禁运行:工具齐备,命令已写在阶段 5 一节。 +阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节); +**阶段 6 已完成(运行正确性与失效状态,见下)**,依据是对 `6de6b8b` 的独立审查 +(`_review_scene3d_20260914/review.md`)与其可控复现。阶段 7(二维后端统一与回放入口收口)、 +阶段 8(界面与有效控制)、阶段 9(性能与发布收尾)尚未开始。 +唯一按用户要求暂缓的是 ARM 板卡上的能力报告与长时门禁运行:工具齐备,命令已写在阶段 5/9 一节。 每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。 原则(写下来是因为后面每阶段都会被诱惑违反一次): @@ -368,13 +371,124 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3 --- +## 阶段 6:运行正确性与失效状态(批次 A)—— 已完成 + +依据:对 `6de6b8b` 的独立审查(`_review_scene3d_20260914/review.md`)给出的 P1 项 R1/R2/R3/R7。 +审查看法经逐条核对后有两处修正:报告提到的 `BindingInformation` 在仓库中不存在(对应物是 +`contracts.SurfaceBinding`);`finger_worker.py` 在 `apps/studio/shared` 一侧而不在 SDK,且 +`compute_error` 是局部变量。`valid=False && flow 非空` 有两种成因(读取失败时 `flow=None`; +算法链失败时 `flow` 仍被填入),所以按**字段级来源**处理,而不是把 `packet.valid` 机械套到三维。 + +**做**: + +- `scene3d/coordinator.py`:**取消 epoch**。`_Inflight`(future/epoch/identity/started)取代裸 Future; + `work()` 只 `return` 合成结果,结果统一在 GUI 线程的 `_collect_inflight()` 里被接受,且必须同时满足 + `epoch` 与 `_identity_is_current()`(source/generation/session/**request_id**);`activate`/ + `begin_session`/`set_visible(False)`/`show_rest`/`close` 走 `_invalidate()`(epoch+1、清两块 mailbox、 + 重置请求号与"最后在场帧")。**未被成功取消的 future 仍占用唯一合成槽**,`CancelledError` 单独吞掉 + (取消不是失败)。新增 `note_field_failure()` 与 `_stale_since` 宽限状态机:字段被拒 / 帧 invalid / + compose 抛错 / 合成结果 `computed_frame_id` 落后 → 立即 `stale` 并保留最后一帧,宽限 + (`stale_grace_s`,默认 1.0 s,`clock` 可注入)到期回参考模型并转 `no_data`,收到好帧回 `live`。 + 顺带修掉"等待首帧时每 tick 重新上传同一个参考模型"(`_rest_presented`)。 +- `acquisition/models.py` + `finger_worker.py`/`sdk_worker.py`:`SdkPacket` 增 `flow_valid`/ + `flow_frame_id`/`depth_valid`/`depth_frame_id`/`forces_valid`(`None` = 未声明),两个生产 worker 逐帧 + 填写"该数组实际算出来的帧号";`stale_forces` 语义不变(力通道)。 +- `scene3d/adapters.py`:`_field_validity()` 三态(未声明→`field_unverified`;声明为假→`field_stale` + 并把 `invalid_reason` 带进诊断;帧号不是本帧→`field_stale` 且两个帧号进 details); + `_intersect_device_mask` 换成 `_device_mask()`——设备 mask 形状不符**拒绝该帧**(既不拉伸也不静默忽略); + 发布帧带 `computed_frame_id`。box 适配器按字段分别判定,形变链失败仍可用有效光流。 +- `scene3d/contracts.py`:`SceneState += "stale"`;诊断码 `field_stale`/`field_unverified`/ + `geometry_unverified`;`SurfaceFieldFrame.computed_frame_id`、`RenderFrame.computed_frame_id` + (composer 原样透传);`validate_surface_frame` 校验帧号。 +- `scene3d/view.py`:`submit_packet()` 把 adapter 的拒绝原因转给 `note_field_failure()`;`tick()` 把协调器 + 诊断的变化经已有 `diagnosticRaised` 冒泡(协调器自身仍零 Qt 依赖)。 +- **设备几何身份(R3)**:`digest.source_geometry_digest(world_xyz)`(无单位);`legacy.convert()` 写入 + `SceneAssets.source_geometry_id`;manifest 增 `source_geometry` 块并还原 `compatible` + (`compatible_sensor_model`/`compatible_hardware_revisions`);`ExpectedGeometry += source_geometry_id`; + `validate_assets()` 只比较**已声明**字段,`source_geometry_id` 不符即拒绝,包内没记录 → 返回 + `geometry_unverified` 警告;修掉 `expected.sensor_model != assets.parts[0].role` 这行无意义比较。 + 新增 `acquisition/geometry.py::describe_sensor_geometry()`:从 `manifest.json` 的 `assets_dir`、 + `sensor.json` 的 `resolution_stitch`/`source_scale` 与据此命名的 unroll map 推导设备侧描述 + (domain + 无单位几何;米制 `geometry_id` 故意留空——那句单位声明只有人能写)。定位不到 bundle → + `verified=False`(可显示、不声明已校验);bundle 变了(map 缺失/与配置不符)→ 拒绝并指名文件与形状。 +- **接线**:`app_window._sync_3d_source()` 传 `expected_provider=`;`SceneAssetLoader` 在**工作线程**解析 + 它与包;`view.use_scene(assets, source_id, expected)` 用 `validate_assets(assets, expected)`,记录 + `device_verified`,`_make_snapshot()` 用**设备**的身份构造 adapter(设备没声明的字段才回退资产); + `describe()` 记录 `scene.device_verified` 与设备身份。 +- **资产加载请求身份(R7-a)**:`SceneAssetLoader` 增请求号与 pending 槽(忙时保留**最新**请求而不是丢弃)、 + 完成信号带请求号(`loaded(assets, 描述, id, device)` / `failed(diag, id)`)、失败可重试; + `Mesh3dPanel` 丢弃过期完成。 +- 工具与包:`compile_scene.py`/`validate_scene.py` 报告 `geometry_id`/`field_domain_id`/ + `source_geometry_id`/兼容信息;`tools/scene_assets/README.md` 说明无单位身份;**重编发布包** + `apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene` + (geometry_id 仍是 `1a2e0e92…`、1032 顶点 / 1974 三角形,新增 `source_geometry_id=46a03a5f…`, + asset_hash `dbdf861c…` → `ea4beab6…`)。新增 locale `scene3d.state_stale`(中英各一条)。 + +**验收(已通过)**: + +| 用例 | 结果 | +|---|---| +| R1 停止后旧任务复活 | 可控复现反号:`.repro/probe_stage6_r1.py` → `stopped_frame_returns` 由 `{valid_after_old_worker_finishes: true, state: "live"}` 变为 `{false, "no_data", epoch_bumped: true, slot_free_again: true}`;20 轮 begin_session→submit→tick→show_rest 循环里最后一个画面上永远没有有效测量,`max_in_flight==1` | +| R7-b request_id | 同一探针:`request_id_not_checked` 由 `{old_submit: "accepted", shown_request: 1}` 变为 `{old_submit: "stale", shown_request: 2, diagnostic: "stale_frame"}`;"较旧请求的结果晚到"在接收侧也被丢弃 | +| R2 失效字段 | 探针 `.repro/probe_stage6_*` 与单测:`flow_valid=False`(带 `invalid_reason`)→ 拒绝并报 `field_stale`;未声明 → `field_unverified`;`flow_frame_id` 落后 → `field_stale`;设备 mask 域不符 → `field_domain_mismatch` 拒帧;compose 抛错 → `stale` + 诊断,好帧恢复 `live`;宽限内保留画面、到期回参考模型 | +| R3 设备几何 | 探针 `.repro/probe_stage6_device.py` → `probe_stage6_device.json`(真实 bundle + 真实包):设备描述 `verified=true`、domain `feb7f71e…`、source geometry `46a03a5f…`、300x280、米制 id 未声明;发布包通过该描述;**同尺寸不同映射**(只改 map 一个像素)→ domain 变化、shape 不变、包被 `field_domain_mismatch` 拒绝;设备什么都不声明 → 接受 + `geometry_unverified` | +| R7-a 加载请求 | 单测:忙时第二个请求成为 pending 并在第一个结束后运行(请求号递增)→ 面板丢弃过期完成 → 失败后可重试 | +| 回归 | apps/studio **234 项 0 失败 14 跳过**(跳过的是 offscreen 下的真 GL 用例)、shared **316 项 0 失败 1 跳过**;真 GL 冒烟 `QT_QPA_PLATFORM=windows` **14 项 0 失败 0 跳过**;`ui_check` 587 项 0 失败;`locale_check` 0 缺失;`validate_scene.py` 对重编包全项 ok | + +新增/改动的测试:`test_scene3d_coordinator.py` +13(epoch/取消/request_id/宽限状态机)、 +`test_scene3d_adapters.py` +6(字段来源与 mask 域)、`test_scene3d_view.py` +9(诊断到界面、设备身份、 +最新请求、过期完成)、`shared/tests/test_scene3d_device_geometry.py` 新增 9 项、 +`test_scene3d_contracts.py` +4、`test_scene3d_package.py` +2。 + +**这一阶段抓到的**(按重要性): + +1. **`Future.cancel()` 不是"完成"**:旧代码在 `_cancel_inflight()` 里就把 `_inflight` 置空,只比较 + generation;于是"停止显示"之后旧 work 结束仍把结果写进 mailbox,下一次 tick 取到它并把状态改回 + `live`——画面回来了,而没有任何东西在测量它。现在接受点在 GUI 线程,epoch 让"这一刻的显示声明" + 成为可比较的事实,未成功取消的任务继续占用唯一合成槽(反复隐藏/显示不会在线程池里排队)。 +2. **"没声明"必须当成"不可用"**:adapter 原来只看 `flow` 是否有有限值。加上字段级来源后, + `None`(生产者没说)→ `field_unverified`,"链失败留下的旧数组"→ `field_stale`。这一条也会打到 + 测试假包上(`ui_check._fake_packet`、`_Packet` 都补了 provenance),这正是"没声明就不算数"的证据。 +3. **静默忽略 mask 域不符 = 悄悄扩大可信范围**:原来形状不符就把设备 mask 丢掉,于是整个 UV 域都算 + "有效"。现在拒绝该帧并写明两个形状。 +4. **"自证匹配"**:主窗口从不传 `expected`,`validate_assets(assets, None)` 跳过全部比对,adapter 的 + geometry/domain/shape 全来自资产本身。修法是两层:设备侧从**运行时真正加载的文件**推导描述, + 且 adapter 用设备的身份盖章。顺带发现米制 `geometry_id` 设备**根本无法复算**(需要"1 源单位 = N 米" + 这句只有人能写的声明),于是补了无单位的 `source_geometry_id`——这才是设备能真正验的那一半。 +5. **`mapping_digest` 没有加**:`field_domain_id` 本身就是由映射内容摘要派生的(含 flip/scale/origin、 + shape 与索引约定),再加一个字段只是同一个事实的第二个名字;计划里那一项据此取消,理由写在此处。 +6. **旧包不能"看起来已验证"**:没有 `source_geometry` 记录的包仍然可读,但 `validate_assets()` 返回 + `geometry_unverified`(info),`describe()` 记 `device_verified=false`。 +7. **工具的门禁一度把"没记录"判成失败**:`validate_scene.py` 新增的 `source geometry recorded` 一开始 + 要求该字段存在,于是 `test_the_release_gate_fails_an_unsigned_package` 里的合成包被判失败——工具测试 + 抓到了这个"兼容即失败"的错误,现在它是中性说明(发布门禁仍由 `--require-signature` 表达)。 + +**兼容说明**: + +- 契约新增字段**全部可选且有默认**:`SdkPacket` 的五个来源字段默认为 `None`(因此**旧测试假包会被拒绝**, + 这是有意的严格化;两个生产 worker 都已声明);`SurfaceFieldFrame`/`RenderFrame.computed_frame_id` + 默认 `None`;`ExpectedGeometry.source_geometry_id`、`SceneAssets` 三个新字段默认空串/空元组。 +- **旧包照读**:manifest 里没有 `source_geometry` 时读成空串→未校验;`compatible` 一直存在,现在才被 + 真正用来核对型号/硬件版本(`*` = 任意版本)。 +- **重编包改变 `asset_hash`**(`dbdf861c…` → `ea4beab6…`,geometry_id 不变):旧录像的 + `visualization3d` 块只是记录,不影响读取;`session.json` 的块版本未变(只增键)。 +- 审查用的复现脚本里 `coordinator._inflight` 被当作 Future 使用,现在它是 `_Inflight` 记录,脚本无法 + 原样运行;等价探针(`.repro/probe_stage6_r1.py`)覆盖同样的两条结论并留在 `.repro/`。 +- **真机待验证一项**:算法链失败时 SDK 的 `VRAW`(光流)是否也过期。当前实现取保守解释(链失败即本帧 + 派生量不可声明);若实测证明 `VRAW` 仍可靠,`flow_valid` 放宽为只看读取结果是一行改动。 +- 长度单位仍只在编译期:设备侧不声明米制几何,`ExpectedGeometry.geometry_id` 留空,绝不猜。 + +--- + ## 依赖关系(并行度) ``` -阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┐ - │ ├─> 阶段 5 -阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘ +阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┬─> 阶段 5 ─> 阶段 6 (正确性/失效) ─┬─> 阶段 7 (二维后端统一) + │ │ └─> 阶段 8 (界面与能力) +阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘ └─> 阶段 9 (性能/发布) ``` -阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3。 +阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3; +阶段 6 需要 3 与 4 的接线;阶段 7 与 8 可并行(7 不依赖三维,8 依赖 6 的失效状态与能力信号); +阶段 9 需要 6 的时钟/状态与 7 的后端收口。 每阶段开始前先确认上一阶段的失败样例仍然被覆盖(回归门)。