曲线图表头加齿轮,绘图设置对话框里可以直接调滤波 - What: ui/icons.py 新增 settings(齿轮)图标(八个齿 + 圆环,颜色取自主题 token,与另外三个图标同一套画法);force_plots.py 表头新增 btn_settings 与 plot_settings_requested 信号(放在缩放按钮之后、单位芯片之前),换主题时一并重绘图标,retranslate 里补 tooltip/可访问名;plot_settings_dialog.py 增加"滤波"分区:预设下拉(off/low_latency/balanced/low_noise + 自定义,显示的就是写进 sensor.json 的那个名字)、光流 EMA α(0.05–1.00,1.00 = 关)、中值窗口(1–31,1 = 关)、低通截止(0–60 Hz,0 = 关)、低通阶数(1–6)、采样率("自动跟随帧率"复选框 + 固定值)、"同时显示未滤波曲线";选预设即写入该预设的数值,手改任一数值自动切到"自定义",选中的预设名会随 filter_values() 一起带出(preset 字段),"_reset_defaults" 回到"什么都不滤";对话框底部按当前数值给提示:光流 EMA 打开时提示"这是测量设置、两次录制不可比"、全关时提示"当前不滤波"、录制中显示"录制中不能更改滤波"并禁用整个分区;截止频率达到或超过奈奎斯特(帧率一半)时用 dialog.filter_nyquist 明确说出"设备不会滤",其中 cutoff/nyquist/rate 三个数字由 FilterSettings.nyquist_issue() 提供(problems() 仍返回英文串供日志与测试用,判断条件只有一处)。filters.py 新增 FALLBACK_PRESETS 与 available_presets()(SDK 能导入就用 SDK 的 FILTER_PRESETS,导入不了才用这份文档副本,测试保证两者逐值一致)。app_window.py:把 plot_settings_requested 接到 _on_plot_settings,打开对话框时传入当前 _filter_settings()、filters_locked(正在跑或正在录)、以及 _device_sample_rate()(实测帧率优先,否则 30 fps,用于奈奎斯特检查);接受后把 filter_values().as_dict() 逐键写成 plot_* 偏好(与 _filter_settings() 读回的是同一组键),未在采集时立刻 set_filter_settings() 交给两个 worker,并在状态栏回报"已保存:{describe}(下次采集生效)"。两个 locale 文件补齐 21+3 个新键,中英各 343 键一一对应。tests/test_ui_smoke.py 新增 4 项:齿轮确实触发设置信号、预设/自定义切换与数值一致、录制中分区被禁用且给出理由、对话框写的键与 _filter_settings 读回的键是同一组。 - Why: 用户要求"建议曲线图增加一个齿轮的配置按键,可以调节曲线图的上限和滤波等"。此前滤波只能靠手改 preferences.json 的 plot_* 键(上一轮留下的口子),而 app 默认已改成"什么都不滤"——没有这个入口,等于把默认变成了唯一选择。 - Verification: studio 全量 89 passed(原 85 + 新增 4);shared 全量 158 passed;ui_check 554 项检查 0 失败;.repro/locale_check.py 扫描 64 个源文件里 287 个 t("…") 字面键,两个 locale 全部命中且键集合完全相同(343/343);离屏实测对话框:预设 balanced→flowEMA(0.5)+median(3)、改中值→自定义、选 low_noise→带低通、30 Hz 采样率下填 30 Hz 截止→弹出奈奎斯特警告、恢复默认→off/不滤波。 - Not in this change: CSV 的 17 列与未滤波第二 buffer(SessionStore/记录格式版本)、回放界面显示滤波前曲线、标定面板显示 filters 块、发布说明里"默认不再滤波"的措辞。

