采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - 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",
|
||||
"no_data": "—",
|
||||
"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_tip": "The charts read the open recording; capture and recording continue",
|
||||
"return_latest": "Back to latest",
|
||||
"return_latest_tip": "Leave history browsing and follow the newest data again",
|
||||
"overview": "Whole session",
|
||||
@@ -150,6 +153,9 @@
|
||||
"box_zoom": "Box zoom",
|
||||
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
|
||||
"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",
|
||||
"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)",
|
||||
@@ -326,7 +332,6 @@
|
||||
"apply_no_target": "No device to apply to (run an acquisition first)"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Ready",
|
||||
"phase_idle": "Idle",
|
||||
"phase_connecting": "Connecting",
|
||||
"phase_running": "Acquiring",
|
||||
@@ -339,15 +344,12 @@
|
||||
"calibrated_axes": "Calibrated · {axes}",
|
||||
"uncalibrated": "Uncalibrated",
|
||||
"config_none": "no config",
|
||||
"device_label": "config {config}",
|
||||
"camera_none": "No cameras found",
|
||||
"camera_found": "Found {count} camera(s): {names}",
|
||||
"preview_started": "Preview started (camera {camera_id})",
|
||||
"preview_stopped": "Preview stopped",
|
||||
"preview_failed": "Preview failed: {error}",
|
||||
"connecting": "Connecting…",
|
||||
"connect_slow": "This is taking a while — click “Cancel” or press Esc to abort",
|
||||
"run_started": "Running ({source})",
|
||||
"run_stopped": "Stopped",
|
||||
"run_stopping": "Stopping… (releasing the device)",
|
||||
"run_stopping_cancelled": "Connection cancelled (releasing the device)",
|
||||
|
||||
@@ -141,8 +141,11 @@
|
||||
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
|
||||
"no_data": "—",
|
||||
"mode_follow": "跟随实时",
|
||||
"mode_browse": "浏览历史(显示暂停,采集与记录继续)",
|
||||
"mode_follow_tip": "曲线跟随最新样本,窗口随采集滚动",
|
||||
"mode_browse": "浏览历史",
|
||||
"mode_browse_tip": "显示暂停,采集与记录继续;拖动或滚轮改变窗口,点「返回最新」回到实时",
|
||||
"mode_replay": "回放录制(浏览)",
|
||||
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
|
||||
"return_latest": "返回最新",
|
||||
"return_latest_tip": "退出历史浏览,恢复跟随最新数据",
|
||||
"overview": "全程总览",
|
||||
@@ -150,6 +153,9 @@
|
||||
"box_zoom": "框选",
|
||||
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
||||
"selection": "选区 {duration}s / {samples} 点 — {detail}",
|
||||
"selection_summary": "选区 {duration}s / {samples} 点",
|
||||
"selection_detail": "均 {mean} · σ {std}",
|
||||
"selection_peak_tip": "上方大号数字是选区的峰值;均值为选区均值",
|
||||
"selection_empty": "选区无有效样本",
|
||||
"value_unavailable": "不可用",
|
||||
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
|
||||
@@ -326,7 +332,6 @@
|
||||
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
|
||||
},
|
||||
"status": {
|
||||
"ready": "就绪",
|
||||
"phase_idle": "空闲",
|
||||
"phase_connecting": "连接中",
|
||||
"phase_running": "采集中",
|
||||
@@ -339,15 +344,12 @@
|
||||
"calibrated_axes": "已标定 · {axes}",
|
||||
"uncalibrated": "未标定",
|
||||
"config_none": "未选配置",
|
||||
"device_label": "配置 {config}",
|
||||
"camera_none": "未发现摄像机",
|
||||
"camera_found": "发现 {count} 个摄像机: {names}",
|
||||
"preview_started": "预览已启动(摄像机 {camera_id})",
|
||||
"preview_stopped": "预览已停止",
|
||||
"preview_failed": "预览失败: {error}",
|
||||
"connecting": "正在连接…",
|
||||
"connect_slow": "连接耗时较长,可点击「取消连接」或按 Esc 中断",
|
||||
"run_started": "运行中({source})",
|
||||
"run_stopped": "已停止",
|
||||
"run_stopping": "正在停止…(正在释放设备)",
|
||||
"run_stopping_cancelled": "已取消连接(正在释放设备)",
|
||||
|
||||
@@ -52,6 +52,25 @@ UI 线程 _on_run_tick ──> 曲线显示(仍可降帧)
|
||||
控件更新必须留在 UI 线程。
|
||||
- **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` 是**容器速率**,必须等于传感器的采集速率,取自传感器配置
|
||||
(`config/sensors/<type>/sensor.json` 的 `fps`,box 为 30),**不是** packet 里那个
|
||||
`fps` 字段 —— 后者是"管线每秒完成多少帧"的计数器,开流瞬间的缓冲突发或视频文件源会让
|
||||
它读到 48,用它写 30 fps 的容器就等于把回放播成了快进。这是"传感器 30 fps、录制结果
|
||||
48 fps"的直接原因。
|
||||
`fps` 字段 —— 后者是"本机每秒处理了多少帧"的实时速率(见 §2.1),开流瞬间的缓冲突发会
|
||||
让它高于配置值,用它写 30 fps 的容器就等于把回放播成了快进。这是"传感器 30 fps、录制
|
||||
结果 48 fps"的直接原因。
|
||||
|
||||
- `StudioMainWindow._video_fps()`:实时相机 → 传感器配置的 `fps`;视频文件源 → 实测值;
|
||||
实时相机的实测值另存 `video.measured_fps`,两者都进 `session.json`,对不上时看得见。
|
||||
|
||||
@@ -118,11 +118,31 @@
|
||||
- **符号不替代语义**:每个按钮都带 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`(无标定 → 显示原始单位)以前每
|
||||
一帧都发一次,等于永久占住通知行;现在每次运行只提示一次。
|
||||
|
||||
### 单位切换是"开关",不是"重新解释"
|
||||
|
||||
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
|
||||
|
||||
@@ -220,6 +220,11 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
|
||||
按钮,所以不参与 §6.5 的"同一时刻只有一个实心按钮"计数(`QLabel#unitBadge` 仍是通道
|
||||
徽标与传感器缩略图的样式,两者靠控件类型区分)。
|
||||
|
||||
8. **数值卡的小字用等宽体,且不会把面板顶宽**。选区统计(均值 · σ)画在数值卡内的 9pt
|
||||
等宽小字上,与 16pt 的大号数字同一列;底部状态栏不再出现长句,因此曲线面板的宽度由
|
||||
三个数值卡决定,而不是由一行统计文字决定。`ui_check.py` 的 `session browsing` 段断言
|
||||
该小字 `width() >= sizeHint().width()`(中文与英文都不裁切)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 三个顶层视图 = 同一窗口的三套布局
|
||||
|
||||
@@ -112,6 +112,22 @@ def _reveal_in_explorer(path: Path) -> None:
|
||||
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):
|
||||
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
|
||||
super().__init__()
|
||||
@@ -127,6 +143,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self._running = False
|
||||
self._connect_started_at = 0.0
|
||||
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_timer = QTimer(self)
|
||||
self._preview_timer.setInterval(33)
|
||||
@@ -226,13 +244,12 @@ class StudioMainWindow(QMainWindow):
|
||||
self.replay_bar.setVisible(False)
|
||||
root.addWidget(self.replay_bar)
|
||||
|
||||
self.setStatusBar(QStatusBar())
|
||||
# The phase/record state lives in the toolbar's rail; the status bar is
|
||||
# for transient messages plus one permanent line of device identity.
|
||||
self.device_label = QLabel()
|
||||
self.device_label.setObjectName("panelMeta")
|
||||
self.statusBar().addPermanentWidget(self.device_label)
|
||||
self.cal_panel.status_message.connect(self.statusBar().showMessage)
|
||||
self.setStatusBar(_NoticeBar())
|
||||
# The status bar carries **one-off notices** only (a recording finished, a
|
||||
# calibration was applied, a failure). The phase, the source and the
|
||||
# validity of the data are already on the toolbar's rail, so repeating
|
||||
# them here only put stale text under the window for the whole session.
|
||||
self.cal_panel.status_message.connect(self._notify)
|
||||
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
|
||||
self.cal_panel.apply_requested.connect(self._on_calibration_apply)
|
||||
self._set_phase("idle")
|
||||
@@ -1058,10 +1075,18 @@ class StudioMainWindow(QMainWindow):
|
||||
self._refresh_status_chip()
|
||||
self._refresh_status_rail()
|
||||
self._refresh_record_state()
|
||||
if not self._preview_open and not self._running:
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
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:
|
||||
"""Track the acquisition phase and reflect it in the status chip."""
|
||||
self._session_phase = phase if phase in SESSION_PHASES else "idle"
|
||||
@@ -1117,7 +1142,6 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar.set_data_state(
|
||||
t("status.data_invalid", count=self._record_error_count), "warning"
|
||||
)
|
||||
self._refresh_device_label()
|
||||
|
||||
def _calibration_summary(self) -> str:
|
||||
"""What the calibration state means for the three axes.
|
||||
@@ -1143,14 +1167,6 @@ class StudioMainWindow(QMainWindow):
|
||||
return t("status.uncalibrated")
|
||||
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
|
||||
def _record_stats(self):
|
||||
"""``(state, stats, directory)`` — the single read of the record channel.
|
||||
@@ -1844,6 +1860,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._set_phase("connecting")
|
||||
self._connect_started_at = time.perf_counter()
|
||||
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_running(True)
|
||||
self.toolbar.set_source_controls_enabled(False)
|
||||
@@ -1854,7 +1871,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self._update_run_enabled()
|
||||
self._refresh_status_rail()
|
||||
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(
|
||||
"Connecting type=%s source=%s config=%s",
|
||||
sensor_type,
|
||||
@@ -1988,9 +2006,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self._connect_slow_notified = True
|
||||
self.toolbar.set_reset_enabled(True)
|
||||
self._sync_shortcut_actions()
|
||||
self.statusBar().showMessage(
|
||||
t("status.run_started", source=self._run_source_label)
|
||||
)
|
||||
# No status-bar line here: the rail's phase chip says 采集中 and the
|
||||
# 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:
|
||||
self._last_plot_packet_key = packet_key
|
||||
@@ -2005,7 +2023,14 @@ class StudioMainWindow(QMainWindow):
|
||||
sample=record,
|
||||
)
|
||||
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_display_fallback = bool(display.using_raw_fallback)
|
||||
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
|
||||
#: enough that reading a recording from disk stays free while scrubbing.
|
||||
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):
|
||||
@@ -140,6 +144,9 @@ class ForcePlotsPanel(QFrame):
|
||||
self._mode = "follow"
|
||||
self._window: tuple[float, float] = (0.0, 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._overview_dirty = True
|
||||
self._last_overview_at = 0.0
|
||||
@@ -198,17 +205,21 @@ class ForcePlotsPanel(QFrame):
|
||||
values_row.setSpacing(14)
|
||||
self.value_cards: list[QFrame] = []
|
||||
self.value_labels: list[QLabel] = []
|
||||
self.value_details: list[QLabel] = []
|
||||
self.channel_badges: list[QLabel] = []
|
||||
self.channel_names: list[QLabel] = []
|
||||
self.value_units: list[QLabel] = []
|
||||
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)
|
||||
self.value_cards.append(card)
|
||||
self.channel_badges.append(badge)
|
||||
self.channel_names.append(name_label)
|
||||
self.value_labels.append(value_label)
|
||||
self.value_units.append(unit_label)
|
||||
self.value_details.append(detail_label)
|
||||
root.addWidget(self.value_group)
|
||||
|
||||
self.plot_frame = QFrame()
|
||||
@@ -316,11 +327,15 @@ class ForcePlotsPanel(QFrame):
|
||||
|
||||
def _build_value_card(
|
||||
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.
|
||||
|
||||
The value is deliberately borderless and large — three boxed cards side
|
||||
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.setObjectName("valueGroup")
|
||||
@@ -354,7 +369,13 @@ class ForcePlotsPanel(QFrame):
|
||||
value_row.addWidget(unit_label)
|
||||
value_row.addStretch(1)
|
||||
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:
|
||||
self._theme = theme
|
||||
@@ -446,11 +467,13 @@ class ForcePlotsPanel(QFrame):
|
||||
self._mode = "follow"
|
||||
self._window = (0.0, 0.0)
|
||||
self._cursor_t = 0.0
|
||||
self._selection = None
|
||||
self._overview_dirty = True
|
||||
self._interactive(False)
|
||||
self.set_cursor(0.0)
|
||||
self.overview_curve.setData([], [])
|
||||
self.stats_label.setText("")
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
self._apply_axis_mode()
|
||||
self._refresh_mode_label()
|
||||
@@ -633,13 +656,17 @@ class ForcePlotsPanel(QFrame):
|
||||
self._clear_curves()
|
||||
self._mode = "follow"
|
||||
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._refresh_mode_label()
|
||||
self._apply_axis_mode()
|
||||
if not self._source_ready:
|
||||
self.overview_curve.setData([], [])
|
||||
self.window_label.setText("")
|
||||
self.stats_label.setText("")
|
||||
return
|
||||
self._sync_overview()
|
||||
self._reload_from_store()
|
||||
@@ -728,6 +755,9 @@ class ForcePlotsPanel(QFrame):
|
||||
self._interactive(mode == "browse")
|
||||
self._refresh_mode_label()
|
||||
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.mode_changed.emit(mode)
|
||||
|
||||
@@ -818,6 +848,10 @@ class ForcePlotsPanel(QFrame):
|
||||
return
|
||||
if not self._source_ready:
|
||||
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()
|
||||
t0 = max(first, 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()
|
||||
|
||||
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:
|
||||
self._selection = None
|
||||
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
|
||||
t0, t1 = self._window
|
||||
stats = self._source.statistics(t0, t1, unit=self._unit)
|
||||
if stats.samples == 0:
|
||||
self._selection = None
|
||||
self.stats_label.setText(t("chart.selection_empty"))
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
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)
|
||||
parts = []
|
||||
for index, label in enumerate(CHANNEL_LABELS):
|
||||
peak = stats.peaks[index]
|
||||
mean = stats.means[index]
|
||||
std = stats.stds[index]
|
||||
peak = float(stats.peaks[index])
|
||||
if not np.isfinite(peak):
|
||||
continue
|
||||
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(
|
||||
t(
|
||||
return t(
|
||||
"chart.selection",
|
||||
duration=f"{stats.duration_s:.2f}",
|
||||
samples=stats.samples,
|
||||
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:
|
||||
if not self._source_ready:
|
||||
@@ -938,11 +1021,19 @@ class ForcePlotsPanel(QFrame):
|
||||
return 1200
|
||||
|
||||
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():
|
||||
key = "chart.mode_replay"
|
||||
else:
|
||||
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
|
||||
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
|
||||
# recording has no latest sample to return to.
|
||||
self.btn_latest.setEnabled(
|
||||
@@ -1042,25 +1133,64 @@ class ForcePlotsPanel(QFrame):
|
||||
curve.setData([], [])
|
||||
|
||||
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}"
|
||||
unavailable = t("chart.value_unavailable")
|
||||
for index, (value_label, unit_label, value) in enumerate(
|
||||
zip(self.value_labels, self.value_units, self._last_values)
|
||||
selecting = self._selection
|
||||
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
|
||||
# presented as a newton value.
|
||||
value_label.setText(unavailable)
|
||||
unit_label.setText("")
|
||||
detail_label.setText("")
|
||||
value_label.setToolTip(
|
||||
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
|
||||
)
|
||||
continue
|
||||
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))
|
||||
detail_label.setText(
|
||||
self._selection_detail(index) if selecting is not None else ""
|
||||
)
|
||||
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:
|
||||
self.title.setText(t("panels.plots"))
|
||||
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._refresh_mode_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"))
|
||||
|
||||
|
||||
# ------------------------------------------- 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),
|
||||
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(
|
||||
not hasattr(window.toolbar, "source_combo"),
|
||||
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.
|
||||
window._open_sensor_dialog()
|
||||
@@ -1617,6 +1623,29 @@ def main() -> int:
|
||||
f"the arrow footer does not say what is magnified: "
|
||||
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()
|
||||
app.processEvents()
|
||||
|
||||
@@ -1761,6 +1790,25 @@ def main() -> int:
|
||||
finally:
|
||||
app_module.recordings_dir = original_root
|
||||
_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")
|
||||
stuck.release()
|
||||
app.processEvents()
|
||||
@@ -2460,9 +2508,16 @@ def main() -> int:
|
||||
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
|
||||
"browsing does not enable panning",
|
||||
)
|
||||
window_texts = plots.stats_label.text()
|
||||
check(window_texts != "", "no selection statistics in browse mode")
|
||||
check("峰" in window_texts, f"the peak is missing from the readout: {window_texts!r}")
|
||||
# "Whole session" is the default fit, not a selection: the cards still state
|
||||
# the sample under the cursor, and the strip carries no selection summary.
|
||||
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.
|
||||
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,
|
||||
"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;
|
||||
# "back to latest" must actually leave it.
|
||||
|
||||
@@ -149,14 +149,39 @@ def _run_scene(window, app, scene: str):
|
||||
|
||||
def _demo_data(window) -> None:
|
||||
"""Fill the plots, the calibration table and the arrow canvas."""
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
|
||||
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):
|
||||
t = i / 30.0
|
||||
fn = 6.0 + 5.0 * math.sin(t * 1.4)
|
||||
fx = 4.0 * math.sin(t * 2.1 + 0.4)
|
||||
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")
|
||||
|
||||
# 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.set_running(True)
|
||||
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"
|
||||
),
|
||||
)
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -235,6 +268,14 @@ def main() -> int:
|
||||
except ValueError:
|
||||
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()}
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
@@ -281,6 +322,13 @@ def main() -> int:
|
||||
for _ in range(4):
|
||||
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.parent.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
#: previous session that is still winding down before opening the device.
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
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]:
|
||||
"""Run the live processing chain for one already-read frame.
|
||||
|
||||
@@ -595,6 +623,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
||||
saw_frame = False
|
||||
last_frame_mono = 0.0
|
||||
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,
|
||||
# the CSV and the calibration records.
|
||||
frame_id = 0
|
||||
@@ -615,6 +646,18 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
||||
break
|
||||
|
||||
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()
|
||||
if processed 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
|
||||
t1 = time.perf_counter()
|
||||
frame_times.append(t1)
|
||||
cutoff = t1 - 1.0
|
||||
cutoff = t1 - FPS_WINDOW_S
|
||||
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)
|
||||
invalid_reason = forces.get("invalid_reason") or compute_error
|
||||
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