From e2570925e80b742f4cecc93f7e96ac7a41d4edbb Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 21:35:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(studio-3d):=20=E8=83=BD=E5=8A=9B=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E3=80=81=E7=9C=9F=E5=AE=9E=E8=89=B2=E6=A0=87=E4=B8=8E?= =?UTF-8?q?=E5=A4=8D=E4=BD=8D=E8=AF=AD=E4=B9=89=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=8F=AA=E6=8F=90=E4=BE=9B=E6=95=B0=E6=8D=AE=E6=BA=90=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E5=96=82=E5=BE=97=E5=87=BA=E6=9D=A5=E7=9A=84=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=EF=BC=88=E9=98=B6=E6=AE=B5=208=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - What: 1. **能力声明**:`contracts.SceneCapabilities`(quantity/unit/displacement_available/ displacement_quality/source_kind);两个适配器都声明 `displacement_available = False`(它们只把标量 画在表面上,从不发布位移);`Scene3DView.capabilities()` 从**实际绑定的适配器**读出并推给面板。 `ThreeDPanel.set_capabilities()` 因此把"放大形变"/增益置灰并给出原因(新键 `scene3d.deform_unavailable`), `settings()` 在能力缺失时**强制** `deform_enabled=False`(否则持久化的增益会悄悄作用在没有东西可放大的源上); 禁用状态在 `set_enabled`/`apply_settings` 之后仍然成立(原来 `apply_settings` 只按复选框状态使能增益)。 2. **真实色标**:`GLRenderer` 记录并暴露本次绘制**真正使用的**上下限与模式(`effective_scale()`), 写进 `RenderStats.scale_lower/scale_upper/scale_mode`;`_scalar_range` 在 `release()` 与 `upload_scene()` 重置(原来换场景/重建 context 后首个自动范围沿用旧场景);面板新增 `ColorScaleBar` (用 `gl_backend.lut_rgba` 的同一张 256×1 表绘制),图例改为 `{quantity} [{unit}] · {自动|固定}色标 {lower} – {upper} · {quality}`(新键 `scene3d.legend_effective`/ `legend_auto`/`legend_fixed`),自动模式下不再让被禁用的输入框冒充"当前范围"。 3. **文案**:`scene3d.quantity_flow_magnitude`="光流幅值"/`quantity_depth_response`,preset "default" 显示为 "默认视角"(数据仍是资产里的真名),`scene3d.stats` 的"丢弃"改为"显示跳帧"(与录像丢帧区分)。 4. **复位语义**:`Scene3DView.reset_view()` 改为回到**资产自己的默认视角**(`camera_presets[0]`), 与首次显示一致;只有在没有 preset 时才退回 `OrbitCamera.reset()`。原来它把 yaw/pitch 清零并 refit, 与首帧呈现的 preset 视角不是同一个画面。 5. `describe()` 记录 `capabilities`(含 `displacement_available`)与 `effective_scale`(实际上下限与模式), 这样一条录像能说明"当时的颜色代表什么",而不是只留下操作者输入框里的数字。 - Why: 审查报告 R6:`FingerFrameAdapter` 只输出光流幅值、`displacement_sensor_m` 始终为 None,而面板仍允许 "放大形变"和 0×..500×——用户能操作一个没有真实输入的功能;自动色标每帧按实际范围变化,但面板范围输入框 仍是保存的 0..1,没有把颜色和数值联系起来的图例;`reset_view()` 与首次显示用的资产 preset 语义不一致。 报告明确要求"由 SceneCapabilities / 当前 FieldDescriptor 控制控件可用性""新增独立 effective_scale 输出" "复位也应统一语义"。 - Verification: · `test_scene3d_view.py` +3:无位移源 → 形变控件禁用且带原因、`settings().deform_enabled` 仍为 False、 `describe()["capabilities"]["displacement_available"] is False`;图例显示实际上下限(0.25–7.5)与"自动"、 不再出现内部键、`describe()["effective_scale"]` 三项一致;复位后的相机矩阵等于 `apply_preset(camera_presets[0])` 的结果(先 orbit+zoom 离开再复位)。 · `test_scene3d_skeleton.py` 的图例断言改为"不含内部键、含操作者语言的名字"。 · 全量:apps/studio 240 项 0 失败 14 跳过;真 GL 冒烟 14 项 0 失败 0 跳过;ui_check 587 项 0 失败; locale_check 0 缺失。 - Not in this change: **画布布局(2D/3D 页签容器、参数收进"显示设置"抽屉、窄窗口回退)留作阶段 8 的第二批** (需要同时改 `ui_check` 的布局断言与截图对比,风险与工作量都更大,独立提交更易审阅);曲线量程的 "可见区间自动/固定"开关(报告建议项)也留到第二批;诊断抽屉的结构调整同理;阶段 9(限速、真实耗时统计、 保护包密钥注入、ARM 与 2 小时门禁)未开始。 --- apps/studio/config/locales/en_US.json | 10 +- apps/studio/config/locales/zh_CN.json | 10 +- .../src/orisys_studio/scene3d/adapters.py | 13 ++ .../src/orisys_studio/scene3d/gl_backend.py | 27 ++++ .../studio/src/orisys_studio/scene3d/panel.py | 152 +++++++++++++++++- apps/studio/src/orisys_studio/scene3d/view.py | 89 ++++++++-- .../src/orisys_studio/scene3d/widget.py | 3 + apps/studio/tests/test_scene3d_skeleton.py | 6 +- apps/studio/tests/test_scene3d_view.py | 86 ++++++++++ .../orisys_app_shared/scene3d/contracts.py | 26 +++ 10 files changed, 400 insertions(+), 22 deletions(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 92e2577..b8489a5 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -401,6 +401,14 @@ "housing": "Housing", "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_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface with the optical-flow magnitude, so exaggeration is unavailable.", + "quantity_flow_magnitude": "Optical flow magnitude", + "quantity_depth_response": "Depth response", + "mode_surface": "surface colouring", + "preset_default": "Default view", + "legend_effective": "{quantity} [{unit}] · {mode} scale {lower} – {upper} · {quality}", + "legend_auto": "automatic", + "legend_fixed": "fixed", "scale": "Colour scale", "scale_range": "Range", "scale_range_tip": "Bounds of the colour scale in fixed mode. In automatic mode the range comes from the valid samples of the frame on screen, so a quantity whose size differs by orders of magnitude is still readable.", @@ -431,6 +439,6 @@ "severity_info": "Note", "severity_warning": "Warning", "severity_error": "Error", - "stats": "shown {displayed} · dropped {dropped} · compose {compose} ms · paint {paint} ms" + "stats": "shown {displayed} · display drops {dropped} · compose {compose} ms · paint {paint} ms" } } diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 129a4ef..0f48dce 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -401,6 +401,14 @@ "housing": "显示外壳", "deform": "放大形变", "deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。", + "deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。", + "quantity_flow_magnitude": "光流幅值", + "quantity_depth_response": "深度响应", + "mode_surface": "曲面着色", + "preset_default": "默认视角", + "legend_effective": "{quantity} [{unit}] · {mode}色标 {lower} – {upper} · {quality}", + "legend_auto": "自动", + "legend_fixed": "固定", "scale": "色标", "scale_range": "范围", "scale_range_tip": "固定模式下色标的上下限。自动模式的范围来自当前帧的有效样本,因此量级差别很大的量也都能看清。", @@ -431,6 +439,6 @@ "severity_info": "提示", "severity_warning": "警告", "severity_error": "错误", - "stats": "已显示 {displayed} · 丢弃 {dropped} · 合成 {compose} ms · 绘制 {paint} ms" + "stats": "已显示 {displayed} · 显示跳帧 {dropped} · 合成 {compose} ms · 绘制 {paint} ms" } } diff --git a/apps/studio/src/orisys_studio/scene3d/adapters.py b/apps/studio/src/orisys_studio/scene3d/adapters.py index a151a11..884d13e 100644 --- a/apps/studio/src/orisys_studio/scene3d/adapters.py +++ b/apps/studio/src/orisys_studio/scene3d/adapters.py @@ -153,6 +153,7 @@ def _intersect(mask: np.ndarray, device: Optional[np.ndarray]) -> np.ndarray: class FingerFrameAdapter: """The finger runtime's field, in the scene's UV domain. + **The live flow is already unrolled, measured rather than assumed.** The pipeline computes optical flow on the frame the unroll stage produced (``FrameUnwarper`` output, ``camera.resolution_stitch`` = 280x300), and @@ -169,6 +170,14 @@ class FingerFrameAdapter: model. """ + #: This adapter colours the surface in place and never publishes a displacement, so + #: a control that would enlarge one describes an input that does not exist. The + #: panel reads this through ``SceneCapabilities`` and disables the control. + displacement_available = False + #: The quantity this adapter can colour, for the same capability record. + quantity = "flow_magnitude" + unit = "px" + def __init__( self, *, @@ -263,6 +272,10 @@ class BoxFrameAdapter: height field at all. """ + displacement_available = False + quantity = "depth_response" + unit = "1" + def __init__( self, *, diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index 63f8de6..92a51fe 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -506,6 +506,9 @@ class GLRenderer: #: Range of the valid samples of the frame currently uploaded, for the #: automatic colour scale. ``None`` until a frame carries valid data. self._scalar_range: Optional[tuple[float, float]] = None + #: The colour bounds of the last draw and how they were chosen. + self._last_scale: Optional[tuple[float, float]] = None + self._last_scale_mode = "" self._sensing_part_id: str = "" self._estimated_gpu_bytes: int = 0 self._initialized = False @@ -545,6 +548,12 @@ class GLRenderer: self._require_initialized() self.release_parts() self._sensing_part_id = assets.sensing_part().part_id + # A new asset means the old per-frame colour range describes a scene that is no + # longer on screen: keeping it made the first automatic draw of the new scene use + # the previous scene's bounds. + self._scalar_range = None + self._last_scale = None + self._last_scale_mode = "" for part in assets.parts: vertices, indices = pack_scene_part(part) vao = int(GL.glGenVertexArrays(1)) @@ -620,6 +629,11 @@ class GLRenderer: projection = np.asarray(camera.projection, dtype=np.float32).reshape(4, 4) scale = settings.color_scale lower, upper = _scale_bounds(scale, self._scalar_range) + # What the shader was actually given, for the legend: in automatic mode the + # operator's spin boxes are disabled and hold values nobody is using, so the + # panel has to be able to ask what the picture means. + self._last_scale = (float(lower), float(upper)) + self._last_scale_mode = str(getattr(scale, "mode", "") or "") self._ensure_lut("diverging" if (scale and scale.descriptor.signed) else "sequential") for part in self._parts.values(): if part.role == "housing" and not settings.housing_visible: @@ -649,12 +663,22 @@ class GLRenderer: self._displayed_frames += 1 return RenderStats( displayed_frames=self._displayed_frames, + # The real cost of the frame's compose is carried on the frame itself + # (``RenderFrame.compose_ms``): the shared composer counter is rewritten by + # the next task, and reading it here reported the wrong frame's cost. compose_ms=0.0, upload_ms=self._last_upload_ms, paint_ms=(time.perf_counter() - started) * 1000.0, estimated_gpu_bytes=self._estimated_gpu_bytes, + scale_lower=self._last_scale[0] if self._last_scale else 0.0, + scale_upper=self._last_scale[1] if self._last_scale else 0.0, + scale_mode=self._last_scale_mode, ) + def effective_scale(self) -> Optional[tuple[float, float]]: + """The colour bounds the last draw actually used (``None`` before any draw).""" + return self._last_scale + def release(self) -> None: """Delete every GPU resource. Idempotent; the owning context must be current.""" from OpenGL import GL @@ -676,6 +700,9 @@ class GLRenderer: self._lut_id = "" self._initialized = False self._estimated_gpu_bytes = 0 + self._scalar_range = None + self._last_scale = None + self._last_scale_mode = "" self._displayed_frames = 0 def release_parts(self) -> None: diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 367d61b..63308bc 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -20,7 +20,10 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional +import numpy as np + from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QImage from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -30,6 +33,7 @@ from PyQt5.QtWidgets import ( QHBoxLayout, QLabel, QPushButton, + QSizePolicy, QVBoxLayout, QWidget, ) @@ -42,6 +46,7 @@ from orisys_app_shared.scene3d.contracts import ( FieldDescriptor, RenderStats, SceneAssets, + SceneCapabilities, SceneState, ) from orisys_studio.scene3d.prefs import FPS_CHOICES @@ -62,6 +67,44 @@ SPIN_WIDTH = 68 __all__ = ["FPS_CHOICES", "ThreeDPanel"] +class ColorScaleBar(QLabel): + """The colour ramp the shader actually used, with the bounds it used them for. + + Generated from the same 256x1 table ``gl_backend`` uploads (``lut_rgba``), so the + legend can never disagree with the picture — which the old text-only legend could, + because in automatic mode the spin boxes beside it held values nobody was using. + """ + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setObjectName("colorScaleBar") + self.setFixedHeight(12) + self.setMinimumWidth(120) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setVisible(False) + self._lut_id = "sequential" + + def set_lut(self, lut_id: str) -> None: + wanted = "diverging" if str(lut_id) == "diverging" else "sequential" + if wanted == self._lut_id: + return + self._lut_id = wanted + self.update() + + def paintEvent(self, event) -> None: # noqa: N802 - Qt naming + from PyQt5.QtGui import QPainter + + from orisys_studio.scene3d.gl_backend import lut_rgba + + # (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call, + # which is what QImage's borrowed buffer needs. + rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes() + image = QImage(rgba, 256, 1, 256 * 4, QImage.Format_RGBA8888) + painter = QPainter(self) + painter.drawImage(self.rect(), image) + painter.end() + + class ThreeDPanel(QFrame): """Toggle, presets, reset, housing visibility, gain, colour scale, status.""" @@ -85,6 +128,12 @@ class ThreeDPanel(QFrame): self._state: SceneState = "disabled" self._source_kind = "live" self._syncing = False + #: What the data source can provide (see ``contracts.SceneCapabilities``): an + #: unavailable capability disables its control and says why, rather than + #: letting the operator operate an input that does not exist. + self._capabilities: Optional[SceneCapabilities] = None + #: The bounds the last draw actually used (the widget's RenderStats). + self._effective_scale: Optional[RenderStats] = None root = QVBoxLayout(self) root.setContentsMargins(8, 6, 8, 8) @@ -187,6 +236,12 @@ class ThreeDPanel(QFrame): self.legend_label.setMinimumWidth(1) root.addWidget(self.legend_label) + # The ramp the shader used, under the numbers it used it for. Text alone could + # not express an *automatic* range, and the disabled spin boxes next to it held + # values that were not being drawn with. + self.scale_bar = ColorScaleBar() + root.addWidget(self.scale_bar) + #: Which *source* the field on screen came from. A separate line from the #: scene state, because they answer different questions: the state says whether #: anything is being measured, this says whether it was acquired live or @@ -223,7 +278,15 @@ class ThreeDPanel(QFrame): self.preset_combo.clear() if assets is not None: for preset in assets.camera_presets: - self.preset_combo.addItem(preset.name, preset.name) + # The asset's own name stays the data (it is what ``apply_preset`` + # looks up); only the shown text is translated, so "default" reads + # as 默认视角 instead of a bare internal word. + label = ( + t("scene3d.preset_default") + if preset.name == "default" + else preset.name + ) + self.preset_combo.addItem(label, preset.name) finally: self._syncing = False self.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count())) @@ -239,20 +302,79 @@ class ThreeDPanel(QFrame): self.scale_combo.setEnabled(False) self.lower_spin.setEnabled(False) self.upper_spin.setEnabled(False) + self.scale_bar.setVisible(False) return self.scale_combo.setEnabled(True) fixed = self.scale_combo.currentData() == "fixed" self.lower_spin.setEnabled(fixed) self.upper_spin.setEnabled(fixed) + self.scale_bar.set_lut( + "diverging" if descriptor.signed and not fixed else "sequential" + ) + self.scale_bar.setVisible(True) + self._render_legend() + + def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None: + """Offer only the controls this data source can actually feed. + + Today's adapters colour a scalar in place and publish no displacement at all, so + "exaggerate" would be a control over an input that does not exist: it is disabled + and says so. The setting itself is also forced off, so a persisted gain cannot + silently apply to a source that has nothing to enlarge. + """ + self._capabilities = capabilities + available = bool(capabilities and capabilities.displacement_available) + enabled = self.enable_check.isChecked() + self.deform_check.setEnabled(available and enabled) + self.gain_spin.setEnabled(available and enabled and self.deform_check.isChecked()) + reason = t("scene3d.deform_unavailable") if not available else t("scene3d.deform_tip") + self.deform_check.setToolTip(reason) + if not available and self.deform_check.isChecked(): + self._syncing = True + try: + self.deform_check.setChecked(False) + finally: + self._syncing = False + + def capabilities(self) -> Optional[SceneCapabilities]: + return self._capabilities + + def _render_legend(self) -> None: + """The legend states what is drawn: quantity, unit and the *effective* range.""" + descriptor = self._descriptor + if descriptor is None or descriptor.quantity == "unavailable": + return + effective = self._effective_scale + quantity = t(f"scene3d.quantity_{descriptor.quantity}") + if quantity.startswith("scene3d."): + quantity = descriptor.quantity + quality = t(f"scene3d.quality_{descriptor.quality}") + if effective is None or effective.scale_mode == "": + self.legend_label.setText( + t( + "scene3d.legend", + quantity=quantity, + unit=descriptor.unit, + quality=quality, + ) + ) + return + mode = t( + "scene3d.legend_auto" + if effective.scale_mode == "automatic" + else "scene3d.legend_fixed" + ) self.legend_label.setText( t( - "scene3d.legend", - quantity=descriptor.quantity, + "scene3d.legend_effective", + quantity=quantity, unit=descriptor.unit, - quality=t(f"scene3d.quality_{descriptor.quality}"), + mode=mode, + lower=f"{effective.scale_lower:.4g}", + upper=f"{effective.scale_upper:.4g}", + quality=quality, ) ) - self.legend_label.setVisible(True) def set_state(self, state: SceneState) -> None: self._state = state @@ -290,6 +412,8 @@ class ThreeDPanel(QFrame): def set_render_stats(self, stats: RenderStats) -> None: """Display-side statistics only: never shown as recording health.""" + self._effective_scale = stats + self._render_legend() self.stats_label.setText( t( "scene3d.stats", @@ -316,6 +440,9 @@ class ThreeDPanel(QFrame): self.reset_button, ): widget.setEnabled(bool(enabled)) + # The capability gate survives an enable/disable cycle: a source with no + # displacement must not become operable just because the pane was toggled. + self.set_capabilities(self._capabilities) self.set_descriptor(self._descriptor if enabled else None) # ----------------------------------------------------------------- outputs @@ -344,7 +471,9 @@ class ThreeDPanel(QFrame): self.fps_combo.setCurrentIndex(index) finally: self._syncing = False - self.gain_spin.setEnabled(self.deform_check.isChecked()) + # Restoring a saved gain must not enable a control the source cannot feed, nor + # leave it enabled while the pane is off. + self.set_capabilities(self._capabilities) self._emit_settings() def settings(self) -> DisplaySettings: @@ -355,9 +484,14 @@ class ThreeDPanel(QFrame): upper = float(self.upper_spin.value()) if upper <= lower: upper = lower + 1.0 + # A source with no displacement has nothing to enlarge: the setting is forced + # off rather than left true and quietly ignored by the composer. + deform = bool(self.deform_check.isChecked()) and bool( + self._capabilities and self._capabilities.displacement_available + ) return DisplaySettings( display_gain=float(self.gain_spin.value()), - deform_enabled=self.deform_check.isChecked(), + deform_enabled=deform, color_scale=ColorScale( descriptor=descriptor, lower=lower, @@ -384,7 +518,8 @@ class ThreeDPanel(QFrame): self.presetRequested.emit(str(name)) def _on_deform_toggled(self, enabled: bool) -> None: - self.gain_spin.setEnabled(bool(enabled) and self.enable_check.isChecked()) + available = bool(self._capabilities and self._capabilities.displacement_available) + self.gain_spin.setEnabled(bool(enabled) and available and self.enable_check.isChecked()) self._emit_settings() def _on_scale_mode_changed(self, index: int) -> None: @@ -418,4 +553,5 @@ class ThreeDPanel(QFrame): self.state_label.setText(t(f"scene3d.state_{self._state}")) self.source_label.setText(t(f"scene3d.source_{self._source_kind}")) self.source_label.setToolTip(t(f"scene3d.source_{self._source_kind}_tip")) + self.set_capabilities(self._capabilities) self.set_descriptor(self._descriptor) diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index abcaa5e..f7e2aab 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -41,6 +41,7 @@ from orisys_app_shared.scene3d.contracts import ( PlatformInfo, RenderStats, SceneAssets, + SceneCapabilities, SceneState, check_platform_lock, diagnostic, @@ -106,6 +107,7 @@ class Scene3DView(QWidget): self._adapter: Any = None self._source_id = "" self._enabled = False + self._source_kind = "live" self._last_diagnostic: Optional[Diagnostic] = None self._last_descriptor: Optional[FieldDescriptor] = None self._last_stats: Optional[RenderStats] = None @@ -210,6 +212,7 @@ class Scene3DView(QWidget): self._coordinator.activate(assets, self._source_id) if self._panel is not None: self._panel.set_scene(assets) + self._publish_capabilities() return True def _lock_allows(self, assets: SceneAssets) -> bool: @@ -365,14 +368,54 @@ class Scene3DView(QWidget): return def reset_view(self) -> None: - if self._assets is not None and self._widget is not None: - self._camera.reset(self._assets.bounds_sensor_m) - self._widget.update() + """Go back to the view the asset itself calls its own. + + Not ``OrbitCamera.reset`` (which zeroes yaw/pitch and refits): the picture the + operator first saw is the asset's first camera preset, so "reset" has to mean + that, or the two disagree by whatever the preset's orientation was. + """ + if self._assets is None or self._widget is None: + return + presets = tuple(self._assets.camera_presets) + if presets: + self.apply_preset(presets[0].name) + return + self._camera.reset(self._assets.bounds_sensor_m) + self._widget.update() def set_source_kind(self, kind: str) -> None: """Tell the pane where its field comes from (see ``ThreeDPanel``).""" + self._source_kind = str(kind) if self._panel is not None: self._panel.set_source_kind(kind) + self._publish_capabilities() + + def capabilities(self) -> Optional[SceneCapabilities]: + """What this data source can provide, for the panel's controls. + + Read off the adapter that is actually bound: an adapter that colours a scalar in + place says so (``displacement_available = False``), and the panel disables what + the source cannot feed instead of offering it and ignoring it. + """ + adapter = self._adapter + descriptor = self._last_descriptor + if adapter is None and descriptor is None: + return None + return SceneCapabilities( + quantity=str( + getattr(adapter, "quantity", "") or getattr(descriptor, "quantity", "") + ), + unit=str(getattr(adapter, "unit", "") or getattr(descriptor, "unit", "")), + displacement_available=bool( + getattr(adapter, "displacement_available", False) + ), + displacement_quality=None, + source_kind=str(self._source_kind), + ) + + def _publish_capabilities(self) -> None: + if self._panel is not None: + self._panel.set_capabilities(self.capabilities()) def begin_session(self) -> None: """The application is starting an acquisition session for this pane. @@ -455,11 +498,12 @@ class Scene3DView(QWidget): if assets is None: return {} widget = self._widget - capabilities = widget.capabilities() if widget is not None else None + gl_capabilities = widget.capabilities() if widget is not None else None settings = self.settings() scale = settings.color_scale descriptor = self._last_descriptor expected = self._expected + capabilities = self.capabilities() or SceneCapabilities() return { "scene": { "asset_id": str(getattr(assets, "asset_id", "")), @@ -492,14 +536,14 @@ class Scene3DView(QWidget): }, "gl": ( { - "profile": str(capabilities.profile), - "vendor": str(capabilities.vendor), - "renderer": str(capabilities.renderer), - "gl_version": str(capabilities.gl_version), - "glsl_version": str(capabilities.glsl_version), - "software_renderer": bool(capabilities.software_renderer), + "profile": str(gl_capabilities.profile), + "vendor": str(gl_capabilities.vendor), + "renderer": str(gl_capabilities.renderer), + "gl_version": str(gl_capabilities.gl_version), + "glsl_version": str(gl_capabilities.glsl_version), + "software_renderer": bool(gl_capabilities.software_renderer), } - if capabilities is not None + if gl_capabilities is not None else {} ), "display": { @@ -521,6 +565,28 @@ class Scene3DView(QWidget): if descriptor is not None else {} ), + "capabilities": ( + { + "quantity": str(capabilities.quantity), + "unit": str(capabilities.unit), + "displacement_available": bool(capabilities.displacement_available), + "source_kind": str(capabilities.source_kind), + } + if capabilities is not None + else {} + ), + # The bounds the shader was actually given (see ``RenderStats``): in + # automatic mode the operator's spin boxes hold values nobody is using, so a + # recording that only had those could not say what its colours meant. + "effective_scale": ( + { + "lower": float(self._last_stats.scale_lower), + "upper": float(self._last_stats.scale_upper), + "mode": str(self._last_stats.scale_mode), + } + if self._last_stats is not None and self._last_stats.scale_mode + else None + ), } def render_totals(self) -> dict: @@ -642,6 +708,7 @@ class Scene3DView(QWidget): self._last_descriptor = descriptor if self._panel is not None: self._panel.set_descriptor(descriptor) + self._publish_capabilities() self.set_settings(self._panel.settings()) self.descriptorChanged.emit(descriptor) diff --git a/apps/studio/src/orisys_studio/scene3d/widget.py b/apps/studio/src/orisys_studio/scene3d/widget.py index 145cd22..15dabdd 100644 --- a/apps/studio/src/orisys_studio/scene3d/widget.py +++ b/apps/studio/src/orisys_studio/scene3d/widget.py @@ -226,6 +226,9 @@ class Sensor3DWidget(QOpenGLWidget): upload_ms=stats.upload_ms, paint_ms=(now - started) * 1000.0, estimated_gpu_bytes=stats.estimated_gpu_bytes, + scale_lower=stats.scale_lower, + scale_upper=stats.scale_upper, + scale_mode=stats.scale_mode, ) ) diff --git a/apps/studio/tests/test_scene3d_skeleton.py b/apps/studio/tests/test_scene3d_skeleton.py index 104ec9c..526c170 100644 --- a/apps/studio/tests/test_scene3d_skeleton.py +++ b/apps/studio/tests/test_scene3d_skeleton.py @@ -147,7 +147,11 @@ def test_no_scene3d_entry_point_is_still_a_stub(qt_app): panel.set_state("ready") assert panel.state_label.text() panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False)) - assert "flow_magnitude" in panel.legend_label.text() + # The legend states the operator-facing name, not the internal key: the key is + # what a recording carries, the label is what a person reads. + legend = panel.legend_label.text() + assert "flow_magnitude" not in legend, legend + assert "光流幅值" in legend or "Optical flow magnitude" in legend, legend panel.set_descriptor(None) panel.set_render_stats(RenderStats(displayed_frames=3, superseded_frames=1)) assert "3" in panel.stats_label.text() diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 299e88c..99a55b1 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -33,6 +33,7 @@ import pytest import ui_check from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QWidget +from orisys_app_shared.i18n import t from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( ColorScale, @@ -870,6 +871,91 @@ def test_a_scene_without_a_device_description_is_shown_as_unverified(qt_app): view.close() +# --------------------------------------- stage 8: capability-controlled controls +def test_a_source_with_no_displacement_cannot_offer_exaggeration(qt_app): + """R6: the control must describe an input that exists.""" + view = _stale_scene_view(qt_app) + try: + panel = view.panel + assert panel is not None + capabilities = view.capabilities() + assert capabilities is not None + assert capabilities.displacement_available is False, ( + "the finger adapter publishes no displacement: the capability says so" + ) + assert not panel.deform_check.isEnabled(), ( + "exaggeration is offered for a source that has no deformation field" + ) + assert not panel.gain_spin.isEnabled() + assert t("scene3d.deform_unavailable") in panel.deform_check.toolTip() + # And the setting itself is forced off, so a persisted gain cannot silently + # apply to a source with nothing to enlarge. + panel.deform_check.setChecked(True) + assert view.settings().deform_enabled is False + + described = view.describe() + assert described["capabilities"]["displacement_available"] is False + assert described["capabilities"]["quantity"] == "flow_magnitude" + finally: + view.close() + + +def test_the_legend_shows_the_range_the_shader_actually_used(qt_app): + """R6: the spin boxes are disabled in automatic mode; the legend must not be.""" + from orisys_app_shared.scene3d.contracts import RenderStats + + view = _stale_scene_view(qt_app) + try: + panel = view.panel + assert panel is not None + from orisys_app_shared.scene3d.contracts import FieldDescriptor + + panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False)) + # The real path: the widget emits stats, the view forwards them to the panel. + view._on_stats( + RenderStats( + displayed_frames=9, + scale_lower=0.25, + scale_upper=7.5, + scale_mode="automatic", + ) + ) + legend = panel.legend_label.text() + assert "0.25" in legend and "7.5" in legend, legend + assert t("scene3d.legend_auto") in legend, legend + assert panel.scale_bar.isVisible() or panel.scale_bar.parent() is not None + assert "flow_magnitude" not in legend + described = view.describe() + assert described["effective_scale"]["lower"] == 0.25 + assert described["effective_scale"]["upper"] == 7.5 + assert described["effective_scale"]["mode"] == "automatic" + finally: + view.close() + + +def test_reset_goes_back_to_the_asset_default_view_not_to_a_zeroed_camera(qt_app): + """The first picture and "reset" must be the same view, or they disagree.""" + view = _stale_scene_view(qt_app) + try: + assets = view.assets + assert assets is not None and assets.camera_presets + preset = assets.camera_presets[0] + view.apply_preset(preset.name) + expected = view._camera.matrices((320, 240)).view.copy() + # Move away from it the way the mouse does. + view._camera.orbit((30.0, 12.0), (320, 240)) + view._camera.zoom(1.5) + assert not np.allclose( + view._camera.matrices((320, 240)).view, expected + ), "the camera never moved" + view.reset_view() + assert np.allclose(view._camera.matrices((320, 240)).view, expected), ( + "reset did not return to the asset's own default view" + ) + finally: + view.close() + + # ------------------------------------------------------- stage 5: the record block def test_a_recording_says_what_the_3d_pane_was( make_window, next_record_root, write_poc, qt_app, monkeypatch diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 48fa737..fc65990 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -509,6 +509,25 @@ class ColorScale: mode: Literal["fixed", "automatic"] = "fixed" +@dataclass(frozen=True) +class SceneCapabilities: + """What the *data source* can put into a frame — not what the pane can draw. + + The panel needs this to offer only the controls that can do something: today's + adapters colour a scalar in place and never publish a displacement, so an + "exaggerate the deformation" control would be operating on an input that does not + exist. Recording it (rather than inferring it from a setting) is also what lets a + recording say which controls were meaningful. + """ + + quantity: str = "" + unit: str = "" + #: Whether the source can ever publish ``displacement_sensor_m``. + displacement_available: bool = False + displacement_quality: Optional[Quality] = None + source_kind: str = "live" + + @dataclass(frozen=True) class DisplaySettings: """Display-only settings. None of these reach the camera or the recording.""" @@ -575,6 +594,13 @@ class RenderStats: upload_ms: float = 0.0 paint_ms: float = 0.0 estimated_gpu_bytes: int = 0 + #: The colour-scale bounds the shader was *actually* given for the frame on screen, + #: and whether they came from the data ("automatic") or from the operator ("fixed"). + #: Without these the panel could only show the spin boxes, which in automatic mode + #: are disabled and hold values nobody is using. + scale_lower: float = 0.0 + scale_upper: float = 0.0 + scale_mode: str = "" @dataclass(frozen=True)