diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png index 3e6375c..7ea4e12 100644 Binary files a/apps/studio/config/compound_eye.png and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 5d9cc6c..bbab459 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -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)", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 0429276..78b2eae 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -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": "已取消连接(正在释放设备)", diff --git a/apps/studio/docs/record-channel.md b/apps/studio/docs/record-channel.md index 5d60431..6f07936 100644 --- a/apps/studio/docs/record-channel.md +++ b/apps/studio/docs/record-channel.md @@ -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/_session/ `session.json` 的 `video.fps` 是**容器速率**,必须等于传感器的采集速率,取自传感器配置 (`config/sensors//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`,对不上时看得见。 diff --git a/apps/studio/docs/shell-lifecycle.md b/apps/studio/docs/shell-lifecycle.md index 6d42c55..19a918d 100644 --- a/apps/studio/docs/shell-lifecycle.md +++ b/apps/studio/docs/shell-lifecycle.md @@ -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`(无标定 → 显示原始单位)以前每 + 一帧都发一次,等于永久占住通知行;现在每次运行只提示一次。 + ### 单位切换是"开关",不是"重新解释" 曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到 diff --git a/apps/studio/docs/ui-palette.md b/apps/studio/docs/ui-palette.md index 3e5ebad..db604d2 100644 --- a/apps/studio/docs/ui-palette.md +++ b/apps/studio/docs/ui-palette.md @@ -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. 三个顶层视图 = 同一窗口的三套布局 diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 0c1f349..43a90b7 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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) diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index a9fb8a2..d6f4d09 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -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,34 +871,83 @@ 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}" - ) - self.stats_label.setText( - t( - "chart.selection", - duration=f"{stats.duration_s:.2f}", - samples=stats.samples, - detail=" ".join(parts), + f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} " + f"· σ {float(stats.stds[index]):.3g}" ) + 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: return @@ -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() diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 9b4fe44..29ff2fb 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -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) + + diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 0738932..fb9d9cb 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -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. diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 86fa428..545a0cb 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -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)) diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 17ee9de..414663b 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -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) diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py new file mode 100644 index 0000000..524ca3d --- /dev/null +++ b/shared/tests/test_worker_frame_rate.py @@ -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)