feat(studio-3d): 能力控件、真实色标与复位语义 —— 只提供数据源真正喂得出来的控制(阶段 8 第一批)
- 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 小时门禁)未开始。
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user