subject: fix calibration cancel, unit mixing, FN clamping, export/apply and depth scale

This commit is contained in:
ykcheungab
2026-09-13 12:35:05 +08:00
parent db228b6f57
commit f501fa93f3
9 changed files with 725 additions and 78 deletions
+5
View File
@@ -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`
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "摄像机已断开",
@@ -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()
+85 -64
View File
@@ -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/<type>/ 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)
@@ -235,18 +235,22 @@ class ForcePlotsPanel(QFrame):
*,
unit: str = "raw",
) -> None:
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.
+151 -1
View File
@@ -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)
+165 -6
View File
@@ -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}")
+226
View File
@@ -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)