This commit is contained in:
ykcheungab
2026-09-14 11:08:25 +08:00
parent 148573934f
commit 92e0b95ca2
9 changed files with 515 additions and 16 deletions
+25
View File
@@ -56,6 +56,28 @@
"plot_shear_raw_max": "Shear raw max", "plot_shear_raw_max": "Shear raw max",
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range", "plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
"reset_defaults": "Reset to defaults", "reset_defaults": "Reset to defaults",
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
"filter_preset": "Preset",
"filter_preset_tip": "A ready-made combination; editing any value below switches to Custom",
"filter_preset_custom": "Custom",
"filter_flow_ema": "Flow EMA α",
"filter_flow_ema_tip": "Temporal smoothing of the optical flow. 1.00 = off. It changes what is *measured* (contact statistics and forces along with it), so two recordings with different α are not comparable — only use it when the flow jitter really has to go",
"filter_median": "Median window",
"filter_median_tip": "Sliding-median window over the force readings (samples). 1 = off. Removes single-sample spikes at the cost of about half a window of delay",
"filter_lowpass": "Low-pass cutoff",
"filter_lowpass_tip": "Low-pass cutoff for the force readings (Hz). 0 = off. Must stay below Nyquist (half the frame rate) or the device will not filter at all",
"filter_order": "Low-pass order",
"filter_order_tip": "Low-pass filter order (1–6). Steeper, with more phase distortion",
"filter_rate": "Sample rate",
"filter_rate_tip": "The rate the low-pass coefficients are designed for (Hz)",
"filter_rate_auto": "Follow the measured frame rate",
"filter_rate_auto_tip": "Design the low-pass for the device's measured rate; clear it to pin a number",
"filter_show_unfiltered": "Also show the unfiltered curves",
"filter_show_unfiltered_tip": "Draw the pre-filter readings beside the filtered ones, so what the filter did can be judged. Display only — the record is unaffected",
"filter_measurement_note": "The flow EMA is a *measurement* setting: changing it makes this recording incomparable with earlier ones.",
"filter_locked": "Filters cannot change while recording; this applies to the next session.",
"filter_nyquist": "Note: a {cutoff} Hz cutoff is at or above the Nyquist frequency of {nyquist} Hz (sample rate {rate} Hz) — the device would not filter. Lower the cutoff or pin a higher sample rate.",
"filter_off": "Not filtering (raw readings)",
"ok": "OK", "ok": "OK",
"cancel": "Cancel" "cancel": "Cancel"
}, },
@@ -161,6 +183,8 @@
"overview_tip": "Fit the entire session; drag the overview strip to move the window", "overview_tip": "Fit the entire session; drag the overview strip to move the window",
"box_zoom": "Box zoom", "box_zoom": "Box zoom",
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)", "box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
"settings": "Chart settings",
"settings_tip": "Time window, axis ranges, and the acquisition filters (screen and record use the same data)",
"selection": "Selection {duration}s / {samples} samples — {detail}", "selection": "Selection {duration}s / {samples} samples — {detail}",
"selection_summary": "Selection {duration}s / {samples} samples", "selection_summary": "Selection {duration}s / {samples} samples",
"selection_detail": "mean {mean} · σ {std}", "selection_detail": "mean {mean} · σ {std}",
@@ -333,6 +357,7 @@
"sample_invalid": "Invalid sample (#{count}): {reason} — marked invalid and excluded from the curves and from calibration", "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)", "samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)",
"run_failed": "Run failed: {error}", "run_failed": "Run failed: {error}",
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
"video_ended": "Video ended", "video_ended": "Video ended",
"camera_disconnected": "Camera disconnected", "camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset", "reset_ok": "Tracker reset",
+25
View File
@@ -56,6 +56,28 @@
"plot_shear_raw_max": "原始剪切上限", "plot_shear_raw_max": "原始剪切上限",
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程", "plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
"reset_defaults": "恢复默认", "reset_defaults": "恢复默认",
"plot_filters": "滤波(作用于采集,界面与记录同步)",
"filter_preset": "预设",
"filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”",
"filter_preset_custom": "自定义",
"filter_flow_ema": "光流 EMA α",
"filter_flow_ema_tip": "光流的时间平滑。1.00 = 关。它改变的是“测量本身”(接触统计与力值都会变),两个 α 不同的录制不可比 —— 只有确实需要压光流抖动时才调",
"filter_median": "中值窗口",
"filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟",
"filter_lowpass": "低通截止",
"filter_lowpass_tip": "力读数低通截止频率(Hz)。0 = 关。必须在奈奎斯特频率(帧率的一半)以下,否则设备不会滤",
"filter_order": "低通阶数",
"filter_order_tip": "低通滤波器阶数(1–6)。越高越陡,相位失真也越大",
"filter_rate": "采样率",
"filter_rate_tip": "低通系数按哪个采样率设计(Hz)",
"filter_rate_auto": "自动跟随帧率",
"filter_rate_auto_tip": "按设备实测帧率设计低通系数;取消可固定一个数值",
"filter_show_unfiltered": "同时显示未滤波曲线",
"filter_show_unfiltered_tip": "在力值卡片上把滤波前的读数一并显示,便于判断滤波到底做了什么。只影响显示,不影响记录",
"filter_measurement_note": "光流 EMA 属于“测量”设置:改了它,本次录制与之前的不可比。",
"filter_locked": "录制中不能更改滤波;下一次采集生效。",
"filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。",
"filter_off": "当前不滤波(原始读数)",
"ok": "确定", "ok": "确定",
"cancel": "取消" "cancel": "取消"
}, },
@@ -161,6 +183,8 @@
"overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗", "overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗",
"box_zoom": "框选", "box_zoom": "框选",
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)", "box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
"settings": "曲线图设置",
"settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)",
"selection": "选区 {duration}s / {samples} 点 — {detail}", "selection": "选区 {duration}s / {samples} 点 — {detail}",
"selection_summary": "选区 {duration}s / {samples} 点", "selection_summary": "选区 {duration}s / {samples} 点",
"selection_detail": "均 {mean} · σ {std}", "selection_detail": "均 {mean} · σ {std}",
@@ -333,6 +357,7 @@
"sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定", "sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定",
"samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)", "samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)",
"run_failed": "启动失败: {error}", "run_failed": "启动失败: {error}",
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
"video_ended": "视频已结束", "video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开", "camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置", "reset_ok": "追踪器已重置",
@@ -658,6 +658,8 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.view_mode_changed.connect(lambda _mode: self._render_replay_frame()) self.arrow_panel.view_mode_changed.connect(lambda _mode: self._render_replay_frame())
# The unit chip on the chart header switches the same unit everywhere. # The unit chip on the chart header switches the same unit everywhere.
self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle) self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle)
# The gear on the chart header opens the same dialog as 视图 ▸ 曲线图设置.
self.force_plots.plot_settings_requested.connect(self._on_plot_settings)
def _wire_replay(self) -> None: def _wire_replay(self) -> None:
"""The controller feeds the views; the chart is the only timeline.""" """The controller feeds the views; the chart is the only timeline."""
@@ -1595,6 +1597,11 @@ class StudioMainWindow(QMainWindow):
self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX
), ),
units=self.toolbar.units(), units=self.toolbar.units(),
filters=self._filter_settings(),
# Asked *now*: the answer is a property of this moment, and the
# dialog is the place that has to say "next session, not this one".
filters_locked=bool(self._running or self.recorder.is_recording),
sample_rate_hz=self._device_sample_rate(),
) )
if dialog.exec_() != PlotSettingsDialog.Accepted: if dialog.exec_() != PlotSettingsDialog.Accepted:
return return
@@ -1604,10 +1611,40 @@ class StudioMainWindow(QMainWindow):
self.prefs["plot_shear_max"] = shear_max self.prefs["plot_shear_max"] = shear_max
self.prefs["plot_fn_raw_max"] = fn_raw_max self.prefs["plot_fn_raw_max"] = fn_raw_max
self.prefs["plot_shear_raw_max"] = shear_raw_max self.prefs["plot_shear_raw_max"] = shear_raw_max
filters = dialog.filter_values()
# The same keys ``_filter_settings()`` reads back, so what the dialog
# shows next time is what the session will actually be given.
for key, value in filters.as_dict().items():
self.prefs[f"plot_{key}"] = value
self.force_plots.set_window_sec(window_sec) self.force_plots.set_window_sec(window_sec)
self.force_plots.set_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max) self.force_plots.set_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max)
self._last_plot_packet_key = None self._last_plot_packet_key = None
self._persist_prefs() self._persist_prefs()
if not self._running and not self.recorder.is_recording:
# Nothing is streaming: hand the choice over now, so the next session
# starts with it even if the operator never touches the dialog again.
self._ensure_workers()
for worker in (self._box_worker, self._finger_worker):
if worker is not None:
worker.set_filter_settings(filters)
self.statusBar().showMessage(
t("status.filters_saved", describe=filters.describe())
)
def _device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for.
The device's measured rate once it has one, else the configured 30 fps —
the dialog needs a number to check a cutoff against Nyquist.
"""
for source in (self._latest_fps, self.prefs.get("measured_fps")):
try:
rate = float(source or 0.0)
except (TypeError, ValueError):
rate = 0.0
if rate > 0:
return rate
return float(FORCE_SAMPLE_RATE_HZ)
def _apply_plot_prefs_to_panel(self) -> None: def _apply_plot_prefs_to_panel(self) -> None:
self.force_plots.set_window_sec( self.force_plots.set_window_sec(
@@ -118,6 +118,8 @@ class ForcePlotsPanel(QFrame):
mode_changed = pyqtSignal(str) mode_changed = pyqtSignal(str)
#: The unit chip was clicked: switch between newton and raw. #: The unit chip was clicked: switch between newton and raw.
unit_toggle_requested = pyqtSignal() unit_toggle_requested = pyqtSignal()
#: The gear was clicked: open the plot settings (window, range, filters).
plot_settings_requested = pyqtSignal()
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned #: The operator took the timeline over (dragged the cursor, box-zoomed, panned
#: or moved the overview marker): playback stops where it is. #: or moved the overview marker): playback stops where it is.
timeline_taken_over = pyqtSignal() timeline_taken_over = pyqtSignal()
@@ -194,6 +196,12 @@ class ForcePlotsPanel(QFrame):
self.btn_box_zoom.setCheckable(True) self.btn_box_zoom.setCheckable(True)
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled) self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
header.addWidget(self.btn_box_zoom) header.addWidget(self.btn_box_zoom)
# Window, range *and filters*. The filters belong here because they are
# what the curves are made of: the chain runs in the SDK, so this opens
# the one place the operator can say what the acquisition should do.
self.btn_settings = self._header_button("settings")
self.btn_settings.clicked.connect(self.plot_settings_requested.emit)
header.addWidget(self.btn_settings)
# The unit chip is a button: switching between newton and raw is a chart # The unit chip is a button: switching between newton and raw is a chart
# concern, so it lives on the chart. # concern, so it lives on the chart.
self.unit_badge = QPushButton() self.unit_badge = QPushButton()
@@ -396,7 +404,8 @@ class ForcePlotsPanel(QFrame):
text = color(theme, "text_primary") text = color(theme, "text_primary")
muted = color(theme, "text_muted") muted = color(theme, "text_muted")
# Icons are painted in the theme's own colour, so they follow a switch. # Icons are painted in the theme's own colour, so they follow a switch.
for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom): for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom,
self.btn_settings):
name = getattr(button, "_icon_name", "") name = getattr(button, "_icon_name", "")
if name: if name:
button.setIcon(icons.icon(name, theme)) button.setIcon(icons.icon(name, theme))
@@ -1220,6 +1229,8 @@ class ForcePlotsPanel(QFrame):
self.btn_overview.setAccessibleName(t("chart.overview")) self.btn_overview.setAccessibleName(t("chart.overview"))
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip")) self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
self.btn_box_zoom.setAccessibleName(t("chart.box_zoom")) self.btn_box_zoom.setAccessibleName(t("chart.box_zoom"))
self.btn_settings.setToolTip(t("chart.settings_tip"))
self.btn_settings.setAccessibleName(t("chart.settings"))
self.unit_badge.setToolTip(t("chart.unit_toggle_tip")) self.unit_badge.setToolTip(t("chart.unit_toggle_tip"))
self.unit_badge.setAccessibleName(t("chart.unit_toggle")) self.unit_badge.setAccessibleName(t("chart.unit_toggle"))
self.window_caption.setText(t("chart.overview")) self.window_caption.setText(t("chart.overview"))
+19 -1
View File
@@ -20,7 +20,7 @@ from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color from orisys_app_shared.theme import color
#: Names of the icons this module can paint. #: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest") NAMES = ("zoom_fit", "box_zoom", "latest", "settings")
ICON_SIZE = 16 ICON_SIZE = 16
_PEN_WIDTH = 1.4 _PEN_WIDTH = 1.4
@@ -76,10 +76,28 @@ def _paint_latest(painter: QPainter, size: int) -> None:
) )
def _paint_settings(painter: QPainter, size: int) -> None:
"""A cog: eight teeth around a ring, with the hole left open."""
import math
centre = size / 2.0
ring = size * 0.20
tooth = size * 0.34
painter.drawEllipse(QRectF(centre - ring, centre - ring, ring * 2, ring * 2))
for index in range(8):
angle = index * math.pi / 4.0
dx, dy = math.cos(angle), math.sin(angle)
painter.drawLine(
int(round(centre + dx * ring)), int(round(centre + dy * ring)),
int(round(centre + dx * tooth)), int(round(centre + dy * tooth)),
)
_PAINTERS = { _PAINTERS = {
"zoom_fit": _paint_zoom_fit, "zoom_fit": _paint_zoom_fit,
"box_zoom": _paint_box_zoom, "box_zoom": _paint_box_zoom,
"latest": _paint_latest, "latest": _paint_latest,
"settings": _paint_settings,
} }
@@ -1,18 +1,35 @@
"""Modal dialog for force-plot window and Y-axis limits.""" """Modal dialog for the force-plot window, Y-axis limits and the filters.
Two things live here because they are answered together, at the chart: how much
time and range the curves show, and what the acquisition is allowed to filter.
The filter half is a *request*: it is handed to the SDK through
``FilterSettings.apply_to`` at the start of the next session, and what the device
reports as done is what the record will say (see ``force/filters.py``).
"""
from __future__ import annotations from __future__ import annotations
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QCheckBox,
QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QDoubleSpinBox, QDoubleSpinBox,
QFormLayout, QFormLayout,
QGroupBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QPushButton, QPushButton,
QSpinBox,
QVBoxLayout, QVBoxLayout,
) )
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force.filters import (
SUGGESTED_LOWPASS_HZ,
FilterSettings,
available_presets,
)
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0 DEFAULT_PLOT_WINDOW_SEC = 4.0
@@ -35,10 +52,17 @@ class PlotSettingsDialog(QDialog):
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX, fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX, shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
units: str = "newton", units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setModal(True) self.setModal(True)
self.resize(380, 260) self.resize(420, 520)
self._sample_rate_hz = float(sample_rate_hz)
#: True while the widgets are being filled in from a preset, so the
#: "edited -> custom" handler does not fire on our own writes.
self._syncing_filters = False
root = QVBoxLayout(self) root = QVBoxLayout(self)
form = QFormLayout() form = QFormLayout()
@@ -102,6 +126,8 @@ class PlotSettingsDialog(QDialog):
for widget in raw_widgets: for widget in raw_widgets:
widget.setEnabled(not newton) widget.setEnabled(not newton)
self._build_filter_section(root, filters or FilterSettings(), filters_locked)
buttons_row = QHBoxLayout() buttons_row = QHBoxLayout()
self.btn_reset = QPushButton() self.btn_reset = QPushButton()
self.btn_reset.clicked.connect(self._reset_defaults) self.btn_reset.clicked.connect(self._reset_defaults)
@@ -115,6 +141,195 @@ class PlotSettingsDialog(QDialog):
self.retranslate() self.retranslate()
# ---------------------------------------------------------------- filters
def _build_filter_section(
self, root: QVBoxLayout, filters: FilterSettings, locked: bool
) -> None:
"""The filter half: a preset, its values, and what they cost."""
self.filters_group = QGroupBox()
group_form = QFormLayout(self.filters_group)
# The preset is shown by its own name — ``balanced`` is the string that
# goes into ``sensor.json``, so the dialog and the config use one word for
# one thing.
self.preset_combo = QComboBox()
self._preset_names = list(available_presets())
for name in self._preset_names:
self.preset_combo.addItem(name)
self.preset_combo.addItem(t("dialog.filter_preset_custom"))
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
self.flow_ema_spin = QDoubleSpinBox()
self.flow_ema_spin.setRange(0.05, 1.0)
self.flow_ema_spin.setSingleStep(0.05)
self.flow_ema_spin.setDecimals(2)
self.flow_ema_spin.setValue(float(filters.flow_ema_alpha))
self.median_spin = QSpinBox()
self.median_spin.setRange(1, 31)
self.median_spin.setValue(int(filters.median_window))
self.lowpass_spin = QDoubleSpinBox()
self.lowpass_spin.setRange(0.0, 60.0)
self.lowpass_spin.setSingleStep(1.0)
self.lowpass_spin.setDecimals(1)
self.lowpass_spin.setSuffix(" Hz")
self.lowpass_spin.setValue(float(filters.lowpass_hz))
self.lowpass_order_spin = QSpinBox()
self.lowpass_order_spin.setRange(1, 6)
self.lowpass_order_spin.setValue(int(filters.lowpass_order))
self.rate_auto_check = QCheckBox()
self.rate_auto_check.setChecked(str(filters.sample_rate_hz) == "auto")
self.rate_spin = QDoubleSpinBox()
self.rate_spin.setRange(1.0, 240.0)
self.rate_spin.setSingleStep(1.0)
self.rate_spin.setDecimals(1)
self.rate_spin.setSuffix(" Hz")
rate = filters.sample_rate_hz
self.rate_spin.setValue(
float(rate) if str(rate) != "auto" else float(FORCE_SAMPLE_RATE_HZ)
)
self.rate_spin.setEnabled(not self.rate_auto_check.isChecked())
self.rate_auto_check.toggled.connect(
lambda checked: self.rate_spin.setEnabled(not checked)
)
self.show_unfiltered_check = QCheckBox()
self.show_unfiltered_check.setChecked(bool(filters.show_unfiltered))
self.filter_preset_label = QLabel()
self.flow_ema_label = QLabel()
self.median_label = QLabel()
self.lowpass_label = QLabel()
self.lowpass_order_label = QLabel()
self.rate_label = QLabel()
group_form.addRow(self.filter_preset_label, self.preset_combo)
group_form.addRow(self.flow_ema_label, self.flow_ema_spin)
group_form.addRow(self.median_label, self.median_spin)
group_form.addRow(self.lowpass_label, self.lowpass_spin)
group_form.addRow(self.lowpass_order_label, self.lowpass_order_spin)
group_form.addRow(self.rate_auto_check, self.rate_spin)
group_form.addRow(self.show_unfiltered_check)
for widget in (self.flow_ema_spin, self.median_spin, self.lowpass_spin,
self.rate_spin, self.rate_auto_check,
self.show_unfiltered_check):
widget.setToolTip("") # filled in by retranslate()
self.flow_ema_spin.valueChanged.connect(self._on_filter_value_edited)
self.median_spin.valueChanged.connect(self._on_filter_value_edited)
self.lowpass_spin.valueChanged.connect(self._on_filter_value_edited)
self.lowpass_order_spin.valueChanged.connect(self._on_filter_value_edited)
self.rate_spin.valueChanged.connect(self._on_filter_value_edited)
self.rate_auto_check.toggled.connect(self._on_filter_value_edited)
# Start on the preset the settings came from, or on "custom" when they
# are a shape no preset has (a config edited by hand, a read-back state).
# A named preset also *means* its values: showing the name next to
# different numbers would be the same lie the config file used to tell.
if filters.preset in self._preset_names:
self._on_preset_chosen(self._preset_names.index(filters.preset))
self._show_preset(filters.preset)
self.filter_note = QLabel()
self.filter_note.setObjectName("hintText")
self.filter_note.setWordWrap(True)
self.filter_warning = QLabel()
self.filter_warning.setObjectName("warnText")
self.filter_warning.setWordWrap(True)
root.addWidget(self.filters_group)
root.addWidget(self.filter_note)
root.addWidget(self.filter_warning)
self._filters_locked = bool(locked)
self.filters_group.setEnabled(not self._filters_locked)
self._refresh_filter_note()
def _show_preset(self, name: str | None) -> None:
"""Point the combo at a preset (or at "custom") without re-applying it."""
index = self._preset_names.index(name) if name in self._preset_names else -1
self._syncing_filters = True
try:
self.preset_combo.setCurrentIndex(
index if index >= 0 else self.preset_combo.count() - 1
)
finally:
self._syncing_filters = False
def _on_preset_chosen(self, index: int) -> None:
if self._syncing_filters:
return
if not (0 <= index < len(self._preset_names)):
return # "custom": leave the numbers as the operator left them
values = available_presets()[self._preset_names[index]]
self._syncing_filters = True
try:
self.flow_ema_spin.setValue(float(values.get("flow_ema_alpha", 1.0)))
self.median_spin.setValue(int(values.get("force_median_window", 1)))
self.lowpass_spin.setValue(float(values.get("force_lowpass_cutoff_hz", 0.0)))
finally:
self._syncing_filters = False
self._refresh_filter_note()
def _on_filter_value_edited(self, *_args) -> None:
if self._syncing_filters:
return
# Any hand edit is no longer the preset the combo used to name.
self._show_preset(None)
self._refresh_filter_note()
def selected_preset(self) -> str | None:
"""The preset the values still are, or ``None`` for a custom shape."""
index = self.preset_combo.currentIndex()
if 0 <= index < len(self._preset_names):
return self._preset_names[index]
return None
def filter_values(self) -> FilterSettings:
rate: float | str = "auto"
if not self.rate_auto_check.isChecked():
rate = float(self.rate_spin.value())
return FilterSettings(
flow_ema_alpha=float(self.flow_ema_spin.value()),
median_window=int(self.median_spin.value()),
lowpass_hz=float(self.lowpass_spin.value()),
lowpass_order=int(self.lowpass_order_spin.value()),
sample_rate_hz=rate,
show_unfiltered=self.show_unfiltered_check.isChecked(),
preset=self.selected_preset(),
).normalised()
def _refresh_filter_note(self) -> None:
"""Say what these numbers amount to, and where they would not work."""
if not hasattr(self, "filter_note"):
return # still being built
settings = self.filter_values()
rate = self._sample_rate_hz
if str(settings.sample_rate_hz) != "auto":
rate = float(settings.sample_rate_hz)
issue = settings.nyquist_issue(rate)
self.filter_warning.setText(
""
if issue is None
else t(
"dialog.filter_nyquist",
cutoff=f"{issue['cutoff_hz']:g}",
nyquist=f"{issue['nyquist_hz']:g}",
rate=f"{issue['rate_hz']:g}",
)
)
self.filter_warning.setVisible(issue is not None)
if self._filters_locked:
self.filter_note.setText(t("dialog.filter_locked"))
elif settings.flow_ema_on:
self.filter_note.setText(t("dialog.filter_measurement_note"))
elif not settings.active():
self.filter_note.setText(t("dialog.filter_off"))
else:
self.filter_note.setText("")
def values(self) -> tuple[float, float, float, float, float]: def values(self) -> tuple[float, float, float, float, float]:
return ( return (
float(self.window_spin.value()), float(self.window_spin.value()),
@@ -130,6 +345,16 @@ class PlotSettingsDialog(QDialog):
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX) self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX) self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX) self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
# "Reset" means the shipped defaults, and those filter nothing — the same
# answer the dialog gives for a preference file that says nothing.
self.flow_ema_spin.setValue(1.0)
self.median_spin.setValue(1)
self.lowpass_spin.setValue(0.0)
self.lowpass_order_spin.setValue(2)
self.rate_auto_check.setChecked(True)
self.show_unfiltered_check.setChecked(False)
self._show_preset("off")
self._refresh_filter_note()
def retranslate(self) -> None: def retranslate(self) -> None:
self.setWindowTitle(t("dialog.plot_settings_title")) self.setWindowTitle(t("dialog.plot_settings_title"))
@@ -144,6 +369,25 @@ class PlotSettingsDialog(QDialog):
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max")) self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip")) self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
self.btn_reset.setText(t("dialog.reset_defaults")) self.btn_reset.setText(t("dialog.reset_defaults"))
self.filters_group.setTitle(t("dialog.plot_filters"))
self.filter_preset_label.setText(t("dialog.filter_preset"))
self.preset_combo.setToolTip(t("dialog.filter_preset_tip"))
self.preset_combo.setItemText(self.preset_combo.count() - 1, t("dialog.filter_preset_custom"))
self.flow_ema_label.setText(t("dialog.filter_flow_ema"))
self.flow_ema_spin.setToolTip(t("dialog.filter_flow_ema_tip"))
self.median_label.setText(t("dialog.filter_median"))
self.median_spin.setToolTip(t("dialog.filter_median_tip"))
self.lowpass_label.setText(t("dialog.filter_lowpass"))
self.lowpass_spin.setToolTip(t("dialog.filter_lowpass_tip"))
self.lowpass_order_label.setText(t("dialog.filter_order"))
self.lowpass_order_spin.setToolTip(t("dialog.filter_order_tip"))
self.rate_auto_check.setText(t("dialog.filter_rate_auto"))
self.rate_auto_check.setToolTip(t("dialog.filter_rate_auto_tip"))
self.rate_label.setText(t("dialog.filter_rate"))
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
self.show_unfiltered_check.setText(t("dialog.filter_show_unfiltered"))
self.show_unfiltered_check.setToolTip(t("dialog.filter_show_unfiltered_tip"))
self._refresh_filter_note()
ok_btn = self.button_box.button(QDialogButtonBox.Ok) ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel) cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
if ok_btn is not None: if ok_btn is not None:
+67
View File
@@ -2105,6 +2105,73 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window):
_finish(window, stuck) _finish(window, stuck)
# ------------------------------------------------------- plot settings dialog
def test_the_chart_gear_opens_the_settings_dialog(qt_app, make_window):
"""The gear is the one place the filters can be changed from the UI."""
window = make_window()
captured = []
window._on_plot_settings = lambda: captured.append(True)
window.force_plots.plot_settings_requested.disconnect()
window.force_plots.plot_settings_requested.connect(window._on_plot_settings)
window.force_plots.btn_settings.click()
assert captured == [True], "the gear did not ask for the settings dialog"
assert window.force_plots.btn_settings.toolTip()
window.close()
def test_the_filter_section_is_a_preset_and_a_custom_shape(qt_app):
"""A preset means its values; editing one turns the choice into "custom"."""
from orisys_app_shared.force.filters import FilterSettings
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
dialog = PlotSettingsDialog(filters=FilterSettings(preset="balanced"))
assert dialog.selected_preset() == "balanced"
assert dialog.filter_values().describe() == "flowEMA(0.5) + median(3)"
dialog.median_spin.setValue(7)
assert dialog.selected_preset() is None, "an edited preset must stop claiming the name"
assert dialog.filter_values().describe() == "flowEMA(0.5) + median(7)"
dialog.preset_combo.setCurrentIndex(3) # low_noise
assert dialog.filter_values().describe() == "flowEMA(0.3) + median(3) + lowpass(10Hz,order2)"
# A cutoff the device would drop is stated before the session starts.
dialog.lowpass_spin.setValue(30.0)
assert "Nyquist" in dialog.filter_warning.text() or "奈奎斯特" in dialog.filter_warning.text()
dialog._reset_defaults()
assert dialog.selected_preset() == "off"
assert dialog.filter_values().active() is False
assert dialog.filter_note.text(), "the dialog should say that nothing is filtered"
def test_the_filter_controls_are_locked_while_a_session_runs(qt_app):
from orisys_app_shared.force.filters import FilterSettings
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
dialog = PlotSettingsDialog(filters=FilterSettings(), filters_locked=True)
assert not dialog.filters_group.isEnabled()
assert dialog.filter_note.text(), "a locked dialog has to say why"
# Reading the values still works: the operator can see what is running.
assert dialog.filter_values().active() is False
def test_the_dialog_writes_the_keys_the_session_reads_back(qt_app):
"""The dialog's own vocabulary must be what ``_filter_settings`` reads."""
from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping
settings = FilterSettings(
flow_ema_alpha=0.5, median_window=3, lowpass_hz=10.0, show_unfiltered=True,
preset="low_noise",
)
stored = {f"plot_{key}": value for key, value in settings.as_dict().items()}
read_back = settings_from_mapping(
{key.removeprefix("plot_"): value for key, value in stored.items()}
)
assert read_back == settings.normalised()
assert read_back.describe() == "flowEMA(0.5) + median(3) + lowpass(10Hz,order2)"
+66 -11
View File
@@ -66,6 +66,48 @@ ALL_KEYS: tuple[str, ...] = tuple(CONFIGURE_PARAMETERS)
#: A sensible cutoff to offer when the operator switches the low-pass on. #: A sensible cutoff to offer when the operator switches the low-pass on.
SUGGESTED_LOWPASS_HZ = 10.0 SUGGESTED_LOWPASS_HZ = 10.0
#: The presets to offer when the SDK cannot be asked for its own. Kept in step
#: with ``orisys.sensor.FILTER_PRESETS`` by tests/test_force_filters.py; the SDK's
#: table wins whenever it can be imported, because that is the table the device
#: resolves against.
FALLBACK_PRESETS: dict[str, dict[str, Any]] = {
"off": {
"flow_ema_alpha": 1.0,
"force_median_window": 1,
"force_lowpass_cutoff_hz": 0.0,
},
"low_latency": {
"flow_ema_alpha": 1.0,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 0.0,
},
"balanced": {
"flow_ema_alpha": 0.5,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 0.0,
},
"low_noise": {
"flow_ema_alpha": 0.3,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 10.0,
},
}
def available_presets() -> dict[str, dict[str, Any]]:
"""Named presets for the dialog: the SDK's own, or the documented copies."""
fallback = {name: dict(values) for name, values in FALLBACK_PRESETS.items()}
try:
from orisys.sensor import available_filter_presets
except Exception: # noqa: BLE001 - the SDK may not be importable at all
return fallback
try:
presets = available_filter_presets()
except Exception: # noqa: BLE001
return fallback
resolved = {str(name): dict(values) for name, values in dict(presets).items()}
return resolved or fallback
def _as_float(value: Any, default: float) -> float: def _as_float(value: Any, default: float) -> float:
try: try:
@@ -199,24 +241,37 @@ class FilterSettings:
"""A short line for the record, upstream stage first.""" """A short line for the record, upstream stage first."""
return describe_state(self.as_state()) return describe_state(self.as_state())
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]: def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]:
"""Why the device would not do what these settings ask. """The cutoff/Nyquist problem as numbers, for a UI to phrase itself.
The SDK drops a low-pass whose cutoff is at or above Nyquist instead of The SDK drops a low-pass whose cutoff is at or above Nyquist instead of
silently re-tuning it, so the dialog has to say so *before* the session silently re-tuning it, so the dialog has to say so *before* a session
starts — otherwise the file would claim a filter the device is not starts — otherwise the file would claim a filter the device is not
running. running.
""" """
settings = self.normalised() settings = self.normalised()
issues: list[str] = [] if not settings.lowpass_on or not sample_rate_hz:
if settings.lowpass_on and sample_rate_hz: return None
nyquist = 0.5 * _as_float(sample_rate_hz, 0.0) rate = _as_float(sample_rate_hz, 0.0)
if nyquist > 0 and settings.lowpass_hz >= nyquist: nyquist = 0.5 * rate
issues.append( if nyquist <= 0 or settings.lowpass_hz < nyquist:
f"lowpass {settings.lowpass_hz:g} Hz is at or above Nyquist " return None
f"({nyquist:g} Hz): the device would not filter" return {
"cutoff_hz": float(settings.lowpass_hz),
"nyquist_hz": float(nyquist),
"rate_hz": float(rate),
}
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]:
"""Why the device would not do what these settings ask (English, for logs)."""
issue = self.nyquist_issue(sample_rate_hz)
if issue is None:
return ()
return (
f"lowpass {issue['cutoff_hz']:g} Hz is at or above Nyquist "
f"({issue['nyquist_hz']:g} Hz at {issue['rate_hz']:g} fps): the device "
"would not filter",
) )
return tuple(issues)
# ---------------------------------------------------------- driving the SDK # ---------------------------------------------------------- driving the SDK
def unsupported_by(self, sensor: object) -> tuple[str, ...]: def unsupported_by(self, sensor: object) -> tuple[str, ...]:
+17
View File
@@ -246,6 +246,23 @@ def test_settings_round_trip_through_a_preferences_mapping():
assert settings_from_mapping(None).active() is False assert settings_from_mapping(None).active() is False
def test_the_documented_presets_match_the_sdk():
"""The dialog's fallback table must not drift away from the device's."""
from orisys_app_shared.force.filters import FALLBACK_PRESETS, available_presets
orisys = pytest.importorskip("orisys")
from orisys.sensor import available_filter_presets
expected = {name: dict(values) for name, values in available_filter_presets().items()}
assert FALLBACK_PRESETS == expected, "the app's preset copy drifted from the SDK"
assert available_presets() == expected
# ...and each one is expressible as settings the app can ask for.
for name, values in expected.items():
settings = settings_from_mapping(values)
assert settings.preset is None, name
assert settings.configure_kwargs()["force_median_window"] == values["force_median_window"]
# ------------------------------------------------- the worker's filter contract # ------------------------------------------------- the worker's filter contract
def test_a_worker_reports_no_filter_state_until_a_session_has_one(): def test_a_worker_reports_no_filter_state_until_a_session_has_one():
"""``{}`` means "not asked yet", which is not the same as "nothing filters".""" """``{}`` means "not asked yet", which is not the same as "nothing filters"."""