Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c538dbf074 | |||
| c2293d4d41 | |||
| 0b87e3e36d | |||
| 650578d738 |
@@ -30,7 +30,9 @@
|
|||||||
"lang_en": "English",
|
"lang_en": "English",
|
||||||
"calibration_panel": "Calibration panel",
|
"calibration_panel": "Calibration panel",
|
||||||
"finger_3d": "Finger 3D view",
|
"finger_3d": "Finger 3D view",
|
||||||
|
"scene3d_settings": "3D display settings…",
|
||||||
"show_fps": "Show FPS",
|
"show_fps": "Show FPS",
|
||||||
|
"show_diagnostics": "Diagnostics",
|
||||||
"settings": "Settings area",
|
"settings": "Settings area",
|
||||||
"plot_settings": "Plot settings…",
|
"plot_settings": "Plot settings…",
|
||||||
"rotate_arrows": "Rotate arrows 90°",
|
"rotate_arrows": "Rotate arrows 90°",
|
||||||
@@ -77,7 +79,8 @@
|
|||||||
"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_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)",
|
"filter_off": "Not filtering (raw readings)",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"search_cameras": "Search",
|
"search_cameras": "Search",
|
||||||
@@ -179,7 +182,9 @@
|
|||||||
"mesh3d_no_asset": "No compiled 3D scene installed.\nA scene package (*.oryscene) is looked for in:\n{directory}\nBuild one with tools/scene_assets/compile_scene.py — the names of the files it needs are in its --help. Only the 3D view is affected; measuring, recording and the charts work as usual.",
|
"mesh3d_no_asset": "No compiled 3D scene installed.\nA scene package (*.oryscene) is looked for in:\n{directory}\nBuild one with tools/scene_assets/compile_scene.py — the names of the files it needs are in its --help. Only the 3D view is affected; measuring, recording and the charts work as usual.",
|
||||||
"mesh3d_loading": "Loading 3D scene {name}…",
|
"mesh3d_loading": "Loading 3D scene {name}…",
|
||||||
"mesh3d_rejected": "The 3D scene was refused: {message}\nOnly the 3D view is affected; measuring, recording and the charts work as usual.",
|
"mesh3d_rejected": "The 3D scene was refused: {message}\nOnly the 3D view is affected; measuring, recording and the charts work as usual.",
|
||||||
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual."
|
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual.",
|
||||||
|
"mesh3d_settings_tip": "3D display settings (view, housing, colour scale, frame rate)",
|
||||||
|
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses"
|
||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"fn": "FN",
|
"fn": "FN",
|
||||||
@@ -401,7 +406,12 @@
|
|||||||
"housing": "Housing",
|
"housing": "Housing",
|
||||||
"deform": "Exaggerate",
|
"deform": "Exaggerate",
|
||||||
"deform_tip": "Display-only magnification of the measured deformation. It changes the picture and nothing else: the recorded data, the calibration and the camera are not touched.",
|
"deform_tip": "Display-only magnification of the measured deformation. It changes the picture and nothing else: the recorded data, the calibration and the camera are not touched.",
|
||||||
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface with the optical-flow magnitude, so exaggeration is unavailable.",
|
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface, so exaggeration is unavailable.",
|
||||||
|
"field": "Colour by",
|
||||||
|
"field_tip": "Which quantity colours the surface: the depth response (the chain's relative deformation response, unitless) or the optical-flow magnitude (the tracker's pixel displacement). Neither is a length in millimetres — both are approximate. When the chosen one cannot be drawn for a frame, the other is used and the line below says so.",
|
||||||
|
"field_depth": "Depth response",
|
||||||
|
"field_flow": "Optical flow magnitude",
|
||||||
|
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
|
||||||
"quantity_flow_magnitude": "Optical flow magnitude",
|
"quantity_flow_magnitude": "Optical flow magnitude",
|
||||||
"quantity_depth_response": "Depth response",
|
"quantity_depth_response": "Depth response",
|
||||||
"mode_surface": "surface colouring",
|
"mode_surface": "surface colouring",
|
||||||
@@ -417,6 +427,8 @@
|
|||||||
"max_fps": "Display rate",
|
"max_fps": "Display rate",
|
||||||
"max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.",
|
"max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.",
|
||||||
"reset_view": "Reset view",
|
"reset_view": "Reset view",
|
||||||
|
"settings_title": "3D display settings",
|
||||||
|
"settings_hint": "Everything here affects the 3D view's display only: measuring, recording and the charts are untouched, and each change applies at once.",
|
||||||
"legend": "{quantity} [{unit}] · {quality}",
|
"legend": "{quantity} [{unit}] · {quality}",
|
||||||
"legend_none": "No measurement on screen — the surface model is shown uncoloured",
|
"legend_none": "No measurement on screen — the surface model is shown uncoloured",
|
||||||
"quality_calibrated": "calibrated",
|
"quality_calibrated": "calibrated",
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
"lang_en": "English",
|
"lang_en": "English",
|
||||||
"calibration_panel": "标定面板",
|
"calibration_panel": "标定面板",
|
||||||
"finger_3d": "手指 3D 视图",
|
"finger_3d": "手指 3D 视图",
|
||||||
|
"scene3d_settings": "三维显示设置…",
|
||||||
"show_fps": "显示 FPS",
|
"show_fps": "显示 FPS",
|
||||||
|
"show_diagnostics": "诊断信息",
|
||||||
"settings": "设置区",
|
"settings": "设置区",
|
||||||
"plot_settings": "绘图设置…",
|
"plot_settings": "绘图设置…",
|
||||||
"rotate_arrows": "旋转箭头图 90°",
|
"rotate_arrows": "旋转箭头图 90°",
|
||||||
@@ -77,7 +79,8 @@
|
|||||||
"filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。",
|
"filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。",
|
||||||
"filter_off": "当前不滤波(原始读数)",
|
"filter_off": "当前不滤波(原始读数)",
|
||||||
"ok": "确定",
|
"ok": "确定",
|
||||||
"cancel": "取消"
|
"cancel": "取消",
|
||||||
|
"close": "关闭"
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"search_cameras": "搜索",
|
"search_cameras": "搜索",
|
||||||
@@ -179,7 +182,9 @@
|
|||||||
"mesh3d_no_asset": "本机未安装已编译的三维场景。\n场景包(*.oryscene)的查找位置:\n{directory}\n可用 tools/scene_assets/compile_scene.py 生成,它需要哪些文件见 --help。只影响三维视图:采集、记录与力曲线照常工作。",
|
"mesh3d_no_asset": "本机未安装已编译的三维场景。\n场景包(*.oryscene)的查找位置:\n{directory}\n可用 tools/scene_assets/compile_scene.py 生成,它需要哪些文件见 --help。只影响三维视图:采集、记录与力曲线照常工作。",
|
||||||
"mesh3d_loading": "正在加载三维场景 {name}…",
|
"mesh3d_loading": "正在加载三维场景 {name}…",
|
||||||
"mesh3d_rejected": "三维场景被拒绝:{message}\n只影响三维视图:采集、记录与力曲线照常工作。",
|
"mesh3d_rejected": "三维场景被拒绝:{message}\n只影响三维视图:采集、记录与力曲线照常工作。",
|
||||||
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。"
|
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。",
|
||||||
|
"mesh3d_settings_tip": "三维显示设置(视角、外壳、色标、显示频率)",
|
||||||
|
"mesh3d_reset_tip": "复位视角:回到场景自带的取景"
|
||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"fn": "FN",
|
"fn": "FN",
|
||||||
@@ -402,6 +407,11 @@
|
|||||||
"deform": "放大形变",
|
"deform": "放大形变",
|
||||||
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
|
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
|
||||||
"deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。",
|
"deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。",
|
||||||
|
"field": "颜色来源",
|
||||||
|
"field_tip": "三维表面用哪个量上色:深度响应(链路的相对形变响应,无量纲)或光流幅值(追踪器的像素位移大小)。两者都不是毫米,都是近似量;选中的那个若在这一帧画不出来,会自动改用另一个,并在这行下面说明。",
|
||||||
|
"field_depth": "深度响应",
|
||||||
|
"field_flow": "光流幅值",
|
||||||
|
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
|
||||||
"quantity_flow_magnitude": "光流幅值",
|
"quantity_flow_magnitude": "光流幅值",
|
||||||
"quantity_depth_response": "深度响应",
|
"quantity_depth_response": "深度响应",
|
||||||
"mode_surface": "曲面着色",
|
"mode_surface": "曲面着色",
|
||||||
@@ -417,6 +427,8 @@
|
|||||||
"max_fps": "显示帧率",
|
"max_fps": "显示帧率",
|
||||||
"max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。",
|
"max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。",
|
||||||
"reset_view": "复位视角",
|
"reset_view": "复位视角",
|
||||||
|
"settings_title": "三维显示设置",
|
||||||
|
"settings_hint": "这里的每一项都只影响三维视图的显示:采集、记录与力曲线完全不受影响,改完立即生效。",
|
||||||
"legend": "{quantity} [{unit}] · {quality}",
|
"legend": "{quantity} [{unit}] · {quality}",
|
||||||
"legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型",
|
"legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型",
|
||||||
"quality_calibrated": "已标定",
|
"quality_calibrated": "已标定",
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
||||||
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
|
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
|
||||||
DEFAULT_SPLITTER_SIZES = (340, 480, 400)
|
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
|
||||||
MIN_PANEL_WIDTHS = {
|
MIN_PANEL_WIDTHS = {
|
||||||
# The 3D pane is the only one whose content is a real control panel rather than a
|
# The 3D pane used to be the one panel whose content was a stack of form fields, so
|
||||||
# label or a picture, and its minimum is measured under the application
|
# its minimum was measured under the stylesheet in both languages (241 px in English)
|
||||||
# stylesheet in both languages: the widest case is English at 241 px
|
# and could not go below 260. The controls now live in the settings dialog and what
|
||||||
# (``tests/test_scene3d_view.py`` pins it), so 240 would have been 1 px short.
|
# is left wraps, so the pane can be dragged narrow again — the picture is the point.
|
||||||
"mesh3d": 260,
|
"mesh3d": 200,
|
||||||
"arrow": 300,
|
"arrow": 300,
|
||||||
"plots": 340,
|
"plots": 340,
|
||||||
"cal": 300,
|
"cal": 300,
|
||||||
@@ -99,9 +99,11 @@ VIEW_PRESETS = ("custom", "live", "replay", "calibration")
|
|||||||
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
|
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
|
||||||
#: Replay is the live layout on purpose: the two views differ in their source
|
#: Replay is the live layout on purpose: the two views differ in their source
|
||||||
#: (a camera, or a recorded video), not in their furniture.
|
#: (a camera, or a recorded video), not in their furniture.
|
||||||
|
#: The 3D pane is the widest of the three: it is the view's subject when it is on, and
|
||||||
|
#: the 2D panel is a reading of the same measurement rather than a second subject.
|
||||||
VIEW_SPLITTER_SIZES = {
|
VIEW_SPLITTER_SIZES = {
|
||||||
"live": (0, 360, 1080, 0),
|
"live": (620, 300, 520, 0),
|
||||||
"replay": (0, 360, 1080, 0),
|
"replay": (620, 300, 520, 0),
|
||||||
"calibration": (0, 0, 560, 880),
|
"calibration": (0, 0, 560, 880),
|
||||||
}
|
}
|
||||||
#: The 3D pane belongs to the live view and to history browsing, and only for a
|
#: The 3D pane belongs to the live view and to history browsing, and only for a
|
||||||
@@ -114,7 +116,7 @@ VIEW_SHOWS_3D = ("custom", "live", "replay")
|
|||||||
#: Width the 3D pane takes from the widest other panel when a preset's table gives it
|
#: Width the 3D pane takes from the widest other panel when a preset's table gives it
|
||||||
#: nothing. The table describes the two-dimensional layout; the pane is optional, and
|
#: nothing. The table describes the two-dimensional layout; the pane is optional, and
|
||||||
#: "not in the table" must not mean "standing at its minimum width".
|
#: "not in the table" must not mean "standing at its minimum width".
|
||||||
DEFAULT_3D_PANE_WIDTH = 340
|
DEFAULT_3D_PANE_WIDTH = 620
|
||||||
|
|
||||||
|
|
||||||
#: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from
|
#: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from
|
||||||
@@ -239,7 +241,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.status_chip = self.toolbar.status_chip
|
self.status_chip = self.toolbar.status_chip
|
||||||
|
|
||||||
self.content_splitter = QSplitter(Qt.Horizontal)
|
self.content_splitter = QSplitter(Qt.Horizontal)
|
||||||
self.mesh3d_panel = Mesh3dPanel()
|
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||||
# The charts browse the whole session, not just their rolling buffer.
|
# The charts browse the whole session, not just their rolling buffer.
|
||||||
@@ -276,6 +278,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
toolbar=self.toolbar,
|
toolbar=self.toolbar,
|
||||||
charts=self.force_plots,
|
charts=self.force_plots,
|
||||||
arrow_panel=self.arrow_panel,
|
arrow_panel=self.arrow_panel,
|
||||||
|
mesh_panel=self.mesh3d_panel,
|
||||||
engine=self.replay,
|
engine=self.replay,
|
||||||
sensor_picker=self.sensor_picker,
|
sensor_picker=self.sensor_picker,
|
||||||
plot_defaults={
|
plot_defaults={
|
||||||
@@ -899,12 +902,29 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.act_finger_3d.setChecked(self._show_finger_3d)
|
self.act_finger_3d.setChecked(self._show_finger_3d)
|
||||||
self.view_menu.addAction(self.act_finger_3d)
|
self.view_menu.addAction(self.act_finger_3d)
|
||||||
|
|
||||||
|
# Everything the 3D pane can be told lives in one dialog: the pane itself is for
|
||||||
|
# looking at (see ``Mesh3dPanel``). This is the menu way in; the pane's header
|
||||||
|
# has the gear, which is the same call.
|
||||||
|
self.act_scene3d_settings = QAction(self)
|
||||||
|
self.act_scene3d_settings.triggered.connect(self._on_scene3d_settings)
|
||||||
|
self.view_menu.addAction(self.act_scene3d_settings)
|
||||||
|
|
||||||
self.act_show_fps = QAction(self)
|
self.act_show_fps = QAction(self)
|
||||||
self.act_show_fps.setCheckable(True)
|
self.act_show_fps.setCheckable(True)
|
||||||
self.act_show_fps.setChecked(self._show_fps)
|
self.act_show_fps.setChecked(self._show_fps)
|
||||||
self.act_show_fps.toggled.connect(self.display_ctl.on_show_fps_toggled)
|
self.act_show_fps.toggled.connect(self.display_ctl.on_show_fps_toggled)
|
||||||
self.view_menu.addAction(self.act_show_fps)
|
self.view_menu.addAction(self.act_show_fps)
|
||||||
|
|
||||||
|
# How much the panels explain themselves around their pictures. Checked *before*
|
||||||
|
# the signal is connected, so restoring the preference cannot look like a click.
|
||||||
|
self.act_show_diagnostics = QAction(self)
|
||||||
|
self.act_show_diagnostics.setCheckable(True)
|
||||||
|
self.act_show_diagnostics.setChecked(self.display_ctl.show_diagnostics)
|
||||||
|
self.act_show_diagnostics.toggled.connect(
|
||||||
|
self.display_ctl.on_show_diagnostics_toggled
|
||||||
|
)
|
||||||
|
self.view_menu.addAction(self.act_show_diagnostics)
|
||||||
|
|
||||||
self.act_settings = QAction(self)
|
self.act_settings = QAction(self)
|
||||||
self.act_settings.setCheckable(True)
|
self.act_settings.setCheckable(True)
|
||||||
self.act_settings.setChecked(self.toolbar.settings_expanded())
|
self.act_settings.setChecked(self.toolbar.settings_expanded())
|
||||||
@@ -1145,7 +1165,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.view_menu.setTitle(t("menu.view"))
|
self.view_menu.setTitle(t("menu.view"))
|
||||||
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
||||||
self.act_finger_3d.setText(t("menu.finger_3d"))
|
self.act_finger_3d.setText(t("menu.finger_3d"))
|
||||||
|
self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
|
||||||
self.act_show_fps.setText(t("menu.show_fps"))
|
self.act_show_fps.setText(t("menu.show_fps"))
|
||||||
|
self.act_show_diagnostics.setText(t("menu.show_diagnostics"))
|
||||||
self.act_settings.setText(t("menu.settings"))
|
self.act_settings.setText(t("menu.settings"))
|
||||||
self.act_plot_settings.setText(t("menu.plot_settings"))
|
self.act_plot_settings.setText(t("menu.plot_settings"))
|
||||||
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
|
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
|
||||||
@@ -1350,6 +1372,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.brand_bar.set_theme(theme)
|
self.brand_bar.set_theme(theme)
|
||||||
self.toolbar.set_theme(theme)
|
self.toolbar.set_theme(theme)
|
||||||
self.arrow_panel.set_theme(theme)
|
self.arrow_panel.set_theme(theme)
|
||||||
|
self.mesh3d_panel.set_theme(theme)
|
||||||
self._refresh_status_chip()
|
self._refresh_status_chip()
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
self._refresh_record_state()
|
self._refresh_record_state()
|
||||||
@@ -1652,6 +1675,14 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
view.submit_packet(packet)
|
view.submit_packet(packet)
|
||||||
|
|
||||||
|
def _on_scene3d_settings(self) -> None:
|
||||||
|
"""Open the 3D settings dialog — or say why there is nothing to configure."""
|
||||||
|
if not self.mesh3d_panel.open_settings():
|
||||||
|
self._notify(
|
||||||
|
self.mesh3d_panel.message.text().strip()
|
||||||
|
or t("panels.mesh3d_no_scene")
|
||||||
|
)
|
||||||
|
|
||||||
def _on_sensor_type_changed(self, sensor_type: str) -> None:
|
def _on_sensor_type_changed(self, sensor_type: str) -> None:
|
||||||
self.prefs["sensor_type"] = str(sensor_type)
|
self.prefs["sensor_type"] = str(sensor_type)
|
||||||
self.prefs["config_name"] = self.sensor_picker.config_name()
|
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||||
@@ -2285,6 +2316,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.mesh3d_panel.display_settings()
|
self.mesh3d_panel.display_settings()
|
||||||
)
|
)
|
||||||
self.prefs["show_fps"] = bool(self._show_fps)
|
self.prefs["show_fps"] = bool(self._show_fps)
|
||||||
|
self.prefs["show_diagnostics"] = bool(self.display_ctl.show_diagnostics)
|
||||||
self.prefs["settings_expanded"] = self.toolbar.settings_expanded()
|
self.prefs["settings_expanded"] = self.toolbar.settings_expanded()
|
||||||
self.prefs["view_preset"] = self._view_preset
|
self.prefs["view_preset"] = self._view_preset
|
||||||
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
|
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class DisplayPrefsController(QObject):
|
|||||||
plot_defaults: dict,
|
plot_defaults: dict,
|
||||||
default_sample_rate_hz: float,
|
default_sample_rate_hz: float,
|
||||||
arrow_rotate_types: frozenset,
|
arrow_rotate_types: frozenset,
|
||||||
|
mesh_panel: Any = None,
|
||||||
rotate_arrows_action: Any = None,
|
rotate_arrows_action: Any = None,
|
||||||
parent: Optional[QObject] = None,
|
parent: Optional[QObject] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -70,6 +71,7 @@ class DisplayPrefsController(QObject):
|
|||||||
self._toolbar = toolbar
|
self._toolbar = toolbar
|
||||||
self._charts = charts
|
self._charts = charts
|
||||||
self._arrow_panel = arrow_panel
|
self._arrow_panel = arrow_panel
|
||||||
|
self._mesh_panel = mesh_panel
|
||||||
self._engine = engine
|
self._engine = engine
|
||||||
self._picker = sensor_picker
|
self._picker = sensor_picker
|
||||||
self._rotate_action = rotate_arrows_action
|
self._rotate_action = rotate_arrows_action
|
||||||
@@ -79,6 +81,10 @@ class DisplayPrefsController(QObject):
|
|||||||
#: Whether the rail shows the frame rate: a display preference, so it lives with
|
#: Whether the rail shows the frame rate: a display preference, so it lives with
|
||||||
#: the other ones rather than with the session that produces the number.
|
#: the other ones rather than with the session that produces the number.
|
||||||
self._show_fps = bool(host.prefs.get("show_fps", False))
|
self._show_fps = bool(host.prefs.get("show_fps", False))
|
||||||
|
#: Whether the panels explain the pipeline around the picture (see
|
||||||
|
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
|
||||||
|
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
|
||||||
|
self._apply_diagnostics()
|
||||||
|
|
||||||
def bind_actions(self, *, rotate_arrows: Any) -> None:
|
def bind_actions(self, *, rotate_arrows: Any) -> None:
|
||||||
"""Take the rotate entry once the menus exist (it follows the sensor)."""
|
"""Take the rotate entry once the menus exist (it follows the sensor)."""
|
||||||
@@ -225,3 +231,33 @@ class DisplayPrefsController(QObject):
|
|||||||
# FPS has one home now: the status rail.
|
# FPS has one home now: the status rail.
|
||||||
self._host._refresh_status_rail()
|
self._host._refresh_status_rail()
|
||||||
self._host._persist_prefs()
|
self._host._persist_prefs()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- diagnostics
|
||||||
|
@property
|
||||||
|
def show_diagnostics(self) -> bool:
|
||||||
|
return self._show_diagnostics
|
||||||
|
|
||||||
|
@show_diagnostics.setter
|
||||||
|
def show_diagnostics(self, visible: bool) -> None:
|
||||||
|
self._show_diagnostics = bool(visible)
|
||||||
|
self._apply_diagnostics()
|
||||||
|
|
||||||
|
def on_show_diagnostics_toggled(self, visible: bool) -> None:
|
||||||
|
"""How much the panels explain themselves around their pictures.
|
||||||
|
|
||||||
|
On: the 2D panel states the pipeline it ran (magnification, source → output,
|
||||||
|
which frames) and the 3D pane adds its informational lines. Off: both show what
|
||||||
|
the picture *is* — quantity, range, where the field came from, and any warning
|
||||||
|
about the measurement — because that is what a viewer needs, while the rest was
|
||||||
|
reading like debug output under the canvas.
|
||||||
|
"""
|
||||||
|
self._show_diagnostics = bool(visible)
|
||||||
|
self._host.prefs["show_diagnostics"] = self._show_diagnostics
|
||||||
|
self._apply_diagnostics()
|
||||||
|
self._host._persist_prefs()
|
||||||
|
|
||||||
|
def _apply_diagnostics(self) -> None:
|
||||||
|
for panel in (self._arrow_panel, self._mesh_panel):
|
||||||
|
setter = getattr(panel, "set_diagnostics_visible", None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(self._show_diagnostics)
|
||||||
|
|||||||
@@ -63,6 +63,27 @@ DEPTH_RESPONSE = FieldDescriptor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _field_name(name: Any) -> str:
|
||||||
|
"""The quantity a setting asked for, repaired to one the adapters can draw."""
|
||||||
|
wanted = str(name or "")
|
||||||
|
return wanted if wanted in ("depth", "flow") else "depth"
|
||||||
|
|
||||||
|
|
||||||
|
#: How much a refusal tells the person reading it. "The frame does not carry this field"
|
||||||
|
#: is the weakest answer (it may simply be the other quantity's turn); "the field is here
|
||||||
|
#: but is not this frame's, or not on this grid" is the strongest.
|
||||||
|
_REFUSAL_RANK = {
|
||||||
|
"field_unavailable": 0,
|
||||||
|
"field_unverified": 1,
|
||||||
|
"field_stale": 2,
|
||||||
|
"field_domain_mismatch": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _refusal_rank(item: Diagnostic) -> int:
|
||||||
|
return _REFUSAL_RANK.get(str(item.code), 0)
|
||||||
|
|
||||||
|
|
||||||
def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
||||||
"""``|flow|`` as float32, or ``None`` when the packet carries no flow."""
|
"""``|flow|`` as float32, or ``None`` when the packet carries no flow."""
|
||||||
flow = getattr(packet, "flow", None)
|
flow = getattr(packet, "flow", None)
|
||||||
@@ -186,6 +207,7 @@ class FingerFrameAdapter:
|
|||||||
shape_hw: tuple[int, int],
|
shape_hw: tuple[int, int],
|
||||||
map_x: Optional[np.ndarray] = None,
|
map_x: Optional[np.ndarray] = None,
|
||||||
map_y: Optional[np.ndarray] = None,
|
map_y: Optional[np.ndarray] = None,
|
||||||
|
preferred_field: str = "depth",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.geometry_id = str(geometry_id)
|
self.geometry_id = str(geometry_id)
|
||||||
self.field_domain_id = str(field_domain_id)
|
self.field_domain_id = str(field_domain_id)
|
||||||
@@ -193,6 +215,18 @@ class FingerFrameAdapter:
|
|||||||
self.map_x = map_x
|
self.map_x = map_x
|
||||||
self.map_y = map_y
|
self.map_y = map_y
|
||||||
self.last_diagnostic: Optional[Diagnostic] = None
|
self.last_diagnostic: Optional[Diagnostic] = None
|
||||||
|
#: Which quantity the operator asked for (``"depth"`` or ``"flow"``). The other
|
||||||
|
#: one is tried when this one cannot be coloured, and the substitution is stated
|
||||||
|
#: rather than silent — a frame that shows another quantity than the setting says
|
||||||
|
#: has to say so, or the picture reads as the deformation response when it is
|
||||||
|
#: actually the flow.
|
||||||
|
self.preferred_field = _field_name(preferred_field)
|
||||||
|
#: Set (and cleared) by every snapshot: the reason the picture is not the field
|
||||||
|
#: the operator chose. ``None`` when the preferred field was the one drawn.
|
||||||
|
self.last_substitution: Optional[Diagnostic] = None
|
||||||
|
|
||||||
|
def set_preferred_field(self, name: str) -> None:
|
||||||
|
self.preferred_field = _field_name(name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
|
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
|
||||||
@@ -208,16 +242,102 @@ class FingerFrameAdapter:
|
|||||||
def snapshot(
|
def snapshot(
|
||||||
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
||||||
) -> Optional[SurfaceFieldFrame]:
|
) -> Optional[SurfaceFieldFrame]:
|
||||||
# Provenance first, and before the cheap array work: a packet that cannot say
|
"""Colour the surface with the field the operator chose, or say why not.
|
||||||
# its flow is this frame's must not be coloured, whatever the array holds.
|
|
||||||
|
Two quantities can be drawn and the operator picks one (``DisplaySettings.field``);
|
||||||
|
the other is tried when the chosen one cannot be coloured, and the substitution is
|
||||||
|
reported through :attr:`last_substitution` so the pane can state it. Nothing here
|
||||||
|
invents a field: a value the producer did not claim for *this* frame is refused
|
||||||
|
whichever quantity it belongs to.
|
||||||
|
"""
|
||||||
|
first = self.preferred_field
|
||||||
|
order = ("depth", "flow") if first == "depth" else ("flow", "depth")
|
||||||
|
refused: list[Diagnostic] = []
|
||||||
|
for name in order:
|
||||||
|
frame = self._snapshot_field(packet, identity, name, refused)
|
||||||
|
if frame is None:
|
||||||
|
continue
|
||||||
|
if name != first:
|
||||||
|
# The operator asked for the other one: the picture is still a measurement
|
||||||
|
# (the legend names it), but the choice was not honoured, and that is a
|
||||||
|
# fact about what is on screen.
|
||||||
|
self.last_substitution = diagnostic(
|
||||||
|
"field_substituted",
|
||||||
|
f"the {first} response cannot be drawn; showing the "
|
||||||
|
f"{'optical flow magnitude' if name == 'flow' else 'deformation response'}",
|
||||||
|
wanted=first,
|
||||||
|
shown=name,
|
||||||
|
why=refused[0].message if refused else "",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.last_substitution = None
|
||||||
|
return frame
|
||||||
|
self.last_substitution = None
|
||||||
|
# Neither could be drawn. The *most informative* refusal leads: a field that is
|
||||||
|
# there but cannot be used (another grid, another frame's array, no statement) says
|
||||||
|
# far more than "this frame does not carry it" — and among equally informative
|
||||||
|
# ones the operator's own choice comes first, because that is what they asked for.
|
||||||
|
self.last_diagnostic = max(refused, key=_refusal_rank) if refused else diagnostic(
|
||||||
|
"field_unavailable", "this frame carries no scalar field"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _snapshot_field(
|
||||||
|
self,
|
||||||
|
packet: SdkPacket,
|
||||||
|
identity: FrameIdentity,
|
||||||
|
name: str,
|
||||||
|
refused: list[Diagnostic],
|
||||||
|
) -> Optional[SurfaceFieldFrame]:
|
||||||
|
"""One quantity, or ``None`` with the reason appended to ``refused``."""
|
||||||
|
if name == "depth":
|
||||||
|
depth = getattr(packet, "depth", None)
|
||||||
|
problem = _field_validity(packet, "depth", identity.frame_id)
|
||||||
|
if problem is not None:
|
||||||
|
refused.append(problem)
|
||||||
|
return None
|
||||||
|
if depth is None:
|
||||||
|
refused.append(
|
||||||
|
diagnostic("field_unavailable", "this frame carries no depth response")
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
array = np.asarray(depth, dtype=np.float32)
|
||||||
|
if array.ndim != 2:
|
||||||
|
refused.append(
|
||||||
|
diagnostic(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the depth response is not a 2-D field",
|
||||||
|
depth=_shape_of(depth),
|
||||||
|
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if tuple(array.shape) != self.shape_hw:
|
||||||
|
refused.append(
|
||||||
|
diagnostic(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the depth response is not in the scene's UV domain and no "
|
||||||
|
"remap is configured",
|
||||||
|
depth=_shape_of(depth),
|
||||||
|
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
scalar = freeze(array, dtype=np.float32)
|
||||||
|
mask = freeze(np.isfinite(array), dtype=np.bool_)
|
||||||
|
return self._finish(
|
||||||
|
packet, identity, name, scalar, mask, DEPTH_RESPONSE, refused
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``flow``: the tracker's field, already unrolled onto the scene's UV domain.
|
||||||
problem = _field_validity(packet, "flow", identity.frame_id)
|
problem = _field_validity(packet, "flow", identity.frame_id)
|
||||||
if problem is not None:
|
if problem is not None:
|
||||||
self.last_diagnostic = problem
|
refused.append(problem)
|
||||||
return None
|
return None
|
||||||
magnitude = flow_magnitude(packet)
|
magnitude = flow_magnitude(packet)
|
||||||
if magnitude is None:
|
if magnitude is None:
|
||||||
self.last_diagnostic = diagnostic(
|
refused.append(
|
||||||
"field_unavailable", "this frame carries no optical flow"
|
diagnostic("field_unavailable", "this frame carries no optical flow")
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
if self.map_x is not None and self.map_y is not None:
|
if self.map_x is not None and self.map_y is not None:
|
||||||
@@ -226,41 +346,60 @@ class FingerFrameAdapter:
|
|||||||
magnitude, map_x=self.map_x, map_y=self.map_y, shape_hw=self.shape_hw
|
magnitude, map_x=self.map_x, map_y=self.map_y, shape_hw=self.shape_hw
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 - a bad map is a diagnostic
|
except Exception as exc: # noqa: BLE001 - a bad map is a diagnostic
|
||||||
self.last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic(
|
refused.append(
|
||||||
"field_domain_mismatch", f"remap failed: {type(exc).__name__}: {exc}"
|
getattr(exc, "diagnostic", None)
|
||||||
|
or diagnostic(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
f"remap failed: {type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
scalar = values
|
scalar, mask = values, valid
|
||||||
mask = valid
|
|
||||||
elif tuple(magnitude.shape[:2]) == self.shape_hw:
|
elif tuple(magnitude.shape[:2]) == self.shape_hw:
|
||||||
scalar = freeze(magnitude, dtype=np.float32)
|
scalar = freeze(magnitude, dtype=np.float32)
|
||||||
mask = freeze(np.isfinite(magnitude), dtype=np.bool_)
|
mask = freeze(np.isfinite(magnitude), dtype=np.bool_)
|
||||||
else:
|
else:
|
||||||
# Say what was seen: this is the message that tells an engineer whether
|
# Say what was seen: this is the message that tells an engineer whether the
|
||||||
# the flow arrives in the tracker's grid or already unrolled.
|
# flow arrives in the tracker's grid or already unrolled.
|
||||||
self.last_diagnostic = diagnostic(
|
refused.append(
|
||||||
"field_domain_mismatch",
|
diagnostic(
|
||||||
"the flow is not in the scene's UV domain and no remap is configured",
|
"field_domain_mismatch",
|
||||||
flow=_shape_of(magnitude),
|
"the flow is not in the scene's UV domain and no remap is configured",
|
||||||
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
|
flow=_shape_of(magnitude),
|
||||||
|
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
# Inside the UV island is the only place a flow value means anything: the
|
return self._finish(
|
||||||
# tracker is looking at masked black outside it.
|
packet, identity, name, scalar, mask, FLOW_MAGNITUDE, refused
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self,
|
||||||
|
packet: SdkPacket,
|
||||||
|
identity: FrameIdentity,
|
||||||
|
name: str,
|
||||||
|
scalar: np.ndarray,
|
||||||
|
mask: np.ndarray,
|
||||||
|
descriptor: FieldDescriptor,
|
||||||
|
refused: list[Diagnostic],
|
||||||
|
) -> Optional[SurfaceFieldFrame]:
|
||||||
|
"""Intersect with the device's own mask and build the frame."""
|
||||||
|
# Inside the UV island is the only place a value means anything: the tracker is
|
||||||
|
# looking at masked black outside it.
|
||||||
device, mask_problem = _device_mask(packet, self.shape_hw)
|
device, mask_problem = _device_mask(packet, self.shape_hw)
|
||||||
if mask_problem is not None:
|
if mask_problem is not None:
|
||||||
self.last_diagnostic = mask_problem
|
refused.append(mask_problem)
|
||||||
return None
|
return None
|
||||||
mask = _intersect(mask, device)
|
|
||||||
self.last_diagnostic = None
|
self.last_diagnostic = None
|
||||||
return SurfaceFieldFrame(
|
return SurfaceFieldFrame(
|
||||||
identity=identity,
|
identity=identity,
|
||||||
geometry_id=self.geometry_id,
|
geometry_id=self.geometry_id,
|
||||||
field_domain_id=self.field_domain_id,
|
field_domain_id=self.field_domain_id,
|
||||||
scalar=scalar,
|
scalar=scalar,
|
||||||
scalar_descriptor=FLOW_MAGNITUDE,
|
scalar_descriptor=descriptor,
|
||||||
valid=mask,
|
valid=_intersect(mask, device),
|
||||||
computed_frame_id=_computed_frame_id(packet, "flow", identity.frame_id),
|
computed_frame_id=_computed_frame_id(packet, name, identity.frame_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,16 +49,16 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
SceneCapabilities,
|
SceneCapabilities,
|
||||||
SceneState,
|
SceneState,
|
||||||
)
|
)
|
||||||
from orisys_studio.scene3d.prefs import FPS_CHOICES
|
from orisys_studio.scene3d.prefs import FPS_CHOICES, DEFAULT_FIELD
|
||||||
|
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||||
|
|
||||||
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
|
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
|
||||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||||
|
|
||||||
#: Width of one control in the pane. Chosen against
|
#: Width of one control in the settings dialog. The pane no longer has to fit them —
|
||||||
#: ``MIN_PANEL_WIDTHS["mesh3d"]`` (240), not against how it looks on a wide screen:
|
#: they live in ``Scene3dSettingsDialog`` — so they are sized to read, not to squeeze.
|
||||||
#: the pane is a splitter child that the operator can drag narrow.
|
FIELD_WIDTH = 108
|
||||||
FIELD_WIDTH = 88
|
|
||||||
SPIN_WIDTH = 68
|
SPIN_WIDTH = 68
|
||||||
|
|
||||||
#: Display limits offered in the panel. ``max_fps`` is a *display* budget; it never
|
#: Display limits offered in the panel. ``max_fps`` is a *display* budget; it never
|
||||||
@@ -66,6 +66,13 @@ SPIN_WIDTH = 68
|
|||||||
#: saved-settings repair has to accept exactly the values this combo offers.
|
#: saved-settings repair has to accept exactly the values this combo offers.
|
||||||
__all__ = ["FPS_CHOICES", "ThreeDPanel"]
|
__all__ = ["FPS_CHOICES", "ThreeDPanel"]
|
||||||
|
|
||||||
|
#: Which quantity each choice of ``DisplaySettings.field`` means on the surface.
|
||||||
|
_FIELD_QUANTITY = {"depth": "depth_response", "flow": "flow_magnitude"}
|
||||||
|
|
||||||
|
#: Diagnostics that stay on screen even with 「诊断信息」 off: they are claims about the
|
||||||
|
#: *measurement* (was it compared with the device?), not explanations of the pipeline.
|
||||||
|
_ALWAYS_VISIBLE_INFO = frozenset({"geometry_unverified"})
|
||||||
|
|
||||||
|
|
||||||
class ColorScaleBar(QLabel):
|
class ColorScaleBar(QLabel):
|
||||||
"""The colour ramp the shader actually used, with the bounds it used them for.
|
"""The colour ramp the shader actually used, with the bounds it used them for.
|
||||||
@@ -134,23 +141,39 @@ class ThreeDPanel(QFrame):
|
|||||||
self._capabilities: Optional[SceneCapabilities] = None
|
self._capabilities: Optional[SceneCapabilities] = None
|
||||||
#: The bounds the last draw actually used (the widget's RenderStats).
|
#: The bounds the last draw actually used (the widget's RenderStats).
|
||||||
self._effective_scale: Optional[RenderStats] = None
|
self._effective_scale: Optional[RenderStats] = None
|
||||||
|
#: Whether the pane explains the pipeline around the picture (the window's
|
||||||
|
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
|
||||||
|
self._show_diagnostics = False
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(8, 6, 8, 8)
|
root.setContentsMargins(8, 6, 8, 6)
|
||||||
root.setSpacing(6)
|
root.setSpacing(6)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ controls
|
||||||
|
# Every *setting* lives in one block that the pane itself never shows. What the
|
||||||
|
# pane is for is the picture, and a column of form fields beside it was what left
|
||||||
|
# the 3D view a corner of its own splitter slot. The block is handed to
|
||||||
|
# ``Scene3dSettingsDialog`` — opened from 视图 ▸ 三维显示设置 or from the gear on
|
||||||
|
# the pane's header — and the widget attributes below stay on this object, so the
|
||||||
|
# rest of the application (and its tests) reach the controls the same way as
|
||||||
|
# before.
|
||||||
|
self.controls_widget = QWidget(self)
|
||||||
|
self.controls_widget.setObjectName("scene3dControls")
|
||||||
|
self.controls_widget.setVisible(False)
|
||||||
|
controls = QVBoxLayout(self.controls_widget)
|
||||||
|
controls.setContentsMargins(0, 0, 0, 0)
|
||||||
|
controls.setSpacing(6)
|
||||||
|
|
||||||
self.enable_check = QCheckBox()
|
self.enable_check = QCheckBox()
|
||||||
self.enable_check.toggled.connect(self._on_enabled_toggled)
|
self.enable_check.toggled.connect(self._on_enabled_toggled)
|
||||||
root.addWidget(self.enable_check)
|
controls.addWidget(self.enable_check)
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setContentsMargins(0, 0, 0, 0)
|
form.setContentsMargins(0, 0, 0, 0)
|
||||||
self.preset_label = QLabel()
|
self.preset_label = QLabel()
|
||||||
self.preset_combo = QComboBox()
|
self.preset_combo = QComboBox()
|
||||||
# The pane is 240 px wide at its narrowest (``MIN_PANEL_WIDTHS["mesh3d"]``),
|
# The dialog is a dialog: the fields are comfortable there, and the pane no
|
||||||
# so the fields are compact and the panel's own minimum stays below that.
|
# longer has to be at least as wide as two spin boxes side by side.
|
||||||
# Without this the layout's minimum is set by two spin boxes side by side,
|
|
||||||
# the splitter cannot honour the panel width, and the picture is squeezed out.
|
|
||||||
self.preset_combo.setMaximumWidth(FIELD_WIDTH)
|
self.preset_combo.setMaximumWidth(FIELD_WIDTH)
|
||||||
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
|
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
|
||||||
form.addRow(self.preset_label, self.preset_combo)
|
form.addRow(self.preset_label, self.preset_combo)
|
||||||
@@ -178,6 +201,27 @@ class ThreeDPanel(QFrame):
|
|||||||
gain_row.addStretch(1)
|
gain_row.addStretch(1)
|
||||||
form.addRow(gain_row)
|
form.addRow(gain_row)
|
||||||
|
|
||||||
|
# Which quantity colours the surface. Two are available and they answer
|
||||||
|
# different questions — "how deep" (the chain's deformation response) and "how
|
||||||
|
# fast is the picture moving" (the tracker's optical flow) — so the operator
|
||||||
|
# picks, and the line under it says which one is actually on screen.
|
||||||
|
self.field_label = QLabel()
|
||||||
|
self.field_combo = QComboBox()
|
||||||
|
self.field_combo.addItem("depth", "depth")
|
||||||
|
self.field_combo.addItem("flow", "flow")
|
||||||
|
self.field_combo.setCurrentIndex(
|
||||||
|
0 if DEFAULT_FIELD == "depth" else 1
|
||||||
|
)
|
||||||
|
self.field_combo.setMaximumWidth(FIELD_WIDTH)
|
||||||
|
self.field_combo.currentIndexChanged.connect(self._on_field_changed)
|
||||||
|
form.addRow(self.field_label, self.field_combo)
|
||||||
|
|
||||||
|
self.field_note = QLabel()
|
||||||
|
self.field_note.setObjectName("hintText")
|
||||||
|
self.field_note.setWordWrap(True)
|
||||||
|
self.field_note.setVisible(False)
|
||||||
|
form.addRow(self.field_note)
|
||||||
|
|
||||||
self.scale_label = QLabel()
|
self.scale_label = QLabel()
|
||||||
self.scale_combo = QComboBox()
|
self.scale_combo = QComboBox()
|
||||||
self.scale_combo.addItem("fixed", "fixed")
|
self.scale_combo.addItem("fixed", "fixed")
|
||||||
@@ -192,9 +236,7 @@ class ThreeDPanel(QFrame):
|
|||||||
form.addRow(self.scale_label, self.scale_combo)
|
form.addRow(self.scale_label, self.scale_combo)
|
||||||
|
|
||||||
# The two bounds get their own row rather than sharing the mode's: three
|
# The two bounds get their own row rather than sharing the mode's: three
|
||||||
# controls side by side made this row the widest thing in the pane, and a pane
|
# controls side by side made this row the widest thing in the pane.
|
||||||
# whose content demands more than ``MIN_PANEL_WIDTHS["mesh3d"]`` clips its own
|
|
||||||
# controls when the operator drags the splitter to the declared minimum.
|
|
||||||
self.range_label = QLabel()
|
self.range_label = QLabel()
|
||||||
self.lower_spin = QDoubleSpinBox()
|
self.lower_spin = QDoubleSpinBox()
|
||||||
self.lower_spin.setRange(-1e6, 1e6)
|
self.lower_spin.setRange(-1e6, 1e6)
|
||||||
@@ -221,36 +263,63 @@ class ThreeDPanel(QFrame):
|
|||||||
self.fps_combo.setMaximumWidth(FIELD_WIDTH)
|
self.fps_combo.setMaximumWidth(FIELD_WIDTH)
|
||||||
self.fps_combo.currentIndexChanged.connect(self._emit_settings)
|
self.fps_combo.currentIndexChanged.connect(self._emit_settings)
|
||||||
form.addRow(self.fps_label, self.fps_combo)
|
form.addRow(self.fps_label, self.fps_combo)
|
||||||
root.addLayout(form)
|
controls.addLayout(form)
|
||||||
|
|
||||||
buttons = QHBoxLayout()
|
buttons = QHBoxLayout()
|
||||||
self.reset_button = QPushButton()
|
self.reset_button = QPushButton()
|
||||||
self.reset_button.clicked.connect(self.resetRequested.emit)
|
self.reset_button.clicked.connect(self.resetRequested.emit)
|
||||||
buttons.addWidget(self.reset_button)
|
buttons.addWidget(self.reset_button)
|
||||||
buttons.addStretch(1)
|
buttons.addStretch(1)
|
||||||
root.addLayout(buttons)
|
controls.addLayout(buttons)
|
||||||
|
|
||||||
self.legend_label = QLabel()
|
# Display-side statistics: an engineering readout, not something to read while
|
||||||
self.legend_label.setObjectName("panelMeta")
|
# measuring, so it belongs with the settings rather than under the picture.
|
||||||
self.legend_label.setWordWrap(True)
|
self.stats_label = QLabel()
|
||||||
self.legend_label.setMinimumWidth(1)
|
self.stats_label.setObjectName("panelMeta")
|
||||||
root.addWidget(self.legend_label)
|
self.stats_label.setWordWrap(True)
|
||||||
|
self.stats_label.setMinimumWidth(1)
|
||||||
|
controls.addWidget(self.stats_label)
|
||||||
|
controls.addStretch(1)
|
||||||
|
|
||||||
# The ramp the shader used, under the numbers it used it for. Text alone could
|
# -------------------------------------------------------------------- legend
|
||||||
# not express an *automatic* range, and the disabled spin boxes next to it held
|
# The ramp and its effective numeric bounds remain visible in normal mode.
|
||||||
# values that were not being drawn with.
|
# Everything explanatory below them follows the diagnostics switch.
|
||||||
|
scale_row = QHBoxLayout()
|
||||||
|
scale_row.setContentsMargins(0, 0, 0, 0)
|
||||||
|
scale_row.setSpacing(6)
|
||||||
|
self.scale_lower_label = QLabel()
|
||||||
|
self.scale_lower_label.setObjectName("panelMeta")
|
||||||
|
self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
scale_row.addWidget(self.scale_lower_label)
|
||||||
self.scale_bar = ColorScaleBar()
|
self.scale_bar = ColorScaleBar()
|
||||||
root.addWidget(self.scale_bar)
|
scale_row.addWidget(self.scale_bar, 1)
|
||||||
|
self.scale_upper_label = QLabel()
|
||||||
|
self.scale_upper_label.setObjectName("panelMeta")
|
||||||
|
self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
scale_row.addWidget(self.scale_upper_label)
|
||||||
|
root.addLayout(scale_row)
|
||||||
|
|
||||||
|
legend_row = QHBoxLayout()
|
||||||
|
legend_row.setContentsMargins(0, 0, 0, 0)
|
||||||
|
legend_row.setSpacing(8)
|
||||||
|
# ``ShrinkableLabel``: the legend is one line that clips (its tooltip keeps the
|
||||||
|
# whole text) instead of wrapping into a second row — a footer that grows a line
|
||||||
|
# whenever a number gets long is what made the pane look like debug output.
|
||||||
|
self.legend_label = ShrinkableLabel()
|
||||||
|
self.legend_label.setObjectName("panelMeta")
|
||||||
|
self.legend_label.setWordWrap(False)
|
||||||
|
legend_row.addWidget(self.legend_label, 3)
|
||||||
|
|
||||||
#: Which *source* the field on screen came from. A separate line from the
|
#: Which *source* the field on screen came from. A separate line from the
|
||||||
#: scene state, because they answer different questions: the state says whether
|
#: scene state, because they answer different questions: the state says whether
|
||||||
#: anything is being measured, this says whether it was acquired live or
|
#: anything is being measured, this says whether it was acquired live or
|
||||||
#: recomputed from a recording — a distinction the design document insists on.
|
#: recomputed from a recording — a distinction the design document insists on.
|
||||||
self.source_label = QLabel()
|
self.source_label = ShrinkableLabel()
|
||||||
self.source_label.setObjectName("panelMeta")
|
self.source_label.setObjectName("panelMeta")
|
||||||
self.source_label.setWordWrap(True)
|
self.source_label.setWordWrap(False)
|
||||||
self.source_label.setMinimumWidth(1)
|
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
root.addWidget(self.source_label)
|
legend_row.addWidget(self.source_label, 2)
|
||||||
|
root.addLayout(legend_row)
|
||||||
|
|
||||||
self.state_label = QLabel()
|
self.state_label = QLabel()
|
||||||
self.state_label.setObjectName("hintText")
|
self.state_label.setObjectName("hintText")
|
||||||
@@ -258,13 +327,6 @@ class ThreeDPanel(QFrame):
|
|||||||
self.state_label.setMinimumWidth(1)
|
self.state_label.setMinimumWidth(1)
|
||||||
root.addWidget(self.state_label)
|
root.addWidget(self.state_label)
|
||||||
|
|
||||||
self.stats_label = QLabel()
|
|
||||||
self.stats_label.setObjectName("panelMeta")
|
|
||||||
self.stats_label.setWordWrap(True)
|
|
||||||
self.stats_label.setMinimumWidth(1)
|
|
||||||
root.addWidget(self.stats_label)
|
|
||||||
|
|
||||||
root.addStretch(1)
|
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
self.set_scene(None)
|
self.set_scene(None)
|
||||||
self.set_state("disabled")
|
self.set_state("disabled")
|
||||||
@@ -294,15 +356,20 @@ class ThreeDPanel(QFrame):
|
|||||||
|
|
||||||
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
|
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
|
||||||
"""The measurement currently on screen — or none, which hides the bar."""
|
"""The measurement currently on screen — or none, which hides the bar."""
|
||||||
|
if descriptor != self._descriptor:
|
||||||
|
self._effective_scale = None
|
||||||
self._descriptor = descriptor
|
self._descriptor = descriptor
|
||||||
|
self._render_field_note()
|
||||||
if descriptor is None or descriptor.quantity == "unavailable":
|
if descriptor is None or descriptor.quantity == "unavailable":
|
||||||
# No measurement: do not leave the previous range on display.
|
# No measurement: do not leave the previous range on display.
|
||||||
self.legend_label.setText(t("scene3d.legend_none"))
|
self.legend_label.setText(t("scene3d.legend_none"))
|
||||||
self.legend_label.setVisible(True)
|
self.legend_label.setVisible(self._show_diagnostics)
|
||||||
self.scale_combo.setEnabled(False)
|
self.scale_combo.setEnabled(False)
|
||||||
self.lower_spin.setEnabled(False)
|
self.lower_spin.setEnabled(False)
|
||||||
self.upper_spin.setEnabled(False)
|
self.upper_spin.setEnabled(False)
|
||||||
self.scale_bar.setVisible(False)
|
self.scale_bar.setVisible(False)
|
||||||
|
self.scale_lower_label.setVisible(False)
|
||||||
|
self.scale_upper_label.setVisible(False)
|
||||||
return
|
return
|
||||||
self.scale_combo.setEnabled(True)
|
self.scale_combo.setEnabled(True)
|
||||||
fixed = self.scale_combo.currentData() == "fixed"
|
fixed = self.scale_combo.currentData() == "fixed"
|
||||||
@@ -312,6 +379,7 @@ class ThreeDPanel(QFrame):
|
|||||||
"diverging" if descriptor.signed and not fixed else "sequential"
|
"diverging" if descriptor.signed and not fixed else "sequential"
|
||||||
)
|
)
|
||||||
self.scale_bar.setVisible(True)
|
self.scale_bar.setVisible(True)
|
||||||
|
self._render_scale_bounds()
|
||||||
self._render_legend()
|
self._render_legend()
|
||||||
|
|
||||||
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
|
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
|
||||||
@@ -350,7 +418,7 @@ class ThreeDPanel(QFrame):
|
|||||||
quantity = descriptor.quantity
|
quantity = descriptor.quantity
|
||||||
quality = t(f"scene3d.quality_{descriptor.quality}")
|
quality = t(f"scene3d.quality_{descriptor.quality}")
|
||||||
if effective is None or effective.scale_mode == "":
|
if effective is None or effective.scale_mode == "":
|
||||||
self.legend_label.setText(
|
self._set_legend(
|
||||||
t(
|
t(
|
||||||
"scene3d.legend",
|
"scene3d.legend",
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -358,28 +426,62 @@ class ThreeDPanel(QFrame):
|
|||||||
quality=quality,
|
quality=quality,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.legend_label.setVisible(self._show_diagnostics)
|
||||||
return
|
return
|
||||||
mode = t(
|
mode = t(
|
||||||
"scene3d.legend_auto"
|
"scene3d.legend_auto"
|
||||||
if effective.scale_mode == "automatic"
|
if effective.scale_mode == "automatic"
|
||||||
else "scene3d.legend_fixed"
|
else "scene3d.legend_fixed"
|
||||||
)
|
)
|
||||||
self.legend_label.setText(
|
self._set_legend(
|
||||||
t(
|
t(
|
||||||
"scene3d.legend_effective",
|
"scene3d.legend_effective",
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
unit=descriptor.unit,
|
unit=descriptor.unit,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
lower=f"{effective.scale_lower:.4g}",
|
# Three significant digits: the footer is one line, and "0.033 – 0.90"
|
||||||
upper=f"{effective.scale_upper:.4g}",
|
# says as much as "0.03337 – 0.8993" while leaving room for the rest.
|
||||||
|
lower=f"{effective.scale_lower:.3g}",
|
||||||
|
upper=f"{effective.scale_upper:.3g}",
|
||||||
quality=quality,
|
quality=quality,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.legend_label.setVisible(self._show_diagnostics)
|
||||||
|
|
||||||
|
def _render_scale_bounds(self) -> None:
|
||||||
|
"""Show only the numeric bounds needed to read the color ramp."""
|
||||||
|
descriptor = self._descriptor
|
||||||
|
visible = bool(
|
||||||
|
descriptor is not None and descriptor.quantity != "unavailable"
|
||||||
|
)
|
||||||
|
lower = ""
|
||||||
|
upper = ""
|
||||||
|
mode = str(self.scale_combo.currentData() or "fixed")
|
||||||
|
effective = self._effective_scale
|
||||||
|
if visible and mode == "fixed":
|
||||||
|
lower = f"{self.lower_spin.value():.3g}"
|
||||||
|
upper = f"{self.upper_spin.value():.3g}"
|
||||||
|
elif (
|
||||||
|
visible
|
||||||
|
and effective is not None
|
||||||
|
and effective.scale_mode == "automatic"
|
||||||
|
):
|
||||||
|
lower = f"{effective.scale_lower:.3g}"
|
||||||
|
upper = f"{effective.scale_upper:.3g}"
|
||||||
|
self.scale_lower_label.setText(lower)
|
||||||
|
self.scale_upper_label.setText(upper)
|
||||||
|
self.scale_lower_label.setVisible(visible)
|
||||||
|
self.scale_upper_label.setVisible(visible)
|
||||||
|
|
||||||
|
def _set_legend(self, text: str) -> None:
|
||||||
|
"""One legend line, with the whole text in the tooltip (it may be clipped)."""
|
||||||
|
self.legend_label.setText(text)
|
||||||
|
self.legend_label.setToolTip(text)
|
||||||
|
|
||||||
def set_state(self, state: SceneState) -> None:
|
def set_state(self, state: SceneState) -> None:
|
||||||
self._state = state
|
self._state = state
|
||||||
self.state_label.setText(t(f"scene3d.state_{state}"))
|
self.state_label.setText(t(f"scene3d.state_{state}"))
|
||||||
self.state_label.setVisible(state != "live")
|
self.state_label.setVisible(self._show_diagnostics and state != "live")
|
||||||
|
|
||||||
def set_source_kind(self, kind: str) -> None:
|
def set_source_kind(self, kind: str) -> None:
|
||||||
"""Say where the field comes from: ``live``, ``replay`` or ``recompute``.
|
"""Say where the field comes from: ``live``, ``replay`` or ``recompute``.
|
||||||
@@ -400,19 +502,86 @@ class ThreeDPanel(QFrame):
|
|||||||
self._source_kind = kind
|
self._source_kind = kind
|
||||||
self.source_label.setText(t(f"scene3d.source_{kind}"))
|
self.source_label.setText(t(f"scene3d.source_{kind}"))
|
||||||
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
|
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
|
||||||
|
self.source_label.setVisible(self._show_diagnostics)
|
||||||
|
|
||||||
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
|
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
|
||||||
"""One diagnostic line. The 2D views stay usable behind it."""
|
"""One diagnostic line. The 2D views stay usable behind it.
|
||||||
if diagnostic is None:
|
|
||||||
|
Warnings and errors are always stated: they are claims about the *measurement*
|
||||||
|
(this frame's field is not its own, the device was never compared with the scene).
|
||||||
|
Informational lines explain the pipeline instead, so they follow the window's
|
||||||
|
「诊断信息」 switch — except the few that make a claim about the measurement too.
|
||||||
|
"""
|
||||||
|
shown = diagnostic is None or self._diagnostics_visible_for(diagnostic)
|
||||||
|
if diagnostic is None or not shown:
|
||||||
self._legend_reset_note()
|
self._legend_reset_note()
|
||||||
return
|
return
|
||||||
prefix = t(f"scene3d.severity_{diagnostic.severity}")
|
prefix = t(f"scene3d.severity_{diagnostic.severity}")
|
||||||
self.legend_label.setText(f"{prefix}: {diagnostic.message}")
|
self.legend_label.setText(f"{prefix}: {diagnostic.message}")
|
||||||
self.legend_label.setVisible(True)
|
self.legend_label.setVisible(True)
|
||||||
|
|
||||||
|
def _diagnostics_visible_for(self, diagnostic: Diagnostic) -> bool:
|
||||||
|
if str(diagnostic.severity) != "info":
|
||||||
|
return True
|
||||||
|
return bool(self._show_diagnostics) or diagnostic.code in _ALWAYS_VISIBLE_INFO
|
||||||
|
|
||||||
|
def set_diagnostics_visible(self, visible: bool) -> None:
|
||||||
|
"""How much the pane explains itself (see the window's 诊断信息 switch).
|
||||||
|
|
||||||
|
Off: only the colour ramp and its numeric bounds remain, except that warnings
|
||||||
|
and errors about the measurement are still shown. On: quantity, source, state,
|
||||||
|
substitutions and pipeline explanations are restored.
|
||||||
|
"""
|
||||||
|
self._show_diagnostics = bool(visible)
|
||||||
|
self._render_field_note()
|
||||||
|
self.source_label.setVisible(self._show_diagnostics)
|
||||||
|
self.state_label.setVisible(
|
||||||
|
self._show_diagnostics and self._state != "live"
|
||||||
|
)
|
||||||
|
self.set_diagnostic(None)
|
||||||
|
|
||||||
|
def diagnostics_visible(self) -> bool:
|
||||||
|
return bool(self._show_diagnostics)
|
||||||
|
|
||||||
|
def _render_field_note(self) -> None:
|
||||||
|
"""Say it when the quantity on screen is not the one the setting asked for.
|
||||||
|
|
||||||
|
The adapter tries the chosen field first and falls back to the other when it
|
||||||
|
cannot colour it (a field the producer did not claim for this frame, or one on a
|
||||||
|
different grid). The legend names whatever *is* drawn, so this line is the other
|
||||||
|
half: the choice was not honoured, and which one is on screen instead.
|
||||||
|
"""
|
||||||
|
wanted = _FIELD_QUANTITY.get(self.field(), "")
|
||||||
|
shown = "" if self._descriptor is None else str(self._descriptor.quantity)
|
||||||
|
if (
|
||||||
|
not self._show_diagnostics
|
||||||
|
or not wanted
|
||||||
|
or not shown
|
||||||
|
or shown == wanted
|
||||||
|
or shown == "unavailable"
|
||||||
|
):
|
||||||
|
self.field_note.setText("")
|
||||||
|
self.field_note.setVisible(False)
|
||||||
|
return
|
||||||
|
self.field_note.setText(
|
||||||
|
t(
|
||||||
|
"scene3d.field_substituted",
|
||||||
|
shown=self._quantity_label(shown),
|
||||||
|
chosen=self._quantity_label(wanted),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.field_note.setVisible(True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _quantity_label(quantity: str) -> str:
|
||||||
|
label = t(f"scene3d.quantity_{quantity}")
|
||||||
|
return quantity if label.startswith("scene3d.") else label
|
||||||
|
|
||||||
|
|
||||||
def set_render_stats(self, stats: RenderStats) -> None:
|
def set_render_stats(self, stats: RenderStats) -> None:
|
||||||
"""Display-side statistics only: never shown as recording health."""
|
"""Display-side statistics only: never shown as recording health."""
|
||||||
self._effective_scale = stats
|
self._effective_scale = stats
|
||||||
|
self._render_scale_bounds()
|
||||||
self._render_legend()
|
self._render_legend()
|
||||||
self.stats_label.setText(
|
self.stats_label.setText(
|
||||||
t(
|
t(
|
||||||
@@ -446,6 +615,10 @@ class ThreeDPanel(QFrame):
|
|||||||
self.set_descriptor(self._descriptor if enabled else None)
|
self.set_descriptor(self._descriptor if enabled else None)
|
||||||
|
|
||||||
# ----------------------------------------------------------------- outputs
|
# ----------------------------------------------------------------- outputs
|
||||||
|
def field(self) -> str:
|
||||||
|
"""Which quantity the operator chose to colour the surface with."""
|
||||||
|
return str(self.field_combo.currentData() or DEFAULT_FIELD)
|
||||||
|
|
||||||
def apply_settings(self, settings: DisplaySettings) -> None:
|
def apply_settings(self, settings: DisplaySettings) -> None:
|
||||||
"""Move the controls to these settings and tell the view about them.
|
"""Move the controls to these settings and tell the view about them.
|
||||||
|
|
||||||
@@ -459,6 +632,9 @@ class ThreeDPanel(QFrame):
|
|||||||
self.deform_check.setChecked(bool(settings.deform_enabled))
|
self.deform_check.setChecked(bool(settings.deform_enabled))
|
||||||
self.gain_spin.setValue(float(settings.display_gain))
|
self.gain_spin.setValue(float(settings.display_gain))
|
||||||
self.housing_check.setChecked(bool(settings.housing_visible))
|
self.housing_check.setChecked(bool(settings.housing_visible))
|
||||||
|
index = self.field_combo.findData(str(settings.field))
|
||||||
|
if index >= 0:
|
||||||
|
self.field_combo.setCurrentIndex(index)
|
||||||
scale = settings.color_scale
|
scale = settings.color_scale
|
||||||
if scale is not None:
|
if scale is not None:
|
||||||
index = self.scale_combo.findData(str(scale.mode))
|
index = self.scale_combo.findData(str(scale.mode))
|
||||||
@@ -501,6 +677,7 @@ class ThreeDPanel(QFrame):
|
|||||||
),
|
),
|
||||||
housing_visible=self.housing_check.isChecked(),
|
housing_visible=self.housing_check.isChecked(),
|
||||||
max_fps=int(self.fps_combo.currentData()),
|
max_fps=int(self.fps_combo.currentData()),
|
||||||
|
field=self.field(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ signals
|
# ------------------------------------------------------------------ signals
|
||||||
@@ -527,9 +704,15 @@ class ThreeDPanel(QFrame):
|
|||||||
self.set_descriptor(self._descriptor)
|
self.set_descriptor(self._descriptor)
|
||||||
self._emit_settings()
|
self._emit_settings()
|
||||||
|
|
||||||
|
def _on_field_changed(self, index: int) -> None:
|
||||||
|
"""A different quantity is a different colour scale as well as a different field."""
|
||||||
|
self._render_field_note()
|
||||||
|
self._emit_settings()
|
||||||
|
|
||||||
def _emit_settings(self) -> None:
|
def _emit_settings(self) -> None:
|
||||||
if self._syncing:
|
if self._syncing:
|
||||||
return
|
return
|
||||||
|
self._render_scale_bounds()
|
||||||
self.settingsChanged.emit(self.settings())
|
self.settingsChanged.emit(self.settings())
|
||||||
|
|
||||||
def _legend_reset_note(self) -> None:
|
def _legend_reset_note(self) -> None:
|
||||||
@@ -542,6 +725,11 @@ class ThreeDPanel(QFrame):
|
|||||||
self.housing_check.setText(t("scene3d.housing"))
|
self.housing_check.setText(t("scene3d.housing"))
|
||||||
self.deform_check.setText(t("scene3d.deform"))
|
self.deform_check.setText(t("scene3d.deform"))
|
||||||
self.deform_check.setToolTip(t("scene3d.deform_tip"))
|
self.deform_check.setToolTip(t("scene3d.deform_tip"))
|
||||||
|
self.field_label.setText(t("scene3d.field"))
|
||||||
|
self.field_label.setToolTip(t("scene3d.field_tip"))
|
||||||
|
self.field_combo.setItemText(0, t("scene3d.field_depth"))
|
||||||
|
self.field_combo.setItemText(1, t("scene3d.field_flow"))
|
||||||
|
self._render_field_note()
|
||||||
self.scale_label.setText(t("scene3d.scale"))
|
self.scale_label.setText(t("scene3d.scale"))
|
||||||
self.range_label.setText(t("scene3d.scale_range"))
|
self.range_label.setText(t("scene3d.scale_range"))
|
||||||
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
|
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
|
||||||
@@ -553,5 +741,9 @@ class ThreeDPanel(QFrame):
|
|||||||
self.state_label.setText(t(f"scene3d.state_{self._state}"))
|
self.state_label.setText(t(f"scene3d.state_{self._state}"))
|
||||||
self.source_label.setText(t(f"scene3d.source_{self._source_kind}"))
|
self.source_label.setText(t(f"scene3d.source_{self._source_kind}"))
|
||||||
self.source_label.setToolTip(t(f"scene3d.source_{self._source_kind}_tip"))
|
self.source_label.setToolTip(t(f"scene3d.source_{self._source_kind}_tip"))
|
||||||
|
self.source_label.setVisible(self._show_diagnostics)
|
||||||
|
self.state_label.setVisible(
|
||||||
|
self._show_diagnostics and self._state != "live"
|
||||||
|
)
|
||||||
self.set_capabilities(self._capabilities)
|
self.set_capabilities(self._capabilities)
|
||||||
self.set_descriptor(self._descriptor)
|
self.set_descriptor(self._descriptor)
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ DEFAULT_MAX_FPS = 30
|
|||||||
MIN_GAIN = 0.0
|
MIN_GAIN = 0.0
|
||||||
MAX_GAIN = 500.0
|
MAX_GAIN = 500.0
|
||||||
SCALE_MODES = ("fixed", "automatic")
|
SCALE_MODES = ("fixed", "automatic")
|
||||||
|
#: Which scalar field may colour the surface; a saved value outside this set is
|
||||||
|
#: repaired to the default rather than handed to an adapter that cannot draw it.
|
||||||
|
FIELD_CHOICES = ("depth", "flow")
|
||||||
|
#: The deformation response rather than the optical flow: it is the quantity an
|
||||||
|
#: operator reads as "how deep", and it is what the panel offers first.
|
||||||
|
DEFAULT_FIELD = "depth"
|
||||||
#: The largest magnitude a colour bound may carry. Beyond this the LUT is useless
|
#: The largest magnitude a colour bound may carry. Beyond this the LUT is useless
|
||||||
#: and the number is certainly a typo ("1e308" in a hand-edited file).
|
#: and the number is certainly a typo ("1e308" in a hand-edited file).
|
||||||
MAX_SCALE_BOUND = 1e12
|
MAX_SCALE_BOUND = 1e12
|
||||||
@@ -73,6 +79,7 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
|
|||||||
"display_gain": float(settings.display_gain),
|
"display_gain": float(settings.display_gain),
|
||||||
"housing_visible": bool(settings.housing_visible),
|
"housing_visible": bool(settings.housing_visible),
|
||||||
"max_fps": int(settings.max_fps),
|
"max_fps": int(settings.max_fps),
|
||||||
|
"field": str(settings.field),
|
||||||
}
|
}
|
||||||
if scale is not None:
|
if scale is not None:
|
||||||
block["color_scale"] = {
|
block["color_scale"] = {
|
||||||
@@ -105,6 +112,9 @@ def settings_from_prefs(
|
|||||||
max_fps = int(_number(raw.get("max_fps"), DEFAULT_MAX_FPS))
|
max_fps = int(_number(raw.get("max_fps"), DEFAULT_MAX_FPS))
|
||||||
if max_fps not in FPS_CHOICES:
|
if max_fps not in FPS_CHOICES:
|
||||||
max_fps = DEFAULT_MAX_FPS
|
max_fps = DEFAULT_MAX_FPS
|
||||||
|
field = str(raw.get("field") or DEFAULT_FIELD)
|
||||||
|
if field not in FIELD_CHOICES:
|
||||||
|
field = DEFAULT_FIELD
|
||||||
scale: Optional[ColorScale] = None
|
scale: Optional[ColorScale] = None
|
||||||
saved = raw.get("color_scale")
|
saved = raw.get("color_scale")
|
||||||
if isinstance(saved, Mapping):
|
if isinstance(saved, Mapping):
|
||||||
@@ -134,4 +144,5 @@ def settings_from_prefs(
|
|||||||
color_scale=scale,
|
color_scale=scale,
|
||||||
housing_visible=_flag(raw.get("housing_visible"), True),
|
housing_visible=_flag(raw.get("housing_visible"), True),
|
||||||
max_fps=max_fps,
|
max_fps=max_fps,
|
||||||
|
field=field,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""``Scene3dSettingsDialog``: the 3D pane's settings, out of the picture's way.
|
||||||
|
|
||||||
|
The pane is for looking at a picture. Every *setting* the three-dimensional view has —
|
||||||
|
whether it is on, which camera preset, the housing, the exaggeration, the colour range
|
||||||
|
and the display rate — therefore lives in one dialog, opened from 视图 ▸ 三维显示设置 or
|
||||||
|
from the gear on the pane's header. What stays under the picture is only what *reading*
|
||||||
|
it needs: the colour ramp, the numbers it was drawn with, and which source produced the
|
||||||
|
field (see ``ThreeDPanel``).
|
||||||
|
|
||||||
|
The dialog owns no state of its own: it hosts the panel's control block
|
||||||
|
(``ThreeDPanel.controls_widget``) and closes with it untouched. That keeps one set of
|
||||||
|
widgets — and one set of signals — whether or not the dialog has ever been opened.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QLabel,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
|
||||||
|
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||||
|
|
||||||
|
|
||||||
|
class Scene3dSettingsDialog(QDialog):
|
||||||
|
"""A modeless home for the 3D pane's controls."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("scene3dSettings")
|
||||||
|
self.setWindowTitle(t("scene3d.settings_title"))
|
||||||
|
# Modeless on purpose: the operator tunes the colour range while watching the
|
||||||
|
# picture change, which a modal dialog over the pane would prevent.
|
||||||
|
self.setModal(False)
|
||||||
|
self._panel = panel
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(16, 14, 16, 12)
|
||||||
|
root.setSpacing(10)
|
||||||
|
|
||||||
|
self.hint = QLabel()
|
||||||
|
self.hint.setObjectName("hintText")
|
||||||
|
self.hint.setWordWrap(True)
|
||||||
|
root.addWidget(self.hint)
|
||||||
|
|
||||||
|
# Adding the block to this layout reparents it: the controls now live here, and
|
||||||
|
# the pane keeps only its legend. They are the *same* widgets the panel holds
|
||||||
|
# references to, so nothing about the settings path changes with the dialog.
|
||||||
|
root.addWidget(panel.controls_widget)
|
||||||
|
panel.controls_widget.setVisible(True)
|
||||||
|
|
||||||
|
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||||
|
self.buttons.rejected.connect(self.close)
|
||||||
|
self.buttons.accepted.connect(self.close)
|
||||||
|
root.addWidget(self.buttons)
|
||||||
|
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(t("scene3d.settings_title"))
|
||||||
|
self.hint.setText(t("scene3d.settings_hint"))
|
||||||
|
# Qt ships its own translations for a button box, and the application installs
|
||||||
|
# none: without this the dialog ends in an English "Close" among Chinese labels.
|
||||||
|
close_button = self.buttons.button(QDialogButtonBox.Close)
|
||||||
|
if close_button is not None:
|
||||||
|
close_button.setText(t("dialog.close"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Qt events
|
||||||
|
def closeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
|
"""Closing *hides* the dialog: the controls have to stay alive in it."""
|
||||||
|
self.hide()
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
|
"""Esc closes the dialog, as it does everywhere else in this application."""
|
||||||
|
if event.key() == Qt.Key_Escape:
|
||||||
|
self.hide()
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
@@ -117,6 +117,9 @@ class Scene3DView(QWidget):
|
|||||||
self._device_verified = False
|
self._device_verified = False
|
||||||
#: Frames the producer did not even build (a frame was already waiting).
|
#: Frames the producer did not even build (a frame was already waiting).
|
||||||
self._skipped_snapshots = 0
|
self._skipped_snapshots = 0
|
||||||
|
#: The settings dialog, built on first use. Held (never deleted) because the
|
||||||
|
#: control widgets live in it once it has been opened.
|
||||||
|
self._settings_dialog = None
|
||||||
|
|
||||||
self._layout = QVBoxLayout(self)
|
self._layout = QVBoxLayout(self)
|
||||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -360,6 +363,11 @@ class Scene3DView(QWidget):
|
|||||||
def set_settings(self, settings: DisplaySettings) -> None:
|
def set_settings(self, settings: DisplaySettings) -> None:
|
||||||
if self._coordinator is not None:
|
if self._coordinator is not None:
|
||||||
self._coordinator.set_settings(settings)
|
self._coordinator.set_settings(settings)
|
||||||
|
# Which quantity colours the surface is an adapter choice, not a coordinator one:
|
||||||
|
# the adapter is the only thing that reads the packet's fields.
|
||||||
|
adapter = self._adapter
|
||||||
|
if adapter is not None and hasattr(adapter, "set_preferred_field"):
|
||||||
|
adapter.set_preferred_field(settings.field)
|
||||||
|
|
||||||
def apply_preset(self, name: str) -> None:
|
def apply_preset(self, name: str) -> None:
|
||||||
if self._assets is None:
|
if self._assets is None:
|
||||||
@@ -425,6 +433,29 @@ class Scene3DView(QWidget):
|
|||||||
if self._panel is not None:
|
if self._panel is not None:
|
||||||
self._panel.set_capabilities(self.capabilities())
|
self._panel.set_capabilities(self.capabilities())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ settings
|
||||||
|
def open_settings(self) -> bool:
|
||||||
|
"""Show the settings dialog. ``False`` when there is nothing to configure.
|
||||||
|
|
||||||
|
The dialog hosts the panel's control block, so it is built here (where the panel
|
||||||
|
is) and only once: a second open reuses it, and the controls' signals were wired
|
||||||
|
at construction either way.
|
||||||
|
"""
|
||||||
|
if self._panel is None:
|
||||||
|
return False
|
||||||
|
if self._settings_dialog is None:
|
||||||
|
from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog
|
||||||
|
|
||||||
|
self._settings_dialog = Scene3dSettingsDialog(self._panel, self)
|
||||||
|
self._settings_dialog.show()
|
||||||
|
self._settings_dialog.raise_()
|
||||||
|
self._settings_dialog.activateWindow()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def settings_dialog(self):
|
||||||
|
"""The dialog, if it has been opened (for tests and for the window)."""
|
||||||
|
return self._settings_dialog
|
||||||
|
|
||||||
def begin_session(self) -> None:
|
def begin_session(self) -> None:
|
||||||
"""The application is starting an acquisition session for this pane.
|
"""The application is starting an acquisition session for this pane.
|
||||||
|
|
||||||
@@ -636,6 +667,10 @@ class Scene3DView(QWidget):
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Release the GPU resources, stop the worker. Idempotent."""
|
"""Release the GPU resources, stop the worker. Idempotent."""
|
||||||
|
if self._settings_dialog is not None:
|
||||||
|
# Hidden, not deleted: the control widgets live in it, and the panel keeps
|
||||||
|
# referring to them (a session's display settings are read back from there).
|
||||||
|
self._settings_dialog.hide()
|
||||||
if self._coordinator is not None:
|
if self._coordinator is not None:
|
||||||
self._coordinator.close()
|
self._coordinator.close()
|
||||||
if self._widget is not None:
|
if self._widget is not None:
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ class ArrowPanel(QFrame):
|
|||||||
#: panel behaved before the plans existed.
|
#: panel behaved before the plans existed.
|
||||||
self._modes: tuple[str, ...] = ()
|
self._modes: tuple[str, ...] = ()
|
||||||
self._plan_note = ""
|
self._plan_note = ""
|
||||||
|
#: Whether the footer explains the *pipeline* (magnification, source → output,
|
||||||
|
#: which frames) or only what the picture is. Off by default; the window's
|
||||||
|
#: 「诊断信息」 switch owns it.
|
||||||
|
self._show_diagnostics = False
|
||||||
self.view_combo = QComboBox()
|
self.view_combo = QComboBox()
|
||||||
self.view_combo.setMinimumWidth(140)
|
self.view_combo.setMinimumWidth(140)
|
||||||
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
|
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
|
||||||
@@ -398,17 +402,34 @@ class ArrowPanel(QFrame):
|
|||||||
What is shown follows the *modes this frame supports*: the magnification and
|
What is shown follows the *modes this frame supports*: the magnification and
|
||||||
the valid area describe a flow drawing, so they are shown only when an arrow
|
the valid area describe a flow drawing, so they are shown only when an arrow
|
||||||
mode is on offer.
|
mode is on offer.
|
||||||
|
|
||||||
|
The three labels that describe the *pipeline* — how much the arrows are
|
||||||
|
magnified, "raw image → algorithm output", which frames — follow the window's
|
||||||
|
「诊断信息」 switch instead: they explain the inside of the pipeline, not what the
|
||||||
|
picture is, and under the canvas they are the first thing to become noise. What
|
||||||
|
stays regardless is what the picture *is*: the plan's own line ("this recording
|
||||||
|
stores only the raw camera image"), the depth scale, and the colour bar.
|
||||||
"""
|
"""
|
||||||
depth = self.view_mode() == DEPTH
|
depth = self.view_mode() == DEPTH
|
||||||
modes = self.view_modes()
|
modes = self.view_modes()
|
||||||
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes
|
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes
|
||||||
self.scale_label.setVisible(not depth and has_arrows)
|
explain = bool(self._show_diagnostics)
|
||||||
self.pipeline_label.setVisible(not depth)
|
self.scale_label.setVisible(explain and not depth and has_arrows)
|
||||||
self.area_label.setVisible(not depth and has_arrows)
|
self.pipeline_label.setVisible(explain and not depth)
|
||||||
|
self.area_label.setVisible(explain and not depth and has_arrows)
|
||||||
self.colorbar.setVisible(depth)
|
self.colorbar.setVisible(depth)
|
||||||
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
|
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
|
||||||
self.note_label.setVisible(bool(self._plan_note))
|
self.note_label.setVisible(bool(self._plan_note))
|
||||||
|
|
||||||
|
def set_diagnostics_visible(self, visible: bool) -> None:
|
||||||
|
"""How much the panel explains the pipeline around the picture (see the window's
|
||||||
|
「诊断信息」 switch)."""
|
||||||
|
self._show_diagnostics = bool(visible)
|
||||||
|
self._refresh_footer_visibility()
|
||||||
|
|
||||||
|
def diagnostics_visible(self) -> bool:
|
||||||
|
return bool(self._show_diagnostics)
|
||||||
|
|
||||||
def _populate_view_combo(self) -> None:
|
def _populate_view_combo(self) -> None:
|
||||||
current = self.view_combo.currentData()
|
current = self.view_combo.currentData()
|
||||||
self.view_combo.blockSignals(True)
|
self.view_combo.blockSignals(True)
|
||||||
|
|||||||
@@ -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", "settings")
|
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view")
|
||||||
|
|
||||||
ICON_SIZE = 16
|
ICON_SIZE = 16
|
||||||
_PEN_WIDTH = 1.4
|
_PEN_WIDTH = 1.4
|
||||||
@@ -93,11 +93,49 @@ def _paint_settings(painter: QPainter, size: int) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _paint_reset_view(painter: QPainter, size: int) -> None:
|
||||||
|
"""A circular arrow: put the view back the way the asset framed it."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
centre = size / 2.0
|
||||||
|
radius = size * 0.30
|
||||||
|
# Three quarters of a circle, drawn as short chords: Qt's arc API needs a rect and
|
||||||
|
# sixteenths of a degree, and the gap is what makes it read as "again".
|
||||||
|
start = -0.30 * math.pi
|
||||||
|
end = 1.45 * math.pi
|
||||||
|
steps = 18
|
||||||
|
previous = None
|
||||||
|
for index in range(steps + 1):
|
||||||
|
angle = start + (end - start) * index / steps
|
||||||
|
point = (
|
||||||
|
centre + radius * math.cos(angle),
|
||||||
|
centre + radius * math.sin(angle),
|
||||||
|
)
|
||||||
|
if previous is not None:
|
||||||
|
painter.drawLine(
|
||||||
|
int(round(previous[0])), int(round(previous[1])),
|
||||||
|
int(round(point[0])), int(round(point[1])),
|
||||||
|
)
|
||||||
|
previous = point
|
||||||
|
# The head sits at the open end of the arc, pointing the way the arrow runs.
|
||||||
|
head = size * 0.20
|
||||||
|
tip_x, tip_y = previous
|
||||||
|
painter.drawLine(
|
||||||
|
int(round(tip_x)), int(round(tip_y)),
|
||||||
|
int(round(tip_x - head)), int(round(tip_y - head * 0.2)),
|
||||||
|
)
|
||||||
|
painter.drawLine(
|
||||||
|
int(round(tip_x)), int(round(tip_y)),
|
||||||
|
int(round(tip_x - head * 0.2)), int(round(tip_y + head)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_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,
|
"settings": _paint_settings,
|
||||||
|
"reset_view": _paint_reset_view,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Callable, Optional
|
from typing import TYPE_CHECKING, Callable, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||||
from PyQt5.QtWidgets import QFrame, QLabel, QStackedLayout, QVBoxLayout, QWidget
|
from PyQt5.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QStackedLayout,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -32,6 +40,7 @@ from orisys_studio.scene3d.asset_source import (
|
|||||||
find_package,
|
find_package,
|
||||||
no_asset_diagnostic,
|
no_asset_diagnostic,
|
||||||
)
|
)
|
||||||
|
from orisys_studio.ui import icons
|
||||||
|
|
||||||
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
|
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
|
||||||
from orisys_app_shared.acquisition.geometry import DeviceGeometry
|
from orisys_app_shared.acquisition.geometry import DeviceGeometry
|
||||||
@@ -71,9 +80,12 @@ class Mesh3dPanel(QFrame):
|
|||||||
#: from this, so no packet is adapted while the pane is off.
|
#: from this, so no packet is adapted while the pane is off.
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = pyqtSignal(bool)
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
def __init__(
|
||||||
|
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
||||||
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("contentPanel")
|
self.setObjectName("contentPanel")
|
||||||
|
self._theme = str(theme)
|
||||||
self._view: Optional["Scene3DView"] = None
|
self._view: Optional["Scene3DView"] = None
|
||||||
self._loader: Optional[SceneAssetLoader] = None
|
self._loader: Optional[SceneAssetLoader] = None
|
||||||
self._source: Optional[SceneSource] = None
|
self._source: Optional[SceneSource] = None
|
||||||
@@ -96,12 +108,37 @@ class Mesh3dPanel(QFrame):
|
|||||||
self._pending_settings = None
|
self._pending_settings = None
|
||||||
self._message_kind = _UNAVAILABLE
|
self._message_kind = _UNAVAILABLE
|
||||||
self._message_args: dict[str, str] = {"reason": ""}
|
self._message_args: dict[str, str] = {"reason": ""}
|
||||||
|
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
|
||||||
|
#: until the view exists (see ``set_diagnostics_visible``).
|
||||||
|
self._show_diagnostics = False
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
# The header carries the title and the two actions that belong to *looking*:
|
||||||
|
# put the view back to the asset's own framing, and open the settings. Everything
|
||||||
|
# else the pane can be told lives in that dialog, so the picture is the pane.
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.setContentsMargins(8, 4, 8, 0)
|
||||||
|
header.setSpacing(4)
|
||||||
self.title = QLabel()
|
self.title = QLabel()
|
||||||
self.title.setObjectName("panelTitle")
|
self.title.setObjectName("panelTitle")
|
||||||
root.addWidget(self.title)
|
header.addWidget(self.title)
|
||||||
|
header.addStretch(1)
|
||||||
|
|
||||||
|
self.btn_reset_view = QPushButton()
|
||||||
|
self.btn_reset_view.setProperty("compact", "true")
|
||||||
|
self.btn_reset_view.setProperty("iconOnly", "true")
|
||||||
|
self.btn_reset_view.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.btn_reset_view.clicked.connect(self.reset_view)
|
||||||
|
header.addWidget(self.btn_reset_view)
|
||||||
|
|
||||||
|
self.btn_settings = QPushButton()
|
||||||
|
self.btn_settings.setProperty("compact", "true")
|
||||||
|
self.btn_settings.setProperty("iconOnly", "true")
|
||||||
|
self.btn_settings.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.btn_settings.clicked.connect(self.open_settings)
|
||||||
|
header.addWidget(self.btn_settings)
|
||||||
|
root.addLayout(header)
|
||||||
|
|
||||||
self._stack = QStackedLayout()
|
self._stack = QStackedLayout()
|
||||||
root.addLayout(self._stack, 1)
|
root.addLayout(self._stack, 1)
|
||||||
@@ -112,8 +149,57 @@ class Mesh3dPanel(QFrame):
|
|||||||
self.message.setWordWrap(True)
|
self.message.setWordWrap(True)
|
||||||
self._stack.addWidget(self.message)
|
self._stack.addWidget(self.message)
|
||||||
|
|
||||||
|
self._apply_icons()
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ actions
|
||||||
|
def open_settings(self) -> bool:
|
||||||
|
"""Show the 3D settings dialog. ``False`` when there is nothing to configure.
|
||||||
|
|
||||||
|
The pane builds the view on first use, so pressing the gear is also what makes
|
||||||
|
the 3D view exist — the same path the View menu's 「三维网格」 takes.
|
||||||
|
"""
|
||||||
|
if self._view is None:
|
||||||
|
self.ensure_view()
|
||||||
|
if self._view is None:
|
||||||
|
return False
|
||||||
|
return bool(self._view.open_settings())
|
||||||
|
|
||||||
|
def reset_view(self) -> None:
|
||||||
|
"""Go back to the framing the asset itself calls its own."""
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.reset_view()
|
||||||
|
|
||||||
|
def set_diagnostics_visible(self, visible: bool) -> None:
|
||||||
|
"""How much the 3D pane explains itself (see ``DisplayPrefsController``).
|
||||||
|
|
||||||
|
Forwarded to the view's own panel, which is where the legend and the state line
|
||||||
|
live; held here because the view is built on first use and has to inherit the
|
||||||
|
switch when it appears.
|
||||||
|
"""
|
||||||
|
self._show_diagnostics = bool(visible)
|
||||||
|
self._apply_diagnostics()
|
||||||
|
|
||||||
|
def diagnostics_visible(self) -> bool:
|
||||||
|
return bool(self._show_diagnostics)
|
||||||
|
|
||||||
|
def _apply_diagnostics(self) -> None:
|
||||||
|
panel = getattr(self._view, "panel", None) if self._view is not None else None
|
||||||
|
if panel is not None:
|
||||||
|
panel.set_diagnostics_visible(self._show_diagnostics)
|
||||||
|
|
||||||
|
def set_theme(self, theme: str) -> None:
|
||||||
|
"""Re-tint the header icons: they are painted from the theme's own colours."""
|
||||||
|
self._theme = str(theme)
|
||||||
|
self._apply_icons()
|
||||||
|
|
||||||
|
def _apply_icons(self) -> None:
|
||||||
|
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
||||||
|
self.btn_reset_view.setIcon(icons.icon("reset_view", self._theme))
|
||||||
|
self.btn_reset_view.setIconSize(size)
|
||||||
|
self.btn_settings.setIcon(icons.icon("settings", self._theme))
|
||||||
|
self.btn_settings.setIconSize(size)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ scene
|
# ------------------------------------------------------------------ scene
|
||||||
def set_sensor(
|
def set_sensor(
|
||||||
self,
|
self,
|
||||||
@@ -186,6 +272,9 @@ class Mesh3dPanel(QFrame):
|
|||||||
# The kind was decided before the view existed (the window sets it when the
|
# The kind was decided before the view existed (the window sets it when the
|
||||||
# view preset changes), so it is pushed again here rather than lost.
|
# view preset changes), so it is pushed again here rather than lost.
|
||||||
view.set_source_kind(self._source_kind)
|
view.set_source_kind(self._source_kind)
|
||||||
|
panel = getattr(view, "panel", None)
|
||||||
|
if panel is not None:
|
||||||
|
panel.set_diagnostics_visible(self._show_diagnostics)
|
||||||
if not view.available:
|
if not view.available:
|
||||||
self._show_message(_UNAVAILABLE, reason=view.reason)
|
self._show_message(_UNAVAILABLE, reason=view.reason)
|
||||||
|
|
||||||
@@ -400,5 +489,7 @@ class Mesh3dPanel(QFrame):
|
|||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
self.title.setText(t("panels.mesh3d"))
|
self.title.setText(t("panels.mesh3d"))
|
||||||
|
self.btn_reset_view.setToolTip(t("panels.mesh3d_reset_tip"))
|
||||||
|
self.btn_settings.setToolTip(t("panels.mesh3d_settings_tip"))
|
||||||
self.setToolTip(t("panels.mesh3d_empty_tip"))
|
self.setToolTip(t("panels.mesh3d_empty_tip"))
|
||||||
self._render_message()
|
self._render_message()
|
||||||
|
|||||||
@@ -208,14 +208,16 @@ class ViewLayoutController(QObject):
|
|||||||
self._cal_panel.setMinimumWidth(self._min_panel_widths["cal"])
|
self._cal_panel.setMinimumWidth(self._min_panel_widths["cal"])
|
||||||
self._set_check(self._actions.finger_3d if self._actions else None, show_3d, enabled=is_finger)
|
self._set_check(self._actions.finger_3d if self._actions else None, show_3d, enabled=is_finger)
|
||||||
self._set_check(self._actions.cal_panel if self._actions else None, show_cal)
|
self._set_check(self._actions.cal_panel if self._actions else None, show_cal)
|
||||||
# Panel weights: the charts are the widest panel in every view, and the 2D
|
# Panel weights: the charts are the widest panel in a view without 3D, the 3D
|
||||||
# panel is deliberately the smaller one.
|
# pane is the widest one when it is on (it is the subject, not an inset), and
|
||||||
|
# the 2D panel is deliberately the smaller one.
|
||||||
arrow_weight = 3
|
arrow_weight = 3
|
||||||
plots_weight = 4
|
plots_weight = 4
|
||||||
|
mesh_weight = 5
|
||||||
for i in range(self._splitter.count()):
|
for i in range(self._splitter.count()):
|
||||||
widget = self._splitter.widget(i)
|
widget = self._splitter.widget(i)
|
||||||
if widget is self._mesh_panel:
|
if widget is self._mesh_panel:
|
||||||
self._splitter.setStretchFactor(i, 2 if show_3d else 0)
|
self._splitter.setStretchFactor(i, mesh_weight if show_3d else 0)
|
||||||
elif widget is self._image_panel:
|
elif widget is self._image_panel:
|
||||||
self._splitter.setStretchFactor(i, arrow_weight if show_arrow else 0)
|
self._splitter.setStretchFactor(i, arrow_weight if show_arrow else 0)
|
||||||
elif widget is self._charts:
|
elif widget is self._charts:
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ whole session (Qt forbids creating more than one).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -77,3 +79,98 @@ def visible_descendants(widget):
|
|||||||
continue
|
continue
|
||||||
if child.isVisible():
|
if child.isVisible():
|
||||||
yield child
|
yield child
|
||||||
|
|
||||||
|
|
||||||
|
def show_window(window, size):
|
||||||
|
"""Show a window at ``size`` and flush pending Qt events."""
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
window.resize(*size)
|
||||||
|
window.show()
|
||||||
|
QApplication.processEvents()
|
||||||
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
def running_window(make_window):
|
||||||
|
"""Return a running window backed by a controllable test worker."""
|
||||||
|
import ui_check
|
||||||
|
|
||||||
|
window = show_window(
|
||||||
|
make_window(sensor_type="box", source_mode="camera"),
|
||||||
|
(1440, 880),
|
||||||
|
)
|
||||||
|
stuck = ui_check._StuckWorker()
|
||||||
|
window._ensure_workers = lambda: None
|
||||||
|
window._box_worker = stuck
|
||||||
|
window._finger_worker = stuck
|
||||||
|
window._start_run()
|
||||||
|
window._run_timer.stop()
|
||||||
|
window._set_phase("running")
|
||||||
|
return window, stuck
|
||||||
|
|
||||||
|
|
||||||
|
def finish_run(window, stuck):
|
||||||
|
"""Stop a test run and release its controllable worker."""
|
||||||
|
window._stop_run(reason="user_stop")
|
||||||
|
stuck.release()
|
||||||
|
|
||||||
|
|
||||||
|
_RECORD_SCRATCH = (
|
||||||
|
Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
|
||||||
|
)
|
||||||
|
_RECORD_3D_SCRATCH = (
|
||||||
|
Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def record_root():
|
||||||
|
"""A clean workspace-local directory for recording tests."""
|
||||||
|
shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True)
|
||||||
|
_RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||||
|
yield _RECORD_SCRATCH
|
||||||
|
shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def next_record_root():
|
||||||
|
"""Create isolated recording directories for comparisons within one test."""
|
||||||
|
shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True)
|
||||||
|
_RECORD_3D_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||||
|
counter = itertools.count()
|
||||||
|
|
||||||
|
def _next() -> Path:
|
||||||
|
path = _RECORD_3D_SCRATCH / f"run{next(counter)}"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
yield _next
|
||||||
|
shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def replay_recording(record_root):
|
||||||
|
"""One short finished recording (video, forces, frames, and metadata)."""
|
||||||
|
import ui_check
|
||||||
|
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||||
|
|
||||||
|
recorder = ExperimentRecorder(
|
||||||
|
RecordConfig(video=True, fps=30.0, segment_seconds=30.0)
|
||||||
|
)
|
||||||
|
recording = recorder.start(
|
||||||
|
root=record_root,
|
||||||
|
session_id=1,
|
||||||
|
metadata={
|
||||||
|
"device": {
|
||||||
|
"sensor_type": "box",
|
||||||
|
"config_ref": str(record_root / "sensor.json"),
|
||||||
|
"config_sha256": "recorded",
|
||||||
|
},
|
||||||
|
"versions": {"sdk": "0.5.1"},
|
||||||
|
"depth": {"stored": False, "replay": "recompute"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for index in range(60):
|
||||||
|
recorder.on_packet(ui_check._fake_packet(index))
|
||||||
|
recorder.stop()
|
||||||
|
yield Path(recording)
|
||||||
|
|||||||
@@ -124,11 +124,14 @@ class _Host:
|
|||||||
self.repaints += 1
|
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)
|
host = _Host(prefs or {}, latest_fps=latest_fps)
|
||||||
toolbar = _Toolbar(units)
|
toolbar = _Toolbar(units)
|
||||||
charts = _Charts("N" if units == "newton" else "raw")
|
charts = _Charts("N" if units == "newton" else "raw")
|
||||||
arrows = _ArrowPanel()
|
arrows = arrow_panel if arrow_panel is not None else _ArrowPanel()
|
||||||
engine = _Engine(loaded)
|
engine = _Engine(loaded)
|
||||||
action = _Action()
|
action = _Action()
|
||||||
controller = DisplayPrefsController(
|
controller = DisplayPrefsController(
|
||||||
@@ -136,6 +139,7 @@ def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False,
|
|||||||
toolbar=toolbar,
|
toolbar=toolbar,
|
||||||
charts=charts,
|
charts=charts,
|
||||||
arrow_panel=arrows,
|
arrow_panel=arrows,
|
||||||
|
mesh_panel=mesh_panel,
|
||||||
engine=engine,
|
engine=engine,
|
||||||
sensor_picker=_Picker(sensor_type),
|
sensor_picker=_Picker(sensor_type),
|
||||||
rotate_arrows_action=action,
|
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"].prefs["show_fps"] is True
|
||||||
assert parts["host"].rail_updates == 1, "the rail was not told to show it"
|
assert parts["host"].rail_updates == 1, "the rail was not told to show it"
|
||||||
assert parts["host"].persisted == 1
|
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
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import json
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
SRC_DIR = APP_ROOT / "src"
|
SRC_DIR = APP_ROOT / "src"
|
||||||
LOCALES_DIR = APP_ROOT / "config" / "locales"
|
LOCALES_DIR = APP_ROOT / "config" / "locales"
|
||||||
@@ -69,13 +71,6 @@ def test_preference_defaults_match_store_defaults():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_locale_files_are_valid_utf8_json():
|
|
||||||
for name in ("zh_CN", "en_US"):
|
|
||||||
data = _load_locale(name)
|
|
||||||
assert isinstance(data, dict)
|
|
||||||
assert data.get("app", {}).get("title")
|
|
||||||
|
|
||||||
|
|
||||||
#: Sequences that only ever appear when UTF-8 text was decoded as a single-byte
|
#: Sequences that only ever appear when UTF-8 text was decoded as a single-byte
|
||||||
#: codepage and then re-encoded. A ``Get-Content -Raw | Set-Content`` round trip
|
#: codepage and then re-encoded. A ``Get-Content -Raw | Set-Content`` round trip
|
||||||
#: in Windows PowerShell does exactly that and silently mangles every non-ASCII
|
#: in Windows PowerShell does exactly that and silently mangles every non-ASCII
|
||||||
@@ -92,19 +87,22 @@ _MOJIBAKE = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_locale_files_are_utf8_without_bom_and_not_mojibake():
|
@pytest.mark.parametrize("name", ("zh_CN", "en_US"))
|
||||||
for name in ("zh_CN", "en_US"):
|
def test_locale_file_is_valid_utf8_json_without_mojibake(name):
|
||||||
path = LOCALES_DIR / f"{name}.json"
|
path = LOCALES_DIR / f"{name}.json"
|
||||||
raw = path.read_bytes()
|
raw = path.read_bytes()
|
||||||
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM"
|
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM"
|
||||||
text = raw.decode("utf-8") # raises if it is not valid UTF-8
|
text = raw.decode("utf-8") # raises if it is not valid UTF-8
|
||||||
|
data = json.loads(text)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert data.get("app", {}).get("title")
|
||||||
|
|
||||||
for sequence in _MOJIBAKE:
|
for sequence in _MOJIBAKE:
|
||||||
assert sequence not in text, (
|
assert sequence not in text, (
|
||||||
f"{name}.json contains {sequence!r}: the file was decoded with the "
|
f"{name}.json contains {sequence!r}: the file was decoded with the "
|
||||||
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
|
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
|
||||||
"of a PowerShell Get-Content/Set-Content round trip."
|
"of a PowerShell Get-Content/Set-Content round trip."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_chinese_locale_actually_contains_chinese():
|
def test_chinese_locale_actually_contains_chinese():
|
||||||
|
|||||||
@@ -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
|
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
|
# ----------------------------------------------------------------- finger adapter
|
||||||
def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
|
def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
|
||||||
adapter = FingerFrameAdapter(
|
adapter = FingerFrameAdapter(
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""The 3D pane's interface: a picture, one legend line, and a settings dialog.
|
||||||
|
|
||||||
|
The pane used to be half form fields, and that is what left the 3D view occupying a
|
||||||
|
corner of its own splitter slot. Everything configurable now lives in
|
||||||
|
``Scene3dSettingsDialog``; what stays under the picture is what *reading* it needs — the
|
||||||
|
colour ramp, the numbers it was drawn with, and which source produced the field.
|
||||||
|
|
||||||
|
These tests pin three things: the pane shows no settings, the dialog holds the **same**
|
||||||
|
control widgets the panel has always had (so the settings path did not fork), and the
|
||||||
|
view menu / the pane's header both reach that dialog.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PyQt5.QtCore import pyqtSignal
|
||||||
|
from PyQt5.QtWidgets import QWidget
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_studio.ui import icons
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def panel(qt_app):
|
||||||
|
"""A ``ThreeDPanel`` with the coordinator and widget it needs, and no scene."""
|
||||||
|
from orisys_studio.scene3d.camera import OrbitCamera
|
||||||
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||||
|
from orisys_studio.scene3d.gl_backend import GLRenderer
|
||||||
|
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||||
|
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||||
|
|
||||||
|
widget = Sensor3DWidget(GLRenderer(), OrbitCamera())
|
||||||
|
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30)
|
||||||
|
created = ThreeDPanel(coordinator, widget)
|
||||||
|
yield created
|
||||||
|
coordinator.close()
|
||||||
|
created.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- the pane
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pane_holds_a_legend_and_no_settings(panel):
|
||||||
|
"""What is on the pane has to be readable, not adjustable."""
|
||||||
|
# The readouts are part of the pane itself...
|
||||||
|
assert panel.legend_label.parent() is panel
|
||||||
|
assert panel.source_label.parent() is panel
|
||||||
|
assert panel.scale_bar.parent() is panel
|
||||||
|
assert panel.scale_lower_label.parent() is panel
|
||||||
|
assert panel.scale_upper_label.parent() is panel
|
||||||
|
# ...and every setting is in one block that the pane never shows.
|
||||||
|
assert panel.controls_widget.parent() is panel
|
||||||
|
assert not panel.controls_widget.isVisible()
|
||||||
|
for control in (
|
||||||
|
panel.enable_check,
|
||||||
|
panel.preset_combo,
|
||||||
|
panel.housing_check,
|
||||||
|
panel.deform_check,
|
||||||
|
panel.gain_spin,
|
||||||
|
panel.scale_combo,
|
||||||
|
panel.fps_combo,
|
||||||
|
panel.reset_button,
|
||||||
|
):
|
||||||
|
assert control.parent() is panel.controls_widget, control
|
||||||
|
# The engineering counters went with the settings rather than under the picture.
|
||||||
|
assert panel.stats_label.parent() is panel.controls_widget
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_settings_dialog_hosts_the_panes_own_controls(panel):
|
||||||
|
"""One set of widgets either way: the dialog moves them, it does not copy them."""
|
||||||
|
from orisys_studio.scene3d.settings_dialog import Scene3dSettingsDialog
|
||||||
|
|
||||||
|
dialog = Scene3dSettingsDialog(panel)
|
||||||
|
try:
|
||||||
|
assert panel.controls_widget.parent() is dialog, (
|
||||||
|
"the dialog kept a second copy of the controls"
|
||||||
|
)
|
||||||
|
assert panel.controls_widget.isVisibleTo(dialog)
|
||||||
|
assert not dialog.isModal(), "the pane has to stay visible while tuning it"
|
||||||
|
assert dialog.windowTitle() == t("scene3d.settings_title")
|
||||||
|
|
||||||
|
# ...and they still work from there: the signal path is the panel's own.
|
||||||
|
seen: list[bool] = []
|
||||||
|
panel.enabledChanged.connect(seen.append)
|
||||||
|
panel.enable_check.setChecked(True)
|
||||||
|
assert seen == [True], "a control in the dialog stopped reaching the view"
|
||||||
|
|
||||||
|
# Closing hides it: the controls have to survive for the next time.
|
||||||
|
dialog.close()
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
assert panel.enable_check.text(), "closing the dialog destroyed its controls"
|
||||||
|
assert panel.settings().max_fps == 30
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pane_header_offers_the_two_actions(qt_app):
|
||||||
|
"""A gear and a view reset: the two things that belong to *looking*."""
|
||||||
|
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
||||||
|
|
||||||
|
assert "reset_view" in icons.NAMES
|
||||||
|
for theme in ("dark", "light"):
|
||||||
|
assert not icons.icon("reset_view", theme).isNull()
|
||||||
|
|
||||||
|
pane = Mesh3dPanel(theme="dark")
|
||||||
|
try:
|
||||||
|
assert pane.btn_settings.toolTip() == t("panels.mesh3d_settings_tip")
|
||||||
|
assert pane.btn_reset_view.toolTip() == t("panels.mesh3d_reset_tip")
|
||||||
|
assert not pane.btn_settings.icon().isNull()
|
||||||
|
assert not pane.btn_reset_view.icon().isNull()
|
||||||
|
|
||||||
|
# A box sensor has no scene, so there is nothing to configure and the pane says
|
||||||
|
# so instead of opening an empty dialog.
|
||||||
|
pane.set_sensor("box")
|
||||||
|
assert pane.open_settings() is False
|
||||||
|
assert pane.view is None
|
||||||
|
|
||||||
|
# Reset forwards to the view when there is one.
|
||||||
|
class _View(QWidget):
|
||||||
|
diagnosticRaised = pyqtSignal(object)
|
||||||
|
enabledChanged = pyqtSignal(bool)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.resets = 0
|
||||||
|
self.available = True
|
||||||
|
|
||||||
|
def set_source_kind(self, kind: str) -> None:
|
||||||
|
self.source_kind = kind
|
||||||
|
|
||||||
|
def set_enabled(self, enabled: bool) -> None:
|
||||||
|
self.enabled = bool(enabled)
|
||||||
|
|
||||||
|
def restore_settings(self, settings) -> None:
|
||||||
|
self.settings_value = settings
|
||||||
|
|
||||||
|
def reset_view(self) -> None:
|
||||||
|
self.resets += 1
|
||||||
|
|
||||||
|
view = _View()
|
||||||
|
pane.attach_view(view) # type: ignore[arg-type]
|
||||||
|
pane.reset_view()
|
||||||
|
assert view.resets == 1
|
||||||
|
finally:
|
||||||
|
pane.deleteLater()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- which quantity
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(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_diagnostics_visible(True)
|
||||||
|
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(panel):
|
||||||
|
"""Warnings are claims about the measurement and always show; info is explanation."""
|
||||||
|
from orisys_app_shared.scene3d.contracts import FieldDescriptor, RenderStats, diagnostic
|
||||||
|
|
||||||
|
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
||||||
|
panel.set_render_stats(
|
||||||
|
RenderStats(scale_lower=0.25, scale_upper=2.5, scale_mode="automatic")
|
||||||
|
)
|
||||||
|
panel.set_diagnostics_visible(False)
|
||||||
|
assert not panel.scale_bar.isHidden()
|
||||||
|
assert panel.scale_lower_label.text() == "0.25"
|
||||||
|
assert panel.scale_upper_label.text() == "2.5"
|
||||||
|
assert not panel.scale_lower_label.isHidden()
|
||||||
|
assert not panel.scale_upper_label.isHidden()
|
||||||
|
assert panel.legend_label.isHidden()
|
||||||
|
assert panel.source_label.isHidden()
|
||||||
|
assert panel.state_label.isHidden()
|
||||||
|
assert panel.field_note.isHidden()
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
assert panel.legend_label.isHidden()
|
||||||
|
|
||||||
|
panel.set_diagnostics_visible(True)
|
||||||
|
assert not panel.legend_label.isHidden()
|
||||||
|
assert not panel.source_label.isHidden()
|
||||||
|
assert not panel.state_label.isHidden()
|
||||||
|
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(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
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_view_menu_opens_the_3d_settings(make_window):
|
||||||
|
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
window = make_window(sensor_type="finger", show_finger_3d=True)
|
||||||
|
window.show()
|
||||||
|
QApplication.processEvents()
|
||||||
|
try:
|
||||||
|
assert window.act_scene3d_settings.text() == t("menu.scene3d_settings")
|
||||||
|
assert window.act_scene3d_settings in window.view_menu.actions()
|
||||||
|
|
||||||
|
view = window.mesh3d_panel.view
|
||||||
|
if view is None or not view.available:
|
||||||
|
# No GL here: the entry has to say why instead of doing nothing.
|
||||||
|
window.act_scene3d_settings.trigger()
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert window.statusBar().currentMessage().strip()
|
||||||
|
return
|
||||||
|
window.mesh3d_panel.ensure_view()
|
||||||
|
window.act_scene3d_settings.trigger()
|
||||||
|
QApplication.processEvents()
|
||||||
|
dialog = window.mesh3d_panel.view.settings_dialog()
|
||||||
|
assert dialog is not None and dialog.isVisible()
|
||||||
|
dialog.hide()
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
@@ -71,47 +71,40 @@ def _source(path: Path) -> SceneSource:
|
|||||||
return SceneSource(path=path, kind="package", directory=path.parent)
|
return SceneSource(path=path, kind="package", directory=path.parent)
|
||||||
|
|
||||||
|
|
||||||
def test_a_signed_package_opens_from_the_application(tmp_path):
|
@pytest.mark.parametrize("sealed", (False, True), ids=("signed", "sealed"))
|
||||||
|
def test_a_protected_package_opens_from_the_application(tmp_path, sealed):
|
||||||
"""The review's R4: the library could read it; the application could not.
|
"""The review's R4: the library could read it; the application could not.
|
||||||
|
|
||||||
With the verification key resolved from the preferences, the pane's own
|
With keys resolved from preferences, the pane's own loader accepts both signed
|
||||||
``load_package`` accepts the package and the scene comes out intact.
|
and sealed release packages and returns their scene intact.
|
||||||
"""
|
"""
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False)
|
package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=sealed)
|
||||||
|
prefs = {PREF_VERIFY_KEY: str(verify_path)}
|
||||||
|
if sealed:
|
||||||
|
prefs.update(
|
||||||
|
{
|
||||||
|
PREF_CONTENT_KEY_FILE: str(key_path),
|
||||||
|
PREF_CONTENT_KEY_ID: KEY_ID,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)})
|
keys = resolve_scene_keys(prefs)
|
||||||
assert keys.verify_key_pem is not None
|
|
||||||
assert keys.describe()["verify_key"] is True
|
|
||||||
assets = load_package(_source(package), _expected(scene), keys)
|
|
||||||
assert assets.geometry_id == scene.geometry_id
|
|
||||||
# The manifest's own hash, recomputed when the package was written: the synthetic
|
|
||||||
# scene's ``asset_hash`` is a placeholder until it goes through ``write_package``.
|
|
||||||
assert len(assets.asset_hash) == 64
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_sealed_package_opens_with_the_content_key_from_the_preferences(tmp_path):
|
|
||||||
scene = synthetic.poc_scene()
|
|
||||||
package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=True)
|
|
||||||
|
|
||||||
keys = resolve_scene_keys(
|
|
||||||
{
|
|
||||||
PREF_VERIFY_KEY: str(verify_path),
|
|
||||||
PREF_CONTENT_KEY_FILE: str(key_path),
|
|
||||||
PREF_CONTENT_KEY_ID: KEY_ID,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert keys.provider is not None
|
|
||||||
described = keys.describe()
|
described = keys.describe()
|
||||||
assert described["content_key"] is True
|
|
||||||
assert described["content_key_id"] == KEY_ID
|
|
||||||
# The description carries paths and ids, never key material.
|
|
||||||
assert CONTENT_KEY_HEX not in json.dumps(described)
|
|
||||||
assert keys.verify_key_pem is not None
|
assert keys.verify_key_pem is not None
|
||||||
|
assert described["verify_key"] is True
|
||||||
assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem
|
assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem
|
||||||
|
if sealed:
|
||||||
|
assert keys.provider is not None
|
||||||
|
assert described["content_key"] is True
|
||||||
|
assert described["content_key_id"] == KEY_ID
|
||||||
|
# The description carries paths and ids, never key material.
|
||||||
|
assert CONTENT_KEY_HEX not in json.dumps(described)
|
||||||
|
|
||||||
assets = load_package(_source(package), _expected(scene), keys)
|
assets = load_package(_source(package), _expected(scene), keys)
|
||||||
assert assets.geometry_id == scene.geometry_id
|
assert assets.geometry_id == scene.geometry_id
|
||||||
|
# The writer replaces the synthetic scene's placeholder with the manifest hash.
|
||||||
|
assert len(assets.asset_hash) == 64
|
||||||
|
|
||||||
|
|
||||||
def test_without_the_verification_key_a_signed_package_is_refused(tmp_path):
|
def test_without_the_verification_key_a_signed_package_is_refused(tmp_path):
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ mathematics (the camera, the sampler and the composer have their own files):
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import itertools
|
|
||||||
import shutil
|
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
@@ -51,33 +49,12 @@ from orisys_studio.scene3d.asset_source import (
|
|||||||
load_package,
|
load_package,
|
||||||
no_asset_diagnostic,
|
no_asset_diagnostic,
|
||||||
)
|
)
|
||||||
from tests.test_ui_smoke import ( # noqa: F401 (fixtures are imported by name)
|
from tests.conftest import (
|
||||||
_finish,
|
finish_run as _finish,
|
||||||
_running_window,
|
running_window as _running_window,
|
||||||
record_root,
|
show_window as _show,
|
||||||
replay_recording,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Scratch root for the two-session comparison. One subdirectory per run, because
|
|
||||||
#: the point of the test is that two recordings are identical, not that one file
|
|
||||||
#: can be overwritten.
|
|
||||||
RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def next_record_root():
|
|
||||||
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
|
|
||||||
RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
|
|
||||||
counter = itertools.count()
|
|
||||||
|
|
||||||
def _next() -> Path:
|
|
||||||
path = RECORD_SCRATCH / f"run{next(counter)}"
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
yield _next
|
|
||||||
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def write_poc(tmp_path):
|
def write_poc(tmp_path):
|
||||||
@@ -496,6 +473,19 @@ def test_a_saved_descriptor_is_never_restored_as_a_measurement():
|
|||||||
assert restored_with.color_scale.descriptor == given
|
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(
|
@pytest.mark.parametrize(
|
||||||
"block",
|
"block",
|
||||||
[
|
[
|
||||||
@@ -515,6 +505,7 @@ def test_a_hand_edited_settings_block_is_repaired(block):
|
|||||||
assert isinstance(settings, DisplaySettings)
|
assert isinstance(settings, DisplaySettings)
|
||||||
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
|
assert scene3d_prefs.MIN_GAIN <= settings.display_gain <= scene3d_prefs.MAX_GAIN
|
||||||
assert settings.max_fps in scene3d_prefs.FPS_CHOICES
|
assert settings.max_fps in scene3d_prefs.FPS_CHOICES
|
||||||
|
assert settings.field in scene3d_prefs.FIELD_CHOICES
|
||||||
if settings.color_scale is not None:
|
if settings.color_scale is not None:
|
||||||
lower, upper = settings.color_scale.lower, settings.color_scale.upper
|
lower, upper = settings.color_scale.lower, settings.color_scale.upper
|
||||||
assert lower == lower and upper == upper, "a NaN survived the repair"
|
assert lower == lower and upper == upper, "a NaN survived the repair"
|
||||||
@@ -1060,8 +1051,6 @@ def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from(
|
|||||||
what a recompute pass produces from the video. Those are two different claims and
|
what a recompute pass produces from the video. Those are two different claims and
|
||||||
the label has to distinguish them.
|
the label has to distinguish them.
|
||||||
"""
|
"""
|
||||||
from tests.test_ui_smoke import _show
|
|
||||||
|
|
||||||
window = _show(make_window(sensor_type="finger"), (1440, 880))
|
window = _show(make_window(sensor_type="finger"), (1440, 880))
|
||||||
try:
|
try:
|
||||||
assert window._load_recording(Path(replay_recording))
|
assert window._load_recording(Path(replay_recording))
|
||||||
@@ -1124,7 +1113,6 @@ def test_every_run_gets_a_new_generation(make_window, qt_app):
|
|||||||
so the picture stays on the first run's last frame forever.
|
so the picture stays on the first run's last frame forever.
|
||||||
"""
|
"""
|
||||||
from orisys_app_shared.scene3d.contracts import FrameIdentity
|
from orisys_app_shared.scene3d.contracts import FrameIdentity
|
||||||
from tests.test_ui_smoke import _show
|
|
||||||
import ui_check
|
import ui_check
|
||||||
|
|
||||||
window = _show(make_window(sensor_type="finger"), (1440, 880))
|
window = _show(make_window(sensor_type="finger"), (1440, 880))
|
||||||
|
|||||||
@@ -9,20 +9,16 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import ui_check
|
import ui_check
|
||||||
from tests.conftest import visible_descendants
|
from tests.conftest import (
|
||||||
|
finish_run as _finish,
|
||||||
|
running_window as _running_window,
|
||||||
|
show_window as _show,
|
||||||
|
visible_descendants,
|
||||||
|
)
|
||||||
|
|
||||||
RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080))
|
RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080))
|
||||||
|
|
||||||
|
|
||||||
def _show(window, size):
|
|
||||||
window.resize(*size)
|
|
||||||
window.show()
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
|
||||||
|
|
||||||
QApplication.processEvents()
|
|
||||||
return window
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("theme", "sensor_type"), [
|
@pytest.mark.parametrize(("theme", "sensor_type"), [
|
||||||
("dark", "box"),
|
("dark", "box"),
|
||||||
("light", "box"),
|
("light", "box"),
|
||||||
@@ -211,8 +207,9 @@ def test_toolbar_run_button_is_labelled(make_window):
|
|||||||
assert window.toolbar.btn_run.text() != label
|
assert window.toolbar.btn_run.text() != label
|
||||||
|
|
||||||
|
|
||||||
def test_stop_cancels_a_hung_connect(make_window):
|
@pytest.mark.parametrize("cancel_via", ("toolbar", "escape"))
|
||||||
"""Stop must stay live and take effect instantly while connecting."""
|
def test_a_hung_connect_can_be_cancelled_immediately(make_window, cancel_via):
|
||||||
|
"""Both cancellation paths stay responsive while a device is connecting."""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import ui_check
|
import ui_check
|
||||||
@@ -226,35 +223,24 @@ def test_stop_cancels_a_hung_connect(make_window):
|
|||||||
|
|
||||||
window.toolbar.btn_run.setChecked(True)
|
window.toolbar.btn_run.setChecked(True)
|
||||||
assert window._session_phase == "connecting"
|
assert window._session_phase == "connecting"
|
||||||
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
|
try:
|
||||||
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
|
if cancel_via == "toolbar":
|
||||||
assert window.act_stop.isEnabled()
|
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
|
||||||
|
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
|
||||||
|
assert window.act_stop.isEnabled()
|
||||||
|
cancel = lambda: window.toolbar.btn_run.setChecked(False)
|
||||||
|
else:
|
||||||
|
assert window.act_stop.shortcut().toString() == "Esc"
|
||||||
|
cancel = window._on_stop_shortcut
|
||||||
|
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
window.toolbar.btn_run.setChecked(False)
|
cancel()
|
||||||
elapsed = time.perf_counter() - started
|
elapsed = time.perf_counter() - started
|
||||||
assert elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s"
|
assert elapsed < 0.5, f"{cancel_via} cancellation blocked for {elapsed:.2f}s"
|
||||||
assert not window._running
|
assert not window._running
|
||||||
assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread"
|
assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread"
|
||||||
|
finally:
|
||||||
|
stuck.release()
|
||||||
def test_escape_cancels_a_hung_connect(make_window):
|
|
||||||
import time
|
|
||||||
|
|
||||||
import ui_check
|
|
||||||
|
|
||||||
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
|
||||||
stuck = ui_check._StuckWorker()
|
|
||||||
window._ensure_workers = lambda: None
|
|
||||||
window._box_worker = stuck
|
|
||||||
window._finger_worker = stuck
|
|
||||||
|
|
||||||
window.toolbar.btn_run.setChecked(True)
|
|
||||||
assert window.act_stop.shortcut().toString() == "Esc"
|
|
||||||
started = time.perf_counter()
|
|
||||||
window._on_stop_shortcut()
|
|
||||||
assert time.perf_counter() - started < 0.5
|
|
||||||
assert not window._running
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_switch_updates_panels(make_window):
|
def test_theme_switch_updates_panels(make_window):
|
||||||
@@ -581,31 +567,6 @@ def test_force_plots_render_demo_data(make_window):
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- record channel
|
# --------------------------------------------------------------- record channel
|
||||||
|
|
||||||
|
|
||||||
def _running_window(make_window):
|
|
||||||
"""A window in the ``running`` phase whose worker never opens a device.
|
|
||||||
|
|
||||||
The Qt timer is stopped deliberately: these tests drive the display path by
|
|
||||||
calling ``_on_run_tick`` themselves, and a concurrent 16 ms timer would race
|
|
||||||
the assertions (and outlive the window on teardown).
|
|
||||||
"""
|
|
||||||
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
|
||||||
stuck = ui_check._StuckWorker()
|
|
||||||
window._ensure_workers = lambda: None
|
|
||||||
window._box_worker = stuck
|
|
||||||
window._finger_worker = stuck
|
|
||||||
window._start_run()
|
|
||||||
window._run_timer.stop()
|
|
||||||
window._set_phase("running")
|
|
||||||
return window, stuck
|
|
||||||
|
|
||||||
|
|
||||||
def _finish(window, stuck):
|
|
||||||
window._stop_run(reason="user_stop")
|
|
||||||
stuck.release()
|
|
||||||
|
|
||||||
|
|
||||||
def test_record_channel_does_not_depend_on_the_ui_timer(make_window):
|
def test_record_channel_does_not_depend_on_the_ui_timer(make_window):
|
||||||
window, stuck = _running_window(make_window)
|
window, stuck = _running_window(make_window)
|
||||||
for index in range(90):
|
for index in range(90):
|
||||||
@@ -965,48 +926,6 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- experiment record
|
# ------------------------------------------------------------- experiment record
|
||||||
|
|
||||||
#: Recording scratch goes under the app's gitignored ``outputs`` tree rather than
|
|
||||||
#: the system temp directory, so the tests also run under a file sandbox that
|
|
||||||
#: only permits writes inside the workspace.
|
|
||||||
RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def record_root():
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
|
|
||||||
RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
|
|
||||||
yield RECORD_SCRATCH
|
|
||||||
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def replay_recording(record_root):
|
|
||||||
"""One short finished recording (video + forces + frames + session.json)."""
|
|
||||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
|
||||||
|
|
||||||
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0))
|
|
||||||
recording = recorder.start(
|
|
||||||
root=record_root,
|
|
||||||
session_id=1,
|
|
||||||
metadata={
|
|
||||||
"device": {
|
|
||||||
"sensor_type": "box",
|
|
||||||
"config_ref": str(record_root / "sensor.json"),
|
|
||||||
"config_sha256": "recorded",
|
|
||||||
},
|
|
||||||
"versions": {"sdk": "0.5.1"},
|
|
||||||
"depth": {"stored": False, "replay": "recompute"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for index in range(60):
|
|
||||||
recorder.on_packet(ui_check._fake_packet(index))
|
|
||||||
recorder.stop()
|
|
||||||
yield Path(recording)
|
|
||||||
|
|
||||||
|
|
||||||
def test_recording_writes_the_documented_experiment_layout(make_window, record_root):
|
def test_recording_writes_the_documented_experiment_layout(make_window, record_root):
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
@@ -1356,6 +1275,12 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
|||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
panel = window.arrow_panel
|
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 panel.scale_label.isVisible()
|
||||||
assert "2" in panel.scale_label.text()
|
assert "2" in panel.scale_label.text()
|
||||||
assert panel.pipeline_label.isVisible() and panel.pipeline_label.text()
|
assert panel.pipeline_label.isVisible() and panel.pipeline_label.text()
|
||||||
@@ -1372,6 +1297,9 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
|||||||
assert not panel.colorbar.isVisible()
|
assert not panel.colorbar.isVisible()
|
||||||
assert panel.scale_label.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
|
# ------------------------------------------------- top rail: sensor and views
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,20 @@ def test_a_preset_that_gives_no_width_to_the_3d_pane_takes_it_from_the_widest():
|
|||||||
assert controller.splitter_sizes_for("live") == list(VIEW_SPLITTER_SIZES["live"])
|
assert controller.splitter_sizes_for("live") == list(VIEW_SPLITTER_SIZES["live"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_live_layout_makes_the_3d_pane_the_widest_panel():
|
||||||
|
"""The 3D view is the subject when it is on, not an inset beside the charts."""
|
||||||
|
from orisys_studio.app_window import DEFAULT_SPLITTER_SIZES, VIEW_SPLITTER_SIZES as LIVE
|
||||||
|
|
||||||
|
mesh, image, charts = DEFAULT_SPLITTER_SIZES
|
||||||
|
assert mesh > image, DEFAULT_SPLITTER_SIZES
|
||||||
|
assert mesh > charts, DEFAULT_SPLITTER_SIZES
|
||||||
|
live_mesh, live_image, live_charts, _cal_live = LIVE["live"]
|
||||||
|
assert live_mesh > live_image and live_mesh > live_charts, LIVE["live"]
|
||||||
|
assert LIVE["replay"] == LIVE["live"], (
|
||||||
|
"the replay view is the live layout with another source"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_calibration_panel_toggle_is_the_calibration_view_switch():
|
def test_the_calibration_panel_toggle_is_the_calibration_view_switch():
|
||||||
controller, parts = _controller(prefs={"view_preset": "live"})
|
controller, parts = _controller(prefs={"view_preset": "live"})
|
||||||
|
|
||||||
|
|||||||
@@ -1366,6 +1366,22 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
arrow = window.arrow_panel
|
arrow = window.arrow_panel
|
||||||
|
# The pipeline labels (magnification, source → output, valid area) are diagnostics:
|
||||||
|
# hidden by default, so the canvas keeps one line about what the picture *is*. The
|
||||||
|
# switch is the same one 视图 ▸ 诊断信息 drives.
|
||||||
|
check(
|
||||||
|
not arrow.diagnostics_visible(),
|
||||||
|
"the panel explains its pipeline before the operator asked for it",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
not arrow.scale_label.isVisible()
|
||||||
|
and not arrow.pipeline_label.isVisible()
|
||||||
|
and not arrow.area_label.isVisible(),
|
||||||
|
"the pipeline labels are shown with 诊断信息 off",
|
||||||
|
)
|
||||||
|
arrow.set_diagnostics_visible(True)
|
||||||
|
for _ in range(2):
|
||||||
|
app.processEvents()
|
||||||
check(
|
check(
|
||||||
arrow.scale_label.isVisible() and arrow.scale_label.text(),
|
arrow.scale_label.isVisible() and arrow.scale_label.text(),
|
||||||
"the arrow view does not show its display magnification",
|
"the arrow view does not show its display magnification",
|
||||||
@@ -1406,6 +1422,14 @@ def main() -> int:
|
|||||||
not arrow.colorbar.isVisible() and arrow.scale_label.isVisible(),
|
not arrow.colorbar.isVisible() and arrow.scale_label.isVisible(),
|
||||||
"leaving the depth view did not restore the arrow legend",
|
"leaving the depth view did not restore the arrow legend",
|
||||||
)
|
)
|
||||||
|
# ...and the switch takes them away again, whatever view is on screen.
|
||||||
|
arrow.set_diagnostics_visible(False)
|
||||||
|
for _ in range(2):
|
||||||
|
app.processEvents()
|
||||||
|
check(
|
||||||
|
not arrow.scale_label.isVisible() and arrow.colorbar.isVisible() is False,
|
||||||
|
"the pipeline labels survived 诊断信息 being turned off",
|
||||||
|
)
|
||||||
|
|
||||||
# The overview strip is the timeline control: dragging it pans the window.
|
# The overview strip is the timeline control: dragging it pans the window.
|
||||||
stuck = _StuckWorker()
|
stuck = _StuckWorker()
|
||||||
|
|||||||
@@ -147,6 +147,117 @@ def _run_scene(window, app, scene: str):
|
|||||||
app.processEvents()
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _flow_scene(window, app, *, frames: int = 240) -> None:
|
||||||
|
"""Drive a synthetic session whose frames carry a real optical-flow field.
|
||||||
|
|
||||||
|
The 3D pane is coloured by the frame's *own* flow (see
|
||||||
|
``orisys_studio.scene3d.adapters``): a packet that does not state one is refused,
|
||||||
|
so a picture with colour needs packets that do. This is the scene the interface is
|
||||||
|
reviewed from — the real widgets, the real renderer, the real adapter, and a field
|
||||||
|
shaped like a pressed fingertip (strongest at the centre, fading to the rim).
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
|
||||||
|
|
||||||
|
from ui_check import _StuckWorker
|
||||||
|
|
||||||
|
stuck = _StuckWorker()
|
||||||
|
window._ensure_workers = lambda: None
|
||||||
|
window._box_worker = stuck
|
||||||
|
window._finger_worker = stuck
|
||||||
|
window.toolbar.btn_run.setChecked(True)
|
||||||
|
for _ in range(4):
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
# The unrolled finger domain: 280x300, which is the grid the scene's UV island
|
||||||
|
# uses. The flow is already unrolled, so the adapter is given no remap.
|
||||||
|
height, width = 300, 280
|
||||||
|
grid_y, grid_x = np.mgrid[0:height, 0:width].astype(np.float32)
|
||||||
|
centre_y, centre_x = height * 0.52, width / 2.0
|
||||||
|
dy, dx = grid_y - centre_y, grid_x - centre_x
|
||||||
|
radius = np.sqrt(dx * dx + dy * dy) + 1e-3
|
||||||
|
profile = np.exp(-((radius / (width * 0.30)) ** 2)).astype(np.float32)
|
||||||
|
flow = np.stack(
|
||||||
|
[dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1
|
||||||
|
).astype(np.float32)
|
||||||
|
# The other quantity the pane can colour: the chain's deformation response (a
|
||||||
|
# relative, unitless "how deep"). Both are carried so the 颜色来源 switch has
|
||||||
|
# something to switch between, and each is stated as this frame's own field.
|
||||||
|
depth = (profile * 0.9).astype(np.float32)
|
||||||
|
# A recognisable camera image as well, so the 2D panel is not black next to it.
|
||||||
|
frame = np.zeros((height, width, 3), np.uint8)
|
||||||
|
frame[..., 1] = (40.0 + profile * 150.0).astype(np.uint8)
|
||||||
|
frame[..., 2] = (profile * 90.0).astype(np.uint8)
|
||||||
|
|
||||||
|
for index in range(frames):
|
||||||
|
# The forces move: a flat line reads as "the demo is not running", and the
|
||||||
|
# charts are half of what the video has to show.
|
||||||
|
phase = index / 30.0
|
||||||
|
# A period of a couple of seconds, so the trace visibly moves inside the chart's
|
||||||
|
# rolling window instead of looking like a slow ramp.
|
||||||
|
normal = 3.4 + 2.4 * math.sin(phase * 2.4)
|
||||||
|
shear_x = 1.6 * math.sin(phase * 3.1 + 0.4)
|
||||||
|
shear_y = -1.3 * math.cos(phase * 2.7)
|
||||||
|
stuck.emit(
|
||||||
|
SdkPacket(
|
||||||
|
raw_frame=frame,
|
||||||
|
processed_frame=frame,
|
||||||
|
flow=flow,
|
||||||
|
depth=depth,
|
||||||
|
timestamp=1000.0 + index / 30.0,
|
||||||
|
mono_t=float(index) / 30.0,
|
||||||
|
fps=30.0,
|
||||||
|
process_ms=6.0,
|
||||||
|
capture_fps=30.0,
|
||||||
|
session_id=1,
|
||||||
|
frame_id=index,
|
||||||
|
valid=True,
|
||||||
|
raw_available=True,
|
||||||
|
normal_force=normal,
|
||||||
|
shear_x=shear_x,
|
||||||
|
shear_y=shear_y,
|
||||||
|
normal_force_raw=9.0 + index * 0.1,
|
||||||
|
shear_x_raw=0.4,
|
||||||
|
shear_y_raw=-0.4,
|
||||||
|
force_unit="N",
|
||||||
|
force_calibrated=True,
|
||||||
|
calibrated_axes=("fn", "fx", "fy"),
|
||||||
|
flow_valid=True,
|
||||||
|
flow_frame_id=index,
|
||||||
|
depth_valid=True,
|
||||||
|
depth_frame_id=index,
|
||||||
|
forces_valid=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# The window's own presenter runs *while* the frames arrive, the way it does in
|
||||||
|
# a session: the charts then hold a real time series instead of one sample.
|
||||||
|
if index % 4 == 0:
|
||||||
|
window._on_run_tick()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
# The 3D picture is composed on the view's own worker and taken one finished frame
|
||||||
|
# at a time by the run timer, so pump both until something has actually been shown.
|
||||||
|
view = window.mesh3d_panel.view
|
||||||
|
deadline = _time.perf_counter() + 20.0
|
||||||
|
while _time.perf_counter() < deadline:
|
||||||
|
window._on_run_tick()
|
||||||
|
app.processEvents()
|
||||||
|
if view is not None and view.render_totals().get("shown", 0) > 0:
|
||||||
|
break
|
||||||
|
_time.sleep(0.01)
|
||||||
|
window._run_timer.stop()
|
||||||
|
# The overview strip is rebuilt at most once a second (it is a whole-session
|
||||||
|
# decimation) and this scene fills eight seconds of session in well under that, so
|
||||||
|
# it is rebuilt once here — the same thing ``_demo_data`` does for the same reason.
|
||||||
|
window.force_plots._overview_dirty = True
|
||||||
|
window.force_plots._last_overview_at = 0.0
|
||||||
|
window.force_plots._sync_overview()
|
||||||
|
for _ in range(6):
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def _demo_data(window) -> None:
|
def _demo_data(window) -> None:
|
||||||
"""Fill the plots, the calibration table and the arrow canvas."""
|
"""Fill the plots, the calibration table and the arrow canvas."""
|
||||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||||
@@ -242,14 +353,16 @@ def main() -> int:
|
|||||||
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
|
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--scene",
|
"--scene",
|
||||||
choices=("idle", "connecting", "recording", "replay"),
|
choices=("idle", "connecting", "recording", "replay", "flow3d"),
|
||||||
default="idle",
|
default="idle",
|
||||||
help=(
|
help=(
|
||||||
"connecting renders the Stop/cancel state of a hung device open; "
|
"connecting renders the Stop/cancel state of a hung device open; "
|
||||||
"recording renders the record strip of a running recording "
|
"recording renders the record strip of a running recording "
|
||||||
"(mocked stats: no file is written, no device is opened); "
|
"(mocked stats: no file is written, no device is opened); "
|
||||||
"replay writes one short synthetic recording and opens it in the "
|
"replay writes one short synthetic recording and opens it in the "
|
||||||
"replay view so the progress bar can be reviewed"
|
"replay view so the progress bar can be reviewed; "
|
||||||
|
"flow3d drives packets that carry a real optical-flow field, which is "
|
||||||
|
"what colours the 3D pane (use with --sensor finger --panels 3d)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -317,7 +430,10 @@ def main() -> int:
|
|||||||
if args.scene == "replay":
|
if args.scene == "replay":
|
||||||
_run_scene(window, app, "replay")
|
_run_scene(window, app, "replay")
|
||||||
|
|
||||||
if args.demo and args.scene != "replay":
|
if args.scene == "flow3d":
|
||||||
|
_flow_scene(window, app)
|
||||||
|
|
||||||
|
if args.demo and args.scene not in ("replay", "flow3d"):
|
||||||
_demo_data(window)
|
_demo_data(window)
|
||||||
for _ in range(4):
|
for _ in range(4):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
|
|||||||
@@ -636,10 +636,23 @@ shared 全部 **262 项**。`ui_check.py` **566 项检查 0 失败**(新增 "3
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
1. **阶段 8 第二批(画布布局)**:2D/3D 页签容器(默认页签切换、可选并排对照)、参数收进"显示设置"抽屉、
|
1. **阶段 8 第二批(画布布局)——部分已做(2026-09-15)**:
|
||||||
常驻只留视角/复位/外壳/紧凑图例、窄窗口回退、三维画布在 1210×800 下 ≥320 px 高、曲线"可见区间自动/
|
*已做*:三维面板本身只留画面 + 一行紧凑图例(色条 + 来源/状态)+ 两个图标(复位视角、齿轮),
|
||||||
固定量程"开关、调试统计移入诊断抽屉。为什么留后:它要动 splitter 的槽位与 `ui_check` 的多处布局断言,
|
全部**配置**(总开关、观察方向、显示外壳、放大形变与增益、颜色来源、色标模式与范围、显示帧率、
|
||||||
并且需要与用户一起看截图定稿(报告里的建议是"传感器视图 + 力曲线两栏")。
|
显示统计)搬进 `视图 ▸ 三维显示设置…` 和面板齿轮打开的非模态对话框
|
||||||
|
(`scene3d/settings_dialog.py`,它承载的还是面板自己那套控件,所以设置通路没有分叉);
|
||||||
|
live/replay 预设把三维设为**最宽**面板(620 : 300 : 520),三维最小宽度 260 → 200;
|
||||||
|
**颜色来源**可选「深度响应(默认)/ 光流幅值」:两者都是近似量、都不是毫米,选中的那个画不出来时
|
||||||
|
自动改用另一个,并在设置对话框里写明"这一帧画的是哪一个";图例永远标注实际画出来的量
|
||||||
|
(`scene3d/panel.py` 的 `field_note`、`adapters.FingerFrameAdapter.last_substitution`);
|
||||||
|
面板脚下的内部解释(2D 的"箭头按位移放大 / 原始图像 → 算法输出 / 全部帧"、3D 的 info 级诊断)
|
||||||
|
改由 `视图 ▸ 诊断信息` 一个开关控制,**默认关**:默认每个面板只剩"画面 + 读数 + 真话声明",
|
||||||
|
异常(过期、无可用场、未与设备核对)照旧显示。
|
||||||
|
*仍未做*:2D/3D 页签容器与"并排对照"、窄窗口回退策略、曲线"可见区间自动/固定量程"开关、
|
||||||
|
调试统计移入独立诊断抽屉(统计现在在设置对话框底部);阶段 9 的真机/板卡门禁同前。
|
||||||
|
为什么仍留后:页签容器要动 splitter 的槽位与 `ui_check` 的多处布局断言,需要与用户一起看截图定稿。
|
||||||
|
*真机待验证*:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到
|
||||||
|
光流;这条只有接上设备才能确认)。
|
||||||
2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完
|
2. **阶段 9 门禁运行**:GUI 模式 **2 小时**长时(`soak_scene.py --minutes 120 --fps 30`)——本阶段已跑完
|
||||||
10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但
|
10 分钟的一份(见验收表格:增长 4.83 MB、尾段 0.371 MB/min,外推 2 小时约 45 MB,低于 64 MB 上限,但
|
||||||
**外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、
|
**外推不是门禁**,2 小时整仍需实跑);以及 Jetson Orin Nano / QCS6490 上的 `probe_gl.py --json`、
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
|
|||||||
#: show the geometry, but it must not claim the measurement was checked against it.
|
#: show the geometry, but it must not claim the measurement was checked against it.
|
||||||
"geometry_unverified": "info",
|
"geometry_unverified": "info",
|
||||||
"stale_frame": "info",
|
"stale_frame": "info",
|
||||||
|
#: The operator asked for one quantity and the frame could only be coloured with the
|
||||||
|
#: other one. Informational: the picture is still a measurement and names itself; what
|
||||||
|
#: it is not is the field the setting says.
|
||||||
|
"field_substituted": "info",
|
||||||
"gl_unsupported": "error",
|
"gl_unsupported": "error",
|
||||||
"shader_compile_failed": "error",
|
"shader_compile_failed": "error",
|
||||||
"gpu_budget_exceeded": "error",
|
"gpu_budget_exceeded": "error",
|
||||||
@@ -537,6 +541,13 @@ class DisplaySettings:
|
|||||||
color_scale: Optional[ColorScale] = None
|
color_scale: Optional[ColorScale] = None
|
||||||
housing_visible: bool = True
|
housing_visible: bool = True
|
||||||
max_fps: int = 30
|
max_fps: int = 30
|
||||||
|
#: Which scalar field colours the surface: the chain's deformation response
|
||||||
|
#: (``"depth"``) or the tracker's optical-flow magnitude (``"flow"``). This is a
|
||||||
|
#: *display* choice between two quantities a frame may carry, never a relabelling:
|
||||||
|
#: whichever is drawn is reported by that frame's own descriptor, and a field the
|
||||||
|
#: producer did not claim for this frame is refused before either is coloured (see
|
||||||
|
#: ``orisys_studio.scene3d.adapters``).
|
||||||
|
field: str = "depth"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user