docs(studio-3d): 阶段 7–9 交付说明 + 接口骨架同步 + 保护包的应用侧配置

- What:
  1. `docs/architecture/studio-3d-stages.md` 新增三节:**阶段 7**(二维图像后端统一与回放入口收口,含起因、
     做法、验收表格)、**阶段 8 第一批**(能力控件/真实色标/复位语义/命名)、**阶段 9 上半**(限速、真实统计、
     无位移复用几何、保护包密钥),并新开一节「尚未完成」把两项写清楚:阶段 8 第二批(画布布局与曲线量程)与
     阶段 9 的门禁运行(GUI 2 小时长时 + 两块 ARM 板的探针/性能/长时报告),外加阶段 6 遗留的真机验证项。
     文首进度与依赖关系图同步。
  2. `docs/architecture/studio-3d-contracts.pyi`:新增 `SceneCapabilities`;`RenderStats` 增
     `paint_calls`/`scale_lower`/`scale_upper`/`scale_mode` 并注明 `displayed_frames` 是"按帧身份的唯一帧"
     而不是绘制次数;`RenderFrame` 增 `compose_ms`。
  3. `tools/scene_assets/README.md`:新增「Studio 应用侧的配置」——`scene3d_verify_key`/
     `scene3d_content_key_file` + `scene3d_content_key_id`/`scene3d_require_signature` 的用法、为什么没有验签
     公钥就打不开签名包、以及"密钥文件缺失是状态而非崩溃、记录里只留布尔与路径"。
- Why: 规格 §13 要求每阶段同时交出实现、测试、输入样例、失败样例、兼容说明与平台诊断报告;阶段 6 的文档提交
  之后又落了阶段 7/8/9 的接口改动,骨架文件必须跟上,否则下一阶段会照着过期文档实现。同时把"没做完的两项"
  写在文档里(而不是留在对话里),下一个接手的人不需要猜。
- Verification: 文档改动本身不执行测试;其中的数字全部来自本仓可复现的命令:`pytest`(shared+apps 同一次
  运行 588 项 0 失败 15 跳过)、真 GL 冒烟 14 项 0 失败、`ui_check.py` 587 项 0 失败、`locale_check.py`
  0 缺失、`bench_scene.py --cpu-only`(20k 顶点 1.03 ms / 真实包 0.17 ms)、`validate_scene.py`(重编包
  全项 ok)与两份 `.repro/probe_stage6_*.json`。
- Not in this change: 不改代码;阶段 8 第二批与阶段 9 门禁仍未做(见文档「尚未完成」一节);ARM 板卡报告
  按用户先前的意思仍是暂缓项。
