From cf45e654e14529219ba656c75024da52ade84694 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 22:02:43 +0800 Subject: [PATCH] =?UTF-8?q?docs(studio-3d):=20=E9=98=B6=E6=AE=B5=207?= =?UTF-8?q?=E2=80=939=20=E4=BA=A4=E4=BB=98=E8=AF=B4=E6=98=8E=20+=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=AA=A8=E6=9E=B6=E5=90=8C=E6=AD=A5=20+=20?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E5=8C=85=E7=9A=84=E5=BA=94=E7=94=A8=E4=BE=A7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 板卡报告 按用户先前的意思仍是暂缓项。 --- docs/architecture/studio-3d-contracts.pyi | 19 ++- docs/architecture/studio-3d-stages.md | 155 +++++++++++++++++++++- tools/scene_assets/README.md | 27 ++++ 3 files changed, 192 insertions(+), 9 deletions(-) diff --git a/docs/architecture/studio-3d-contracts.pyi b/docs/architecture/studio-3d-contracts.pyi index 848745b..c1e7e4b 100644 --- a/docs/architecture/studio-3d-contracts.pyi +++ b/docs/architecture/studio-3d-contracts.pyi @@ -178,6 +178,7 @@ class RenderFrame: scalar_descriptor: FieldDescriptor diagnostics: tuple[Diagnostic, ...] 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) @@ -199,14 +200,28 @@ class GLCapabilities: 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) class RenderStats: - displayed_frames: int + displayed_frames: int # Unique frames presented, by identity (not paint calls). 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. paint_ms: float 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): diff --git a/docs/architecture/studio-3d-stages.md b/docs/architecture/studio-3d-stages.md index 1e65d7a..b63244a 100644 --- a/docs/architecture/studio-3d-stages.md +++ b/docs/architecture/studio-3d-stages.md @@ -4,10 +4,13 @@ 本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。 阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2、3、4、5 已完成(见各节); -**阶段 6 已完成(运行正确性与失效状态,见下)**,依据是对 `6de6b8b` 的独立审查 -(`_review_scene3d_20260914/review.md`)与其可控复现。阶段 7(二维后端统一与回放入口收口)、 -阶段 8(界面与有效控制)、阶段 9(性能与发布收尾)尚未开始。 -唯一按用户要求暂缓的是 ARM 板卡上的能力报告与长时门禁运行:工具齐备,命令已写在阶段 5/9 一节。 +**阶段 6 已完成(运行正确性与失效状态)**,依据是对 `6de6b8b` 的独立审查 +(`_review_scene3d_20260914/review.md`)与其可控复现。**阶段 7 已完成(二维图像后端统一 + 回放入口收口)**、 +**阶段 8 第一批已完成(能力控件、真实色标、复位语义、命名)**、 +**阶段 9 上半已完成(限速、真实统计、无位移复用几何、保护包密钥注入)**。 +未完成的两项写在这里,不藏在细节里:**阶段 8 第二批(画布布局:2D/3D 页签容器、参数抽屉、窄窗口回退、 +诊断抽屉、曲线量程开关)**,以及**阶段 9 的门禁运行**(GUI 模式 2 小时长时;Jetson Orin Nano 与 QCS6490 +上的 `probe_gl.py`/`bench_scene.py`/`soak_scene.py` 报告)。 每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §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 (二维后端统一) - │ │ └─> 阶段 8 (界面与能力) -阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘ └─> 阶段 9 (性能/发布) +阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┬─> 阶段 5 ─> 阶段 6 (正确性/失效) ─┬─> 阶段 7 (二维后端统一) ─> 阶段 9 (性能/发布) + │ │ └─> 阶段 8 (界面与能力,第一批已完成) +阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘ ``` 阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3; diff --git a/tools/scene_assets/README.md b/tools/scene_assets/README.md index f474541..0b5e441 100644 --- a/tools/scene_assets/README.md +++ b/tools/scene_assets/README.md @@ -111,6 +111,33 @@ Release gate: --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 ```powershell