曲线图表头加齿轮,绘图设置对话框里可以直接调滤波 - 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
@@ -658,6 +658,8 @@ class StudioMainWindow(QMainWindow):
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.
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:
"""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
),
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:
return
@@ -1604,10 +1611,40 @@ class StudioMainWindow(QMainWindow):
self.prefs["plot_shear_max"] = shear_max
self.prefs["plot_fn_raw_max"] = fn_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_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max)
self._last_plot_packet_key = None
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:
self.force_plots.set_window_sec(
@@ -118,6 +118,8 @@ class ForcePlotsPanel(QFrame):
mode_changed = pyqtSignal(str)
#: The unit chip was clicked: switch between newton and raw.
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
#: or moved the overview marker): playback stops where it is.
timeline_taken_over = pyqtSignal()
@@ -194,6 +196,12 @@ class ForcePlotsPanel(QFrame):
self.btn_box_zoom.setCheckable(True)
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
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
# concern, so it lives on the chart.
self.unit_badge = QPushButton()
@@ -396,7 +404,8 @@ class ForcePlotsPanel(QFrame):
text = color(theme, "text_primary")
muted = color(theme, "text_muted")
# 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", "")
if name:
button.setIcon(icons.icon(name, theme))
@@ -1220,6 +1229,8 @@ class ForcePlotsPanel(QFrame):
self.btn_overview.setAccessibleName(t("chart.overview"))
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
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.setAccessibleName(t("chart.unit_toggle"))
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
#: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest")
NAMES = ("zoom_fit", "box_zoom", "latest", "settings")
ICON_SIZE = 16
_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 = {
"zoom_fit": _paint_zoom_fit,
"box_zoom": _paint_box_zoom,
"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 PyQt5.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
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
DEFAULT_PLOT_WINDOW_SEC = 4.0
@@ -35,10 +52,17 @@ class PlotSettingsDialog(QDialog):
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
) -> None:
super().__init__(parent)
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)
form = QFormLayout()
@@ -102,6 +126,8 @@ class PlotSettingsDialog(QDialog):
for widget in raw_widgets:
widget.setEnabled(not newton)
self._build_filter_section(root, filters or FilterSettings(), filters_locked)
buttons_row = QHBoxLayout()
self.btn_reset = QPushButton()
self.btn_reset.clicked.connect(self._reset_defaults)
@@ -115,6 +141,195 @@ class PlotSettingsDialog(QDialog):
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]:
return (
float(self.window_spin.value()),
@@ -130,6 +345,16 @@ class PlotSettingsDialog(QDialog):
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_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:
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_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
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)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
if ok_btn is not None: