Files
orisys-app/apps/studio/docs/shell-lifecycle.md
T
ykcheungab e6a1e77a5b 采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - What: 1) 选区统计不再挤在曲线下方(中文长句会顶出面板右边界)——框选后三个数值卡的大号数字变成该窗口的峰值、下方小字为该窗口的均值·σ(chart.selection_detail),峰值语义放在卡片 tooltip;曲线下方只保留「选区 4.00s / 121 点」(完整一行留在它的 tooltip 里)。「全程总览」不算选区(那是默认适配),此时卡片回到"游标处样本";切换单位/换数据源/返回最新都会正确切回。2) 上方的 63.0 FPS 是采集循环自己的轮询速率,根因是同一帧被反复当成新样本:CaptureSource.read_latest 返回后台线程的最新帧,没有新帧时把同一帧再给一次,循环未做判断,于是 30 fps 的设备录出约 63 行/秒(frame_id 还各不相同)。新增 _capture_sequence():读 capture_source.status.last_seq,计数未变即"还没有新帧",睡 2 ms 重试、不进处理链、不发 packet;只有带后台读取线程的实时相机有该标记,视频文件是同步解码、每次都取下一帧,因此忽略(行为不变)。packet.fps 改为速率 (窗口帧数-1)/(首末到达差),窗口 1 s——按"1 秒内到达几帧"数数会系统性偏大。容器速率仍取传感器配置值,与此层无关。3) 底部状态栏只留一次性通知:删除右下角常驻「配置 sensor.json」(顶栏摘要按钮已写明),删除与顶栏重复的 status.run_started(运行中(0))与 status.connecting(正在连接…);QStatusBar 换成 _NoticeBar——Qt 的 showMessage(text) 默认 msecs=0 表示永不消失,这正是那句「运行中(0)」一直挂到底部的原因,子类统一补 6 秒超时;status.units_raw_fallback 之前每帧都发(等于永久占住通知行),改为每次运行只提示一次。附带修复:英文下表头模式名被截断(长句移入 tooltip,标签只留模式名 chart.mode_*_tip);数据源切换后曲线下方的选区文案会残留,改为在 _on_source_changed 里清零。 - Why: 用户评审 3 点——选区统计显示不全、顶部 fps 应受硬件限制最大 30、右下角配置文件名与左下角「运行中(0)」和顶栏信息重复。第 2 点查出根因是重复采样(数据缺陷,不只是显示问题),修掉后设备速率与记录行数才真正一致。 - Verification: ui_check.py 546 项 0 失败(新增:选区统计在卡片、卡片小字不裁切、状态栏无常驻标签且消息过期、顶栏 FPS 即 packet 交付速率、表头模式名中英不裁切);test_ui_smoke.py 71 项 0 失败(新增选区读数与通知行过期两例);drive_shared.py 148 项 0 失败(新增 shared/tests/test_worker_frame_rate.py:同一帧返回 3 次时 packet 数≈交付帧数,去掉判据为 11317:39,且 fps 与交付速率一致);locale_studio.py 267 键齐全、zh==en、prefs 对齐;reproduce.py 退出 0;ui_snapshot.py 新增 --select A,B,六张复核图(选区中英、实时、录制中、回放、标定 1280x720)。 - Not in this change: 录制文件格式与 CSV 列序不变;去重只影响"哪些帧算一帧",视频文件源行为不变;曲线下方不再显示三通道峰/均/σ(数值在卡片上,完整一行在 tooltip 里)。
2026-09-13 21:19:23 +08:00

206 lines
13 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`)仍是文字,它标注的是一块区域。
- **选区统计在数值卡上,不在下方**:框选/缩放出一段窗口后,每张卡的大号数字变成该窗口的
**峰值**、下方小字是该窗口的**均值 · σ**(`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
```