采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - 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 里)。
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -141,8 +141,11 @@
|
|||||||
"window_tip": "Drag the highlighted region to move the window: this strip is the timeline of the whole session",
|
"window_tip": "Drag the highlighted region to move the window: this strip is the timeline of the whole session",
|
||||||
"no_data": "—",
|
"no_data": "—",
|
||||||
"mode_follow": "Following live",
|
"mode_follow": "Following live",
|
||||||
"mode_browse": "Browsing history (display paused; capture and recording continue)",
|
"mode_follow_tip": "The charts follow the newest sample and the window scrolls with it",
|
||||||
|
"mode_browse": "Browsing history",
|
||||||
|
"mode_browse_tip": "Display paused, capture and recording continue; drag or scroll to move the window, “back to latest” returns to live",
|
||||||
"mode_replay": "Replaying a recording (browsing)",
|
"mode_replay": "Replaying a recording (browsing)",
|
||||||
|
"mode_replay_tip": "The charts read the open recording; capture and recording continue",
|
||||||
"return_latest": "Back to latest",
|
"return_latest": "Back to latest",
|
||||||
"return_latest_tip": "Leave history browsing and follow the newest data again",
|
"return_latest_tip": "Leave history browsing and follow the newest data again",
|
||||||
"overview": "Whole session",
|
"overview": "Whole session",
|
||||||
@@ -150,6 +153,9 @@
|
|||||||
"box_zoom": "Box zoom",
|
"box_zoom": "Box zoom",
|
||||||
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
|
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
|
||||||
"selection": "Selection {duration}s / {samples} samples — {detail}",
|
"selection": "Selection {duration}s / {samples} samples — {detail}",
|
||||||
|
"selection_summary": "Selection {duration}s / {samples} samples",
|
||||||
|
"selection_detail": "mean {mean} · σ {std}",
|
||||||
|
"selection_peak_tip": "The large number is the selection's peak; the line under it is its mean",
|
||||||
"selection_empty": "No valid samples in the selection",
|
"selection_empty": "No valid samples in the selection",
|
||||||
"value_unavailable": "n/a",
|
"value_unavailable": "n/a",
|
||||||
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
|
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
|
||||||
@@ -326,7 +332,6 @@
|
|||||||
"apply_no_target": "No device to apply to (run an acquisition first)"
|
"apply_no_target": "No device to apply to (run an acquisition first)"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"ready": "Ready",
|
|
||||||
"phase_idle": "Idle",
|
"phase_idle": "Idle",
|
||||||
"phase_connecting": "Connecting",
|
"phase_connecting": "Connecting",
|
||||||
"phase_running": "Acquiring",
|
"phase_running": "Acquiring",
|
||||||
@@ -339,15 +344,12 @@
|
|||||||
"calibrated_axes": "Calibrated · {axes}",
|
"calibrated_axes": "Calibrated · {axes}",
|
||||||
"uncalibrated": "Uncalibrated",
|
"uncalibrated": "Uncalibrated",
|
||||||
"config_none": "no config",
|
"config_none": "no config",
|
||||||
"device_label": "config {config}",
|
|
||||||
"camera_none": "No cameras found",
|
"camera_none": "No cameras found",
|
||||||
"camera_found": "Found {count} camera(s): {names}",
|
"camera_found": "Found {count} camera(s): {names}",
|
||||||
"preview_started": "Preview started (camera {camera_id})",
|
"preview_started": "Preview started (camera {camera_id})",
|
||||||
"preview_stopped": "Preview stopped",
|
"preview_stopped": "Preview stopped",
|
||||||
"preview_failed": "Preview failed: {error}",
|
"preview_failed": "Preview failed: {error}",
|
||||||
"connecting": "Connecting…",
|
|
||||||
"connect_slow": "This is taking a while — click “Cancel” or press Esc to abort",
|
"connect_slow": "This is taking a while — click “Cancel” or press Esc to abort",
|
||||||
"run_started": "Running ({source})",
|
|
||||||
"run_stopped": "Stopped",
|
"run_stopped": "Stopped",
|
||||||
"run_stopping": "Stopping… (releasing the device)",
|
"run_stopping": "Stopping… (releasing the device)",
|
||||||
"run_stopping_cancelled": "Connection cancelled (releasing the device)",
|
"run_stopping_cancelled": "Connection cancelled (releasing the device)",
|
||||||
|
|||||||
@@ -141,8 +141,11 @@
|
|||||||
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
|
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
|
||||||
"no_data": "—",
|
"no_data": "—",
|
||||||
"mode_follow": "跟随实时",
|
"mode_follow": "跟随实时",
|
||||||
"mode_browse": "浏览历史(显示暂停,采集与记录继续)",
|
"mode_follow_tip": "曲线跟随最新样本,窗口随采集滚动",
|
||||||
|
"mode_browse": "浏览历史",
|
||||||
|
"mode_browse_tip": "显示暂停,采集与记录继续;拖动或滚轮改变窗口,点「返回最新」回到实时",
|
||||||
"mode_replay": "回放录制(浏览)",
|
"mode_replay": "回放录制(浏览)",
|
||||||
|
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
|
||||||
"return_latest": "返回最新",
|
"return_latest": "返回最新",
|
||||||
"return_latest_tip": "退出历史浏览,恢复跟随最新数据",
|
"return_latest_tip": "退出历史浏览,恢复跟随最新数据",
|
||||||
"overview": "全程总览",
|
"overview": "全程总览",
|
||||||
@@ -150,6 +153,9 @@
|
|||||||
"box_zoom": "框选",
|
"box_zoom": "框选",
|
||||||
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
||||||
"selection": "选区 {duration}s / {samples} 点 — {detail}",
|
"selection": "选区 {duration}s / {samples} 点 — {detail}",
|
||||||
|
"selection_summary": "选区 {duration}s / {samples} 点",
|
||||||
|
"selection_detail": "均 {mean} · σ {std}",
|
||||||
|
"selection_peak_tip": "上方大号数字是选区的峰值;均值为选区均值",
|
||||||
"selection_empty": "选区无有效样本",
|
"selection_empty": "选区无有效样本",
|
||||||
"value_unavailable": "不可用",
|
"value_unavailable": "不可用",
|
||||||
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
|
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
|
||||||
@@ -326,7 +332,6 @@
|
|||||||
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
|
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"ready": "就绪",
|
|
||||||
"phase_idle": "空闲",
|
"phase_idle": "空闲",
|
||||||
"phase_connecting": "连接中",
|
"phase_connecting": "连接中",
|
||||||
"phase_running": "采集中",
|
"phase_running": "采集中",
|
||||||
@@ -339,15 +344,12 @@
|
|||||||
"calibrated_axes": "已标定 · {axes}",
|
"calibrated_axes": "已标定 · {axes}",
|
||||||
"uncalibrated": "未标定",
|
"uncalibrated": "未标定",
|
||||||
"config_none": "未选配置",
|
"config_none": "未选配置",
|
||||||
"device_label": "配置 {config}",
|
|
||||||
"camera_none": "未发现摄像机",
|
"camera_none": "未发现摄像机",
|
||||||
"camera_found": "发现 {count} 个摄像机: {names}",
|
"camera_found": "发现 {count} 个摄像机: {names}",
|
||||||
"preview_started": "预览已启动(摄像机 {camera_id})",
|
"preview_started": "预览已启动(摄像机 {camera_id})",
|
||||||
"preview_stopped": "预览已停止",
|
"preview_stopped": "预览已停止",
|
||||||
"preview_failed": "预览失败: {error}",
|
"preview_failed": "预览失败: {error}",
|
||||||
"connecting": "正在连接…",
|
|
||||||
"connect_slow": "连接耗时较长,可点击「取消连接」或按 Esc 中断",
|
"connect_slow": "连接耗时较长,可点击「取消连接」或按 Esc 中断",
|
||||||
"run_started": "运行中({source})",
|
|
||||||
"run_stopped": "已停止",
|
"run_stopped": "已停止",
|
||||||
"run_stopping": "正在停止…(正在释放设备)",
|
"run_stopping": "正在停止…(正在释放设备)",
|
||||||
"run_stopping_cancelled": "已取消连接(正在释放设备)",
|
"run_stopping_cancelled": "已取消连接(正在释放设备)",
|
||||||
|
|||||||
@@ -52,6 +52,25 @@ UI 线程 _on_run_tick ──> 曲线显示(仍可降帧)
|
|||||||
控件更新必须留在 UI 线程。
|
控件更新必须留在 UI 线程。
|
||||||
- **sink 是普通 callable 列表**,某个 sink 抛异常会被记录并只影响该 packet,不会杀掉采集。
|
- **sink 是普通 callable 列表**,某个 sink 抛异常会被记录并只影响该 packet,不会杀掉采集。
|
||||||
|
|
||||||
|
### 2.1 采集循环只认「新帧」,并据此定义帧率
|
||||||
|
|
||||||
|
`CaptureSource.read_latest` 返回的是**后台读取线程的最新一帧**:如果那一刻还没有新帧,
|
||||||
|
它把**同一帧**再给一次。采集循环以前不做判断,于是同一帧被反复处理 —— 30 fps 的设备
|
||||||
|
录出了 63 行/秒(且 `frame_id` 各不相同,看起来像真的),顶栏的 `packet.fps` 显示的是
|
||||||
|
**我们自己的轮询速率**。
|
||||||
|
|
||||||
|
- `_capture_sequence(sensor)` 读 `capture_source.status.last_seq`:计数没变就是"还没有新
|
||||||
|
帧",睡 `CAPTURE_REPEAT_POLL_S`(2 ms)后重试,**不进处理链、不发 packet**。
|
||||||
|
只有带后台读取线程的源才有这个标记;视频文件是同步 `read_raw()`,每次调用都解码下一
|
||||||
|
帧,因此它的计数与"重复"无关,直接忽略(返回 `None`,行为不变)。
|
||||||
|
- `packet.fps` 改为**速率**:`(窗口内帧数 - 1) / (首末到达时刻之差)`,窗口 `FPS_WINDOW_S`
|
||||||
|
= 1 s。按"1 秒内到达了多少帧"数数会系统性地偏大(窗口里正好装一秒,抖动就多算一帧)。
|
||||||
|
- 这一层是"哪些帧算一帧"的唯一判据,记录、曲线、标定与 CSV 都跟着它走;它不改动
|
||||||
|
`packet.fps` 的语义之外的东西 —— **容器速率**仍然是传感器配置值(见 §6.1)。
|
||||||
|
|
||||||
|
`shared/tests/test_worker_frame_rate.py` 用一个假 SDK 驱动真实循环验证:同一帧返回 3 次
|
||||||
|
时,packet 数 ≈ 交付帧数(去掉判据后是 11317 : 39),且 `packet.fps` 与交付速率一致。
|
||||||
|
|
||||||
### 关键文件
|
### 关键文件
|
||||||
|
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
@@ -161,9 +180,9 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
|||||||
|
|
||||||
`session.json` 的 `video.fps` 是**容器速率**,必须等于传感器的采集速率,取自传感器配置
|
`session.json` 的 `video.fps` 是**容器速率**,必须等于传感器的采集速率,取自传感器配置
|
||||||
(`config/sensors/<type>/sensor.json` 的 `fps`,box 为 30),**不是** packet 里那个
|
(`config/sensors/<type>/sensor.json` 的 `fps`,box 为 30),**不是** packet 里那个
|
||||||
`fps` 字段 —— 后者是"管线每秒完成多少帧"的计数器,开流瞬间的缓冲突发或视频文件源会让
|
`fps` 字段 —— 后者是"本机每秒处理了多少帧"的实时速率(见 §2.1),开流瞬间的缓冲突发会
|
||||||
它读到 48,用它写 30 fps 的容器就等于把回放播成了快进。这是"传感器 30 fps、录制结果
|
让它高于配置值,用它写 30 fps 的容器就等于把回放播成了快进。这是"传感器 30 fps、录制
|
||||||
48 fps"的直接原因。
|
结果 48 fps"的直接原因。
|
||||||
|
|
||||||
- `StudioMainWindow._video_fps()`:实时相机 → 传感器配置的 `fps`;视频文件源 → 实测值;
|
- `StudioMainWindow._video_fps()`:实时相机 → 传感器配置的 `fps`;视频文件源 → 实测值;
|
||||||
实时相机的实测值另存 `video.measured_fps`,两者都进 `session.json`,对不上时看得见。
|
实时相机的实测值另存 `video.measured_fps`,两者都进 `session.json`,对不上时看得见。
|
||||||
|
|||||||
@@ -118,11 +118,31 @@
|
|||||||
- **符号不替代语义**:每个按钮都带 tooltip 与 `accessibleName`,`ui_check.py` 的
|
- **符号不替代语义**:每个按钮都带 tooltip 与 `accessibleName`,`ui_check.py` 的
|
||||||
`chart header and unit chip` 段逐项断言 `text()==""` 且这三样齐全——只有符号没有说明
|
`chart header and unit chip` 段逐项断言 `text()==""` 且这三样齐全——只有符号没有说明
|
||||||
等于把动作藏起来。全程总览**区域名**(`window_caption`)仍是文字,它标注的是一块区域。
|
等于把动作藏起来。全程总览**区域名**(`window_caption`)仍是文字,它标注的是一块区域。
|
||||||
|
- **选区统计在数值卡上,不在下方**:框选/缩放出一段窗口后,每张卡的大号数字变成该窗口的
|
||||||
|
**峰值**、下方小字是该窗口的**均值 · σ**(`chart.selection_detail`),卡片的 tooltip 说明
|
||||||
|
大数字是峰值;曲线下方只剩「选区 4.13 s / 183 点」(完整的一行仍在它的 tooltip 里)。
|
||||||
|
以前把三通道的峰/均/σ 塞在一行 `QLabel` 里,中文长句直接把面板顶出右边界。
|
||||||
|
窗口等于整个会话(「全程总览」)不算选区——那是默认适配,此时卡片回到"游标处样本"。
|
||||||
- **单位芯片是可点的按钮**(`QPushButton#unitBadge`,不再是 `QLabel`):点一下就切换
|
- **单位芯片是可点的按钮**(`QPushButton#unitBadge`,不再是 `QLabel`):点一下就切换
|
||||||
`N` ⇄ `raw`,同一条命令(`_set_display_unit`)同时改曲线、数值卡与设置区下拉,三处永远
|
`N` ⇄ `raw`,同一条命令(`_set_display_unit`)同时改曲线、数值卡与设置区下拉,三处永远
|
||||||
一致。旧写法只有「raw/N」两个字,看起来像只读状态。
|
一致。旧写法只有「raw/N」两个字,看起来像只读状态。
|
||||||
- 箭头图页脚的放大倍率写成「箭头按位移放大 ×2」,不再只有一个「×2」。
|
- 箭头图页脚的放大倍率写成「箭头按位移放大 ×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`(无标定 → 显示原始单位)以前每
|
||||||
|
一帧都发一次,等于永久占住通知行;现在每次运行只提示一次。
|
||||||
|
|
||||||
### 单位切换是"开关",不是"重新解释"
|
### 单位切换是"开关",不是"重新解释"
|
||||||
|
|
||||||
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
|
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
|
|||||||
按钮,所以不参与 §6.5 的"同一时刻只有一个实心按钮"计数(`QLabel#unitBadge` 仍是通道
|
按钮,所以不参与 §6.5 的"同一时刻只有一个实心按钮"计数(`QLabel#unitBadge` 仍是通道
|
||||||
徽标与传感器缩略图的样式,两者靠控件类型区分)。
|
徽标与传感器缩略图的样式,两者靠控件类型区分)。
|
||||||
|
|
||||||
|
8. **数值卡的小字用等宽体,且不会把面板顶宽**。选区统计(均值 · σ)画在数值卡内的 9pt
|
||||||
|
等宽小字上,与 16pt 的大号数字同一列;底部状态栏不再出现长句,因此曲线面板的宽度由
|
||||||
|
三个数值卡决定,而不是由一行统计文字决定。`ui_check.py` 的 `session browsing` 段断言
|
||||||
|
该小字 `width() >= sizeHint().width()`(中文与英文都不裁切)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 三个顶层视图 = 同一窗口的三套布局
|
## 7. 三个顶层视图 = 同一窗口的三套布局
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ def _reveal_in_explorer(path: Path) -> None:
|
|||||||
subprocess.run(["xdg-open", str(folder)], check=False)
|
subprocess.run(["xdg-open", str(folder)], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
class _NoticeBar(QStatusBar):
|
||||||
|
"""A status bar whose messages expire.
|
||||||
|
|
||||||
|
Everything that is a *state* — the phase, the source, the calibration, whether
|
||||||
|
the data is valid — is read from the toolbar's rail. This line is for one-off
|
||||||
|
notices, so a message with no timeout is a defect: it is how an hour-old
|
||||||
|
"运行中(0)" was still sitting under a stopped window. Any caller that asks for
|
||||||
|
a permanent message gets ``DEFAULT_TIMEOUT_MS`` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT_MS = 6000
|
||||||
|
|
||||||
|
def showMessage(self, text: str, timeout: int = 0) -> None: # noqa: N802 - Qt name
|
||||||
|
super().showMessage(str(text or ""), int(timeout) or self.DEFAULT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
|
||||||
class StudioMainWindow(QMainWindow):
|
class StudioMainWindow(QMainWindow):
|
||||||
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
|
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -127,6 +143,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._connect_started_at = 0.0
|
self._connect_started_at = 0.0
|
||||||
self._connect_slow_notified = False
|
self._connect_slow_notified = False
|
||||||
|
#: Whether this run has already said "showing raw, nothing is calibrated".
|
||||||
|
self._raw_fallback_notified = False
|
||||||
self._preview = None
|
self._preview = None
|
||||||
self._preview_timer = QTimer(self)
|
self._preview_timer = QTimer(self)
|
||||||
self._preview_timer.setInterval(33)
|
self._preview_timer.setInterval(33)
|
||||||
@@ -226,13 +244,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.replay_bar.setVisible(False)
|
self.replay_bar.setVisible(False)
|
||||||
root.addWidget(self.replay_bar)
|
root.addWidget(self.replay_bar)
|
||||||
|
|
||||||
self.setStatusBar(QStatusBar())
|
self.setStatusBar(_NoticeBar())
|
||||||
# The phase/record state lives in the toolbar's rail; the status bar is
|
# The status bar carries **one-off notices** only (a recording finished, a
|
||||||
# for transient messages plus one permanent line of device identity.
|
# calibration was applied, a failure). The phase, the source and the
|
||||||
self.device_label = QLabel()
|
# validity of the data are already on the toolbar's rail, so repeating
|
||||||
self.device_label.setObjectName("panelMeta")
|
# them here only put stale text under the window for the whole session.
|
||||||
self.statusBar().addPermanentWidget(self.device_label)
|
self.cal_panel.status_message.connect(self._notify)
|
||||||
self.cal_panel.status_message.connect(self.statusBar().showMessage)
|
|
||||||
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
|
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
|
||||||
self.cal_panel.apply_requested.connect(self._on_calibration_apply)
|
self.cal_panel.apply_requested.connect(self._on_calibration_apply)
|
||||||
self._set_phase("idle")
|
self._set_phase("idle")
|
||||||
@@ -1058,10 +1075,18 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._refresh_status_chip()
|
self._refresh_status_chip()
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
self._refresh_record_state()
|
self._refresh_record_state()
|
||||||
if not self._preview_open and not self._running:
|
|
||||||
self.statusBar().showMessage(t("status.ready"))
|
|
||||||
self._update_run_enabled()
|
self._update_run_enabled()
|
||||||
|
|
||||||
|
#: How long a one-off notice stays on the status bar (see ``_NoticeBar``).
|
||||||
|
NOTICE_MS = _NoticeBar.DEFAULT_TIMEOUT_MS
|
||||||
|
|
||||||
|
def _notify(self, text: str, ms: int = NOTICE_MS) -> None:
|
||||||
|
"""Show a one-off notice on the status bar and let it expire."""
|
||||||
|
message = str(text or "")
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
self.statusBar().showMessage(message, int(ms))
|
||||||
|
|
||||||
def _set_phase(self, phase: str) -> None:
|
def _set_phase(self, phase: str) -> None:
|
||||||
"""Track the acquisition phase and reflect it in the status chip."""
|
"""Track the acquisition phase and reflect it in the status chip."""
|
||||||
self._session_phase = phase if phase in SESSION_PHASES else "idle"
|
self._session_phase = phase if phase in SESSION_PHASES else "idle"
|
||||||
@@ -1117,7 +1142,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.toolbar.set_data_state(
|
self.toolbar.set_data_state(
|
||||||
t("status.data_invalid", count=self._record_error_count), "warning"
|
t("status.data_invalid", count=self._record_error_count), "warning"
|
||||||
)
|
)
|
||||||
self._refresh_device_label()
|
|
||||||
|
|
||||||
def _calibration_summary(self) -> str:
|
def _calibration_summary(self) -> str:
|
||||||
"""What the calibration state means for the three axes.
|
"""What the calibration state means for the three axes.
|
||||||
@@ -1143,14 +1167,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return t("status.uncalibrated")
|
return t("status.uncalibrated")
|
||||||
return t("status.calibrated_axes", axes="+".join(names))
|
return t("status.calibrated_axes", axes="+".join(names))
|
||||||
|
|
||||||
def _refresh_device_label(self) -> None:
|
|
||||||
label = getattr(self, "device_label", None)
|
|
||||||
if label is None:
|
|
||||||
return
|
|
||||||
config = self.sensor_picker.config_name()
|
|
||||||
config_name = Path(config).name if config else t("status.config_none")
|
|
||||||
label.setText(t("status.device_label", config=config_name))
|
|
||||||
|
|
||||||
# ---------------------------------------------------------- record state
|
# ---------------------------------------------------------- record state
|
||||||
def _record_stats(self):
|
def _record_stats(self):
|
||||||
"""``(state, stats, directory)`` — the single read of the record channel.
|
"""``(state, stats, directory)`` — the single read of the record channel.
|
||||||
@@ -1844,6 +1860,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._set_phase("connecting")
|
self._set_phase("connecting")
|
||||||
self._connect_started_at = time.perf_counter()
|
self._connect_started_at = time.perf_counter()
|
||||||
self._connect_slow_notified = False
|
self._connect_slow_notified = False
|
||||||
|
self._raw_fallback_notified = False
|
||||||
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
||||||
self.cal_panel.set_running(True)
|
self.cal_panel.set_running(True)
|
||||||
self.toolbar.set_source_controls_enabled(False)
|
self.toolbar.set_source_controls_enabled(False)
|
||||||
@@ -1854,7 +1871,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._update_run_enabled()
|
self._update_run_enabled()
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
self._refresh_record_state()
|
self._refresh_record_state()
|
||||||
self.statusBar().showMessage(t("status.connecting"))
|
# The phase chip on the rail already reads 连接中, so there is no status
|
||||||
|
# bar copy of it here (see ``_NoticeBar``).
|
||||||
logger.info(
|
logger.info(
|
||||||
"Connecting type=%s source=%s config=%s",
|
"Connecting type=%s source=%s config=%s",
|
||||||
sensor_type,
|
sensor_type,
|
||||||
@@ -1988,9 +2006,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._connect_slow_notified = True
|
self._connect_slow_notified = True
|
||||||
self.toolbar.set_reset_enabled(True)
|
self.toolbar.set_reset_enabled(True)
|
||||||
self._sync_shortcut_actions()
|
self._sync_shortcut_actions()
|
||||||
self.statusBar().showMessage(
|
# No status-bar line here: the rail's phase chip says 采集中 and the
|
||||||
t("status.run_started", source=self._run_source_label)
|
# source slot names the camera, so "运行中(0)" was a second copy of
|
||||||
)
|
# the same fact that then stayed on screen for the rest of the session.
|
||||||
|
|
||||||
if is_new:
|
if is_new:
|
||||||
self._last_plot_packet_key = packet_key
|
self._last_plot_packet_key = packet_key
|
||||||
@@ -2005,7 +2023,14 @@ class StudioMainWindow(QMainWindow):
|
|||||||
sample=record,
|
sample=record,
|
||||||
)
|
)
|
||||||
if display.using_raw_fallback:
|
if display.using_raw_fallback:
|
||||||
self.statusBar().showMessage(t("status.units_raw_fallback"))
|
# Announced once per run, not once per frame: a notice that is
|
||||||
|
# re-issued every 16 ms never expires, and the rail already
|
||||||
|
# carries the calibration state.
|
||||||
|
if not self._raw_fallback_notified:
|
||||||
|
self._raw_fallback_notified = True
|
||||||
|
self._notify(t("status.units_raw_fallback"))
|
||||||
|
else:
|
||||||
|
self._raw_fallback_notified = False
|
||||||
self._last_sample_valid = True
|
self._last_sample_valid = True
|
||||||
self._last_display_fallback = bool(display.using_raw_fallback)
|
self._last_display_fallback = bool(display.using_raw_fallback)
|
||||||
self._last_display_available = tuple(bool(flag) for flag in display.available)
|
self._last_display_available = tuple(bool(flag) for flag in display.available)
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ VALUE_FONT_PT = 16
|
|||||||
#: timeline. One second is wide enough that a sample is always inside it and small
|
#: timeline. One second is wide enough that a sample is always inside it and small
|
||||||
#: enough that reading a recording from disk stays free while scrubbing.
|
#: enough that reading a recording from disk stays free while scrubbing.
|
||||||
CURSOR_READOUT_S = 0.5
|
CURSOR_READOUT_S = 0.5
|
||||||
|
#: How much of the session an edge must be inset by before the visible window
|
||||||
|
#: counts as a *selection* rather than the default "whole session" fit. 1% of the
|
||||||
|
#: session absorbs the float noise of a fit without hiding a real zoom.
|
||||||
|
SELECTION_EPSILON = 0.01
|
||||||
|
|
||||||
|
|
||||||
def _window_brush(theme: str):
|
def _window_brush(theme: str):
|
||||||
@@ -140,6 +144,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._mode = "follow"
|
self._mode = "follow"
|
||||||
self._window: tuple[float, float] = (0.0, 0.0)
|
self._window: tuple[float, float] = (0.0, 0.0)
|
||||||
self._cursor_t = 0.0
|
self._cursor_t = 0.0
|
||||||
|
#: The current selection's statistics, or None when the cards describe the
|
||||||
|
#: cursor instead (following live, a fitted session, a replay).
|
||||||
|
self._selection = None
|
||||||
self._syncing = False
|
self._syncing = False
|
||||||
self._overview_dirty = True
|
self._overview_dirty = True
|
||||||
self._last_overview_at = 0.0
|
self._last_overview_at = 0.0
|
||||||
@@ -198,17 +205,21 @@ class ForcePlotsPanel(QFrame):
|
|||||||
values_row.setSpacing(14)
|
values_row.setSpacing(14)
|
||||||
self.value_cards: list[QFrame] = []
|
self.value_cards: list[QFrame] = []
|
||||||
self.value_labels: list[QLabel] = []
|
self.value_labels: list[QLabel] = []
|
||||||
|
self.value_details: list[QLabel] = []
|
||||||
self.channel_badges: list[QLabel] = []
|
self.channel_badges: list[QLabel] = []
|
||||||
self.channel_names: list[QLabel] = []
|
self.channel_names: list[QLabel] = []
|
||||||
self.value_units: list[QLabel] = []
|
self.value_units: list[QLabel] = []
|
||||||
for label in CHANNEL_LABELS:
|
for label in CHANNEL_LABELS:
|
||||||
card, badge, name_label, value_label, unit_label = self._build_value_card(label)
|
card, badge, name_label, value_label, unit_label, detail_label = (
|
||||||
|
self._build_value_card(label)
|
||||||
|
)
|
||||||
values_row.addWidget(card, 1)
|
values_row.addWidget(card, 1)
|
||||||
self.value_cards.append(card)
|
self.value_cards.append(card)
|
||||||
self.channel_badges.append(badge)
|
self.channel_badges.append(badge)
|
||||||
self.channel_names.append(name_label)
|
self.channel_names.append(name_label)
|
||||||
self.value_labels.append(value_label)
|
self.value_labels.append(value_label)
|
||||||
self.value_units.append(unit_label)
|
self.value_units.append(unit_label)
|
||||||
|
self.value_details.append(detail_label)
|
||||||
root.addWidget(self.value_group)
|
root.addWidget(self.value_group)
|
||||||
|
|
||||||
self.plot_frame = QFrame()
|
self.plot_frame = QFrame()
|
||||||
@@ -316,11 +327,15 @@ class ForcePlotsPanel(QFrame):
|
|||||||
|
|
||||||
def _build_value_card(
|
def _build_value_card(
|
||||||
self, channel: str
|
self, channel: str
|
||||||
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]:
|
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel, QLabel]:
|
||||||
"""One axis readout: channel badge + name over a large monospace value.
|
"""One axis readout: channel badge + name over a large monospace value.
|
||||||
|
|
||||||
The value is deliberately borderless and large — three boxed cards side
|
The value is deliberately borderless and large — three boxed cards side
|
||||||
by side were part of what made the panel look fragmented.
|
by side were part of what made the panel look fragmented.
|
||||||
|
|
||||||
|
The third line carries the selection's mean and σ. It exists on every card
|
||||||
|
and is kept at a fixed height even while empty, so entering a selection
|
||||||
|
adds a number instead of moving the charts.
|
||||||
"""
|
"""
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setObjectName("valueGroup")
|
card.setObjectName("valueGroup")
|
||||||
@@ -354,7 +369,13 @@ class ForcePlotsPanel(QFrame):
|
|||||||
value_row.addWidget(unit_label)
|
value_row.addWidget(unit_label)
|
||||||
value_row.addStretch(1)
|
value_row.addStretch(1)
|
||||||
layout.addLayout(value_row)
|
layout.addLayout(value_row)
|
||||||
return card, badge, name_label, value_label, unit_label
|
|
||||||
|
detail_label = QLabel()
|
||||||
|
detail_label.setObjectName("hintText")
|
||||||
|
detail_label.setFont(_mono_font(9))
|
||||||
|
detail_label.setFixedHeight(detail_label.fontMetrics().height())
|
||||||
|
layout.addWidget(detail_label)
|
||||||
|
return card, badge, name_label, value_label, unit_label, detail_label
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
def set_theme(self, theme: str) -> None:
|
||||||
self._theme = theme
|
self._theme = theme
|
||||||
@@ -446,11 +467,13 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._mode = "follow"
|
self._mode = "follow"
|
||||||
self._window = (0.0, 0.0)
|
self._window = (0.0, 0.0)
|
||||||
self._cursor_t = 0.0
|
self._cursor_t = 0.0
|
||||||
|
self._selection = None
|
||||||
self._overview_dirty = True
|
self._overview_dirty = True
|
||||||
self._interactive(False)
|
self._interactive(False)
|
||||||
self.set_cursor(0.0)
|
self.set_cursor(0.0)
|
||||||
self.overview_curve.setData([], [])
|
self.overview_curve.setData([], [])
|
||||||
self.stats_label.setText("")
|
self.stats_label.setText("")
|
||||||
|
self.stats_label.setToolTip("")
|
||||||
self._render_value_cards()
|
self._render_value_cards()
|
||||||
self._apply_axis_mode()
|
self._apply_axis_mode()
|
||||||
self._refresh_mode_label()
|
self._refresh_mode_label()
|
||||||
@@ -633,13 +656,17 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._clear_curves()
|
self._clear_curves()
|
||||||
self._mode = "follow"
|
self._mode = "follow"
|
||||||
self._window = (0.0, 0.0)
|
self._window = (0.0, 0.0)
|
||||||
|
self._selection = None
|
||||||
|
# The strip described the *previous* source's window: a summary left
|
||||||
|
# behind here would claim a selection that no longer exists.
|
||||||
|
self.stats_label.setText("")
|
||||||
|
self.stats_label.setToolTip("")
|
||||||
self._overview_dirty = True
|
self._overview_dirty = True
|
||||||
self._refresh_mode_label()
|
self._refresh_mode_label()
|
||||||
self._apply_axis_mode()
|
self._apply_axis_mode()
|
||||||
if not self._source_ready:
|
if not self._source_ready:
|
||||||
self.overview_curve.setData([], [])
|
self.overview_curve.setData([], [])
|
||||||
self.window_label.setText("")
|
self.window_label.setText("")
|
||||||
self.stats_label.setText("")
|
|
||||||
return
|
return
|
||||||
self._sync_overview()
|
self._sync_overview()
|
||||||
self._reload_from_store()
|
self._reload_from_store()
|
||||||
@@ -728,6 +755,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._interactive(mode == "browse")
|
self._interactive(mode == "browse")
|
||||||
self._refresh_mode_label()
|
self._refresh_mode_label()
|
||||||
self._sync_overview()
|
self._sync_overview()
|
||||||
|
if mode == "follow":
|
||||||
|
# Leaving the selection: the cards go back to describing the cursor.
|
||||||
|
self._update_selection_readout()
|
||||||
self._readout_at_cursor()
|
self._readout_at_cursor()
|
||||||
self.mode_changed.emit(mode)
|
self.mode_changed.emit(mode)
|
||||||
|
|
||||||
@@ -818,6 +848,10 @@ class ForcePlotsPanel(QFrame):
|
|||||||
return
|
return
|
||||||
if not self._source_ready:
|
if not self._source_ready:
|
||||||
return
|
return
|
||||||
|
if self._selection_active():
|
||||||
|
# The cards are stating the selection's peak / mean / σ; the cursor is
|
||||||
|
# inside that window and must not overwrite them.
|
||||||
|
return
|
||||||
first, last = self._source.extent()
|
first, last = self._source.extent()
|
||||||
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
|
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
|
||||||
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
|
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
|
||||||
@@ -837,33 +871,82 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._render_value_cards()
|
self._render_value_cards()
|
||||||
|
|
||||||
def _update_selection_readout(self) -> None:
|
def _update_selection_readout(self) -> None:
|
||||||
|
"""Refresh what the selection is worth: the cards, and the strip's size.
|
||||||
|
|
||||||
|
The per-channel numbers live on the cards (peak, then mean · σ); the strip
|
||||||
|
under the charts keeps only how long the selection is and how many samples
|
||||||
|
it holds, because that is all that fits there. The full line stays in the
|
||||||
|
strip's tooltip.
|
||||||
|
"""
|
||||||
if not self._source_ready:
|
if not self._source_ready:
|
||||||
|
self._selection = None
|
||||||
self.stats_label.setText("")
|
self.stats_label.setText("")
|
||||||
|
self.stats_label.setToolTip("")
|
||||||
|
self._render_value_cards()
|
||||||
|
return
|
||||||
|
if not self._selection_active():
|
||||||
|
# Following live, or fitted to the whole session: the cards are the
|
||||||
|
# readout of the cursor, not of a window.
|
||||||
|
self._selection = None
|
||||||
|
self.stats_label.setText("")
|
||||||
|
self.stats_label.setToolTip("")
|
||||||
|
self._render_value_cards()
|
||||||
return
|
return
|
||||||
t0, t1 = self._window
|
t0, t1 = self._window
|
||||||
stats = self._source.statistics(t0, t1, unit=self._unit)
|
stats = self._source.statistics(t0, t1, unit=self._unit)
|
||||||
if stats.samples == 0:
|
if stats.samples == 0:
|
||||||
|
self._selection = None
|
||||||
self.stats_label.setText(t("chart.selection_empty"))
|
self.stats_label.setText(t("chart.selection_empty"))
|
||||||
|
self.stats_label.setToolTip("")
|
||||||
|
self._render_value_cards()
|
||||||
return
|
return
|
||||||
|
self._selection = stats
|
||||||
|
self.stats_label.setText(
|
||||||
|
t(
|
||||||
|
"chart.selection_summary",
|
||||||
|
duration=f"{stats.duration_s:.2f}",
|
||||||
|
samples=stats.samples,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stats_label.setToolTip(self._selection_tooltip(stats))
|
||||||
|
self._render_value_cards()
|
||||||
|
|
||||||
|
def _selection_tooltip(self, stats) -> str:
|
||||||
|
"""The full selection readout, for the strip's tooltip."""
|
||||||
unit = unit_display(self._unit)
|
unit = unit_display(self._unit)
|
||||||
parts = []
|
parts = []
|
||||||
for index, label in enumerate(CHANNEL_LABELS):
|
for index, label in enumerate(CHANNEL_LABELS):
|
||||||
peak = stats.peaks[index]
|
peak = float(stats.peaks[index])
|
||||||
mean = stats.means[index]
|
|
||||||
std = stats.stds[index]
|
|
||||||
if not np.isfinite(peak):
|
if not np.isfinite(peak):
|
||||||
continue
|
continue
|
||||||
parts.append(
|
parts.append(
|
||||||
f"{label} 峰 {peak:.3g}{unit} · 均 {mean:.3g} · σ {std:.3g}"
|
f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} "
|
||||||
|
f"· σ {float(stats.stds[index]):.3g}"
|
||||||
)
|
)
|
||||||
self.stats_label.setText(
|
return t(
|
||||||
t(
|
|
||||||
"chart.selection",
|
"chart.selection",
|
||||||
duration=f"{stats.duration_s:.2f}",
|
duration=f"{stats.duration_s:.2f}",
|
||||||
samples=stats.samples,
|
samples=stats.samples,
|
||||||
detail=" ".join(parts),
|
detail=" ".join(parts),
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
def _selection_active(self) -> bool:
|
||||||
|
"""Whether the visible window is a *selection* and not the whole session.
|
||||||
|
|
||||||
|
Following live is not a selection, and neither is "whole session" — that is
|
||||||
|
the default fit, where the number the operator wants is the one under the
|
||||||
|
cursor. A window that covers the session (the fit, or a zoom back out to
|
||||||
|
it) therefore keeps the cursor readout.
|
||||||
|
"""
|
||||||
|
if self._mode != "browse" or not self._source_ready:
|
||||||
|
return False
|
||||||
|
first, last = self._source.extent()
|
||||||
|
span = last - first
|
||||||
|
if span <= 0.0:
|
||||||
|
return False
|
||||||
|
start, end = self._window
|
||||||
|
edge = span * SELECTION_EPSILON
|
||||||
|
return (start - first) > edge or (last - end) > edge
|
||||||
|
|
||||||
def _sync_overview(self) -> None:
|
def _sync_overview(self) -> None:
|
||||||
if not self._source_ready:
|
if not self._source_ready:
|
||||||
@@ -938,11 +1021,19 @@ class ForcePlotsPanel(QFrame):
|
|||||||
return 1200
|
return 1200
|
||||||
|
|
||||||
def _refresh_mode_label(self) -> None:
|
def _refresh_mode_label(self) -> None:
|
||||||
|
"""The mode name on the header, with its meaning in the tooltip.
|
||||||
|
|
||||||
|
The long form ("Browsing history (display paused; capture and recording
|
||||||
|
continue)") did not fit next to the header's icon buttons at 1280–1440 px
|
||||||
|
in English and was simply cut mid-word, so the parenthetical lives in the
|
||||||
|
tooltip and the label stays a name.
|
||||||
|
"""
|
||||||
if self.is_recording_source():
|
if self.is_recording_source():
|
||||||
key = "chart.mode_replay"
|
key = "chart.mode_replay"
|
||||||
else:
|
else:
|
||||||
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
|
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
|
||||||
self.mode_label.setText(t(key))
|
self.mode_label.setText(t(key))
|
||||||
|
self.mode_label.setToolTip(t(f"{key}_tip"))
|
||||||
# "Back to latest" only means something while reading a live session; a
|
# "Back to latest" only means something while reading a live session; a
|
||||||
# recording has no latest sample to return to.
|
# recording has no latest sample to return to.
|
||||||
self.btn_latest.setEnabled(
|
self.btn_latest.setEnabled(
|
||||||
@@ -1042,25 +1133,64 @@ class ForcePlotsPanel(QFrame):
|
|||||||
curve.setData([], [])
|
curve.setData([], [])
|
||||||
|
|
||||||
def _render_value_cards(self) -> None:
|
def _render_value_cards(self) -> None:
|
||||||
|
"""One card per channel: what it reads *now*, in the active unit.
|
||||||
|
|
||||||
|
While a selection is being inspected the big number is that window's peak
|
||||||
|
and the line under it its mean and σ — the selection statistics used to be
|
||||||
|
a single long line under the charts, which could not fit and pushed the
|
||||||
|
panel wider.
|
||||||
|
"""
|
||||||
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
|
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
|
||||||
unavailable = t("chart.value_unavailable")
|
unavailable = t("chart.value_unavailable")
|
||||||
for index, (value_label, unit_label, value) in enumerate(
|
selecting = self._selection
|
||||||
zip(self.value_labels, self.value_units, self._last_values)
|
for index, (value_label, unit_label, detail_label, value) in enumerate(
|
||||||
|
zip(
|
||||||
|
self.value_labels,
|
||||||
|
self.value_units,
|
||||||
|
self.value_details,
|
||||||
|
self._last_values,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
if not self._available[index]:
|
available = self._available[index]
|
||||||
|
if selecting is not None:
|
||||||
|
peak = float(selecting.peaks[index])
|
||||||
|
available = bool(np.isfinite(peak))
|
||||||
|
value = peak
|
||||||
|
if not available:
|
||||||
# An axis with no curve in this unit: a dash, never a raw count
|
# An axis with no curve in this unit: a dash, never a raw count
|
||||||
# presented as a newton value.
|
# presented as a newton value.
|
||||||
value_label.setText(unavailable)
|
value_label.setText(unavailable)
|
||||||
unit_label.setText("")
|
unit_label.setText("")
|
||||||
|
detail_label.setText("")
|
||||||
value_label.setToolTip(
|
value_label.setToolTip(
|
||||||
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
|
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
value_label.setText(fmt.format(value=value))
|
value_label.setText(fmt.format(value=value))
|
||||||
value_label.setToolTip("")
|
value_label.setToolTip(
|
||||||
|
t("chart.selection_peak_tip") if selecting is not None else ""
|
||||||
|
)
|
||||||
unit_label.setText(unit_display(self._unit))
|
unit_label.setText(unit_display(self._unit))
|
||||||
|
detail_label.setText(
|
||||||
|
self._selection_detail(index) if selecting is not None else ""
|
||||||
|
)
|
||||||
self.unit_badge.setText(unit_display(self._unit))
|
self.unit_badge.setText(unit_display(self._unit))
|
||||||
|
|
||||||
|
def _selection_detail(self, index: int) -> str:
|
||||||
|
"""``mean · σ`` for one channel of the current selection."""
|
||||||
|
stats = self._selection
|
||||||
|
if stats is None:
|
||||||
|
return ""
|
||||||
|
mean = float(stats.means[index])
|
||||||
|
std = float(stats.stds[index])
|
||||||
|
if not np.isfinite(mean):
|
||||||
|
return ""
|
||||||
|
return t(
|
||||||
|
"chart.selection_detail",
|
||||||
|
mean=f"{mean:.4g}",
|
||||||
|
std=f"{std:.4g}" if np.isfinite(std) else "-",
|
||||||
|
)
|
||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
self.title.setText(t("panels.plots"))
|
self.title.setText(t("panels.plots"))
|
||||||
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
||||||
@@ -1079,4 +1209,6 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.window_caption.setToolTip(t("chart.window_tip"))
|
self.window_caption.setToolTip(t("chart.window_tip"))
|
||||||
self._refresh_mode_label()
|
self._refresh_mode_label()
|
||||||
self._refresh_window_label()
|
self._refresh_window_label()
|
||||||
self._render_value_cards()
|
# Which readout a card carries depends on the mode, so the language switch
|
||||||
|
# goes through the same path as a data refresh.
|
||||||
|
self._update_selection_readout()
|
||||||
|
|||||||
@@ -1968,6 +1968,117 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor
|
|||||||
assert panel.value_labels[0].text() not in ("", t("chart.value_unavailable"))
|
assert panel.value_labels[0].text() not in ("", t("chart.value_unavailable"))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- selection readout and notices
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window):
|
||||||
|
"""The per-channel line under the charts did not fit; the cards hold it now.
|
||||||
|
|
||||||
|
Selecting a window makes the big number that window's peak and the line under
|
||||||
|
it its mean · σ. The strip keeps only how big the selection is, and leaving
|
||||||
|
the selection puts the cursor readout back.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
window, stuck = _running_window(make_window)
|
||||||
|
window.toolbar.units_combo.setCurrentIndex(0) # newton
|
||||||
|
window.force_plots.reset()
|
||||||
|
for index in range(90):
|
||||||
|
stuck.emit(ui_check._fake_packet(index))
|
||||||
|
window._on_run_tick()
|
||||||
|
|
||||||
|
plots = window.force_plots
|
||||||
|
assert plots._selection is None, "following live is not a selection"
|
||||||
|
assert plots.stats_label.text() == ""
|
||||||
|
assert plots.value_details[0].text() == "", "a live reading carries a mean line"
|
||||||
|
|
||||||
|
plots.set_window(1.0, 3.0)
|
||||||
|
assert plots.mode() == "browse"
|
||||||
|
assert plots._selection is not None, "the window was not read as a selection"
|
||||||
|
|
||||||
|
summary = plots.stats_label.text()
|
||||||
|
assert summary, "the strip does not state the size of the selection"
|
||||||
|
assert t("chart.selection_summary", duration="1", samples=2)[:4] in summary or (
|
||||||
|
"选区" in summary or "Selection" in summary
|
||||||
|
), summary
|
||||||
|
# The numbers a reader cannot fit in the strip are on the cards instead.
|
||||||
|
expected_peak = float(plots._selection.peaks[0])
|
||||||
|
expected = f"{expected_peak:.3f}"
|
||||||
|
assert plots.value_labels[0].text() == expected, (
|
||||||
|
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}"
|
||||||
|
)
|
||||||
|
detail = plots.value_details[0].text()
|
||||||
|
assert detail and detail != "", detail
|
||||||
|
assert f"{float(plots._selection.means[0]):.4g}" in detail, (detail, plots._selection.means[0])
|
||||||
|
assert "均" in detail or "mean" in detail, detail
|
||||||
|
assert plots.value_labels[0].toolTip(), "the peak is not explained anywhere"
|
||||||
|
|
||||||
|
# "Whole session" is the default fit, so it is not a selection.
|
||||||
|
plots.show_all()
|
||||||
|
assert plots._selection is None, "'whole session' is still treated as a selection"
|
||||||
|
assert plots.value_details[0].text() == ""
|
||||||
|
|
||||||
|
# ...and neither is live mode.
|
||||||
|
plots.set_window(1.0, 3.0)
|
||||||
|
assert plots._selection is not None
|
||||||
|
plots.follow_latest()
|
||||||
|
assert plots._selection is None, "the selection survived 'back to latest'"
|
||||||
|
assert plots.stats_label.text() == ""
|
||||||
|
_finish(window, stuck)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window):
|
||||||
|
"""The rail is the state; the bar shows one-off notices and lets them expire.
|
||||||
|
|
||||||
|
A permanent "config sensor.json" line repeated what the rail's sensor chip
|
||||||
|
already says, and a message with no timeout (the old "运行中(0)") stayed under
|
||||||
|
the window for the whole session.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
import orisys_studio.app_window as app_module
|
||||||
|
|
||||||
|
window = _show(make_window(), (1440, 880))
|
||||||
|
assert not hasattr(window, "device_label"), (
|
||||||
|
"the status bar still carries a permanent device line"
|
||||||
|
)
|
||||||
|
assert isinstance(window.statusBar(), app_module._NoticeBar)
|
||||||
|
|
||||||
|
# Qt's default is msecs=0, which means "no timeout": a message that never
|
||||||
|
# expires is what left an hour-old "运行中(0)" under the window.
|
||||||
|
window.statusBar().showMessage("plain call")
|
||||||
|
assert window.statusBar().currentMessage() == "plain call"
|
||||||
|
|
||||||
|
original_timeout = app_module._NoticeBar.DEFAULT_TIMEOUT_MS
|
||||||
|
app_module._NoticeBar.DEFAULT_TIMEOUT_MS = 40
|
||||||
|
try:
|
||||||
|
window.statusBar().showMessage("expiring notice")
|
||||||
|
assert window.statusBar().currentMessage() == "expiring notice"
|
||||||
|
deadline = time.perf_counter() + 1.5
|
||||||
|
while time.perf_counter() < deadline:
|
||||||
|
QApplication.processEvents()
|
||||||
|
if window.statusBar().currentMessage() == "":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert window.statusBar().currentMessage() == "", (
|
||||||
|
"a notice stayed on the status bar after its timeout"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app_module._NoticeBar.DEFAULT_TIMEOUT_MS = original_timeout
|
||||||
|
|
||||||
|
window._notify("")
|
||||||
|
assert window.statusBar().currentMessage() == ""
|
||||||
|
window._notify("something happened")
|
||||||
|
assert window.statusBar().currentMessage() == "something happened"
|
||||||
|
|
||||||
|
# The rail states the phase, so the bar does not repeat it.
|
||||||
|
window, stuck = _running_window(make_window)
|
||||||
|
assert window.toolbar.status_chip.text().strip(), "the rail lost its phase chip"
|
||||||
|
_finish(window, stuck)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -910,15 +910,21 @@ def main() -> int:
|
|||||||
not rail_squeezed(window),
|
not rail_squeezed(window),
|
||||||
f"{theme}: the status rail squeezes {rail_squeezed(window)}",
|
f"{theme}: the status rail squeezes {rail_squeezed(window)}",
|
||||||
)
|
)
|
||||||
check(
|
|
||||||
"sensor.json" in window.device_label.text(),
|
|
||||||
f"{theme}: the status bar does not name the config file: "
|
|
||||||
f"{window.device_label.text()!r}",
|
|
||||||
)
|
|
||||||
check(
|
check(
|
||||||
not hasattr(window.toolbar, "source_combo"),
|
not hasattr(window.toolbar, "source_combo"),
|
||||||
f"{theme}: the source mode is still a combobox",
|
f"{theme}: the source mode is still a combobox",
|
||||||
)
|
)
|
||||||
|
# The status bar is a notice line, not a second state line: the config
|
||||||
|
# file and the phase are already on the rail, and a message must expire.
|
||||||
|
check(
|
||||||
|
not hasattr(window, "device_label"),
|
||||||
|
f"{theme}: the status bar still carries a permanent device line",
|
||||||
|
)
|
||||||
|
window.statusBar().showMessage("notice")
|
||||||
|
check(
|
||||||
|
window.statusBar().currentMessage() == "notice",
|
||||||
|
f"{theme}: the status bar does not show a message at all",
|
||||||
|
)
|
||||||
|
|
||||||
# The sensor dialog owns the pickers and the calibration facts.
|
# The sensor dialog owns the pickers and the calibration facts.
|
||||||
window._open_sensor_dialog()
|
window._open_sensor_dialog()
|
||||||
@@ -1617,6 +1623,29 @@ def main() -> int:
|
|||||||
f"the arrow footer does not say what is magnified: "
|
f"the arrow footer does not say what is magnified: "
|
||||||
f"{header_window.arrow_panel.scale_label.text()!r}",
|
f"{header_window.arrow_panel.scale_label.text()!r}",
|
||||||
)
|
)
|
||||||
|
# The header's mode label shares its row with three icon buttons and the unit
|
||||||
|
# chip: the long form of the mode sentence was cut mid-word in English.
|
||||||
|
for lang in ("zh_CN", "en_US"):
|
||||||
|
header_window._set_language(lang)
|
||||||
|
for _ in range(3):
|
||||||
|
app.processEvents()
|
||||||
|
for mode, name in ((False, "follow"), (True, "browse")):
|
||||||
|
if mode:
|
||||||
|
header_plots.set_window(0.2, 1.0)
|
||||||
|
app.processEvents()
|
||||||
|
label = header_plots.mode_label
|
||||||
|
check(
|
||||||
|
label.width() >= label.sizeHint().width(),
|
||||||
|
f"{lang}/{name}: the chart mode label is clipped: "
|
||||||
|
f"{label.width()} < {label.sizeHint().width()} ({label.text()!r})",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
label.toolTip() != "",
|
||||||
|
f"{lang}/{name}: the mode label does not explain itself",
|
||||||
|
)
|
||||||
|
# The locale is process-wide: leave it where the other sections expect it.
|
||||||
|
header_window._set_language("zh_CN")
|
||||||
|
app.processEvents()
|
||||||
header_window.close()
|
header_window.close()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
|
|
||||||
@@ -1761,6 +1790,25 @@ def main() -> int:
|
|||||||
finally:
|
finally:
|
||||||
app_module.recordings_dir = original_root
|
app_module.recordings_dir = original_root
|
||||||
_shutil.rmtree(scratch, ignore_errors=True)
|
_shutil.rmtree(scratch, ignore_errors=True)
|
||||||
|
|
||||||
|
# The rail's FPS is the worker's *delivered* frame rate. It used to be a count
|
||||||
|
# of loop iterations (a 30 fps device read 63), so the number the rail prints
|
||||||
|
# must be the packet's, verbatim.
|
||||||
|
window.act_show_fps.setChecked(True)
|
||||||
|
app.processEvents()
|
||||||
|
packet = _fake_packet(0, mono_t=0.0)
|
||||||
|
packet.fps = 30.4
|
||||||
|
stuck.emit(packet)
|
||||||
|
window._on_run_tick()
|
||||||
|
app.processEvents()
|
||||||
|
check(
|
||||||
|
"30.4 FPS" in window.toolbar.source_summary.text(),
|
||||||
|
f"the rail does not show the delivered frame rate: "
|
||||||
|
f"{window.toolbar.source_summary.text()!r}",
|
||||||
|
)
|
||||||
|
window.act_show_fps.setChecked(False)
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
window._stop_run(reason="user_stop")
|
window._stop_run(reason="user_stop")
|
||||||
stuck.release()
|
stuck.release()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
@@ -2460,9 +2508,16 @@ def main() -> int:
|
|||||||
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
|
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
|
||||||
"browsing does not enable panning",
|
"browsing does not enable panning",
|
||||||
)
|
)
|
||||||
window_texts = plots.stats_label.text()
|
# "Whole session" is the default fit, not a selection: the cards still state
|
||||||
check(window_texts != "", "no selection statistics in browse mode")
|
# the sample under the cursor, and the strip carries no selection summary.
|
||||||
check("峰" in window_texts, f"the peak is missing from the readout: {window_texts!r}")
|
check(
|
||||||
|
plots._selection is None,
|
||||||
|
"fitted to the whole session, the cards still hold selection statistics",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
plots.stats_label.text() == "",
|
||||||
|
f"a fitted session shows a selection summary: {plots.stats_label.text()!r}",
|
||||||
|
)
|
||||||
|
|
||||||
# Decimation must keep the spike while bounding the point count.
|
# Decimation must keep the spike while bounding the point count.
|
||||||
drawn = max(int(curve.getData()[0].size) for curve in plots.curves)
|
drawn = max(int(curve.getData()[0].size) for curve in plots.curves)
|
||||||
@@ -2476,6 +2531,50 @@ def main() -> int:
|
|||||||
plots.overview_curve.getData()[0].size > 0,
|
plots.overview_curve.getData()[0].size > 0,
|
||||||
"the whole-session overview strip is empty",
|
"the whole-session overview strip is empty",
|
||||||
)
|
)
|
||||||
|
# A window narrower than the session is a selection: its peak becomes the big
|
||||||
|
# number on each card and its mean/σ the line under it, while the strip keeps
|
||||||
|
# only the size of the selection (the long per-channel line did not fit).
|
||||||
|
check(plots._selection is not None, "zooming in did not record a selection")
|
||||||
|
summary = plots.stats_label.text()
|
||||||
|
check(summary != "", "the strip does not state the size of the selection")
|
||||||
|
check(
|
||||||
|
"峰" not in summary and "均" not in summary,
|
||||||
|
f"the per-channel statistics are still in the strip: {summary!r}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"选区" in summary or "Selection" in summary,
|
||||||
|
f"the strip does not say that the numbers describe a selection: {summary!r}",
|
||||||
|
)
|
||||||
|
expected_peak = float(plots._selection.peaks[0])
|
||||||
|
peak_text = (
|
||||||
|
f"{expected_peak:.3f}" if plots.unit() == "N" else f"{expected_peak:.1f}"
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
plots.value_labels[0].text() == peak_text,
|
||||||
|
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
plots.value_details[0].text() != ""
|
||||||
|
and ("均" in plots.value_details[0].text() or "mean" in plots.value_details[0].text()),
|
||||||
|
f"the card does not state the selection's mean: "
|
||||||
|
f"{plots.value_details[0].text()!r}",
|
||||||
|
)
|
||||||
|
for card, label, detail in zip(
|
||||||
|
plots.value_cards, plots.value_labels, plots.value_details
|
||||||
|
):
|
||||||
|
check(
|
||||||
|
detail.width() >= detail.sizeHint().width(),
|
||||||
|
f"the selection detail line is clipped: "
|
||||||
|
f"{detail.width()} < {detail.sizeHint().width()}",
|
||||||
|
)
|
||||||
|
# Leaving the selection hands the cards back to the cursor readout.
|
||||||
|
plots.follow_latest()
|
||||||
|
check(plots._selection is None, "the selection survived 'back to latest'")
|
||||||
|
check(
|
||||||
|
plots.value_details[0].text() == "",
|
||||||
|
f"the mean line survived leaving the selection: "
|
||||||
|
f"{plots.value_details[0].text()!r}",
|
||||||
|
)
|
||||||
|
|
||||||
# A user gesture (the signal pyqtgraph emits for a wheel/drag) enters browse;
|
# A user gesture (the signal pyqtgraph emits for a wheel/drag) enters browse;
|
||||||
# "back to latest" must actually leave it.
|
# "back to latest" must actually leave it.
|
||||||
|
|||||||
@@ -149,14 +149,39 @@ def _run_scene(window, app, scene: str):
|
|||||||
|
|
||||||
def _demo_data(window) -> None:
|
def _demo_data(window) -> None:
|
||||||
"""Fill the plots, the calibration table and the arrow canvas."""
|
"""Fill the plots, the calibration table and the arrow canvas."""
|
||||||
|
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||||
|
|
||||||
plots = window.force_plots
|
plots = window.force_plots
|
||||||
|
# The panel reads a *selection* out of the attached source, so the demo needs
|
||||||
|
# a real session store behind it (a store-less panel can only follow live).
|
||||||
|
store = SessionStore()
|
||||||
|
plots.set_store(store)
|
||||||
for i in range(240):
|
for i in range(240):
|
||||||
t = i / 30.0
|
t = i / 30.0
|
||||||
fn = 6.0 + 5.0 * math.sin(t * 1.4)
|
fn = 6.0 + 5.0 * math.sin(t * 1.4)
|
||||||
fx = 4.0 * math.sin(t * 2.1 + 0.4)
|
fx = 4.0 * math.sin(t * 2.1 + 0.4)
|
||||||
fy = -3.0 + 2.5 * math.cos(t * 1.1)
|
fy = -3.0 + 2.5 * math.cos(t * 1.1)
|
||||||
|
store.append(
|
||||||
|
SessionSample(
|
||||||
|
frame_id=i,
|
||||||
|
t_rel_s=t,
|
||||||
|
fn_raw=fn * 7.9 + 9.0,
|
||||||
|
fx_raw=fx * 7.9,
|
||||||
|
fy_raw=fy * 7.9,
|
||||||
|
fn_n=fn,
|
||||||
|
fx_n=fx,
|
||||||
|
fy_n=fy,
|
||||||
|
calibrated_axes=("fn", "fx", "fy"),
|
||||||
|
)
|
||||||
|
)
|
||||||
plots.push(t, fn, fx, fy, unit="N")
|
plots.push(t, fn, fx, fy, unit="N")
|
||||||
|
|
||||||
|
# The overview reads the *source*, and ``push`` only rebuilds it once a second
|
||||||
|
# (the demo fills a whole session in a few milliseconds), so build it once here.
|
||||||
|
plots._overview_dirty = True
|
||||||
|
plots._last_overview_at = 0.0
|
||||||
|
plots._sync_overview()
|
||||||
|
|
||||||
cal = window.cal_panel
|
cal = window.cal_panel
|
||||||
cal.set_running(True)
|
cal.set_running(True)
|
||||||
cal.push_raw(9.81, -0.42, 0.10)
|
cal.push_raw(9.81, -0.42, 0.10)
|
||||||
@@ -227,6 +252,14 @@ def main() -> int:
|
|||||||
"replay view so the progress bar can be reviewed"
|
"replay view so the progress bar can be reviewed"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--select",
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"A,B — browse the live demo session inside that window so the "
|
||||||
|
"selection readout (cards = peak / mean · σ) can be reviewed"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument("--out", required=True, help="output PNG path")
|
parser.add_argument("--out", required=True, help="output PNG path")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -235,6 +268,14 @@ def main() -> int:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
parser.error("--size must look like 1440x880")
|
parser.error("--size must look like 1440x880")
|
||||||
|
|
||||||
|
selection = None
|
||||||
|
if args.select:
|
||||||
|
try:
|
||||||
|
start, end = (float(part) for part in args.select.split(",", 1))
|
||||||
|
except ValueError:
|
||||||
|
parser.error("--select must look like 2,6")
|
||||||
|
selection = (start, end)
|
||||||
|
|
||||||
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
|
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
@@ -281,6 +322,13 @@ def main() -> int:
|
|||||||
for _ in range(4):
|
for _ in range(4):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
|
|
||||||
|
if selection is not None:
|
||||||
|
# A selection is a browse window narrower than the session: the value
|
||||||
|
# cards state its peak and mean · σ, the strip only its size.
|
||||||
|
window.force_plots.set_window(*selection)
|
||||||
|
for _ in range(4):
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
out = Path(args.out)
|
out = Path(args.out)
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
ok = window.grab().save(str(out))
|
ok = window.grab().save(str(out))
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ STOP_JOIN_TIMEOUT_S = 5.0
|
|||||||
#: How long a new session waits (in its own thread, never on the GUI) for a
|
#: How long a new session waits (in its own thread, never on the GUI) for a
|
||||||
#: previous session that is still winding down before opening the device.
|
#: previous session that is still winding down before opening the device.
|
||||||
HANDOVER_TIMEOUT_S = 60.0
|
HANDOVER_TIMEOUT_S = 60.0
|
||||||
|
#: Window over which the delivered frame rate is measured.
|
||||||
|
FPS_WINDOW_S = 1.0
|
||||||
|
#: How long to wait before asking again when the capture source has not produced
|
||||||
|
#: a new frame yet. Long enough not to spin, short enough not to add latency.
|
||||||
|
CAPTURE_REPEAT_POLL_S = 0.002
|
||||||
|
|
||||||
# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the
|
# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the
|
||||||
# definitions live in ``models`` so neither worker depends on the other.
|
# definitions live in ``models`` so neither worker depends on the other.
|
||||||
@@ -72,6 +77,29 @@ def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) ->
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_sequence(sensor) -> Optional[int]:
|
||||||
|
"""The capture source's delivered-frame counter, when it can mark a repeat.
|
||||||
|
|
||||||
|
``CaptureSource.read_latest`` returns the newest frame from its background
|
||||||
|
reader; if that reader has not produced another frame yet, the *same* one
|
||||||
|
comes back. Its ``status.last_seq`` advances once per delivered frame, so an
|
||||||
|
unchanged counter means "no new frame". Only a source with a running
|
||||||
|
background reader has that marker: a video file is read synchronously and
|
||||||
|
every call decodes the next frame, so its counter says nothing about repeats
|
||||||
|
and is ignored (``None``).
|
||||||
|
"""
|
||||||
|
source = getattr(sensor, "capture_source", None)
|
||||||
|
if source is None:
|
||||||
|
source = getattr(sensor, "_capture_source", None)
|
||||||
|
if source is None or not bool(getattr(source, "running", False)):
|
||||||
|
return None
|
||||||
|
status = getattr(source, "status", None)
|
||||||
|
try:
|
||||||
|
return int(getattr(status, "last_seq"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
|
def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
|
||||||
"""Run the live processing chain for one already-read frame.
|
"""Run the live processing chain for one already-read frame.
|
||||||
|
|
||||||
@@ -595,6 +623,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
saw_frame = False
|
saw_frame = False
|
||||||
last_frame_mono = 0.0
|
last_frame_mono = 0.0
|
||||||
none_streak_start: Optional[float] = None
|
none_streak_start: Optional[float] = None
|
||||||
|
#: The capture sequence of the last frame that was actually *processed*:
|
||||||
|
#: an unchanged counter means the same frame came back again.
|
||||||
|
last_delivered: Optional[int] = None
|
||||||
# Per-session monotonic frame index: the join key shared by the video,
|
# Per-session monotonic frame index: the join key shared by the video,
|
||||||
# the CSV and the calibration records.
|
# the CSV and the calibration records.
|
||||||
frame_id = 0
|
frame_id = 0
|
||||||
@@ -615,6 +646,18 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
break
|
break
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
# ``CaptureSource.read_latest`` hands back the newest frame and,
|
||||||
|
# until the pipeline produces another one, that **same** frame
|
||||||
|
# again. Treating the repeat as a new sample doubled every
|
||||||
|
# measurement (a 30 fps device recorded 63 rows per second) and
|
||||||
|
# made the reported rate this loop's own polling rate. A source
|
||||||
|
# without a background reader (a video file) decodes a new frame
|
||||||
|
# per call, so it has no repeat marker and is left alone.
|
||||||
|
delivered = _capture_sequence(sensor)
|
||||||
|
if delivered is not None and delivered == last_delivered:
|
||||||
|
time.sleep(CAPTURE_REPEAT_POLL_S)
|
||||||
|
continue
|
||||||
|
last_delivered = delivered
|
||||||
processed = sensor.get_img()
|
processed = sensor.get_img()
|
||||||
if processed is None:
|
if processed is None:
|
||||||
if saw_frame and is_file and none_streak_start is None:
|
if saw_frame and is_file and none_streak_start is None:
|
||||||
@@ -658,9 +701,14 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
compute_error = compute_error or chain_error
|
compute_error = compute_error or chain_error
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
frame_times.append(t1)
|
frame_times.append(t1)
|
||||||
cutoff = t1 - 1.0
|
cutoff = t1 - FPS_WINDOW_S
|
||||||
frame_times = [t for t in frame_times if t >= cutoff]
|
frame_times = [t for t in frame_times if t >= cutoff]
|
||||||
fps = float(len(frame_times)) if frame_times else 0.0
|
# A *rate*, not a count: counting arrivals in a one-second window
|
||||||
|
# reads one frame high whenever the window holds a full second of
|
||||||
|
# a jittery stream. The span between the first and last arrival is
|
||||||
|
# exactly what "frames per second" means.
|
||||||
|
span = frame_times[-1] - frame_times[0] if len(frame_times) > 1 else 0.0
|
||||||
|
fps = (len(frame_times) - 1) / span if span > 0.0 else 0.0
|
||||||
forces = read_forces_from_sensor(sensor, config_ref=mode.config_ref)
|
forces = read_forces_from_sensor(sensor, config_ref=mode.config_ref)
|
||||||
invalid_reason = forces.get("invalid_reason") or compute_error
|
invalid_reason = forces.get("invalid_reason") or compute_error
|
||||||
raw_frame = _ensure_bgr(raw)
|
raw_frame = _ensure_bgr(raw)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Which frame the acquisition loop counts, and at what rate.
|
||||||
|
|
||||||
|
``CaptureSource.read_latest`` returns the newest frame from its background reader
|
||||||
|
and, if that reader has not produced another one yet, the *same* frame again. The
|
||||||
|
loop must not treat the repeat as a new sample: doing so duplicated every
|
||||||
|
measurement (a 30 fps device recorded 63 rows per second) and the rail read the
|
||||||
|
loop's own polling rate as the device's frame rate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
|
||||||
|
|
||||||
|
#: The fake device's own frame period: ~100 frames per second.
|
||||||
|
FRAME_PERIOD_S = 0.01
|
||||||
|
#: How many times the SDK hands the same frame back before the next one arrives.
|
||||||
|
REPEATS_PER_FRAME = 3
|
||||||
|
|
||||||
|
|
||||||
|
class _Status:
|
||||||
|
"""Stands in for ``orisys.capture.config.CaptureStatus``."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.last_seq = 0
|
||||||
|
self.dropped_frames = 0
|
||||||
|
|
||||||
|
|
||||||
|
class _CaptureSource:
|
||||||
|
"""A background reader that publishes a new frame on its own thread.
|
||||||
|
|
||||||
|
The real one is a thread too: the sequence advances whether or not the
|
||||||
|
consumer asks for a frame, which is exactly why an unchanged sequence means
|
||||||
|
"no new frame" rather than "the consumer has not called yet".
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, period_s: float = FRAME_PERIOD_S) -> None:
|
||||||
|
self.status = _Status()
|
||||||
|
self.running = True
|
||||||
|
self._period = float(period_s)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._publish, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _publish(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
if self._stop.wait(timeout=self._period):
|
||||||
|
return
|
||||||
|
self.status.last_seq += 1
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.running = False
|
||||||
|
self._stop.set()
|
||||||
|
self._thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class _Info:
|
||||||
|
"""The channel names the worker reads off ``sensor.info``."""
|
||||||
|
|
||||||
|
FNORMAL = "FNORMAL"
|
||||||
|
FSHEARX = "FSHEARX"
|
||||||
|
FSHEARY = "FSHEARY"
|
||||||
|
FNORMAL_RAW = "FNORMAL_RAW"
|
||||||
|
FSHEARX_RAW = "FSHEARX_RAW"
|
||||||
|
FSHEARY_RAW = "FSHEARY_RAW"
|
||||||
|
|
||||||
|
|
||||||
|
#: Every sensor the fake SDK handed out, so a test can read its frame counter.
|
||||||
|
CREATED: list = []
|
||||||
|
|
||||||
|
|
||||||
|
class _Sensor:
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
self.configured: dict = {}
|
||||||
|
self.connected = True
|
||||||
|
self.capture_source = _CaptureSource()
|
||||||
|
self.frame = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||||
|
self.info = _Info()
|
||||||
|
self.force_calibrated = True
|
||||||
|
CREATED.append(self)
|
||||||
|
|
||||||
|
def configure(self, **kwargs):
|
||||||
|
self.configured = kwargs
|
||||||
|
|
||||||
|
def read_info(self, *names):
|
||||||
|
"""The SDK's readout: one value per requested channel, in order."""
|
||||||
|
values = {
|
||||||
|
_Info.FNORMAL: 1.5,
|
||||||
|
_Info.FSHEARX: 0.25,
|
||||||
|
_Info.FSHEARY: -0.25,
|
||||||
|
_Info.FNORMAL_RAW: 15.0,
|
||||||
|
_Info.FSHEARX_RAW: 2.5,
|
||||||
|
_Info.FSHEARY_RAW: -2.5,
|
||||||
|
}
|
||||||
|
return tuple(values.get(str(name), 0.0) for name in names)
|
||||||
|
|
||||||
|
def get_img(self):
|
||||||
|
# ``read_latest``: hand back the newest frame immediately, the same one
|
||||||
|
# again when nothing new has arrived.
|
||||||
|
return self.frame
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.connected = False
|
||||||
|
self.capture_source.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _install() -> object:
|
||||||
|
saved = {name: sys.modules.get(name) for name in ("orisys", "orisys.finger")}
|
||||||
|
module = types.ModuleType("orisys")
|
||||||
|
module.__version__ = "fake"
|
||||||
|
module.Sensor = _Sensor # type: ignore[attr-defined]
|
||||||
|
finger_module = types.ModuleType("orisys.finger")
|
||||||
|
finger_module.FingerRuntime = _Sensor # type: ignore[attr-defined]
|
||||||
|
module.finger = finger_module # type: ignore[attr-defined]
|
||||||
|
sys.modules["orisys"] = module
|
||||||
|
sys.modules["orisys.finger"] = finger_module
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def _restore(saved) -> None:
|
||||||
|
for name, original in saved.items():
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
def _collect(duration_s: float = 1.2):
|
||||||
|
"""Run the real worker loop against the fake device.
|
||||||
|
|
||||||
|
Returns ``(packets, delivered)`` — what the loop recorded, and how many frames
|
||||||
|
the fake capture source actually delivered in the same period.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
|
||||||
|
|
||||||
|
CREATED.clear()
|
||||||
|
saved = _install()
|
||||||
|
worker = SdkAcquisitionWorker()
|
||||||
|
packets: list = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def sink(packet) -> None:
|
||||||
|
with lock:
|
||||||
|
packets.append(packet)
|
||||||
|
|
||||||
|
try:
|
||||||
|
worker.add_sink(sink)
|
||||||
|
worker.start("0", config_ref=BOX_CONFIG)
|
||||||
|
time.sleep(duration_s)
|
||||||
|
worker.stop(wait=True)
|
||||||
|
delivered = CREATED[-1].capture_source.status.last_seq if CREATED else 0
|
||||||
|
finally:
|
||||||
|
_restore(saved)
|
||||||
|
return packets, int(delivered)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate(packets) -> float:
|
||||||
|
span = float(packets[-1].mono_t) - float(packets[0].mono_t)
|
||||||
|
return (len(packets) - 1) / span if span > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_frame_is_not_counted_twice():
|
||||||
|
"""One packet per delivered frame, whatever the loop's polling rate is."""
|
||||||
|
packets, delivered = _collect()
|
||||||
|
assert packets, "the loop emitted nothing at all"
|
||||||
|
assert delivered > 10, f"the fake device only delivered {delivered} frames"
|
||||||
|
assert len(packets) <= delivered * 1.5, (
|
||||||
|
f"{len(packets)} packets for {delivered} delivered frames: the repeats "
|
||||||
|
"are being recorded as samples"
|
||||||
|
)
|
||||||
|
assert len(packets) >= delivered * 0.7, (
|
||||||
|
f"only {len(packets)} packets for {delivered} delivered frames"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reported_rate_is_the_delivered_rate():
|
||||||
|
"""``packet.fps`` describes the frames, not how often the loop asked."""
|
||||||
|
packets, delivered = _collect()
|
||||||
|
reported = [float(packet.fps) for packet in packets if packet.fps]
|
||||||
|
assert reported, "no packet carried a frame rate"
|
||||||
|
measured = _rate(packets)
|
||||||
|
settled = reported[-5:]
|
||||||
|
assert all(value > 10.0 for value in settled), settled
|
||||||
|
assert abs(settled[-1] - measured) < 0.25 * measured, (settled[-1], measured)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||||
|
failures = 0
|
||||||
|
for name, function in sorted(globals().items()):
|
||||||
|
if name.startswith("test_") and callable(function):
|
||||||
|
try:
|
||||||
|
function()
|
||||||
|
print(f" ok {name}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
failures += 1
|
||||||
|
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
|
||||||
|
print(f"failures: {failures}")
|
||||||
|
raise SystemExit(1 if failures else 0)
|
||||||
Reference in New Issue
Block a user