c2293d4d41
- What(颜色来源): `DisplaySettings` 增 `field`(`"depth"` 默认 / `"flow"`),随显示设置持久化 (`scene3d/prefs.py`:新增 `FIELD_CHOICES`/`DEFAULT_FIELD`,手改文件里的非法值修回默认)。 `FingerFrameAdapter` 按所选字段先试、失败再用另一个,并把"换了量"这件事记在 `last_substitution`(新诊断码 `field_substituted`,info 级);两个字段都拿不到时, `last_diagnostic` 取**信息量最大**的那条(域不符/过期/未声明 > 没这个字段),而不是"所选字段"的 那条空话。设置对话框新增「颜色来源」下拉,下面一行 `field_note` 直接写"这一帧画的是 X,不是所选的 Y"; 图例(`legend_label`)永远标注**实际**画出来的量,所以设定与画面不一致时两处都能看出来。 深度响应 `[1]`、光流幅值 `[px]`,两者都是 `approximate`——代码与文案都不把它们说成毫米。 - What(诊断信息开关): 视图菜单新增 `诊断信息`(可勾选、持久化 `show_diagnostics`、**默认关**, 由 `DisplayPrefsController` 持有并推给两个面板)。关掉后:2D 面板只留"画面 + 计划说明 + 深度色标", 三条解释流水线的标签(箭头按位移放大、原始图像 → 算法输出、全部帧)隐藏;3D 面板只留 色条 + 一行图例 + 来源,info 级诊断隐藏。**永不隐藏**:warning/error 级诊断(测量是否过期、 字段是否本帧的、是否与设备核对过——`geometry_unverified` 虽然是 info 也白名单保留)与 深度色标。3D 图例改成 `ShrinkableLabel`(一行、裁切 + tooltip,不再折行),色标数值从 4 位有效数字 收到 3 位("0.0334 – 0.899")。设置对话框控件宽度 88 → 108 px(不再需要迁就 240 px 面板)。 - What(取景脚本): `tools/ui_snapshot.py --scene flow3d` 现在同时给 flow 与 depth(各带 provenance), 所以「颜色来源」开关有东西可切;再加 `.repro/shot_variants.py` 一次出四张图 (深度版 / 光流版 / 诊断开 / 设置对话框)。 - What(测试): `test_scene3d_adapters.py` 增 3 项(默认深度、换量时给出 `field_substituted` 并在恢复后 清掉、depth 不在场景栅格时按 `field_domain_mismatch` 拒绝而不是缩放);`test_scene3d_view.py` 增 1 项(field 的持久化与修复,并把 `field in FIELD_CHOICES` 加进"手改文件会被修复"的参数化); `test_scene3d_pane_ui.py` 增 3 项(两种量可选 + 说明行 + 恢复的偏好会移动下拉;诊断开关只隐藏 info、 保留 warning 与"未核对";2D 脚注三标签的显隐与窗口开关一致);`test_display_prefs.py` 增 1 项 (开关推到两个面板、写偏好)。`test_ui_smoke.py` 与 `tools/ui_check.py` 里"箭头图例可见"的四条断言 改为按新规则检查(默认隐藏、开诊断后可见、关掉再隐藏)。 - Why: ①"深度"比"光流"更贴近操作员口中的"压得多深",但两者都只是近似量,所以做成开关而不是替换—— 并且换量必须说出来(否则画面会被读成所选的量)。②面板脚下的解释文字是"内部管线"而不是"画面是什么", 拍视频与日常看都只需要后者;用一个开关收起,比做一个滚动日志面板诚实(这些是每帧状态,不是事件)。 - Verification: pytest shared/tests apps/studio/tests(offscreen)658 passed / 0 failed / 15 skipped; 真 GL 组(gl_smoke + view + pane_ui)70 passed / 0 failed / 1 skipped; `tools/ui_check.py` 590 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 7 个 key 双语齐备)。 目视验收:同一批合成数据渲染的四张图(深色主题)——深度版(默认)、光流版、诊断打开、设置对话框。 - Not in this change: 不改采集/记录/契约之外的三维渲染;不做页签容器与窄窗口回退;曲线量程仍是出厂 ±20 N。 真机待验证:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到光流)。
312 lines
10 KiB
Python
312 lines
10 KiB
Python
"""Acceptance for the display preferences, on their own.
|
|
|
|
``DisplayPrefsController`` decides how a measurement is *presented*: newtons or raw
|
|
counts, which chart range, which arrow style, whether the rail shows the frame rate. It
|
|
drives the panels through their own interfaces and borrows a few window-level operations
|
|
through ``DisplayHost``, so both can be stood in for — which these tests do.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from orisys_studio.display_prefs import DisplayPrefsController
|
|
|
|
PLOT_DEFAULTS = {
|
|
"window_sec": 10.0,
|
|
"fn_max": 5.0,
|
|
"shear_max": 5.0,
|
|
"fn_raw_max": 1e5,
|
|
"shear_raw_max": 1e5,
|
|
}
|
|
|
|
|
|
class _Action:
|
|
def __init__(self) -> None:
|
|
self.enabled: bool | None = None
|
|
|
|
def setEnabled(self, value: bool) -> None: # noqa: N802
|
|
self.enabled = bool(value)
|
|
|
|
|
|
class _Toolbar:
|
|
def __init__(self, units: str = "newton") -> None:
|
|
self._units = units
|
|
self.set_units_calls: list[str] = []
|
|
|
|
def units(self) -> str:
|
|
return self._units
|
|
|
|
def set_units(self, units: str) -> None:
|
|
self.set_units_calls.append(str(units))
|
|
self._units = str(units)
|
|
|
|
|
|
class _Charts:
|
|
def __init__(self, unit: str = "N") -> None:
|
|
self._unit = unit
|
|
self.fixed_newton: list[bool] = []
|
|
self.units: list[str] = []
|
|
self.window_sec: list[float] = []
|
|
self.limits: list[tuple] = []
|
|
|
|
def unit(self) -> str:
|
|
return self._unit
|
|
|
|
def set_fixed_newton(self, value: bool) -> None: # noqa: N802
|
|
self.fixed_newton.append(bool(value))
|
|
|
|
def set_units(self, unit: str) -> None:
|
|
self.units.append(str(unit))
|
|
self._unit = str(unit)
|
|
|
|
def set_window_sec(self, value: float) -> None: # noqa: N802
|
|
self.window_sec.append(float(value))
|
|
|
|
def set_axis_limits(self, *values) -> None: # noqa: N802
|
|
self.limits.append(tuple(float(v) for v in values))
|
|
|
|
|
|
class _ArrowPanel:
|
|
def __init__(self) -> None:
|
|
self.rotation_enabled: list[bool] = []
|
|
self.scales: list[float] = []
|
|
self.rotations = 0
|
|
|
|
def set_rotation_enabled(self, enabled: bool) -> None:
|
|
self.rotation_enabled.append(bool(enabled))
|
|
|
|
def set_arrow_scale(self, scale: float) -> None:
|
|
self.scales.append(float(scale))
|
|
|
|
def rotate_90_ccw(self) -> int:
|
|
self.rotations += 1
|
|
return 180
|
|
|
|
|
|
class _Engine:
|
|
def __init__(self, loaded: bool = False) -> None:
|
|
self._loaded = loaded
|
|
self.units: list[str] = []
|
|
|
|
def is_loaded(self) -> bool:
|
|
return self._loaded
|
|
|
|
def set_unit(self, unit: str) -> None:
|
|
self.units.append(str(unit))
|
|
|
|
|
|
class _Picker:
|
|
def __init__(self, sensor_type: str = "box") -> None:
|
|
self._sensor = sensor_type
|
|
|
|
def sensor_type(self) -> str:
|
|
return self._sensor
|
|
|
|
|
|
class _Host:
|
|
def __init__(self, prefs: dict, latest_fps=None) -> None:
|
|
self.prefs = dict(prefs)
|
|
self.persisted = 0
|
|
self.rail_updates = 0
|
|
self.markers = 0
|
|
self.repaints = 0
|
|
self._latest_fps = latest_fps
|
|
|
|
def _persist_prefs(self) -> None:
|
|
self.persisted += 1
|
|
|
|
def _refresh_status_rail(self) -> None:
|
|
self.rail_updates += 1
|
|
|
|
def _drop_render_marker(self) -> None:
|
|
self.markers += 1
|
|
|
|
def _repaint_live_frame(self) -> None:
|
|
self.repaints += 1
|
|
|
|
|
|
def _controller(
|
|
*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None,
|
|
arrow_panel=None, mesh_panel=None,
|
|
):
|
|
host = _Host(prefs or {}, latest_fps=latest_fps)
|
|
toolbar = _Toolbar(units)
|
|
charts = _Charts("N" if units == "newton" else "raw")
|
|
arrows = arrow_panel if arrow_panel is not None else _ArrowPanel()
|
|
engine = _Engine(loaded)
|
|
action = _Action()
|
|
controller = DisplayPrefsController(
|
|
host=host,
|
|
toolbar=toolbar,
|
|
charts=charts,
|
|
arrow_panel=arrows,
|
|
mesh_panel=mesh_panel,
|
|
engine=engine,
|
|
sensor_picker=_Picker(sensor_type),
|
|
rotate_arrows_action=action,
|
|
plot_defaults=PLOT_DEFAULTS,
|
|
default_sample_rate_hz=30.0,
|
|
arrow_rotate_types=frozenset({"box", "mini"}),
|
|
)
|
|
return controller, {
|
|
"host": host,
|
|
"toolbar": toolbar,
|
|
"charts": charts,
|
|
"arrows": arrows,
|
|
"engine": engine,
|
|
"action": action,
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------- tests
|
|
|
|
|
|
def test_the_unit_reaches_the_toolbar_the_charts_and_the_replay():
|
|
controller, parts = _controller(prefs={}, units="newton", loaded=True)
|
|
|
|
controller.set_display_unit("raw")
|
|
assert parts["toolbar"].set_units_calls == ["raw"]
|
|
assert parts["engine"].units == ["raw"], "an open recording was not told"
|
|
assert parts["charts"].fixed_newton == [False]
|
|
assert parts["charts"].units == ["raw"]
|
|
assert parts["host"].prefs["units"] == "raw"
|
|
assert parts["host"].persisted == 1
|
|
|
|
|
|
def test_a_unit_change_from_the_toolbar_does_not_talk_back_to_it():
|
|
"""The combo already *is* on that unit; writing it again is how a loop starts."""
|
|
controller, parts = _controller(prefs={}, units="raw")
|
|
|
|
controller.on_units_changed("newton")
|
|
assert parts["toolbar"].set_units_calls == [], parts["toolbar"].set_units_calls
|
|
assert parts["charts"].units == ["N"]
|
|
assert parts["charts"].fixed_newton == [True]
|
|
|
|
|
|
def test_the_chart_chip_flips_which_unit_the_charts_use():
|
|
controller, parts = _controller(prefs={}, units="newton")
|
|
controller.on_chart_unit_toggle()
|
|
assert parts["charts"].units == ["raw"]
|
|
|
|
controller.on_chart_unit_toggle()
|
|
assert parts["charts"].units == ["raw", "N"]
|
|
|
|
|
|
def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
|
|
controller, parts = _controller(prefs={"plot_window_sec": 3.0})
|
|
|
|
settings = controller.plot_settings()
|
|
assert settings["window_sec"] == 3.0
|
|
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
|
|
assert settings["units"] == "newton"
|
|
|
|
|
|
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
|
|
controller, parts = _controller(prefs={})
|
|
controller.apply_plot_settings(
|
|
{
|
|
"window_sec": 20.0,
|
|
"fn_max": 8.0,
|
|
"shear_max": 9.0,
|
|
"fn_raw_max": 2e5,
|
|
"shear_raw_max": 3e5,
|
|
}
|
|
)
|
|
assert parts["host"].prefs["plot_window_sec"] == 20.0
|
|
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
|
|
assert parts["charts"].window_sec == [20.0]
|
|
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
|
|
assert parts["host"].persisted == 1
|
|
|
|
|
|
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
|
|
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
|
|
assert controller.device_sample_rate() == 48.5
|
|
|
|
parts["host"]._latest_fps = None
|
|
assert controller.device_sample_rate() == 12.0
|
|
|
|
parts["host"].prefs.pop("measured_fps")
|
|
assert controller.device_sample_rate() == 30.0, "the default did not apply"
|
|
|
|
|
|
def test_rotation_is_offered_only_where_the_sensor_can_be_rotated():
|
|
controller, parts = _controller(sensor_type="finger")
|
|
controller.apply_arrow_rotation_for_sensor()
|
|
assert parts["arrows"].rotation_enabled[-1] is False
|
|
assert parts["action"].enabled is False
|
|
|
|
parts["arrows"].rotations = 0
|
|
controller.on_rotate_arrows()
|
|
assert parts["arrows"].rotations == 0, "a finger sensor was rotated anyway"
|
|
assert "arrow_rotation_deg" not in parts["host"].prefs
|
|
|
|
|
|
def test_rotating_remembers_the_angle_and_a_box_sensor_may_rotate():
|
|
controller, parts = _controller(sensor_type="box")
|
|
controller.apply_arrow_rotation_for_sensor()
|
|
assert parts["arrows"].rotation_enabled[-1] is True
|
|
assert parts["action"].enabled is True
|
|
|
|
controller.on_rotate_arrows()
|
|
assert parts["host"].prefs["arrow_rotation_deg"] == 180
|
|
assert parts["host"].persisted == 1
|
|
|
|
|
|
def test_the_arrow_legend_describes_the_configured_sensor():
|
|
controller, parts = _controller(sensor_type="finger")
|
|
controller.apply_arrow_legend()
|
|
finger_scale = parts["arrows"].scales[-1]
|
|
|
|
controller, parts = _controller(sensor_type="box")
|
|
controller.apply_arrow_legend()
|
|
box_scale = parts["arrows"].scales[-1]
|
|
|
|
assert finger_scale != box_scale, "the two draw styles share one magnification"
|
|
|
|
|
|
def test_a_new_2d_view_mode_repaints_the_panel_once():
|
|
controller, parts = _controller(prefs={})
|
|
controller.on_image_view_mode_changed("depth")
|
|
assert parts["host"].prefs["image_view_mode"] == "depth"
|
|
assert parts["host"].markers == 1, "the stale frame was kept"
|
|
assert parts["host"].repaints == 1
|
|
assert parts["host"].persisted == 1
|
|
|
|
|
|
def test_the_fps_readout_is_a_preference_like_any_other():
|
|
controller, parts = _controller(prefs={})
|
|
assert controller.show_fps is False
|
|
|
|
controller.on_show_fps_toggled(True)
|
|
assert controller.show_fps is True
|
|
assert parts["host"].prefs["show_fps"] is True
|
|
assert parts["host"].rail_updates == 1, "the rail was not told to show it"
|
|
assert parts["host"].persisted == 1
|
|
|
|
|
|
def test_the_diagnostics_switch_reaches_both_panels():
|
|
"""One preference decides how much the panels explain around their pictures."""
|
|
|
|
class _Panel:
|
|
def __init__(self) -> None:
|
|
self.visible: list[bool] = []
|
|
|
|
def set_diagnostics_visible(self, visible: bool) -> None:
|
|
self.visible.append(bool(visible))
|
|
|
|
arrows = _Panel()
|
|
mesh = _Panel()
|
|
controller, parts = _controller(prefs={}, arrow_panel=arrows, mesh_panel=mesh)
|
|
# The constructor applies the stored preference to whatever panels exist.
|
|
assert arrows.visible == [False] and mesh.visible == [False]
|
|
|
|
controller.on_show_diagnostics_toggled(True)
|
|
assert arrows.visible[-1] is True and mesh.visible[-1] is True
|
|
assert controller.show_diagnostics is True
|
|
assert parts["host"].prefs["show_diagnostics"] is True
|
|
assert parts["host"].persisted == 1
|
|
|
|
controller.on_show_diagnostics_toggled(False)
|
|
assert arrows.visible[-1] is False and mesh.visible[-1] is False
|
|
assert parts["host"].prefs["show_diagnostics"] is False
|