采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - 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:
ykcheungab
2026-09-13 21:19:23 +08:00
parent 824544fc7a
commit e6a1e77a5b
13 changed files with 783 additions and 66 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

+7 -5
View File
@@ -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)",
+7 -5
View File
@@ -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": "已取消连接(正在释放设备)",
+22 -3
View File
@@ -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`,对不上时看得见。
+20
View File
@@ -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`(无标定 → 显示原始单位)以前每
一帧都发一次,等于永久占住通知行;现在每次运行只提示一次。
### 单位切换是"开关",不是"重新解释"
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
+5
View File
@@ -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. 三个顶层视图 = 同一窗口的三套布局
+48 -23
View File
@@ -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)
+148 -16
View File
@@ -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()
+111
View File
@@ -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)
+107 -8
View File
@@ -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.
+48
View File
@@ -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)
+206
View File
@@ -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)