Files
orisys-app/apps/studio/docs/shell-lifecycle.md
T
ykcheungab 824544fc7a 回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 9 项评审问题 - What: 回放不再是独立窗口——删除 PlaybackDialog,拆成 replay.py::ReplayController(读盘、游标、深度重算,只发信号)与 ui/replay_bar.py(录制选择、可拖拽进度条、单位、重算深度、提示行),品牌轨「历史回放」直接换主窗口布局,文件菜单两条入口都切到该视图,曲线改读 RecordingCurveSource(区间按需读盘 + 缓存包络),2D 面板切「原始图像 / 深度图」;传感器与标定合并到 SensorDialog(类型/配置下拉 + 当前生效标定 + 最近导出系数),标定面板加纵向滚动区、九按钮两行、六列表头 ResizeToContents。评审 9 项:1) 开场尖峰两个根因——push() 遇单位变化只记 _unit 而保留旧单位样本(raw 计数被画在牛顿轴上),改为「切换即换单位」:清缓冲按新单位重建、触发切换的那一帧仍刷数值卡;_start_run 先 session_store.reset() 再 set_store(),新会话不再带上次的尾巴(复现脚本 .repro/probe_item1.py:旧 39 样本峰值 9.841 → 新 37 样本峰值 2.390)。2) 箭头页脚改为「箭头按位移放大 ×{factor}」。3) 表头单位芯片由只读 QLabel 改为可点按钮,一次点击同时改曲线、数值卡与设置区下拉。4-6) 全程总览/框选/返回最新 改为 ui/icons.py 按主题令牌运行时绘制的图标,tooltip 与 accessibleName 保留文字,全程总览区域名仍是文字。7) stats() 在录制进行中即返回 _last_t - _t0,记录条开始计时(此前只在 _finalise 写、整场停在 00:00.0)。8) 容器 fps 改用传感器配置的速率(packet.fps 是"每秒完成多少帧"的计数器,突发/文件源会读到 48),视频按 1/fps 节流并把超前的帧计入 resampled_video(frames.csv/forces.csv 一行不少,报告写明原因),session.json 同时记 fps 与 measured_fps。9) _replay_owns_image() 让回放视图独占 2D 面板,实时帧不再覆盖;push() 在录制源挂载时连单位都不切,曲线/数值卡/单位一并归录制。附带:数值卡改读游标处样本(_readout_at_cursor)、回放按录制实际有的单位打开(_replay_unit_for + ReplayController.set_unit,避免 raw-only 录制在 newton 偏好下画三张空图)、视频速率下限。 - Why: 用户评审 9 点,其中 1/7/8/9 是代码缺陷(开场尖峰、录制时长不更新、录制 48fps 与实际 30fps 不符、回放时 2D 面板仍被摄像头刷新),2-6 是可读性问题(×2 含义不明、单位与视图控制应可点/应符号化)。 - Verification: ui_check.py 524 项 0 失败(新增 chart header and unit chip / acquisition correctness / replay owns the 2D pane 三段)、test_ui_smoke.py 69 项 0 失败(新增 7 个回归用例:单位切换不混单位、单位芯片、图标语义、录制中时长、传感器帧率+节流、回放独占面板、回放游标读数与单位回退)、drive_shared.py 146 项 0 失败、locale_studio.py(268 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图(深色实时/回放、浅色 1280x720 实时与标定)。 - Not in this change: 录制文件格式(forces.csv 列序、frames.csv、session.json 路径与分段规则)不变,节流只影响"哪些帧进视频",并显式计数上报;三维面板仍是占位符。
2026-09-13 20:46:27 +08:00

186 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```