This commit is contained in:
ykcheungab
2026-09-14 22:02:43 +08:00
parent ef487098c9
commit cf45e654e1
3 changed files with 192 additions and 9 deletions
+17 -2
View File
@@ -178,6 +178,7 @@ class RenderFrame:
scalar_descriptor: FieldDescriptor scalar_descriptor: FieldDescriptor
diagnostics: tuple[Diagnostic, ...] diagnostics: tuple[Diagnostic, ...]
computed_frame_id: int | None # Carried through so the display side re-checks it. computed_frame_id: int | None # Carried through so the display side re-checks it.
compose_ms: float # This frame's own compose cost; the shared counter is rewritten.
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -199,14 +200,28 @@ class GLCapabilities:
diagnostics: tuple[Diagnostic, ...] diagnostics: tuple[Diagnostic, ...]
@dataclass(frozen=True)
class SceneCapabilities:
"""What the *data source* can provide — not what the pane can draw."""
quantity: str
unit: str
displacement_available: bool # False for the current adapters: they colour in place.
displacement_quality: Quality | None
source_kind: str # live, recompute, replay.
@dataclass(frozen=True) @dataclass(frozen=True)
class RenderStats: class RenderStats:
displayed_frames: int displayed_frames: int # Unique frames presented, by identity (not paint calls).
superseded_frames: int superseded_frames: int
compose_ms: float paint_calls: int # Every paintGL, so a slow camera move is not a slow pipeline.
compose_ms: float # The frame's own measured cost (RenderFrame.compose_ms).
upload_ms: float # CPU submission time unless a separate GPU timer is used. upload_ms: float # CPU submission time unless a separate GPU timer is used.
paint_ms: float paint_ms: float
estimated_gpu_bytes: int estimated_gpu_bytes: int
scale_lower: float # The colour bounds the shader was actually given.
scale_upper: float
scale_mode: str # 'automatic' or 'fixed'.
class KeyProvider(Protocol): class KeyProvider(Protocol):
+148 -7
View File
@@ -4,10 +4,13 @@
本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。 本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。
阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节); 阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节);
**阶段 6 已完成(运行正确性与失效状态,见下)**,依据是对 `6de6b8b` 的独立审查 **阶段 6 已完成(运行正确性与失效状态)**,依据是对 `6de6b8b` 的独立审查
(`_review_scene3d_20260914/review.md`)与其可控复现。阶段 7(二维后端统一与回放入口收口)、 (`_review_scene3d_20260914/review.md`)与其可控复现。**阶段 7 已完成(二维图像后端统一 + 回放入口收口)**、
阶段 8(界面与有效控制)、阶段 9(性能与发布收尾)尚未开始。 **阶段 8 第一批已完成(能力控件、真实色标、复位语义、命名)**、
唯一按用户要求暂缓的是 ARM 板卡上的能力报告与长时门禁运行:工具齐备,命令已写在阶段 5/9 一节。 **阶段 9 上半已完成(限速、真实统计、无位移复用几何、保护包密钥注入)**。
未完成的两项写在这里,不藏在细节里:**阶段 8 第二批(画布布局:2D/3D 页签容器、参数抽屉、窄窗口回退、
诊断抽屉、曲线量程开关)**,以及**阶段 9 的门禁运行**(GUI 模式 2 小时长时;Jetson Orin Nano 与 QCS6490
上的 `probe_gl.py`/`bench_scene.py`/`soak_scene.py` 报告)。
每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。 每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。
原则(写下来是因为后面每阶段都会被诱惑违反一次): 原则(写下来是因为后面每阶段都会被诱惑违反一次):
@@ -480,12 +483,150 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
--- ---
## 阶段 7:二维图像后端统一与回放入口收口 —— 已完成
依据:用户新增的两条问题——「历史回放面板的 2 维图只能显示原始采集图像,没有进行任何处理」与
「实时检测界面还是可以回放视频」,以及"每一个 2D 图的后端保持一致"的建议。核对后确认根因是架构:
二维实际有**四个**生产者(实时、重算、回放、摄像头预览)与**两套**成像栈(实时/重算走
`draw_arrow_display`/`depth_map_to_bgr`,回放/预览把 BGR 直接交给面板),而视图模式与注解由**视图预设**
(`set_source_kind("replay")`)而不是帧内容决定;`_bgr_to_qimage` 还有两份实现。
**做**:
- 新增 `shared/.../acquisition/image_backend.py`(纯 NumPy):`ImageFrame`(底图 + flow/depth/valid_mask +
source/frame_id/t_rel_s/stale)、`ImagePlan`、`plan_image()`、`render_image()`、模式常量
(`arrows_pure`/`arrows_stitched`/`depth`/`recorded`)与来源常量(live/recompute/replay/preview)。
`plan_image()` 分两个问题回答:**可选项**由来源决定(live/recompute = 三种派生视图;录像/预览 = 只有
`recorded`),**这一帧能不能画**由 `missing` 说明(某一帧缺 flow/depth 不会悄悄改掉操作者选的视图,而是
在页脚写一行)。`render_image()` 是唯一渲染入口,内部复用 `arrows.py` 的三个公共函数(签名不变);
深度图**改用主题色带**——原来窗口没传 colormap,深度图走 OpenCV JET 而色条画品牌色带,图例与图片两张表。
- `ui/arrow_panel.py`:新增 `bgr_to_qimage()`(全应用唯一一份;`app_window._bgr_to_qimage` 成为别名、
`replay.py` 的重复实现删除);`set_plan()/plan_modes()` 决定模式集与页脚注解,新增一行 `note_label`
说明 plan(如"录像只保存原始相机图像…"),`view_modes()` 在无 plan 时回退旧的预设式列表,
`set_view_mode()/_populate_view_combo()` 按当前模式集校验(否则 `recorded` 会被强行改回 `arrows_pure`)。
- `app_window.py`:唯一入口 `_show_image_frame(ImageFrame)`(plan → 渲染 → 一个 QImage 给面板),
**四个生产者全部改走它**;`_render_run_image` 里按 `mode` 分支画箭头的代码整体删除。
- `replay.py`:新增 `video_frame_bgr()`(按光标缓存 BGR),`video_image()` 保留为兼容薄封装。
- **回放一键重算**:`ui/toolbar.py` 的 replay 组新增 `btn_recompute`("由视频重算…",与菜单
`act_recompute` 同一个开关),2D 面板的 note 直接指路。
- **实时界面禁止回放入口**:新增 `_sync_replay_actions()`——`act_open_video`/`act_playback`/
`act_playback_latest` 只在历史回放视图可用,否则禁用 + tooltip 说明原因;
`_load_replay_video()`/`_open_latest_recording()` 在非 replay 预设**拒绝并提示**,不再把采集源偷偷
切成文件。实时视图用视频**当采集源**("回到摄像机")是另一项既有能力,保留不变。
- locale:`panels.replay_raw_note`/`frame_missing_note`/`field_flow`/`field_depth`/`recompute_pipeline(_tip)`、
`toolbar.recompute(_tip)`/`replay_view_only`(中英各一套)。
**验收(已通过)**:
| 用例 | 结果 |
|---|---|
| 后端单一 | `shared/tests/test_image_backend.py` 15 项:三类来源的 mode 计划、`missing`/note、模式回退、**与旧渲染逐像素一致**(arrows 对 `draw_arrow_display`、depth 对 `depth_map_to_bgr` 带主题色带)、原始帧不缩放不替图、缺 depth → None、缺 flow → 底图 |
| 路径一致 | `test_ui_smoke.py`:同一段视频在**回放**路径只出 `recorded` 且有说明行、在**重算/实时**路径出三种派生视图且都经过同一个 `render_image`(spy 断言来源与模式);"全应用只有一处 BGR→QImage"的结构断言 |
| 回放入口收口 | 实时预设下三个文件菜单动作禁用且 tooltip 带原因、rail 无回放/重算按钮、`_load_replay_video()` 返回 False 且 `source_mode()`/播放定时器不变、空格不启动回放;切到历史回放后三者可用、裸视频仍能打开、rail 的重算按钮可点并同步 `act_recompute` |
| 不回归 | 缺深度仍报"本帧没有深度数据"(不回退显示普通形变图);只导入视频仍可播放(既有用例) |
---
## 阶段 8(第一批):能力控件、真实色标、复位语义、命名 —— 已完成
**做**:
- `contracts.SceneCapabilities`(quantity/unit/displacement_available/displacement_quality/source_kind);
两个适配器都声明 `displacement_available = False`(只把标量画在表面上,从不发布位移);
`Scene3DView.capabilities()` 从**实际绑定的适配器**读出并推给面板。`ThreeDPanel.set_capabilities()` 把
"放大形变"/增益置灰并给出原因(`scene3d.deform_unavailable`),`settings()` 在能力缺失时**强制**
`deform_enabled=False`;禁用状态在 `set_enabled`/`apply_settings` 之后仍成立。
- 真实色标:`GLRenderer` 记录并暴露本次绘制**真正使用的**上下限与模式(`effective_scale()`),写进
`RenderStats.scale_lower/scale_upper/scale_mode`;`_scalar_range` 在 `release()` 与 `upload_scene()` 重置;
面板新增 `ColorScaleBar`(同一张 `lut_rgba` 表),图例改为
`{quantity} [{unit}] · {自动|固定}色标 {lower} – {upper} · {quality}`,自动模式下不再让被禁用的输入框
冒充"当前范围"。
- 文案:`quantity_flow_magnitude`="光流幅值"、preset "default" 显示为"默认视角"(数据仍是资产里的真名)、
`scene3d.stats` 的"丢弃"改为"显示跳帧"(与录像丢帧区分)。
- 复位语义:`reset_view()` 回到**资产自己的默认视角**(`camera_presets[0]`),与首次显示一致;无 preset 时
才退回 `OrbitCamera.reset()`。
- `describe()` 记录 `capabilities` 与 `effective_scale`。
**验收(已通过)**:`test_scene3d_view.py` +3(无位移源 → 控件禁用且带原因、设置仍为 False、能力与
`describe()` 一致;图例显示实际上下限 0.25–7.5 与"自动"且不含内部键;复位后的相机矩阵等于默认 preset 的
矩阵);`test_scene3d_skeleton.py` 的图例断言改为"不含内部键、含操作者语言的名字"。
---
## 阶段 9(上半):限速、真实统计、无位移复用几何、保护包密钥 —— 已完成
**做**:
- **限速**:`ThreeDCoordinator.tick()` 用注入的单调时钟与 `display_period_s() = 1/max_fps` 限制**合成启动**
频率;声明边界把预算清零。生产者侧改为**按需**跳过:`submit_packet()` 在 `has_pending_frame()` 为真时
不再构造 O(H×W) 快照(mailbox 只保留最新一帧,两次拷贝必然丢一个),计入新的 `skipped_producer`,
与"显示跳帧"分开,且**不丢任何本该显示的帧**。
- **真实统计**:`RenderFrame.compose_ms`(composer 在构造帧之前测量并写入);`GLRenderer` 的绘制计数改名
`paint_calls`,`RenderStats.displayed_frames` 由 widget 按**帧身份**统计;`render_totals()` 增
`compose_ms`/`latency_ms`/`paint_calls`/`skipped_producer`,`shown`/`compose_ms` 改从 widget 读
(无 GL 的机器也能报告)。
- **无位移复用资产几何**:`SceneComposer` 缓存 `asset_hash → (positions, normals)`;变形时照旧重算法线,
不变形时直接用资产自己的只读数组。
- **保护包密钥注入**:新增 `scene3d/keys.py`(preference 的 `scene3d_verify_key`/
`scene3d_content_key_file`+`scene3d_content_key_id`/`scene3d_require_signature` → `SceneKeys`),
`AppWindow._sync_3d_source()` 解析并 `Mesh3dPanel.set_keys()`;缺失/不可读的密钥是状态而非崩溃,
密钥材料永不进日志;`describe()` 的 scene 块记录 `keys`(布尔 + 路径)。
**验收(已通过)**:
| 用例 | 结果 |
|---|---|
| 限速 | 假时钟下 15 fps:1.32 s 内约 20 次合成;60 fps 对照 ≥55 次;`display_period_s()` 跟随设置;结果延迟是真实单调差值 |
| 生产者预算 | 等待中的帧让第二个快照被跳过(`skipped_producer`),取出后立即恢复 |
| 复用几何 | 无位移时发布帧**就是**资产自己的只读数组;有标定位移时仍重算;`compose_ms` 属于该帧且不被下一次合成改写 |
| 保护包 | Studio 入口打开签名包、用 preference 里的内容密钥打开加密包、缺验签公钥 → `asset_auth_failed`、密钥文件缺失/无 key id → 状态、发布模式拒绝无签名包而开发模式接受 |
| **性能**(`bench_scene.py --cpu-only`) | 20k 顶点 compose p50 **6.15 ms → 1.03 ms**(法线段 4.9 ms → 0.0001 ms,剩下是采样);真实发布包(1032 顶点)**0.50 ms → 0.17 ms**;synthetic poc 0.016 ms |
| **长时**(本阶段后的 10 分钟 GUI 复核) | RTX 4080 SUPER、30 fps × 10 min = **18 000 帧**、40 864 次 tick:RSS 首四分位均值 192.70 MB → 末四分位 197.53 MB、峰值 197.98 MB、**增长 4.83 MB**、尾段速率 **0.3712 MB/min**,四分位速率 4.42 → 0.60 → 0.70 → 0.37(收敛,不是线性泄漏);队列 mailbox=1、同时最多一次合成、`superseded_by_mailbox=0`(限速之后生产与显示同步,不再靠丢帧追上);生产侧 p50 0.07 ms / p95 0.099 ms |
| 回归 | shared+apps 同一次运行 588 项 0 失败 15 跳过;真 GL 冒烟 14 项 0 失败;ui_check 587 项 0 失败;locale_check 0 缺失 |
**这一阶段抓到的**:
1. **两个真 GL 用例本来就有顺序依赖**(在阶段 8 的提交上单跑同样失败,不是本阶段引入):
`test_the_producer_side_never_waits_for_the_gpu` 收尾没有排空 mailbox,留下的一帧会被下一个测试上传;
`test_an_automatic_colour_scale_follows_the_frame` 在渲染**之前**就断言"范围来自本帧",并用"不同颜色数"
当自动量程的代理(对可见面积敏感——固定量程反而能产生更多种颜色,因为色带最暗那一段很陡)。
现在前者排空并断言排空完成,后者先渲染再断言、判据改为**平均亮度**。
2. **`_scalar_range` 跨场景残留**:换资产或重建 context 后,首个自动色标会用上一个场景的范围。
现在 `release()` 与 `upload_scene()` 都清零。
3. **"compose 0.0 ms"的历史**:`draw()` 里那个常量背后其实有两层错——共享 `last_stats` 会被下一任务改写,
而显示链路从来没读过它。成本现在跟着帧走。
**兼容说明**:
- 新字段全部有默认值;`SdkPacket` 的来源字段已经在阶段 6 落地。
- 深度视图的颜色**变了**(改回与色条相同的主题色带)——这是修正,不是回归;如果哪份报告引用旧颜色,
以本文件为准。
- 录像仍只保存原始相机图像(用户已选定"不改记录格式");回放里的处理画面由「由视频重算」产生。
- 签名私钥仍只在构建机;本阶段只落地"读"的一半(验签公钥 + 内容密钥 provider + 发布模式开关)。
---
## 尚未完成(写清楚,不用"待办"含糊带过)
1. **阶段 8 第二批(画布布局)**:2D/3D 页签容器(默认页签切换、可选并排对照)、参数收进"显示设置"抽屉、
常驻只留视角/复位/外壳/紧凑图例、窄窗口回退、三维画布在 1210×800 下 ≥320 px 高、曲线"可见区间自动/
固定量程"开关、调试统计移入诊断抽屉。为什么留后:它要动 splitter 的槽位与 `ui_check` 的多处布局断言,
并且需要与用户一起看截图定稿(报告里的建议是"传感器视图 + 力曲线两栏")。
2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完
10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但
**外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、
`bench_scene.py --package …`、`soak_scene.py --no-window --minutes 10` 报告。板卡不在手边时按"未跑"
记录,**不得**写成通过。
3. **真机待验证一项**(阶段 6 遗留):算法链失败时 SDK 的 `VRAW`(光流)是否也过期。当前取保守解释。
---
## 依赖关系(并行度) ## 依赖关系(并行度)
``` ```
阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┬─> 阶段 5 ─> 阶段 6 (正确性/失效) ─┬─> 阶段 7 (二维后端统一) 阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┬─> 阶段 5 ─> 阶段 6 (正确性/失效) ─┬─> 阶段 7 (二维后端统一) ─> 阶段 9 (性能/发布)
│ │ └─> 阶段 8 (界面与能力) │ │ └─> 阶段 8 (界面与能力,第一批已完成)
阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘ └─> 阶段 9 (性能/发布) 阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘
``` ```
阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3; 阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3;
+27
View File
@@ -111,6 +111,33 @@ Release gate:
--verify-key keys\build-ed25519.pub.pem --require-signature --verify-key keys\build-ed25519.pub.pem --require-signature
``` ```
### What the Studio application needs for a protected package
The application reads the same material from its preferences file (paths only — the
private half never leaves the build machine):
```json
{
"scene3d_verify_key": "C:\\keys\\build-ed25519.pub.pem",
"scene3d_content_key_file": "C:\\keys\\finger-display.key",
"scene3d_content_key_id": "finger-display",
"scene3d_require_signature": true
}
```
- `scene3d_verify_key` is the **public** key `--write-verify-key` produced. Without it a
signed package is refused by the container ("a verification key is required"), which is
why the application could not open a protected release at all before this existed.
- `scene3d_content_key_file` + `scene3d_content_key_id` are only needed for a **sealed**
(`aes-gcm`) package. The file may hold the key as hex, base64 or raw bytes.
- `scene3d_require_signature` is the application's own release gate: with it on, an
unsigned package is refused (`asset_auth_failed`) instead of being quietly drawn.
Leave it off for engineering builds — the shipped `finger-display-1.0.0.oryscene` is
deliberately plain.
- A missing or unreadable file is *not* a crash: the pane reports why, and the
two-dimensional application keeps running. `session.json` records which of these were
configured (booleans and paths, never key material).
## Performance and long runs ## Performance and long runs
```powershell ```powershell