# Studio 软件架构 本文说明已接线的 S1–S3;后续工作和验收由 [分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据, 不能用其中的行数或“已完成”判断当前代码。 ## 当前职责与数据流 `app_window.py::StudioMainWindow` 装配界面、控制器并连接信号。2026-10-19 的 S2 接线 从 3,070 行减至 2,894 行。窗口仍承担业务接线、预览、标定交互、分析配置和运行协调, 尚未完成薄窗口目标。 `status_presenter.py::StatusPresenter` 只持有工具栏,不持有窗口或会话控制器。 窗口在 GUI 线程收集一次性的 `RailState`,Presenter 负责 phase、来源、标定、有效性及 录制统计的文字和控件更新。快照不缓存、不反写状态;主题和语言刷新沿用原入口。 分析结果的传感器身份来自结果 provenance,进度读取现有 `AnalysisProgress.state()`, 不再通过 `ReplayController._progress` 访问其私有控件。 `live_calibration.py::LiveCalibrationController` 持有实时标定选择:按 bundle 保存的选择、 profile 默认文件、Finger 序列号检查、导出后选择及确认/提交请求;文件身份与匹配复用 `CalibrationService`。窗口保留 `live_calibration_for` 等窄接口转发,供 `SensorDialog`、 运行入口、分析提示和测试使用;应用仍只是向 worker 提交请求,设备确认与记录版本仍走记录通道。 ```mermaid graph TD W[StudioMainWindow 装配与接线] --> S[SessionController 会话与遥测] W --> R[ReplayController 回放与分析执行] W --> L[ViewLayoutController 布局] W --> D[DisplayPrefsController 显示偏好] W --> C[RecordingCoordinator 记录与设备确认] W --> LC[LiveCalibrationController 实时标定选择] LC --> CF[CalibrationService 文件身份与匹配] W --> P[StatusPresenter 状态显示] F[RailState 只读显示事实] --> P P --> T[ToolbarWidget 状态轨] W --> A[AcquirePresenter 实时呈现] W --> V[FrameView 二维与共享深度范围] W --> U[WindowUi 控件与布局] W --> M[WindowMenus 菜单与快捷键] ``` 会话状态仍属于 `SessionController`,录制状态仍属于 `RecordingCoordinator`。 `_record_stats` 保留为 UI 检查和截图的注入入口,实际状态选择及显示已委托 Presenter。 关闭中的录制继续读取实时统计,完成后读取最终报告;丢失计数包括未能记下的 gap。 设备确认与录制仍走采集线程通道;本次未改变定时器、停止等待或 3D 懒加载。 验证入口:`test_status_presenter.py` 可在没有主窗口和设备的情况下检查状态显示; `test_live_calibration.py` 独立覆盖实时标定选择/解析/导出/应用规则;`test_ui_smoke.py`、 `test_replay_wiring.py` 和 `tools/ui_check.py` 覆盖窗口集成。 执行结果、环境及尚未验证范围记录在实施计划。状态转发暂留用于现有调用方,后续按职责迁移, 不增加新的全局状态容器或将剩余逻辑全部并入回放控制器。 ## S2 界面构建与接线 `ui/window_ui.py::WindowUi` 创建控件和布局,提供局部主题、文字更新; `ui/window_menus.py::WindowMenus` 创建菜单、快捷键和勾选初值,不绑定业务回调。 窗口通过 `ui`、`menus` 使用它们,旧控件和 action 属性保留同一对象引用供控制器和工具访问。 窗口在控制器接线前装配 FrameView、共享 domain latch 和曲线 store;菜单初值先于业务 回调绑定。偏好保存、全局主题与语言、reset 文字、pause 状态和操作可用性仍由原负责人管理。 标定读取与应用路径保持原实现。`test_window_surfaces.py` 覆盖独立模块与主窗口接线; 布局、传感器切换和回放由现有集成测试覆盖,结果及验证限制见实施计划。 ## 历史测量的适用范围 以下第 1–6 节及配套 `studio-architecture.svg`、`studio-architecture.png` 是旧扫描快照。 原扫描的对应 revision 与完整运行输入不可用,生成脚本已于 2026-09-27 清理,无法原样复跑。 数字口径为含空行行数;fan-in/out 为模块导入关系。旧测试计数、无环结论和完成状态均未在本次 重新全量测量,不是当前保证;后续重构顺序以实施计划为准。 ## 1. 分层视图(真实依赖方向) ```mermaid graph TB subgraph L0["① 入口"] MAIN["main.py
109 行 · QApplication + 主题 + 首选项"] end subgraph L1["② 窗口外壳(只有它能看见全部 UI)"] WIN["app_window.py · StudioMainWindow
2309 行 · 158 个方法 · fan-out 37"] BAR["brand_bar · toolbar · record_status"] end subgraph L15["②b Studio 控制器(一个变更驱动一个文件)"] SESS["session_controller.py
会话与相位 · 437 行"] RPLC["replay_controller.py
回放策略 · 641 行"] LAY["view_layout.py
面板布局 · 398 行"] DISP["display_prefs.py
显示偏好 · 227 行"] 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
记录协调 · 267 行"] REP["replay.py
回放引擎 · 291 行(QObject)"] CALSVC["calibration_service.py
标定文件与归属 · 194 行 · 无 Qt"] COORD["scene3d/coordinator.py
619 行 · **无 Qt**"] CAM["scene3d/camera.py
238 行 · **无 Qt**"] VIEW["scene3d/view.py
757 行 · 3D 唯一入口"] ASSET["scene3d/asset_source.py
256 行 · 场景加载线程"] GLB["scene3d/gl_backend.py
870 行 · **GL 只在运行时惰性 import**"] end subgraph L4["⑤ 共享领域层(采集/记录/力/三维契约,无 Qt 依赖)"] AQ["acquisition
2627 行 · worker / packet / sinks / 图像后端"] RC["record
3008 行 · 录像读写 / CSV / session.json"] FORCE["force
3057 行 · 力曲线源 / 标定 / 滤波"] S3D["scene3d
4565 行 · 契约 / 采样 / 合成 / 包格式 / 签名"] MISC["camera · prefs · paths · i18n · theme · logging"] end MAIN --> WIN WIN --> BAR WIN --> SESS WIN --> RPLC WIN --> LAY WIN --> DISP 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 ├─ 控制器: session_controller · replay_controller · view_layout · display_prefs · live_calibration ├─ live_calibration 选择规则 → calibration_service(文件身份/匹配) → worker.set_pending_calibration 请求 ├─ 面板: 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["回放:ReplayEngine.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. 边界规则与证据 | 规则 | 是否成立 | 证据 | |---|---|---| | 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** | | 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PySide6 的模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PySide6 **画图标时**才 import(函数内 + try 保护)。两个 app 的 `test_studio_binding_purity.py`/`test_binding_purity.py` 现在静态强制"只允许 PySide6"(在此之前 `qss.py` 的 import 因为写的是 PyQt5 而静默失败,AutoCal 的箭头图标一直没有画出来) | | OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 | | 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/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` | 2309 | **37** | 158 个方法,其中约 40 个是给测试/`ui_check` 的一层转发;装配 + 信号转发 | | `orisys_studio.ui.force_plots` | 1242 | 8 | 曲线、量程、光标、概览、单位徽章都在一个类里 | | `orisys_studio.scene3d.gl_backend` | 870 | 2 | 着色器、打包、纹理、统计;被 4 个模块依赖 | | `orisys_studio.ui.cal_panel` | 792 | 7 | 标定表 + 采样 + 拟合入口 | | `orisys_studio.scene3d.view` | 757 | 8 | 3D 的唯一入口(刻意的) | | `orisys_studio.replay_controller` | 641 | 5 | 回放策略(本轮从 app_window 拆出) | | `orisys_studio.scene3d.coordinator` | 619 | 3 | 三维调度(**无 Qt**) | | `orisys_studio.session_controller` | 437 | 3 | 会话/相位/遥测(本轮从 app_window 拆出) | `app_window` 的 **fan-out 仍然最高(37)**,因为它本来就是装配器:它 import 的模块多, 但**规则**已经不在它里面——单次功能改动落在 `session_controller`(会话)、 `replay_controller`(回放)、`view_layout`(布局)、`display_prefs`(显示偏好)或 `calibration_service`(标定)之一,而不是"改哪都要动同一个 2300 行文件"。 **稳定核心(被依赖最多)**:`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 文件 / 7226 行 / 334 项;应用测试 15 文件 / 8413 行 / 310 项;工具 `ui_check.py` 3401 行(587 项 UI 断言)、`bench_scene.py`、`soak_scene.py`、`validate_scene.py`、 `ui_snapshot.py`、`probe_gl.py`。 **生产面 vs 验证面**(同一口径:含空行): | | 行数 | 其中三维 | |---|---:|---:| | `apps/studio/src`(生产,37 模块) | 14 655 | — | | `shared`(生产,57 模块,无 Qt) | 14 764 | 4 565 | | 测试(shared 7226 + studio 8413) | 15 639 | — | | 工具(ui_check 3401 + scene_assets 工具 645) | 4 046 | — | 即**验证面(19.7k 行)比 studio 的生产代码(14.7k 行)还大**。共享层里记录 3008、力 3057、采集 2627 行, 三维 4565 行——三维不是附属功能,它和"记录/力"是同一量级的子系统。 ## 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 生命周期、业务决策、持久化与诊断)都在一个文件里。 现在它 2309 行 / fan-out 37,规则分到四个控制器 + 标定服务: `session_controller`(谁在跑、哪一相位、报了什么数)、`replay_controller`(回放策略与重算)、 `view_layout`(哪些面板、什么布局、哪个视图)、`display_prefs`(单位/量程/箭头/fps)、 `calibration_service`(标定文件与"这份标定是谁的")。 **仍然留在窗口里的**:装配与信号转发、菜单/对话框、状态栏与轨道、 预览(`_start_preview`/`_stop_preview`/`_on_preview_tick`)与 2D 渲染 (`_show_image_frame`/`_render_run_image`/`_depth_range`)——它们同时属于面板与相机, 目前没有第二个变更驱动,硬拆只会把耦合搬家。 **代价与风险(诚实记账)**:为了让 tests 与 `tools/ui_check`(它们通过窗口驱动 UI) 保持全绿,窗口保留了一层转发(约 40 个成员)。它们是**过渡层**,不是最终形态: 下一次改动落在某个控制器时,应顺手把对应的转发与测试调用点改成 `self..*`。 另外窗口的 fan-out 反而从 36 升到 37(它多 import 了四个控制器)——这正是"装配器" 应有的样子:它知道所有人,但不再替所有人做决定。 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` 3401 行单文件**:它是产品 UI 的"第二份规格"(587 项断言),但没有分层,改 UI 就改它, 且它引用了不少产品私有属性/组件名。它同时是**最有价值**的回归门和**最脆**的资产。 6. **测试对私有成员的依赖**:`tests` 里直接读 `view._camera`、`widget._renderer`、`coordinator._inflight`、 `loader._pending`、`panel._descriptor` 等。接口一改就要改测试(这几轮已经通过 `attach_view` / `describe` / `render_totals` / `_snapshot_diagnostic` 之类的公开缝减少了一批)。 7. **验证面 > 生产面**:19.7k 行测试/工具 vs 14.7k 行 studio 生产代码。好处是回归门硬;代价是每次改接口 的成本被放大,尤其是 `ui_check` 里的字符串/布局断言(拆 `app_window` 时为保住这套门而留的转发,就是这个代价)。 ### 历史评估结论 **组织是合理的、容易维护的**(无环、分层单向、共享层无 Qt、危险知识集中在有测试的地方)。 原来唯一的明确单点 `app_window.py` 已按职责拆成 `session_controller` / `replay_controller` / `view_layout` / `display_prefs` + 标定服务, 窗口退回装配与转发;每个新模块都有自己的公开接口单测(会话元数据 15 + 四个控制器 37 + 标定服务 4 = 56 项),且在不需要窗口的情况下可测。 这是旧扫描时的评估结论;当前仍有窗口职责待迁移,实施顺序见下节链接。 ## 后续迁移 当前工作由 [分步重构计划](studio-window-refactor-plan.zh.md) 的 S4–S6 管理, 包括分析配置、运行与预览、显示入口和兼容桥收尾;S3 已完成,结果见计划交接。 每一步完成后更新本节之前的当前职责;历史指标不随搬运代码自动改写。 --- _以上历史快照与图由源码扫描生成;扫描脚本 `.repro/studio_arch_scan.py`、`.repro/studio_arch_imports.py` 与图表脚本 `.repro/make_studio_svg.py` → `.repro/svg_to_png.py`、原始数据 `.repro/studio_arch.json`、 `.repro/studio_arch_imports.json` **均已在 2026-09-27 的工作区整理中删除**,未迁移。保留下来的是 `.repro/` 之外的本文与图本身(`studio-architecture.svg`、`studio-architecture.png`)。 数字口径:行数含空行,`fan-out` = 该模块 import 了多少个模块。_ _生成时间:拆分 `app_window`(A1/A2 + B + C1 + C2 + 标定收尾 + 清零确认修复)之后。_