曲线图表头加齿轮,绘图设置对话框里可以直接调滤波 - 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
+67 -12
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.
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:
try:
@@ -199,24 +241,37 @@ class FilterSettings:
"""A short line for the record, upstream stage first."""
return describe_state(self.as_state())
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]:
"""Why the device would not do what these settings ask.
def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]:
"""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
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
running.
"""
settings = self.normalised()
issues: list[str] = []
if settings.lowpass_on and sample_rate_hz:
nyquist = 0.5 * _as_float(sample_rate_hz, 0.0)
if nyquist > 0 and settings.lowpass_hz >= nyquist:
issues.append(
f"lowpass {settings.lowpass_hz:g} Hz is at or above Nyquist "
f"({nyquist:g} Hz): the device would not filter"
)
return tuple(issues)
if not settings.lowpass_on or not sample_rate_hz:
return None
rate = _as_float(sample_rate_hz, 0.0)
nyquist = 0.5 * rate
if nyquist <= 0 or settings.lowpass_hz < nyquist:
return None
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",
)
# ---------------------------------------------------------- driving the SDK
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
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
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"."""