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:
ykcheungab
2026-09-14 21:35:47 +08:00
parent 7e415c4ae9
commit e2570925e8
10 changed files with 400 additions and 22 deletions
+9 -1
View File
@@ -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"
}
}
+9 -1
View File
@@ -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:
+144 -8
View File
@@ -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)
+78 -11
View File
@@ -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,
)
)
+5 -1
View File
@@ -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()
+86
View File
@@ -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