diff --git a/docs/architecture/studio-architecture.md b/docs/architecture/studio-architecture.md new file mode 100644 index 0000000..73f6077 --- /dev/null +++ b/docs/architecture/studio-architecture.md @@ -0,0 +1,280 @@ +# Studio 软件架构(测量版) + +本文不是"理想架构",而是**当前代码的真实依赖结构**:所有框图、数字与结论都由下面的脚本从源码里扫出来, +每条判断后面都跟着它的证据。配套的矢量图是 `studio-architecture.svg`(浏览器直接打开)。 + +生成方式(可复现,改完代码重跑即可): + +```powershell +$env:PYTHONPATH = 'D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src' +& $py D:\orisys_ws\.repro\studio_arch_scan.py # 模块清单、包级依赖、环、Qt/GL/SDK 归属 +& $py D:\orisys_ws\.repro\studio_arch_imports.py # 哪些模块在 import 时就拉进 Qt/GL/SDK,哪些是惰性 +``` + +统计口径:行数含空行;`fan-in` = 有多少模块 import 它;`fan-out` = 它 import 了多少模块(同一模块多次 +import 只算一条边)。 + +--- + +## 1. 分层视图(真实依赖方向) + +```mermaid +graph TB + subgraph L0["① 入口"] + MAIN["main.py
110 行 · QApplication + 主题 + 首选项"] + end + subgraph L1["② 窗口外壳(只有它能看见全部 UI)"] + WIN["app_window.py · StudioMainWindow
3044 行 · 158 个方法 · fan-out 36"] + BAR["brand_bar · toolbar · record_status"] + end + subgraph L2["③ 面板(各自的画布与控件)"] + ARROW["ui/arrow_panel
2D 图像 · 519 行"] + MESH["ui/mesh3d_panel
3D 宿主容器 · 405 行"] + PLOTS["ui/force_plots
三轴曲线 · 1243 行"] + CAL["ui/cal_panel
标定表 · 793 行"] + DLG["sensor_dialog · plot_settings_dialog · cal_point_dialog"] + end + subgraph L3["④ Studio 领域服务(无 Qt 的与有 Qt 的分开)"] + REC["recording.py
记录协调 · 268 行"] + REP["replay.py
回放引擎 · 286 行(QObject)"] + CALSVC["calibration_service.py
131 行"] + COORD["scene3d/coordinator.py
560 行 · **无 Qt**"] + CAM["scene3d/camera.py
210 行 · **无 Qt**"] + VIEW["scene3d/view.py
758 行 · 3D 唯一入口"] + ASSET["scene3d/asset_source.py
257 行 · 场景加载线程"] + GLB["scene3d/gl_backend.py
871 行 · **GL 只在运行时惰性 import**"] + end + subgraph L4["⑤ 共享领域层(采集/记录/力/三维契约,无 Qt 依赖)"] + AQ["acquisition
2343 行 · worker / packet / sinks / 图像后端"] + RC["record
2673 行 · 录像读写 / CSV / session.json"] + FORCE["force
2645 行 · 力曲线源 / 标定 / 滤波"] + S3D["scene3d
4057 行 · 契约 / 采样 / 合成 / 包格式 / 签名"] + MISC["camera · prefs · paths · i18n · theme · logging"] + end + MAIN --> WIN + WIN --> BAR + WIN --> ARROW + WIN --> MESH + WIN --> PLOTS + WIN --> CAL + WIN --> DLG + MESH --> VIEW + VIEW --> COORD + VIEW --> ASSET + VIEW --> GLB + VIEW --> CAM + COORD --> S3D + VIEW --> S3D + ASSET --> S3D + WIN --> REC + WIN --> REP + WIN --> CALSVC + REC --> RC + REP --> RC + CALSVC --> FORCE + WIN --> AQ + WIN --> FORCE + WIN --> RC + PLOTS --> FORCE + CAL --> FORCE + ARROW --> AQ + MESH --> S3D +``` + +纯文本版(无 Mermaid 渲染器时看这个): + +``` +main.py + └─ app_window.py ──┬─ 外壳: brand_bar / toolbar / record_status + ├─ 面板: arrow_panel(2D) · mesh3d_panel(3D) · force_plots · cal_panel · 对话框 + ├─ 领域服务: recording · replay · calibration_service · scene3d.{view,asset_source,coordinator,keys} + └─ 共享层(无 Qt): acquisition · record · force · scene3d · camera/prefs/paths/i18n/theme/logging +mesh3d_panel → scene3d/view (3D 的唯一入口) + view → { coordinator(无 Qt) , asset_source(线程) , gl_backend(惰性 GL) , camera(无 Qt) , widget } +coordinator → composer(共享) → contracts(共享) +``` + +## 2. 运行时与线程视图 + +```mermaid +graph LR + subgraph GUI["GUI 线程(Qt 事件循环)"] + W["StudioMainWindow"] + T1["运行定时器 16 ms
_on_run_tick"] + T2["回放定时器 33 ms
_on_play_tick"] + T3["预览定时器"] + PANELS["面板:2D / 3D / 曲线 / 标定"] + end + subgraph ACQ["采集线程(每会话一条)"] + FW["FingerAcquisitionWorker / BoxWorker"] + PKT["SdkPacket"] + end + subgraph RECW["记录写线程"] + EXP["ExperimentRecorder
视频 + forces.csv + session.json"] + end + subgraph S3DW["三维合成线程(1 条)"] + CMP["SceneComposer.compose"] + end + subgraph S3DA["场景加载线程(1 条)"] + LOAD["SceneAssetLoader
读包 + 验签 + 设备几何校验"] + end + FW --> PKT + PKT -->|记录 sink| EXP + PKT -->|UI sink
submit_packet| W + W -->|ImageFrame→image_backend| PANELS + W -->|"submit(容量 1 mailbox,尝试不阻塞)"| CMP + CMP -->|结果在 GUI 线程校验 epoch| W + LOAD -->|loaded 信号| W + T1 --> W + T2 --> W + T3 --> W + W --> PANELS +``` + +关键性质(都有测试钉住): + +- **采集线程永不等待显示**:`submit` 只做身份/形状的廉价校验,容量 1 的 mailbox 由后来的帧替换前者; + 生产者还会在"已有一帧在等"时干脆不做那次 O(H×W) 的拷贝(`skipped_producer`)。 +- **合成线程与加载线程各只有 1 条**,且同时最多一次合成在飞行;结果必须通过 `epoch` + 身份(source / + session / generation / request_id)校验才会被 GUI 线程接受。 +- **记录通道与显示通道共用同一个 packet,但计数完全分开**:显示丢帧(`superseded`)永不进记录。 + +## 3. 二维 / 三维显示子系统(最近两轮重构后的样子) + +```mermaid +graph TB + subgraph SRC["生产者(4 条)"] + LIVE["实时 packet.processed_frame + flow + depth"] + RECOMP["重算:同一采集链跑在录像上"] + REPLAY["回放:ReplayController.video_frame_bgr() 原始帧"] + PREV["预览:摄像头原始帧"] + end + IB["image_backend.py(共享)
ImageFrame → plan_image() → render_image()"] + PANEL2D["ui/arrow_panel
模式集/注解来自 plan"] + AP["packet → adapters → coordinator(mailbox+epoch)→ SceneComposer(线程)"] + VIEW3D["scene3d/view
describe() / render_totals() / capabilities()"] + PANEL3D["scene3d/panel(参数+图例+色条)"] + GLW["scene3d/widget → gl_backend(GL 惰性)"] + LIVE --> IB + RECOMP --> IB + REPLAY --> IB + PREV --> IB + IB --> PANEL2D + LIVE --> AP + RECOMP --> AP + AP --> VIEW3D + VIEW3D --> GLW + VIEW3D --> PANEL3D +``` + +两条显示路径**互不干扰**:2D 是一条纯数据流水线(`ImageFrame → 一个渲染函数 → 一个 QImage`), +3D 是"契约 + 合成线程 + GL"的另一条。它们的共同上游只是同一个 `SdkPacket`。 + +## 4. 边界规则与证据 + +| 规则 | 是否成立 | 证据 | +|---|---|---| +| 模块级无循环依赖 | ✅ | 扫描 **89 个模块**(studio 32 + shared 57)的 import:**环 0 个** | +| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PyQt5 的 21 个模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PyQt5 **画图标时**才 import(函数内 + try 保护) | +| OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 | +| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PyQt5/OpenGL/SDK | +| 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" | +| 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 | +| 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO | +| 契约集中在一处 | ✅ | `orisys_app_shared.scene3d.contracts`:1185 行、**fan-in 22**(全仓最高),fan-out 0(只依赖 numpy) | + +## 5. 指标 + +**改动最贵的地方(行数 × 扇出)** + +| 模块 | 行数 | fan-out | 说明 | +|---|---:|---:|---| +| `orisys_studio.app_window` | 3044 | **36** | 158 个方法,其中 50 个 `_on_*` 处理器;唯一 fan-in=1 的中心 | +| `orisys_studio.ui.force_plots` | 1243 | 8 | 曲线、量程、光标、概览、单位徽章都在一个类里 | +| `orisys_studio.scene3d.gl_backend` | 871 | 2 | 着色器、打包、纹理、统计;被 4 个模块依赖 | +| `orisys_studio.ui.cal_panel` | 793 | 7 | 标定表 + 采样 + 拟合入口 | +| `orisys_studio.scene3d.view` | 758 | 8 | 3D 的唯一入口(刻意的) | + +**稳定核心(被依赖最多)**:`scene3d.contracts`(22) > `i18n`(16) > `paths`(11) = `theme`(11) > +`record.sample`(8) > `acquisition.models`(5) = `force.filters`(5) = `force.session`(5) = `scene3d.digest`(5)… + +**验证面**:共享库测试 22 文件 / 5992 行;应用测试 10 文件 / 5641 行;工具 `ui_check.py` 3168 行、 +`bench_scene.py` 608、`soak_scene.py` 503、`validate_scene.py` 302、`ui_snapshot.py` 292、`probe_gl.py` 192。 + +**生产面 vs 验证面**(同一口径:含空行): + +| | 行数 | 其中三维 | +|---|---:|---:| +| `apps/studio/src`(生产) | 13 376 | 4 326 | +| `shared`(生产,无 Qt) | 14 820 | 4 576 | +| 测试(shared 5992 + studio 5641) | 11 633 | — | +| 工具(ui_check 3168 + scene_assets 工具 2104) | 5 272 | — | + +即**验证面(16.9k 行)比 studio 的生产代码(13.4k 行)还大**。共享层里记录 2673、力 2645、采集 2343 行, +三维 4576 行——三维不是附属功能,它和"记录/力"是同一量级的子系统。 + +## 6. 评估:合理与不合理的地方 + +### 已经合理、值得保住的 + +1. **依赖无环 + 分层单向**(扫描证据)。这不是小事:有环的项目才会出现"改 A 必须改 B 再改回来"的 + 连锁;这里没有。新增模块时只要不反向 import 就不会破坏。 +2. **共享层与 UI 的物理隔离**:`shared` 里 `import` 时不拉 Qt/GL,采集线程、CI、离线工具都能在无 GUI + 环境跑;`contracts` 甚至只依赖 numpy。 +3. **三维子系统的接口是显式的**:`describe()`(我是什么)/`render_totals()`(显示了什么)/ + `capabilities()`(数据源能给什么)/`set_plan()`(这一帧能画什么)。窗口只调这四个,不碰渲染细节—— + 这几轮重构主要就是在把隐式耦合换成这四条缝。 +4. **降级路径是设计出来的,不是碰巧**:无 GL / 无资产 / 几何不符 / 字段失效 / 场景被平台锁拒绝, + 每一种都只降级三维或某个面板,2D 与记录不受影响(有专门测试)。 +5. **危险的知识点集中在有测试的地方**:单位与坐标系(`legacy.py` + Procrustes 残差)、包格式与签名 + (`package.py`/`protect.py`)、采样无效语义(`sampling.py`)——这些最容易静默出错的部分都有 + 输入样例 + 失败样例。 + +### 不合理、会随时间变贵的地方(按严重程度) + +1. **`app_window.py` 是上帝对象**:3044 行 / 158 方法 / fan-out 36。它同时扮演四个角色—— + ①视图装配器 ②会话与 worker 生命周期控制器 ③业务决策(单位回退、量程、重算/回放互斥、面板尺寸) + ④持久化与诊断聚合。后果:任何功能改动都要动它(本次两轮修 bug 里,`_load_replay_video`、 + `_sync_replay_actions`、`_update_run_enabled`、`_render_*` 全在同一个文件里互相影响); + 它是**唯一** fan-in=1 的中心,也就是说没有第二个人能独立改它的任一部分而不碰冲突热点。 +2. **三个"panel"名字打架**:`ui/arrow_panel`(2D 面板)、`ui/mesh3d_panel`(3D 宿主容器)、 + `scene3d/panel`(3D 参数控件)。新人第一次找"面板加个开关"要在三个文件里试。同类问题还有 + `scene3d/replay.py`(studio 回放引擎) 与 `shared/record/playback.py`(录像读取)。 +3. **两套 `scene3d` 包**:`orisys_app_shared.scene3d`(契约/采样/合成/包,无 Qt)与 + `orisys_studio.scene3d`(coordinator/view/panel/widget/GL)。边界理由是"Qt/GL 不进共享层", + 但 `coordinator` 其实无 Qt(已实测),放在 studio 只是因为"它服务 GUI 计时器"——这条理由不硬。 +4. **共享层 `force` 硬依赖 SDK**:`calibration_io` 在 import 时 `import orisys`,于是"共享层可以脱离 + SDK 做单元测试"这条**只对三维成立,对力不成立**(力相关的测试必须装 SDK)。这也解释了为什么 + 离线工具与力曲线测试总是整套跑。 +5. **`ui_check.py` 3168 行单文件**:它是产品 UI 的"第二份规格"(587 项断言),但没有分层,改 UI 就改它, + 且它引用了不少产品私有属性/组件名。它同时是**最有价值**的回归门和**最脆**的资产。 +6. **测试对私有成员的依赖**:`tests` 里直接读 `view._camera`、`widget._renderer`、`coordinator._inflight`、 + `loader._pending`、`panel._descriptor` 等。接口一改就要改测试(这几轮已经通过 + `attach_view` / `describe` / `render_totals` / `_snapshot_diagnostic` 之类的公开缝减少了一批)。 +7. **验证面 > 生产面**:16k 行测试/工具 vs 12.9k 行 studio 生产代码。好处是回归门硬;代价是每次改接口 + 的成本被放大,尤其是 `ui_check` 里的字符串/布局断言。 + +### 一句话结论 + +**组织是合理的、容易维护的**(无环、分层单向、共享层无 Qt、危险知识集中在有测试的地方), +**但有一个明确的单点:`app_window.py`**。它现在是"能维护"和"改一处要被它绊住"之间的分界线; +再往上加功能(下一个面板、下一种传感器、下一个回放能力)之前,把它拆开是性价比最高的一件事。 + +## 7. 建议(按性价比排序) + +| # | 建议 | 收益 | 成本/风险 | 回归门 | +|---|---|---|---|---| +| 1 | **拆 `app_window`** 为三个控制器:`SessionController`(worker 生命周期 + sinks + 记录)、`RunViewPresenter`(面板可见性/尺寸/单位/量程)、`ReplayCoordinator`(把窗口里回放相关逻辑并进已有的 `replay.py`),窗口只剩装配 + 信号转发 | 改动热点从 1 个 3044 行文件变成 4 个 400–700 行文件;并行改动不再互相踩 | 中(纯搬运,不改行为);风险是搬运时把某个"隐式顺序依赖"弄丢 | 现有 `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 | +| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PyQt5/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 | +| 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` | +| 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 | +| 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3168 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) | +| 6 | **测试私有访问清单化**:把所有 `obj._x` 访问集中到 `tests/support/` 一层小适配器 | 内部重构不再一次性打碎几十个测试 | 小-中 | 全套 | + +不建议做的事:**不要**为了"看起来分层漂亮"把 `scene3d` 从 studio 搬进 shared(第 3 条第 5 项理由不硬, +但现在搬动没有收益,反而打断三维子系统已经稳定的接口);也**不要**为了统一而重写 `ui_check`。 + +--- + +_本文与图由源码扫描生成;扫描脚本在 `.repro/studio_arch_scan.py`、`.repro/studio_arch_imports.py`, +原始数据在 `.repro/studio_arch.json`、`.repro/studio_arch_imports.json`。_ diff --git a/docs/architecture/studio-architecture.png b/docs/architecture/studio-architecture.png new file mode 100644 index 0000000..1b3aad9 Binary files /dev/null and b/docs/architecture/studio-architecture.png differ diff --git a/docs/architecture/studio-architecture.svg b/docs/architecture/studio-architecture.svg new file mode 100644 index 0000000..4b97296 --- /dev/null +++ b/docs/architecture/studio-architecture.svg @@ -0,0 +1,141 @@ + + +Orisys Studio 架构(按源码实际 import 关系生成) +绿 = import 时不拉 Qt/OpenGL 蓝 = 需要 Qt 橙 = GL 仅运行时惰性加载 紫 = 验证面 数字为行数(含空行) + +① 入口 + +② 窗口外壳 +只有它能看见全部 UI + +③ 面板 +各自的画布与控件 + +④ Studio 领域服务 +有 Qt 的与无 Qt 的分开 + +⑤ 共享领域层 +采集 / 记录 / 力 / 三维契约 —— import 时不拉 Qt + +⑥ 验证面 +为什么这个架构敢改 + +main.py +110 行 · QApplication · 主题 · 首选项 · 单实例 + +app_window.py · StudioMainWindow +3044 行 · 158 个方法 · 50 个 _on_* 处理器 · fan-out 36 +唯一 fan-in = 1 的中心:改任何一处都会碰它 + +brand_bar +146 行 + +toolbar +760 行 + +record_status +205 行 + +ui/arrow_panel +2D 图像 · 519 行 +→ image_backend(共享) + +ui/mesh3d_panel +3D 宿主容器 · 405 行 +→ scene3d/view + +ui/force_plots +三轴曲线 · 1243 行 +→ force.history + +ui/cal_panel +标定表 · 793 行 +→ force 标定 + +对话框 ×3 +sensor / plot / cal_point + +recording.py +记录协调 · 268 行 + +replay.py +回放引擎 · 286 行(QObject) +→ record 读取 + +calibration_service.py +标定 · 131 行 + +scene3d/coordinator.py +调度 · 560 行 · 无 Qt +mailbox + epoch + +scene3d/camera.py +相机 · 210 行 · 无 Qt + +scene3d/view.py +3D 唯一入口 · 758 行 +describe() / render_totals() + +scene3d/asset_source.py +加载线程 · 257 行 +验签 + 设备几何校验 + +scene3d/gl_backend.py +GL 惰性 · 871 行 + +orisys_app_shared.scene3d +契约 / 采样 / 合成 / 包 / 签名 · 4057 行 + +orisys_app_shared.acquisition +worker / packet / sinks / 图像后端 · 2343 行 + +orisys_app_shared.record +录像读写 / CSV / session.json · 2673 行 + +orisys_app_shared.force +力源 / 标定 / 滤波 · 2645 行 + +camera · prefs · paths · i18n · theme · logging +基础设施 · 1268 行 +⚠ 唯一的例外:force.calibration_io 在 import 时 +  import orisys(力相关单测需要装 SDK) + +shared/tests +22 文件 · 5992 行 · 316 项 + +apps/studio/tests +10 文件 · 5641 行 · 240 项 + 真 GL 14 项 + +tools/ui_check.py +3168 行 · 587 项 UI 断言 + +tools/bench_scene.py · soak_scene.py · probe_gl.py +性能 / 长时 / 平台报告 + + +启动 + + +装配窗口 + 面板 + 信号转发 + + + + +领域服务:会话 / 记录 / 回放 / 标定 + + +三维:view → coordinator / GL / 加载线程 + + +契约 · packet · mailbox(无 Qt 的共享层) + + + + +验证:单测 · 真 GL · UI 检查 · 性能长时报告 + + +依赖方向只有向下:扫全部 89 个模块(studio 32 + shared 57),模块级环 0 个;新增模块只要不反向 import 就不会破坏分层。 +2D 与 3D 是两条独立流水线,共同上游只有同一个 SdkPacket;任一失败只降级自己(2D/记录/曲线不受影响)。 +最贵的一处是 app_window.py(3044 行 / fan-out 36):视图装配、会话生命周期、业务决策、持久化四个角色目前都在它里面。 + \ No newline at end of file