界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字
- 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(若不是,面板会写明并回退到光流)。
This commit is contained in:
@@ -124,11 +124,14 @@ class _Host:
|
||||
self.repaints += 1
|
||||
|
||||
|
||||
def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None):
|
||||
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 = _ArrowPanel()
|
||||
arrows = arrow_panel if arrow_panel is not None else _ArrowPanel()
|
||||
engine = _Engine(loaded)
|
||||
action = _Action()
|
||||
controller = DisplayPrefsController(
|
||||
@@ -136,6 +139,7 @@ def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False,
|
||||
toolbar=toolbar,
|
||||
charts=charts,
|
||||
arrow_panel=arrows,
|
||||
mesh_panel=mesh_panel,
|
||||
engine=engine,
|
||||
sensor_picker=_Picker(sensor_type),
|
||||
rotate_arrows_action=action,
|
||||
@@ -278,3 +282,30 @@ def test_the_fps_readout_is_a_preference_like_any_other():
|
||||
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
|
||||
|
||||
@@ -73,6 +73,84 @@ def test_the_flow_magnitude_handles_both_shapes():
|
||||
assert flow_magnitude(FakePacket(flow=np.zeros((2, 2, 3), dtype=np.float32))) is None
|
||||
|
||||
|
||||
def test_the_finger_adapter_prefers_the_quantity_the_operator_chose(scene):
|
||||
"""The choice between the two quantities is a display setting, honoured per frame.
|
||||
|
||||
The depth response is what an operator reads as "how deep", so it is the default —
|
||||
and when the frame carries it, that is what is drawn.
|
||||
"""
|
||||
adapter = FingerFrameAdapter(
|
||||
geometry_id=scene.geometry_id,
|
||||
field_domain_id=scene.field_domain_id,
|
||||
shape_hw=scene.shape_hw,
|
||||
)
|
||||
assert adapter.preferred_field == "depth"
|
||||
depth = np.full(scene.shape_hw, 2.5, dtype=np.float32)
|
||||
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
|
||||
flow[..., 0] = 7.0 # |flow| = 7 px
|
||||
|
||||
frame = adapter.snapshot(FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity())
|
||||
assert frame is not None
|
||||
assert frame.scalar_descriptor == DEPTH_RESPONSE
|
||||
assert frame.scalar_descriptor.unit == "1"
|
||||
assert np.allclose(np.asarray(frame.scalar), 2.5)
|
||||
assert adapter.last_substitution is None, "the chosen field was drawn; nothing to say"
|
||||
|
||||
adapter.set_preferred_field("flow")
|
||||
frame = adapter.snapshot(FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity())
|
||||
assert frame is not None
|
||||
assert frame.scalar_descriptor == FLOW_MAGNITUDE
|
||||
assert np.allclose(np.asarray(frame.scalar), 7.0)
|
||||
assert adapter.last_substitution is None
|
||||
|
||||
|
||||
def test_a_frame_that_cannot_honour_the_choice_says_which_quantity_it_drew(scene):
|
||||
"""Falling back is allowed; falling back silently is not.
|
||||
|
||||
The frame is still a measurement and the legend names it, but the *setting* said
|
||||
something else, and the pane has to be able to say so instead of letting the picture
|
||||
read as the quantity the operator asked for.
|
||||
"""
|
||||
adapter = FingerFrameAdapter(
|
||||
geometry_id=scene.geometry_id,
|
||||
field_domain_id=scene.field_domain_id,
|
||||
shape_hw=scene.shape_hw,
|
||||
)
|
||||
# No depth in this frame: the flow is all it has.
|
||||
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
|
||||
flow[..., 0] = 4.0 # |flow| = 4 px
|
||||
frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity())
|
||||
assert frame is not None
|
||||
assert frame.scalar_descriptor == FLOW_MAGNITUDE
|
||||
note = adapter.last_substitution
|
||||
assert note is not None and note.code == "field_substituted"
|
||||
assert note.details["wanted"] == "depth" and note.details["shown"] == "flow"
|
||||
assert "no depth response" in note.details["why"]
|
||||
|
||||
# ...and the note goes away as soon as the chosen field can be drawn again.
|
||||
depth = np.full(scene.shape_hw, 1.0, dtype=np.float32)
|
||||
frame = adapter.snapshot(
|
||||
FakePacket(flow=flow, depth=depth), runtime=None, identity=_identity()
|
||||
)
|
||||
assert frame is not None and frame.scalar_descriptor == DEPTH_RESPONSE
|
||||
assert adapter.last_substitution is None
|
||||
|
||||
|
||||
def test_a_depth_response_on_another_grid_is_refused_not_resized(scene):
|
||||
"""The depth field gets the same treatment as the flow: no resizing, ever."""
|
||||
adapter = FingerFrameAdapter(
|
||||
geometry_id=scene.geometry_id,
|
||||
field_domain_id=scene.field_domain_id,
|
||||
shape_hw=scene.shape_hw,
|
||||
)
|
||||
wrong = np.zeros((7, 7), dtype=np.float32)
|
||||
frame = adapter.snapshot(FakePacket(depth=wrong), runtime=None, identity=_identity())
|
||||
assert frame is None, "a depth response that is not on the scene's grid was coloured"
|
||||
item = adapter.last_diagnostic
|
||||
assert item is not None and item.code == "field_domain_mismatch"
|
||||
assert item.details["depth"] == "7x7"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- finger adapter
|
||||
def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
|
||||
adapter = FingerFrameAdapter(
|
||||
|
||||
@@ -145,6 +145,101 @@ def test_the_reset_view_icon_is_painted_from_the_theme():
|
||||
assert not icons.icon("reset_view", theme).isNull()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- which quantity
|
||||
|
||||
|
||||
def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(qt_app, panel):
|
||||
"""The choice is the operator's; a frame that cannot honour it says so."""
|
||||
from orisys_app_shared.scene3d.contracts import FieldDescriptor
|
||||
|
||||
assert [panel.field_combo.itemData(i) for i in range(panel.field_combo.count())] == [
|
||||
"depth",
|
||||
"flow",
|
||||
]
|
||||
assert panel.field() == "depth", "the depth response is what the pane offers first"
|
||||
assert panel.settings().field == "depth"
|
||||
|
||||
# The chosen quantity is drawn: no note.
|
||||
panel.set_descriptor(FieldDescriptor("depth_response", "1", "approximate", False))
|
||||
assert not panel.field_note.isVisible() or not panel.field_note.text()
|
||||
|
||||
# The other one is drawn instead: the note names both, and the legend still shows
|
||||
# what is really on screen (the range is for the field that was drawn).
|
||||
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
||||
note = panel.field_note.text()
|
||||
assert t("scene3d.field_depth") in note and t("scene3d.field_flow") in note, note
|
||||
assert "flow_magnitude" not in panel.legend_label.text()
|
||||
|
||||
# Choosing flow makes that frame the honoured one, and the note goes away.
|
||||
index = panel.field_combo.findData("flow")
|
||||
panel.field_combo.setCurrentIndex(index)
|
||||
assert panel.settings().field == "flow"
|
||||
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
||||
assert not panel.field_note.isVisible() or not panel.field_note.text()
|
||||
|
||||
# A restored preference moves the combo (and therefore the adapter).
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
|
||||
panel.apply_settings(DisplaySettings(field="depth"))
|
||||
assert panel.field() == "depth"
|
||||
|
||||
|
||||
def test_the_diagnostics_switch_governs_what_the_pane_explains(qt_app, panel):
|
||||
"""Warnings are claims about the measurement and always show; info is explanation."""
|
||||
from orisys_app_shared.scene3d.contracts import diagnostic
|
||||
|
||||
panel.set_descriptor(None)
|
||||
panel.set_diagnostics_visible(False)
|
||||
panel.set_diagnostic(diagnostic("field_stale", "the array is from another frame"))
|
||||
assert "another frame" in panel.legend_label.text(), (
|
||||
"a warning about the measurement was hidden"
|
||||
)
|
||||
|
||||
panel.set_diagnostic(diagnostic("geometry_unverified", "the scene was not compared"))
|
||||
assert "not compared" in panel.legend_label.text(), (
|
||||
"a claim about verification was hidden"
|
||||
)
|
||||
|
||||
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
|
||||
assert "not current" not in panel.legend_label.text(), (
|
||||
"an informational line survived diagnostics being off"
|
||||
)
|
||||
|
||||
panel.set_diagnostics_visible(True)
|
||||
panel.set_diagnostic(diagnostic("stale_frame", "the picture is not current"))
|
||||
assert "not current" in panel.legend_label.text()
|
||||
|
||||
|
||||
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(qt_app, make_window):
|
||||
"""One line under the canvas: what the picture is, not how it was made."""
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
window = make_window()
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
try:
|
||||
arrow = window.arrow_panel
|
||||
arrow.set_plan(None)
|
||||
arrow.set_diagnostics_visible(False)
|
||||
assert not arrow.scale_label.isVisible()
|
||||
assert not arrow.pipeline_label.isVisible()
|
||||
assert not arrow.area_label.isVisible()
|
||||
|
||||
arrow.set_diagnostics_visible(True)
|
||||
assert arrow.pipeline_label.isVisible() or not arrow.view_modes(), (
|
||||
"the pipeline line came back"
|
||||
)
|
||||
|
||||
# The window's switch is the same thing, and it is remembered.
|
||||
window.act_show_diagnostics.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window.display_ctl.show_diagnostics is True
|
||||
assert arrow.diagnostics_visible() is True
|
||||
assert window.prefs["show_diagnostics"] is True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- the menu
|
||||
|
||||
|
||||
|
||||
@@ -496,6 +496,19 @@ def test_a_saved_descriptor_is_never_restored_as_a_measurement():
|
||||
assert restored_with.color_scale.descriptor == given
|
||||
|
||||
|
||||
def test_a_saved_field_choice_survives_and_a_bad_one_is_repaired():
|
||||
"""Which quantity colours the surface is a preference; an unknown one is repaired."""
|
||||
block = scene3d_prefs.settings_to_prefs(DisplaySettings(field="flow"))
|
||||
assert block["field"] == "flow"
|
||||
assert scene3d_prefs.settings_from_prefs(block).field == "flow"
|
||||
|
||||
# The default is the depth response (the operator chose it for the pane), and a
|
||||
# hand-edited or older file that names something else gets that default.
|
||||
assert DisplaySettings().field == scene3d_prefs.DEFAULT_FIELD == "depth"
|
||||
for bad in ({"field": "displacement"}, {"field": 3}, {"field": None}, {}):
|
||||
assert scene3d_prefs.settings_from_prefs(bad).field == "depth", bad
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"block",
|
||||
[
|
||||
@@ -515,6 +528,7 @@ def test_a_hand_edited_settings_block_is_repaired(block):
|
||||
assert isinstance(settings, DisplaySettings)
|
||||
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
|
||||
assert settings.max_fps in scene3d_prefs.FPS_CHOICES
|
||||
assert settings.field in scene3d_prefs.FIELD_CHOICES
|
||||
if settings.color_scale is not None:
|
||||
lower, upper = settings.color_scale.lower, settings.color_scale.upper
|
||||
assert lower == lower and upper == upper, "a NaN survived the repair"
|
||||
|
||||
@@ -1356,6 +1356,12 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
panel = window.arrow_panel
|
||||
# The pipeline labels are diagnostics: off by default (the canvas keeps what the
|
||||
# picture *is*), on when the operator asks — 视图 ▸ 诊断信息.
|
||||
assert not panel.scale_label.isVisible()
|
||||
assert not panel.pipeline_label.isVisible()
|
||||
assert not panel.area_label.isVisible()
|
||||
panel.set_diagnostics_visible(True)
|
||||
assert panel.scale_label.isVisible()
|
||||
assert "2" in panel.scale_label.text()
|
||||
assert panel.pipeline_label.isVisible() and panel.pipeline_label.text()
|
||||
@@ -1372,6 +1378,9 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
||||
assert not panel.colorbar.isVisible()
|
||||
assert panel.scale_label.isVisible()
|
||||
|
||||
panel.set_diagnostics_visible(False)
|
||||
assert not panel.scale_label.isVisible(), "诊断信息 off still shows the pipeline"
|
||||
|
||||
|
||||
# ------------------------------------------------- top rail: sensor and views
|
||||
|
||||
|
||||
Reference in New Issue
Block a user