# Studio 外壳:视图与关闭流程 这份文档记录主窗口的三件"外壳"行为:三个顶层视图如何切换面板、回放如何并入主窗口 (含可拖拽进度条),以及窗口关闭后进程如何回到命令行。它们都不属于记录通道,但都会 直接影响可用性。 --- ## 1. 三个顶层视图 = 同一窗口的三套布局 品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局 (`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`): | 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) | |---|---|---|---| | 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` | | 历史回放 `replay` | 2D 图像 + 力曲线 + **回放条** | 读打开的录制;未打开时仍是实时跟随 | `0, 320, 1120, 0` | | 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` | | 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 | - **为什么标定视图给标定面板 880 px**:六列诊断表头加九个动作按钮,在 300–400 px 的 三栏布局里必然被挤压。标定面板自身还有纵向滚动区,窗口再矮也够得到「应用到设备」。 - **`custom` 不点亮任何导航项**:手工摆出来的布局不属于三个视图中的任何一个。 - **`视图 → 标定面板` 与「标定」导航是同一个开关**:勾上进入标定视图,取消回到实时视图。 - **布局菜单里的「自由布局 / 二维模式」已删除**:导航视图 + 分割条已经覆盖它们,而 「深度图 + 曲线」由 2D 面板自带的「深度图」模式一键完成;`layout_preset` 偏好一并移除。 ### 1.1 采集来源随视图,不再是「来源」下拉 `ToolbarWidget` 的状态轨上有一个来源槽(`source_slot`): | 情形 | 轨上显示 | |---|---| | 实时监测 + 摄像机 | `摄像机 [编号] [搜索]` | | 实时监测 + 视频文件 | `使用摄像机` 按钮 + 摘要里的 `视频文件: xxx.mp4` | | 历史回放 | 不占位(回放条里有录制选择与路径) | 「用视频文件跑一次采集」仍从「文件 → 打开视频…」进入,`source_mode` / `video_path` 偏好保留,但不再有一个需要先选「来源」才能用的下拉。 ### 1.2 传感器与标定共用一个弹窗 轨上只有一个摘要按钮(`BOX · box/sensor.json`,`ToolbarWidget.btn_sensor`); 点击打开 `apps/studio/src/orisys_studio/ui/sensor_dialog.py::SensorDialog`: - 传感器类型 / 传感器配置两个下拉(原 `SensorPickerWidget`,仍是窗口的属性, 因此 `sensor_type()` / `config_name()` 与信号语义不变); - 「当前生效的标定」:设备确认的版本(`_active_calibration_id`)与已标定轴; - 「最近导出的系数」:文件、大小、时间与文件身份(类型 / SN / 配置,来自 `calibration_identity()`);导出/应用按钮转调 `CalPanel.export_coefficients()` / `apply_coefficients()`,实现只有一份。 弹窗常驻一个实例、每次打开 `refresh()`,所以设备刚确认的标定立刻可见。 --- ## 2. 回放并入主窗口(含进度条) 回放**不再是一个独立窗口**。`PlaybackDialog` 已删除,它被拆成两半: - `apps/studio/src/orisys_studio/replay.py::ReplayController(QObject)`:录制读取、游标、 深度重算(daemon 线程 + 120 ms 轮询、请求绑定 `(_record_serial, seq, frame)`、旧结果一律丢弃)、 深度色标(按录制锁存,与实时同一规则)、回放提示(降采样 / 跨版本 / 深度状态)。 它不画任何东西,只发信号:`position_changed` / `frames_changed` / `status_changed` / `recording_changed`。 - `apps/studio/src/orisys_studio/ui/replay_bar.py::ReplayBar`:录制选择、录制信息、 **可拖拽的 QSlider 进度条**、`pos / dur` 读数、单位切换、重算深度、以及第二行的提示。 主窗口把两者接起来: - 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读 录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上; 表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。 - **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw, 否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。 单位由 `ReplayController.set_unit` 一处决定,回放条、曲线、数值卡与设置区下拉一起改。 - 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像 / 深度图」 (录制里没有光流,不提供箭头),页脚显示「录制视频 → 深度重算」并隐藏放大倍率与有效区域; 深度模式显示按录制锁存的色条与范围。 - 游标是同一个:拖进度条 → 视频帧 + 曲线游标;拖曲线游标 → 回放位置。 - **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时, 三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放 时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。 - **2D 面板归当前视图的来源所有**(`_replay_owns_image()`):回放视图打开录制后,采集线程 仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板, `_render_run_image` 直接返回。否则回放画面会被摄像头帧一帧一帧盖掉("回放时好像用的是 摄像头")。切回实时视图才把面板交还给实时帧。 - 离开回放视图:曲线挂回实时 store 并恢复跟随,回放条隐藏,**已打开的录制保留**。 - 文件菜单两条入口(`打开录制目录…` / `回放最近一次录制`)都只是切到回放视图并加载录制, 不再弹第二个窗口。 `apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核 进度条与回放布局(不写业务数据,也不需要硬件)。 ### 曲线缩放与默认模式 - 三条曲线在**所有**模式下 `mouseEnabled(x=True)`:滚轮缩放、左键平移。手势信号用 pyqtgraph 的 `sigRangeChangedManually`(只有真实滚轮/拖动才会发),不是 `sigXRangeChanged`——后者在窗口缩放沿 X 轴联动传播时也会发,会把"缩放窗口"误判成 "用户开始浏览"。y 轴仍按配置的固定量程。 - 浏览模式下共享光标线可拖动,拖动即移动播放头(回放视图里就是回放位置)。 - 启动、新一次运行、切换单位/传感器、切回「实时监测」都保证回到「跟随实时」; 只有打开录制后的回放视图才处于浏览状态。 ### 曲线表头:动作是图标,单位是开关 表头右侧三个动作(`ForcePlotsPanel._header_button`)只画符号、不写字,符号由 `apps/studio/src/orisys_studio/ui/icons.py` 在运行时用当前主题的颜色绘制 (`zoom_fit` 取景框+角标、`box_zoom` 虚线框、`latest` 箭头进入竖条),因此**没有新增色值**, 换主题时按令牌重新着色,也不会出现位图在不同底色上发灰: | 动作 | 符号 | 文字(tooltip / 无障碍名) | |---|---|---| | 全程总览 | `zoom_fit` | `chart.overview` | | 框选 | `box_zoom` | `chart.box_zoom` | | 返回最新 | `latest` | `chart.return_latest` | - **符号不替代语义**:每个按钮都带 tooltip 与 `accessibleName`,`ui_check.py` 的 `chart header and unit chip` 段逐项断言 `text()==""` 且这三样齐全——只有符号没有说明 等于把动作藏起来。全程总览**区域名**(`window_caption`)仍是文字,它标注的是一块区域。 - **单位芯片是可点的按钮**(`QPushButton#unitBadge`,不再是 `QLabel`):点一下就切换 `N` ⇄ `raw`,同一条命令(`_set_display_unit`)同时改曲线、数值卡与设置区下拉,三处永远 一致。旧写法只有「raw/N」两个字,看起来像只读状态。 - 箭头图页脚的放大倍率写成「箭头按位移放大 ×2」,不再只有一个「×2」。 ### 单位切换是"开关",不是"重新解释" 曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到 raw;标定一到位就切到 N —— 这一次切换**丢弃**缓冲区里的旧单位样本(会话存储仍然保留 它们,`RecordingCurveSource` / `SessionStore` 可重建),而不是把 raw 计数画到牛顿轴上。 旧写法只把 `_unit` 记成新单位、样本原样留着,于是每次开始采集的第一个画面都是一根 ~1e5 的尖峰。触发切换的那一帧本身仍然会刷新数值卡(早期实现里它被 `return` 吃掉了), 而浏览/回放时卡片改为按新单位重读游标处的样本。 同样地,`_start_run` 现在**先** `session_store.reset()` 再 `force_plots.set_store(...)`: 反过来的话新会话开局会带着上一次会话的尾巴。 切换单位后(`set_units`)立即按新单位重读游标处的样本,因为浏览/回放时数值卡说的是游标, 不是实时样本——否则换一下单位,卡片会短暂显示上一次实时读数。 --- ## 3. 关闭流程:窗口关掉后进程必须回到命令行 关闭顺序(`StudioMainWindow.closeEvent` → `main()`): 1. `closeEvent`: - 先摘掉记录通道的 sink,避免正在收尾的采集线程继续持有窗口; - `_stop_run(reason="user_stop", wait=True)`:阻塞地释放设备; - `_stop_recording(wait=True)`:写出 `session.json` 并关闭 MP4/CSV; - `_persist_prefs()`:保存窗口几何、分割比例、视图与各项开关。 2. `app.exec_()` 返回后,`main()` **立刻结束进程**: ```python code = int(app.exec_()) logging.shutdown() for stream in (sys.stdout, sys.stderr): try: stream.flush() except Exception: pass os._exit(code) ``` **为什么用 `os._exit`**:相机 SDK 与 OpenCV 各自持有原生线程与句柄,正常返回会在它们之上 再跑一遍解释器收尾(模块销毁、DLL 卸载),进程就卡在那里——症状正是"命令行不会完全退出"。 所有需要落盘的东西在 `closeEvent` 与 `logging.shutdown()` 里已经写完,这里跳过的只是 "已经没有东西可写的库"的析构。 `tools/ui_check.py` 的 `clean shutdown` 段在子进程里跑真实的 `main()`,用定时器关掉窗口 并断言进程在 45 秒内以 0 返回(实测约 2 秒),所以这条路径有回归保护。 --- ## 4. 复核方式 ```powershell $py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe" $env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src" cd D:\orisys_ws\orisys-app\apps\studio & $py tools/ui_check.py # 含 top area / views / instrument panels / replay / clean shutdown & $py tools/ui_snapshot.py --demo --scene replay --out outputs/_snap_replay.png & $py tools/ui_snapshot.py --demo --preset calibration --out outputs/_snap_calibration.png ```