a4313f300d
replay-panel.md 超出 general 上限 5、shell-lifecycle.md 超出 reference 上限 27。两者超的都是重复:前者的「手动清空/退出清空/导出无代码」在缓存一节已写过,后者重复解释了同一条症状并删掉了已被修复解决的括注。 验证:doc_count 显示 1482/1500 与 3593/3599;doc_lint changed 与 --all 均干净。
245 lines
16 KiB
Markdown
245 lines
16 KiB
Markdown
# Studio 外壳:视图与关闭流程
|
||
|
||
这份文档记录主窗口的三件"外壳"行为:三个顶层视图如何切换面板、回放如何并入主窗口
|
||
(含可拖拽进度条),以及窗口关闭后进程如何回到命令行。它们都不属于记录通道,但都会
|
||
直接影响可用性。
|
||
|
||
---
|
||
|
||
## 1. 三个顶层视图 = 同一窗口的三套布局
|
||
|
||
品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局
|
||
(`ViewLayoutController.apply_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`):
|
||
|
||
| 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) |
|
||
|---|---|---|---|
|
||
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `620, 300, 520, 0` |
|
||
| 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `620, 300, 520, 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. 回放与实时是同一套界面(只换来源)
|
||
|
||
「历史回放」不另开窗口,**也不另起一套控件**:它与「实时监测」是同一套布局、同一条状态轨、
|
||
同一套图表手势。**唯一的差别是左上的来源槽**——实时是摄像机,回放是一段录制视频。
|
||
`ui/replay_bar.py`(进度条 + 录制目录选择)与 `PlaybackDialog` 都已删除。
|
||
|
||
- `apps/studio/src/orisys_studio/replay.py::ReplayEngine(QObject)`:录制读取、游标、
|
||
回放提示(降采样 / 跨版本)。它不画任何东西,只发信号:`position_changed` /
|
||
`frames_changed` / `status_changed` / `recording_changed`。深度重算引擎
|
||
(`DepthRecomputer` / `depth_backends`)已随回放条一起删除——用户不需要这条回放路径,
|
||
要复核算法就用「由视频重算」。
|
||
它叫 *engine* 是因为它只回答「t 时刻是什么」;传输策略(何时开始、播完意味着什么、
|
||
重算是否在跑)与 2D 渲染属于窗口级的 `ReplayController`(见
|
||
`docs/architecture/studio-architecture.md` 建议 1)。
|
||
- **选视频即选录制**:状态轨的「选择录制视频…」(`toolbar.btn_replay_video`)与
|
||
「文件 → 打开视频…」都走 `_load_replay_video(path)`——视频所在目录就是录制目录,
|
||
于是曲线自动配对同目录的 `forces.csv`。视频与 CSV 仍然同时记录(录制格式不变)。
|
||
- 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读
|
||
录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上;
|
||
表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。
|
||
- **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw,
|
||
否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。
|
||
表头的单位芯片是唯一的单位开关(`_set_display_unit` 同时告诉曲线、工具栏与控制器)。
|
||
- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像」一项
|
||
(`REPLAY_VIEW_MODES`;录制里没有光流,也没有存储深度),页脚显示「录制视频(原始相机
|
||
图像)」并隐藏放大倍率与有效区域。
|
||
- **游标就是播放头,运输控制就是状态轨上那颗主按钮**:回放视图里
|
||
`toolbar.set_run_role("playback")` 把它换成「▶ 回放」/「⏸ 暂停」(实时视图仍是「运行」),
|
||
以 1× 推进游标,到末尾自动停;拖动游标、框选、滚轮平移或拖总览条都会**自动暂停**
|
||
(`ForcePlotsPanel.timeline_taken_over` → `_pause_playback`)。空格键同效。没有独立进度条,
|
||
曲线表头也不再放第二个播放按钮(一个运输控制,不会两处打架)。
|
||
- **视频解码器常开**:`RecordingReader.video_frame_at` 以前每帧 `VideoCapture` 重开 + seek
|
||
(小片段 10 ms,1080p H.264 上百 ms),30 fps 播放时每一拍都卡在 UI 线程上,视频根本
|
||
不动。现在按分段缓存一个解码器,**顺序帧用 `read()` 步进**(实测 0.5 ms/帧),
|
||
跳转才 seek,`RecordingReader.close()` 负责释放。
|
||
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
|
||
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
|
||
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
|
||
- **2D 面板归当前视图的来源所有**(`_replay_owns_image()`):回放视图打开录制后,采集线程
|
||
仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板,
|
||
`_render_run_image` 直接返回,否则回放画面会被摄像头帧一帧一帧盖掉。切回实时视图才把
|
||
面板交还给实时帧。
|
||
- **回放提示不占位置**:降采样 / 跨版本等说明放在来源摘要的 tooltip 里(状态栏只留一次性
|
||
通知,见 §2.2),打开录制时另发一条 6 秒通知。
|
||
- 离开回放视图:曲线挂回实时 store 并恢复跟随,来源槽换回摄像机,**已打开的录制保留**,
|
||
摘要里的视频名**不再出现**。
|
||
- 文件菜单两条入口(`打开录制视频…` / `回放最近一次录制`)都只是切到回放视图并加载录制。
|
||
|
||
### 2.1 「由视频重算」:与打开录像同一条分析入口
|
||
|
||
`视图 → 由视频重算力值`(`act_recompute`)调用
|
||
`ReplayController.on_analysis_requested()`——与打开录像后自动开分析是同一条路径(见
|
||
[replay-analysis-part-b.md](replay-analysis-part-b.md) 第 10 节):先读兼容缓存,没有再跑
|
||
离线 pass。分析结果经 `show_analysis_packet` 喂二维与三维;有 `forces.csv` 的录制仍让图表
|
||
读 CSV,裸视频才用结果里重算的力迹。
|
||
|
||
| | 默认(关闭) | 打开(分析) |
|
||
|---|---|---|
|
||
| 曲线数据 | 录制目录的 `forces.csv`(有则保留) | 同上;裸视频读结果里的力迹 |
|
||
| 2D / 3D | 录制帧;有结果时派生模式读缓存 | 同一游标上的分析结果 packet |
|
||
| 时间轴 | 任意定位(游标、框选、总览条、播放) | 仍可任意定位;pass 跑完前派生模式等缓存 |
|
||
| 用途 | 看当时的记录 | 打开即分析 / 强制再跑一遍离线 pass |
|
||
|
||
关闭开关时取消进行中的分析 pass,并挂回录制来源。Stop / Esc 停播放与分析 pass,不再为
|
||
「重算」另开一条采集链。视图切换仍只改家具:`ViewLayoutController.apply_preset`。
|
||
|
||
`apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核
|
||
统一后的回放布局(不写业务数据,也不需要硬件)。
|
||
|
||
### 2.2 状态栏与来源摘要的分工
|
||
|
||
- 状态轨的来源槽按视图切换:实时 = 摄像机编号 + 搜索;回放 = 「选择录制视频…」。
|
||
`source_summary` 在回放视图里写「视频文件: xxx.mp4」,在实时视图里**只**写帧率——
|
||
视频名出现在实时视图里就是残留。
|
||
- 底部状态栏只放一次性通知(见 §1 表格与 `_NoticeBar`),回放提示与完整路径放在来源摘要的
|
||
tooltip 里。
|
||
|
||
### 曲线缩放与默认模式
|
||
|
||
- 三条曲线在**所有**模式下 `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`)仍是文字,它标注的是一块区域。
|
||
- **选区统计在数值卡上,不在下方**:框选/缩放出一段窗口后,每张卡的大号数字变成该窗口的
|
||
**峰值**、下方小字是该窗口的**均值 · σ**(`chart.selection_detail`),卡片的 tooltip 说明
|
||
大数字是峰值;曲线下方只剩「选区 4.13 s / 183 点」(完整的一行仍在它的 tooltip 里)。
|
||
以前把三通道的峰/均/σ 塞在一行 `QLabel` 里,中文长句直接把面板顶出右边界。
|
||
窗口等于整个会话(「全程总览」)不算选区——那是默认适配,此时卡片回到"游标处样本"。
|
||
- **单位芯片是可点的按钮**(`QPushButton#unitBadge`,不再是 `QLabel`):点一下就切换
|
||
`N` ⇄ `raw`,同一条命令(`_set_display_unit`)同时改曲线、数值卡与设置区下拉,三处永远
|
||
一致。旧写法只有「raw/N」两个字,看起来像只读状态。
|
||
- 箭头图页脚的放大倍率写成「箭头按位移放大 ×2」,不再只有一个「×2」。
|
||
|
||
### 状态栏是一条通知行,不是第二条状态行
|
||
|
||
底部 `QStatusBar` 只承载**一次性通知**(录制结束/失败、标定已应用、连接慢、错误原因):
|
||
|
||
- **没有常驻控件**。以前右下角有一条 `配置 sensor.json`(`status.device_label`),而状态轨
|
||
的传感器摘要按钮已经写着 `MINI · sensor.json`,右下角是同一件事实的第二遍。
|
||
- **消息会过期**(`_NoticeBar.DEFAULT_TIMEOUT_MS` = 6 s)。Qt 的 `showMessage(text)` 默认
|
||
`msecs=0`,意思是"永不消失",于是会话刚开始那句「运行中(0)」一直挂到退出;只读状态
|
||
的读法应该在顶栏(阶段芯片 + 来源槽),所以子类把"没有超时"一律换成 6 秒。
|
||
- **与状态轨重复的两条提示被删掉**:`status.run_started`(「运行中(0)」)与
|
||
`status.connecting`(「正在连接…」)在轨上分别有「采集中」芯片与相机编号、以及
|
||
「连接中」芯片。
|
||
- **逐帧重复的通告只发一次**:`status.units_raw_fallback`(无标定 → 显示原始单位)以前每
|
||
一帧都发一次,等于永久占住通知行;现在每次运行只提示一次。
|
||
|
||
### 单位切换是"开关",不是"重新解释"
|
||
|
||
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
|
||
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
|
||
```
|