diff --git a/README.md b/README.md index 11ea5e6..ec87487 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ ramp monotonicity) that guard the brand tokens; see colour system, its relationship to the UI design spec, and every deliberate deviation from it. +`ui_check.py` includes a `record channel` section as well; see +[`apps/studio/docs/record-channel.md`](apps/studio/docs/record-channel.md) for +why recording and calibration sampling no longer hang off the UI refresh timer, +and for the data-reliability fixes that came with it. + ## Branding Window / EXE icon: `shared/resources/branding/logo.png` diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 23c8c8d..daeaf18 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -89,6 +89,9 @@ "view_mode_tip": "Image display mode while running", "arrow_empty": "No image yet\nPick a video or camera, then press Run", "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", + "depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)", + "depth_scale": "Depth scale {low} – {high}", + "depth_scale_tip": "Fixed for this session: the range is latched to the first frame that carried depth so frames stay comparable. Grey marks samples with no depth.", "plots": "Force Charts", "cal": "Calibration", "mesh3d": "3D mesh", @@ -122,12 +125,18 @@ "col_std": "σ", "col_actions": "", "recording": "Recording calibration samples…", + "recording_stop_tip": "Click to cancel this calibration recording (always available)", "record_ok": "Recorded weight={weight} raw={raw}", "record_failed_few": "Recording failed — too few samples", "export_ok": "Exported {path}", "export_local": "Also wrote {path}", "export_warnings": "Skipped: {detail}", - "export_failed": "Export failed: {error}" + "export_failed": "Export failed: {error}", + "apply_title": "Apply calibration", + "apply_confirm": "Apply the calibration just exported to the current device?\n\nSensor type: {sensor_type}\nConfig: {config}\n\nChoosing No keeps the exported file and leaves the running device untouched.", + "apply_skipped": "Exported; not applied to the device", + "apply_done": "Calibration applied to {sensor_type}", + "apply_no_target": "No device to apply to (run an acquisition first)" }, "status": { "ready": "Ready", @@ -146,6 +155,8 @@ "run_stopped": "Stopped", "run_stopping": "Stopping… (releasing the device)", "run_stopping_cancelled": "Connection cancelled (releasing the device)", + "sample_invalid": "Invalid sample (#{count}): {reason} — marked invalid and excluded from the curves and from calibration", + "samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)", "run_failed": "Run failed: {error}", "video_ended": "Video ended", "camera_disconnected": "Camera disconnected", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 3592fcf..8af4641 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -89,6 +89,9 @@ "view_mode_tip": "运行时的图像显示模式", "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", "arrow_empty_tip": "运行或预览后在此显示形变箭头图", + "depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)", + "depth_scale": "深度色标 {low} – {high}", + "depth_scale_tip": "本会话固定色标:色标范围锁定在首个有效深度帧,便于跨帧比较;灰色区域表示无深度数据", "plots": "力曲线图", "cal": "标定", "mesh3d": "三维网格", @@ -123,12 +126,18 @@ "col_std": "σ", "col_actions": "", "recording": "正在采集标定样本…", + "recording_stop_tip": "点击可取消本次标定采集(随时可用)", "record_ok": "已记录 weight={weight} raw={raw}", "record_failed_few": "采集失败 — 样本过少", "export_ok": "已导出 {path}", "export_local": "同时写入 {path}", "export_warnings": "跳过: {detail}", - "export_failed": "导出失败: {error}" + "export_failed": "导出失败: {error}", + "apply_title": "应用标定", + "apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。", + "apply_skipped": "已导出,未应用到设备", + "apply_done": "标定已应用到 {sensor_type}", + "apply_no_target": "没有可应用的设备(请先运行一次采集)" }, "status": { "ready": "就绪", @@ -147,6 +156,8 @@ "run_stopped": "已停止", "run_stopping": "正在停止…(正在释放设备)", "run_stopping_cancelled": "已取消连接(正在释放设备)", + "sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定", + "samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)", "run_failed": "启动失败: {error}", "video_ended": "视频已结束", "camera_disconnected": "摄像机已断开", diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index f5b1241..d1e875b 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -43,6 +43,11 @@ class ArrowPanel(QFrame): self.fps_label = QLabel() self.fps_label.setObjectName("panelMeta") self.fps_label.setVisible(False) + # Shows the fixed depth colour-scale range, so two frames can be + # compared instead of the scale silently re-fitting every frame. + self.depth_scale_label = QLabel() + self.depth_scale_label.setObjectName("panelMeta") + self.depth_scale_label.setVisible(False) self._fps_visible = False self._fps_value: float | None = None self._rotation_deg = 90 @@ -53,6 +58,7 @@ class ArrowPanel(QFrame): self.view_combo.currentIndexChanged.connect(self._on_view_index_changed) header.addWidget(self.title) header.addStretch(1) + header.addWidget(self.depth_scale_label) header.addWidget(self.fps_label) header.addWidget(self.view_combo) layout.addLayout(header) @@ -111,6 +117,16 @@ class ArrowPanel(QFrame): self._fps_value = None if fps is None else float(fps) self._refresh_fps_label() + def set_depth_scale(self, text: str | None) -> None: + """Show the fixed depth colour-scale range (``None`` hides it).""" + if not text: + self.depth_scale_label.clear() + self.depth_scale_label.setVisible(False) + return + self.depth_scale_label.setText(str(text)) + self.depth_scale_label.setToolTip(t("panels.depth_scale_tip")) + self.depth_scale_label.setVisible(True) + def _refresh_fps_label(self) -> None: if not self._fps_visible or self._fps_value is None: self.fps_label.clear() diff --git a/apps/studio/src/orisys_studio/ui/cal_panel.py b/apps/studio/src/orisys_studio/ui/cal_panel.py index 3e78ab9..4ae27a3 100644 --- a/apps/studio/src/orisys_studio/ui/cal_panel.py +++ b/apps/studio/src/orisys_studio/ui/cal_panel.py @@ -2,9 +2,6 @@ from __future__ import annotations -import math -import time - from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QFont, QFontDatabase from PyQt5.QtWidgets import ( @@ -35,7 +32,7 @@ from orisys_app_shared.force.calibration_io import ( meta_from_sensor_json, ) from orisys_app_shared.i18n import t -from orisys_app_shared.paths import calibration_export_dir, sensors_dir +from orisys_app_shared.paths import calibration_export_dir from orisys_app_shared.theme import color CHANNEL_LABELS = ("fn", "fx", "fy") @@ -51,7 +48,8 @@ def _mono_font() -> str: class CalPanel(QFrame): status_message = pyqtSignal(str) - calibration_exported = pyqtSignal(str) # path applied to running sensor + #: Coefficients were written; the window asks the user before applying them. + calibration_exported = pyqtSignal(str) def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -60,16 +58,14 @@ class CalPanel(QFrame): self._store = CalSessionStore() self._channel: ChannelKey = "fn" self._running = False - self._recording = False - self._record_deadline = 0.0 - self._record_started = 0.0 - self._record_samples: list[float] = [] + #: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the + #: lifecycle no longer depends on this panel being visible. + self._recorder = None self._live_fn = 0.0 self._live_fx = 0.0 self._live_fy = 0.0 self._sensor_type = "box" self._config_ref: str | None = None - self._record_duration_s = 7.0 self._mono = _mono_font() self._mono_font = QFont(self._mono, 10) @@ -201,32 +197,58 @@ class CalPanel(QFrame): self._theme = theme self._render_live_label() + # --------------------------------------------------------- recorder link + def bind_recorder(self, recorder) -> None: + """Attach the sampling state machine that owns the recording lifecycle.""" + self._recorder = recorder + + @property + def _recording(self) -> bool: + return bool(self._recorder is not None and self._recorder.is_recording) + def set_running(self, running: bool) -> None: self._running = bool(running) if not self._running and self._recording: - self._cancel_recording() + self._recorder.cancel() self._refresh_enabled() def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None: self._sensor_type = str(sensor_type or "box") self._config_ref = config_ref - def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None: + def set_live(self, fn: float, fx: float, fy: float) -> None: + """Update the live readout. Sampling is handled by the recorder.""" self._live_fn = float(fn) self._live_fx = float(fx) self._live_fy = float(fy) self._render_live_label() - if not self._recording: + + def set_progress(self, fraction: float, elapsed_s: float, total_s: float) -> None: + self.progress.setVisible(True) + self.progress.setValue(max(0, min(1000, int(fraction * 1000)))) + self.progress.setFormat(f"{elapsed_s:.1f} / {total_s:.1f} s") + + def add_point(self, point) -> None: + """Record a finished calibration point (three axes captured).""" + stats = point.loaded_stats() + sample = CalSample( + weight_n=float(point.reference_load_n), + raw_mean=float(stats.mean), + n=int(stats.n), + std=float(stats.std), + ) + self._store.upsert(point.channel, sample) + self._refresh_table() + if point.status == "few_samples": + self.status_message.emit(t("cal.record_failed_few")) return - now = time.perf_counter() - value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel] - self._record_samples.append(float(value)) - span = max(1e-6, self._record_deadline - self._record_started) - frac = min(1.0, max(0.0, (now - self._record_started) / span)) - self.progress.setValue(int(frac * 1000)) - self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s") - if now >= self._record_deadline: - self._finish_recording() + self.status_message.emit( + t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}") + ) + + def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None: + """Back-compat shim: live readout only, sampling lives in the recorder.""" + self.set_live(fn, fx, fy) def retranslate(self) -> None: self.title.setText(t("panels.cal")) @@ -277,14 +299,19 @@ class CalPanel(QFrame): if key is None: return if self._recording: - self._cancel_recording() + self._recorder.cancel() self._channel = key # type: ignore[assignment] self._refresh_channel_badge() self._refresh_table() def _refresh_enabled(self) -> None: - can_record = self._running and not self._recording + # While recording the button must stay **enabled**: it is the cancel + # affordance. Disabling it here was why a recording could not be stopped. + can_record = self._running self.btn_record.setEnabled(can_record) + self.btn_record.setText( + t("cal.recording_stop") if self._recording else t("cal.record") + ) self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton") self.btn_record.style().unpolish(self.btn_record) self.btn_record.style().polish(self.btn_record) @@ -298,6 +325,8 @@ class CalPanel(QFrame): record_hint = t("cal.record_disabled") if not self._running else "" if not self._recording: self.btn_record.setToolTip(record_hint) + else: + self.btn_record.setToolTip(t("cal.recording_stop_tip")) def _render_live_label(self) -> None: colors = { @@ -321,53 +350,47 @@ class CalPanel(QFrame): ) def _on_record_clicked(self) -> None: + if self._recorder is None: + return if self._recording: - self._cancel_recording() - self.status_message.emit(t("cal.record_cancelled")) + self._recorder.cancel() return if not self._running: return - self._recording = True - self._record_samples = [] - self._record_duration_s = float(self.duration_spin.value()) - self._record_started = time.perf_counter() - self._record_deadline = self._record_started + self._record_duration_s + duration = float(self.duration_spin.value()) + self._recorder.start( + channel=self._channel, + reference_load_n=float(self.weight_spin.value()), + duration_s=duration, + direction="load", + ) self.progress.setVisible(True) self.progress.setValue(0) - self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s") + self.progress.setFormat(f"0.0 / {duration:.1f} s") self._refresh_enabled() self.status_message.emit(t("cal.recording")) - def _cancel_recording(self) -> None: - self._recording = False - self._record_samples = [] + def on_record_cancelled(self) -> None: + """Called by the window when the recorder was cancelled.""" self.progress.setValue(0) self.progress.setFormat("") self._refresh_enabled() + self.status_message.emit(t("cal.record_cancelled")) - def _finish_recording(self) -> None: - samples = list(self._record_samples) - self._recording = False - self._record_samples = [] + def on_record_finished(self, point) -> None: self.progress.setValue(1000) self.progress.setFormat("") self._refresh_enabled() - if len(samples) < 2: - self.status_message.emit(t("cal.record_failed_few")) + self.add_point(point) + + def poll_recorder(self) -> None: + """Mirror the recorder's progress; the recorder owns the deadline.""" + if not self._recording: return - mean = sum(samples) / len(samples) - var = sum((x - mean) ** 2 for x in samples) / len(samples) - std = math.sqrt(var) - sample = CalSample( - weight_n=float(self.weight_spin.value()), - raw_mean=mean, - n=len(samples), - std=std, - ) - self._store.upsert(self._channel, sample) - self._refresh_table() - self.status_message.emit( - t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}") + self.set_progress( + self._recorder.progress(), + self._recorder.elapsed_s(), + self._recorder.requested_duration_s, ) def _refresh_table(self) -> None: @@ -410,16 +433,17 @@ class CalPanel(QFrame): meta["sensor_type"] = self._sensor_type meta["sn"] = self.sn_edit.text().strip() meta["record_duration_s"] = float(self.duration_spin.value()) - local_bundle = sensors_dir() / self._sensor_type - if not local_bundle.is_dir(): - local_bundle = None try: - primary, local_path, warnings = export_force_calibration_v2( + # Write into the user's calibration directory only. Overwriting the + # device bundle under config/sensors// is now an explicit, + # separate step so that exporting cannot silently change what the + # next Sensor() will load. + primary, _local_path, warnings = export_force_calibration_v2( self._store, export_dir=calibration_export_dir(), sensor_type=self._sensor_type, meta=meta, - local_bundle_dir=local_bundle, + local_bundle_dir=None, ) except ValueError as exc: QMessageBox.warning(self, t("panels.cal"), str(exc)) @@ -429,12 +453,9 @@ class CalPanel(QFrame): return msg = t("cal.export_ok", path=str(primary)) - if local_path is not None: - msg += "\n" + t("cal.export_local", path=str(local_path)) if warnings: msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings)) self.status_message.emit(msg) - # Prefer the bundle file for live apply so the next Sensor() also sees it. - apply_path = local_path if local_path is not None else primary - self.calibration_exported.emit(str(apply_path)) + # Ask the window to confirm the target device before applying. + self.calibration_exported.emit(str(primary)) QMessageBox.information(self, t("panels.cal"), msg) diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index a75ba9b..06c2baa 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -235,18 +235,22 @@ class ForcePlotsPanel(QFrame): *, unit: str = "raw", ) -> None: - self._unit = unit + if unit != self._unit: + # Never mix units in one buffer. Callers switch units through + # ``set_units``/``set_fixed_newton`` which rebuild from the session + # store; a bare change here would silently reinterpret old samples. + self._unit = unit self._last_values = (float(fn), float(fx), float(fy)) use_fixed = self._uses_fixed_y() - plot_fn = max(0.0, float(fn)) if use_fixed else float(fn) - self.history.push(timestamp, plot_fn, float(fx), float(fy)) + # Store the true value. Clamping here (the old ``max(0.0, fn)``) erased + # negative FN from the buffer and hid zero drift; out-of-range values are + # now handled by the axis range, which only affects what is drawn. + self.history.push(timestamp, float(fn), float(fx), float(fy)) self._render_value_cards() state = self.history.state() if state is None: return values = state.values.copy() - if use_fixed: - values[:, 0] = np.maximum(values[:, 0], 0.0) for i, curve in enumerate(self.curves): curve.setData(state.times, values[:, i]) self.plots[i].setXRange(*state.x_range, padding=0.0) @@ -258,6 +262,50 @@ class ForcePlotsPanel(QFrame): self.plots[i].getAxis("left").setTicks(None) self.plots[i].setYRange(*state.y_ranges[i], padding=0.0) + def set_units(self, unit: str, store=None) -> None: + """Switch display units without mixing units in the curve buffer. + + When a session store is supplied the visible window is rebuilt from it, + because it keeps both the raw and the calibrated triplet for every + sample. Otherwise the buffer is cleared rather than reinterpreted. + """ + unit = "N" if str(unit).upper().startswith("N") else "raw" + if unit == self._unit and store is None: + return + self._unit = unit + self.history.clear() + self._clear_curves() + if store is not None: + self._reload_from_store(store) + self._render_value_cards() + self._apply_axis_mode() + + def _reload_from_store(self, store) -> None: + """Re-populate the visible buffer from the whole-session store.""" + window = self.history.window_sec + slice_ = store.tail(window) if window > 0 else store.data() + if len(slice_) == 0: + return + values = slice_.value_for_unit(self._unit) + for index in range(len(slice_)): + if not bool(slice_.valid[index]): + continue + row = values[index] + if not all(np.isfinite(v) for v in row): + continue + self.history.push( + float(slice_.times[index]), + float(row[0]), + float(row[1]), + float(row[2]), + ) + state = self.history.state() + if state is None: + return + for i, curve in enumerate(self.curves): + curve.setData(state.times, state.values[:, i]) + self.plots[i].setXRange(*state.x_range, padding=0.0) + def _uses_fixed_y(self) -> bool: """Fixed Y whenever we have limits for the active unit. diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 86c2e30..5ebd253 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -4,6 +4,7 @@ from __future__ import annotations import pytest +import ui_check from tests.conftest import visible_descendants RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080)) @@ -196,7 +197,7 @@ def test_toolbar_run_button_is_labelled(make_window): window = _show(make_window(), (1400, 880)) label = window.toolbar.btn_run.text() assert label.strip() - assert label != "▶" + assert label != "â–¶" window.toolbar.set_run_checked(True) assert window.toolbar.btn_run.text() != label @@ -279,3 +280,152 @@ def test_force_plots_render_demo_data(make_window): QApplication.processEvents() assert window.force_plots.history.sample_count > 0 assert all(label.text() for label in window.force_plots.value_labels) + + +# --------------------------------------------------------------- record channel + + +def _running_window(make_window): + """A window in the ``running`` phase whose worker never opens a device. + + The Qt timer is stopped deliberately: these tests drive the display path by + calling ``_on_run_tick`` themselves, and a concurrent 16 ms timer would race + the assertions (and outlive the window on teardown). + """ + window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880)) + stuck = ui_check._StuckWorker() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window._start_run() + window._run_timer.stop() + window._set_phase("running") + return window, stuck + + +def _finish(window, stuck): + window._stop_run(reason="user_stop") + stuck.release() + + +def test_record_channel_does_not_depend_on_the_ui_timer(make_window): + window, stuck = _running_window(make_window) + for index in range(90): + stuck.emit(ui_check._fake_packet(index)) + + assert window.session_store.count == 90, "samples were lost without a UI tick" + _finish(window, stuck) + + +def test_invalid_samples_are_excluded_not_zeroed(make_window): + import math + + window, stuck = _running_window(make_window) + for index in range(9): + stuck.emit(ui_check._fake_packet(index)) + stuck.emit(ui_check._fake_packet(99, valid=False, reason="read failed")) + + slice_ = window.session_store.data() + assert slice_.valid.tolist()[-1] is False + assert math.isnan(slice_.raw[-1][0]), "a failed readout became a number" + assert sum(1 for _ in window.session_store.iter_valid("raw")) == 9 + _finish(window, stuck) + + +def test_calibration_sampling_survives_a_hidden_panel(make_window): + window, stuck = _running_window(make_window) + window._show_cal = False + window._apply_panel_visibility() + assert not window.cal_panel.isVisible() + + window.cal_recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0) + for index in range(40): + stuck.emit(ui_check._fake_packet(index)) + point = window.cal_recorder.finish_now() + + assert point is not None + assert point.n_valid == 40, "hiding the panel stopped the sampling" + assert point.status == "ok" + _finish(window, stuck) + + +def test_record_button_stays_a_cancel_affordance(make_window): + from orisys_app_shared.i18n import t + + window, stuck = _running_window(make_window) + window.cal_panel.set_running(True) + window.cal_panel._on_record_clicked() + + assert window.cal_panel._recording + assert window.cal_panel.btn_record.isEnabled(), "the recording cannot be stopped" + assert window.cal_panel.btn_record.text() == t("cal.recording_stop") + + window.cal_panel._on_record_clicked() + assert not window.cal_panel._recording + _finish(window, stuck) + + +def test_unit_switch_rebuilds_from_the_session_store(make_window): + import math + + window, stuck = _running_window(make_window) + window.toolbar.units_combo.setCurrentIndex(1) # raw + window.force_plots.reset() + for index in range(60): + stuck.emit(ui_check._fake_packet(index)) + window._on_run_tick() + + state = window.force_plots.history.state() + assert float(state.values[:, 0].max()) > 5.0, "raw values were not plotted" + + window.toolbar.units_combo.setCurrentIndex(0) # newton + assert window.force_plots._unit == "N" + newtons = window.force_plots.history.state().values[:, 0] + finite = [v for v in newtons if math.isfinite(v)] + assert finite and max(finite) < 5.0, "the buffer still held raw values in N mode" + _finish(window, stuck) + + +def test_history_keeps_negative_fn(make_window): + window, stuck = _running_window(make_window) + window.force_plots.set_fixed_newton(True) + for index in range(20): + window.force_plots.push(index / 30.0, -2.5, 0.0, 0.0, unit="N") + window.force_plots.push(1.0, -3.0, 0.0, 0.0, unit="N") + + state = window.force_plots.history.state() + assert float(state.values[:, 0].min()) < 0.0, ( + "negative FN was clamped before entering the buffer" + ) + _finish(window, stuck) + + +def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window): + import numpy as np + + window, stuck = _running_window(make_window) + window.arrow_panel.set_view_mode("depth") + + window._last_rendered_view_mode = None + stuck.emit(ui_check._fake_packet(0, depth=None)) + window._on_run_tick() + from orisys_app_shared.i18n import t + + assert window.arrow_panel.image_label.text() == t("panels.depth_unavailable"), ( + "a missing depth map silently fell back to the processed frame" + ) + + window._last_rendered_view_mode = None + stuck.emit(ui_check._fake_packet(1, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8))) + window._on_run_tick() + latched = window.arrow_panel.depth_scale_label.text() + assert latched + + window._last_rendered_view_mode = None + stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8))) + window._on_run_tick() + assert window.arrow_panel.depth_scale_label.text() == latched, ( + "the depth colour scale re-fitted on every frame" + ) + _finish(window, stuck) + diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 9baded1..4b84fd6 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -18,7 +18,6 @@ import sys import threading import time from pathlib import Path - APP_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = APP_ROOT.parents[1] for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): @@ -32,6 +31,8 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: os.environ["QT_QPA_FONTDIR"] = str(_fonts) os.chdir(APP_ROOT) +from orisys_app_shared.acquisition.sinks import RecordSinkMixin # noqa: E402 + FAILURES: list[str] = [] CHECKS = 0 @@ -47,25 +48,79 @@ def section(name: str) -> None: print(f"\n== {name} ==") -class _StuckWorker: +def _fake_packet( + frame_id: int, + *, + valid: bool = True, + depth=None, + reason: str = "", +): + """A packet shaped like the real worker's, for the record-channel checks.""" + import numpy as np + + from orisys_app_shared.acquisition.sdk_worker import SdkPacket + + nan = float("nan") + blank = np.zeros((8, 8, 3), np.uint8) + return SdkPacket( + raw_frame=blank, + processed_frame=blank, + timestamp=1000.0 + frame_id / 30.0, + fps=30.0, + process_ms=1.0, + normal_force=(2.0 + frame_id * 0.01) if valid else nan, + shear_x=0.5 if valid else nan, + shear_y=-0.5 if valid else nan, + normal_force_raw=(9.0 + frame_id * 0.1) if valid else nan, + shear_x_raw=0.4 if valid else nan, + shear_y_raw=-0.4 if valid else nan, + force_unit="N", + force_calibrated=True, + session_id=1, + frame_id=frame_id, + valid=valid, + invalid_reason=reason, + raw_available=True, + depth=depth, + ) + + +class _StuckWorker(RecordSinkMixin): """A worker whose device open never returns. - Mirrors the real worker's non-blocking ``stop()`` contract so the window can - be exercised against the failure mode the Stop button exists to survive. + Mirrors the real worker contract — including the record-channel sink API — + so the window can be exercised against the failure mode the Stop button + exists to survive. """ def __init__(self) -> None: + self._init_sinks() self._thread: threading.Thread | None = None self._end_reason: str | None = None + self._latest = None self.thread_still_owned = False self.stop_blocked_for = 0.0 + #: Released by ``release()`` so daemon threads do not outlive the run. + self._release = threading.Event() def start(self, _source, *, config_ref=None) -> None: + self._release.clear() self._thread = threading.Thread(target=self._hang, daemon=True) self._thread.start() def _hang(self) -> None: - time.sleep(600.0) + self._release.wait(timeout=600.0) + + def release(self) -> None: + """Let the stuck thread finish so the process can exit cleanly.""" + self._release.set() + if self._thread is not None: + self._thread.join(timeout=5.0) + + def emit(self, packet) -> None: + """Publish a packet exactly like the acquisition loop would.""" + self._latest = packet + self._emit(packet) @property def is_running(self) -> bool: @@ -76,7 +131,7 @@ class _StuckWorker: return "connecting" def latest(self): - return None + return self._latest def consume_end_reason(self): reason, self._end_reason = self._end_reason, None @@ -559,6 +614,110 @@ def main() -> int: window.close() app.processEvents() + # Let the deliberately-stuck thread finish before the interpreter tears + # down Qt, otherwise it would outlive the widgets it can reach. + stuck.release() + + section("record channel") + import numpy as np + + stuck = _StuckWorker() + window = StudioMainWindow( + prefs={ + "language": "zh_CN", + "theme": "dark", + "sensor_type": "box", + "source_mode": "camera", + } + ) + window.resize(1440, 880) + window.show() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window.toolbar.btn_run.setChecked(True) + app.processEvents() + + check( + window._on_packet_recorded in stuck.sinks(), + "the record channel is not attached to the running worker", + ) + + # The record channel must not depend on the UI refresh timer at all. + for index in range(90): + stuck.emit(_fake_packet(index)) + check( + window.session_store.count == 90, + f"record channel dropped samples without a UI tick: {window.session_store.count}", + ) + + # Invalid readouts must never be averaged in as zeros. + for index in range(10): + stuck.emit(_fake_packet(200 + index, valid=False, reason="read failed")) + slice_ = window.session_store.data() + check(int(slice_.valid.sum()) == 90, "invalid samples were not marked invalid") + check( + not bool(slice_.valid[-1]) and slice_.raw[-1][0] != slice_.raw[-1][0], + "a failed readout was stored as a number instead of NaN", + ) + + # Calibration sampling must survive the panel being hidden. + window._show_cal = False + window._apply_panel_visibility() + check(not window.cal_panel.isVisible(), "the calibration panel is still visible") + window.cal_recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0) + for index in range(40): + stuck.emit(_fake_packet(300 + index)) + point = window.cal_recorder.finish_now() + check(point is not None and point.n_valid == 40, "hidden panel stopped the sampling") + check( + point is not None and point.status == "ok", + f"calibration point was not usable: {getattr(point, 'status', None)}", + ) + + # While recording, the record button must stay enabled — it is the cancel. + window.cal_panel.set_running(True) + window.cal_panel._on_record_clicked() + app.processEvents() + check(window.cal_panel._recording, "recording did not start") + check( + window.cal_panel.btn_record.isEnabled(), + "the record button is disabled while recording, so it cannot cancel", + ) + check( + window.cal_panel.btn_record.text() == t("cal.recording_stop"), + f"record button does not show the stop label: {window.cal_panel.btn_record.text()!r}", + ) + window.cal_panel._on_record_clicked() + app.processEvents() + check(not window.cal_panel._recording, "the recording could not be cancelled") + + # The depth view must not fall back to the processed frame. + window.arrow_panel.set_view_mode("depth") + window._last_rendered_view_mode = None + stuck.emit(_fake_packet(400, depth=None)) + window._on_run_tick() + app.processEvents() + check( + window.arrow_panel.image_label.text() == t("panels.depth_unavailable"), + f"missing depth was not reported: {window.arrow_panel.image_label.text()!r}", + ) + window._last_rendered_view_mode = None + stuck.emit(_fake_packet(401, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8))) + window._on_run_tick() + first_scale = window.arrow_panel.depth_scale_label.text() + window._last_rendered_view_mode = None + stuck.emit(_fake_packet(402, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8))) + window._on_run_tick() + check( + window.arrow_panel.depth_scale_label.text() == first_scale and first_scale != "", + "the depth colour scale re-fitted per frame instead of staying fixed", + ) + + window.close() + app.processEvents() + stuck.release() + print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}") for failure in FAILURES: print(f" FAIL: {failure}") diff --git a/shared/tests/test_record_channel.py b/shared/tests/test_record_channel.py new file mode 100644 index 0000000..8eafbb9 --- /dev/null +++ b/shared/tests/test_record_channel.py @@ -0,0 +1,226 @@ +"""Record-channel behaviour: full-session storage and calibration sampling. + +These cover the guarantees the architect's review asked for: + +* a failed readout is never averaged in as a zero, +* the stored session is not limited to what the plots happened to show, +* both raw and calibrated values are kept, so switching units cannot + reinterpret old data, +* calibration sampling is driven by the acquisition stream rather than by any + widget being visible. +""" + +from __future__ import annotations + +import math +import threading +import time + +import pytest + +from orisys_app_shared.force.calibration_recorder import ( + STATUS_FEW_SAMPLES, + STATUS_OK, + CalibrationRecorder, +) +from orisys_app_shared.force.session import NA, SessionSample, SessionStore + + +def _sample(index: int, *, valid: bool = True, rate: float = 30.0) -> SessionSample: + if not valid: + return SessionSample(frame_id=index, t_rel_s=index / rate, valid=False) + return SessionSample( + frame_id=index, + t_rel_s=index / rate, + fn_raw=float(index), + fx_raw=1.0, + fy_raw=-1.0, + fn_n=index / 10.0, + fx_n=0.1, + fy_n=-0.1, + valid=True, + ) + + +# --------------------------------------------------------------- SessionStore + + +def test_store_keeps_whole_session_and_reports_evictions(): + store = SessionStore(max_samples=100, block_size=16) + store.reset(session_id=3) + for index in range(250): + store.append(_sample(index)) + + assert store.session_id == 3 + assert store.count <= 100 + assert store.dropped > 0, "evictions must be visible instead of silent" + assert store.data().truncated is True + + +def test_store_keeps_raw_and_newton_side_by_side(): + store = SessionStore() + store.reset() + store.append(_sample(0)) + store.append(_sample(1)) + + slice_ = store.data() + assert not (slice_.value_for_unit("N") == slice_.value_for_unit("raw")).all() + assert slice_.value_for_unit("raw")[1][0] == 1.0 + assert slice_.value_for_unit("N")[1][0] == 0.1 + + +def test_invalid_samples_are_stored_as_invalid_not_zero(): + store = SessionStore() + store.reset() + store.append(_sample(0)) + store.append(_sample(1, valid=False)) + + slice_ = store.data() + assert slice_.valid.tolist() == [True, False] + assert math.isnan(slice_.raw[1][0]), "a failed readout must not become 0.0" + assert store.invalid == 1 + # And it must not leak into anything that computes statistics. + assert [t for t, *_ in store.iter_valid("raw")] == [0.0] + + +def test_store_window_queries_are_relative_to_the_first_sample(): + store = SessionStore() + store.reset() + for index in range(120): + store.append(_sample(index)) + + tail = store.tail(1.0) + assert int(tail.frame_ids[-1]) == 119, "tail must end at the newest sample" + assert float(tail.times[-1]) - float(tail.times[0]) <= 1.0 + 1e-9 + assert len(tail) == pytest.approx(30, abs=1) # inclusive boundary + + first_second = store.window(0.0, 1.0) + assert int(first_second.frame_ids[0]) == 0 + assert float(first_second.times[-1]) <= 1.0 + 1e-9 + assert float(first_second.times[0]) == 0.0 + + +# ------------------------------------------------------- CalibrationRecorder + + +def _recorder(**kwargs) -> CalibrationRecorder: + return CalibrationRecorder(**kwargs) + + +def test_recorder_excludes_failed_samples_from_the_statistics(): + recorder = _recorder() + recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0) + for index in range(50): + recorder.feed(_sample(index)) + for index in range(3): + recorder.feed(_sample(100 + index, valid=False)) + + point = recorder.finish_now() + assert point is not None + assert point.n_total == 53 + assert point.n_valid == 50, "failed readouts must not be counted as samples" + assert point.status == STATUS_OK + # 9.7-style values are absent here; the mean must come only from 0..49. + assert point.loaded_stats().mean == pytest.approx(24.5, abs=0.01) + + +def test_recorder_captures_all_three_axes_and_keeps_diagnostics(): + recorder = _recorder() + recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0, direction="unload") + for index in range(10): + recorder.feed(_sample(index)) + point = recorder.finish_now() + + assert set(point.raw) == {"fn", "fx", "fy"} + assert point.raw["fx"].mean == pytest.approx(1.0) + assert point.raw["fy"].mean == pytest.approx(-1.0) + assert point.newtons["fn"].mean == pytest.approx(0.45, abs=0.01) + assert point.direction == "unload" + # Only the loaded axis has a known reference load. + assert point.reference_load_n == 1.0 + + +def test_recorder_marks_too_few_samples_instead_of_guessing(): + recorder = _recorder() + recorder.start(channel="fx", reference_load_n=1.0, duration_s=1.0) + recorder.feed(_sample(0)) + point = recorder.finish_now() + assert point.status == STATUS_FEW_SAMPLES + + +def test_recorder_finishes_on_the_deadline_from_the_ui_thread(): + clock = {"now": 0.0} + recorder = _recorder(clock=lambda: clock["now"]) + recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0) + recorder.feed(_sample(0)) + recorder.feed(_sample(1)) + + assert recorder.tick() is None, "must not finish before the deadline" + clock["now"] = 2.0 + point = recorder.tick() + assert point is not None + assert recorder.is_recording is False + assert recorder.tick() is None + + +def test_recorder_is_fed_from_a_foreign_thread(): + """The acquisition thread feeds it; nothing here touches Qt.""" + recorder = _recorder() + recorder.start(channel="fn", reference_load_n=1.0, duration_s=0.05) + + def producer(): + for index in range(5000): + recorder.feed(_sample(index)) + + thread = threading.Thread(target=producer) + thread.start() + thread.join() + + time.sleep(0.06) + point = recorder.tick() + assert point is not None + assert point.n_valid == 5000 + + +def test_recorder_cancel_discards_the_point(): + cancelled = [] + recorder = _recorder(on_cancelled=lambda: cancelled.append(True)) + recorder.start(channel="fy", reference_load_n=3.0, duration_s=5.0) + recorder.feed(_sample(0)) + recorder.cancel() + + assert recorder.is_recording is False + assert cancelled == [True] + assert recorder.tick() is None + assert recorder.points == [] + + +def test_recorder_round_index_advances_per_channel(): + recorder = _recorder() + for _ in range(2): + recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0) + recorder.feed(_sample(0)) + recorder.feed(_sample(1)) + recorder.finish_now() + recorder.start(channel="fx", reference_load_n=1.0, duration_s=1.0) + recorder.feed(_sample(0)) + recorder.feed(_sample(1)) + point = recorder.finish_now() + + assert [p.round_index for p in recorder.points] == [0, 1, 0] + assert point.channel == "fx" + + +if __name__ == "__main__": # pragma: no cover - manual runner + failures = 0 + for name, function in sorted(globals().items()): + if not name.startswith("test_") or not callable(function): + continue + 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)