阶段 1:平台 PoC —— 真实 GL 上跑通静态场景,并修掉它抓出的相机/上下文错误 - What: 按 docs/architecture/studio-3d-stages.md 阶段 1 落地图形栈。OrbitCamera 全部实现(target/distance/yaw/pitch 表示;fit 按包围球与较窄的那个视场轴定距离;orbit/pan/zoom 有界;set_preset 从 eye/target 反解角度使拖动可延续;matrices 输出只读 float32 view/projection;另加 depth_range_m() 便于断言)。gl_backend:GLDispatch.bind_current_context 真正绑定 PyOpenGL 并读 vendor/renderer/GL/GLSL/max texture、判软件渲染、profile 不达标时抛 ThreeDUnsupported 并带回能力;GLRenderer 实现 initialize(两个 program,desktop33/gles30 共用同一份着色器主体、只换头部)、upload_scene(每 part 一套 VAO/VBO/EBO,交错 layout position/normal/scalar/valid = 8 float)、update_surface(只对感知面 glBufferSubData,绝不重建索引)、draw(绑 Qt 的 defaultFramebufferObject、深度测试、按 role 选 program、housing 隐藏即跳过、scalar 经 LUT 纹理与 range uniform 上色,Python 不生成 RGB 数组)、release(幂等);纯函数 pack_part/pack_scene_part/estimate_gpu_bytes/rest_surface/shader_source/shader_header/lut_rgba/is_software_renderer/profile_from_renderable_type 都可在无 GPU 环境测试。Sensor3DWidget 实现 initializeGL/paintGL/resizeGL/release_resources/aboutToBeDestroyed/showEvent/hideEvent 与鼠标事件;加载场景时先采用资产自带的默认视角再 fit;一帧几何不匹配只"拒绝该帧"(计数 + 诊断 + 用 rest_surface 清掉上次测量)而不是把整个视图打成不可用。shared/scene3d/synthetic.py 提供 PoC 与测试共用的已知场景(20mm 感知板 + 外壳盒,米、sensor 坐标系、带默认三视角预设)。tools/scene_assets/probe_gl.py 真实现:真实窗口取 context 并输出 JSON 报告(平台/Qt/PyOpenGL/vendor/renderer/GL/GLSL/DPR/framebuffer/是否软件渲染),退出码即门禁(0 通过、3 软件渲染、4 无上下文);apps/studio/pyproject.toml 增加 [three-d] 可选依赖(仅 PyOpenGL;Open3D 不进运行时)。测试:apps/studio/tests/test_scene3d_stage1.py 24 项(相机数学、PoC 场景合规、打包布局、着色器两方言共用主体、LUT、软件渲染识别、renderableType 映射、GPU 预算、参考帧语义,全部无需 GPU)+ test_scene3d_gl_smoke.py 8 项(真实 context:能力、两个 part 都画出来、外壳可隐藏、参考帧中性 vs 色图有差异、动态更新不动 housing 缓冲、坏几何帧被拒且视图存活、resize 跟随、release 幂等且新 context 能重传场景、探针如实报告);offscreen 下后者自动 skip。docs/architecture/gl-report-windows-dev.json 为本机平台报告。 - Why: 用户要求"从骨架搭起→计划阶段→逐阶段实现与验收",并明确 ARM 暂缓。阶段 1 的产出本身就是一份能回答"这块板的图形栈能不能用"的报告,所以先把它做实、把 profile 冻结点定在真实 context 上,而不是先堆屏幕上的东西。 - Verification: 真实 context(QT_QPA_PLATFORM=windows)下 8 项 GL 验收全过;offscreen 下 24 项 CPU 项全过、GL 项按设计 skip;shared 全量 187 passed;studio 全量 116 passed(含新 32 项);ui_check 554 项 0 失败;probe_gl 输出 verdict=ok(NVIDIA RTX 4080 SUPER、GL 3.3 Core、GLSL 3.30、非软件渲染)。过程中这套验收抓出并修掉了 7 个真实缺陷:Qt renderableType 映射反了(NVIDIA 兼容 ES 着色器所以本机看不出,Intel/ARM 会直接失败)、观察相机是左手基导致整模型镜像(det=-1)、近平面过近使 NDC z 全挤在 0.99(深度精度归零、外壳与感知面会 z-fighting)、手动 makeCurrent 早于 Qt 首次绘制会把 GPU 对象建在被丢弃的 context 里(glBindVertexArray 报 INVALID_OPERATION)、context 重建后不重传场景、一帧几何不匹配把整个视图打死、以及 grabFramebuffer 是 BGRA+stride 导致颜色断言全部读错通道。 - Not in this change: 未接入 app_window/main.py(三维对现有应用仍完全惰性,故"开/关三维 CSV 行序一致"这条验收随阶段 4 做);阶段 2 的资产导入/坐标/包格式;ARM 平台实测(probe 已可在板上直接运行)。
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""Stage 1 acceptance: a real GL context draws the scene, and survives its life.
|
||||
|
||||
This file deliberately does **not** force ``QT_QPA_PLATFORM=offscreen``: the
|
||||
offscreen plugin cannot create a platform GL context at all ("this plugin does not
|
||||
support createPlatformOpenGLContext"), and the design document is explicit that an
|
||||
offscreen UI test cannot stand in for a GPU. Every test here skips itself when no
|
||||
real context can be obtained, and asserts on pixels otherwise.
|
||||
|
||||
Run it with a real platform, e.g.::
|
||||
|
||||
QT_QPA_PLATFORM=windows python -m pytest tests/test_scene3d_gl_smoke.py
|
||||
|
||||
Two rules the tests themselves have to respect, learned the hard way here: the
|
||||
widget is driven the way the application drives it (``show``/``repaint``, so Qt
|
||||
owns the context), and pixels are read through Qt's own ``grabFramebuffer`` —
|
||||
calling ``makeCurrent``/``initializeGL`` by hand creates the context *before* Qt
|
||||
does, and the GPU objects then belong to a context that is thrown away.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ColorScale,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
FrameIdentity,
|
||||
RenderFrame,
|
||||
freeze,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
|
||||
reason="the offscreen/minimal plugin cannot create a platform GL context",
|
||||
)
|
||||
|
||||
#: The renderer's clear colour in 8-bit, so "background" can be excluded from a
|
||||
#: pixel assertion instead of dominating it.
|
||||
BACKGROUND_RGB = np.array([15, 18, 23])
|
||||
|
||||
|
||||
def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
|
||||
"""Let Qt paint until ``predicate`` holds. Returns whether it did."""
|
||||
import time
|
||||
|
||||
deadline = time.perf_counter() + timeout_s
|
||||
while time.perf_counter() < deadline:
|
||||
widget.repaint()
|
||||
qt_app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def gl_widget(qt_app):
|
||||
"""A real, shown QOpenGLWidget with the synthetic scene, driven by Qt."""
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer, configure_surface_format
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
renderer = GLRenderer()
|
||||
widget = Sensor3DWidget(renderer, OrbitCamera(), dispatch=GLDispatch())
|
||||
failures: list = []
|
||||
widget.diagnosticRaised.connect(failures.append)
|
||||
widget.resize(240, 180)
|
||||
widget.show()
|
||||
widget.set_scene(synthetic.poc_scene())
|
||||
painted = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
|
||||
if not painted:
|
||||
widget.deleteLater()
|
||||
pytest.skip(
|
||||
"no usable GL context here: "
|
||||
f"{widget.diagnostic() or 'the widget never initialized'}"
|
||||
)
|
||||
yield widget, renderer, failures
|
||||
widget.release_resources()
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
qt_app.processEvents()
|
||||
|
||||
|
||||
def _render(widget, qt_app) -> np.ndarray:
|
||||
"""One Qt-driven paint, read back through Qt's own framebuffer grab.
|
||||
|
||||
``grabFramebuffer`` hands back a ``Format_RGB32`` image, whose bytes are BGRA
|
||||
on a little-endian machine, with a stride that can exceed the width — so the
|
||||
conversion and the stride trim are both load-bearing.
|
||||
"""
|
||||
from PyQt5.QtGui import QImage
|
||||
|
||||
widget.repaint()
|
||||
qt_app.processEvents()
|
||||
image = widget.grabFramebuffer()
|
||||
assert not image.isNull(), "Qt returned no framebuffer"
|
||||
image = image.convertToFormat(QImage.Format_RGB888)
|
||||
width, height = image.width(), image.height()
|
||||
buffer = image.constBits().asarray(image.byteCount())
|
||||
stride = image.bytesPerLine()
|
||||
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
|
||||
return rows[:, : width * 3].reshape(height, width, 3).copy()
|
||||
|
||||
|
||||
def _drawn(pixels: np.ndarray) -> np.ndarray:
|
||||
"""Pixels that are not the clear colour, as ints."""
|
||||
flat = pixels.reshape(-1, 3).astype(int)
|
||||
background = np.abs(flat - BACKGROUND_RGB).max(axis=1) <= 3
|
||||
return flat[~background]
|
||||
|
||||
|
||||
def _plate_is_grey(pixels: np.ndarray) -> float:
|
||||
"""Mean |r-g| of the drawn pixels. The neutral path is grey, the LUT is not."""
|
||||
drawn = _drawn(pixels)
|
||||
assert drawn.size, "nothing was drawn"
|
||||
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean())
|
||||
|
||||
|
||||
def test_the_capabilities_come_from_a_real_context(gl_widget):
|
||||
widget, _, _ = gl_widget
|
||||
capabilities = widget.capabilities()
|
||||
assert capabilities is not None
|
||||
assert capabilities.vendor and capabilities.renderer
|
||||
assert capabilities.profile == "desktop33", "a desktop context must compile desktop shaders"
|
||||
assert not capabilities.software_renderer, "the dev machine must be driven by a GPU"
|
||||
assert capabilities.max_texture_size >= 1024
|
||||
assert widget.viewport_pixels() == (240, 180)
|
||||
|
||||
|
||||
def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
|
||||
widget, renderer, _ = gl_widget
|
||||
assert set(renderer.part_ids) == {"sensing_surface", "housing"}
|
||||
|
||||
with_housing = _drawn(_render(widget, qt_app))
|
||||
assert with_housing.size, "nothing was drawn at all"
|
||||
widget.set_settings(DisplaySettings(housing_visible=False))
|
||||
without_housing = _drawn(_render(widget, qt_app))
|
||||
assert 0 < without_housing.size < with_housing.size, (
|
||||
"hiding the housing did not reduce what is on screen "
|
||||
f"({without_housing.size} vs {with_housing.size} pixels)"
|
||||
)
|
||||
widget.set_settings(DisplaySettings(housing_visible=True))
|
||||
|
||||
|
||||
def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_app):
|
||||
"""No measurement yet: the plate must not be coloured as if it had one.
|
||||
|
||||
Checked as a difference: the neutral path is grey (r≈g), the colour map's low
|
||||
end is not. Counting "grey pixels" over the whole frame would just be counting
|
||||
the background.
|
||||
"""
|
||||
widget, _, _ = gl_widget
|
||||
assets = synthetic.poc_scene()
|
||||
count = int(assets.sensing_part().positions_sensor_m.shape[0])
|
||||
widget.set_settings(DisplaySettings(housing_visible=False))
|
||||
|
||||
rest_grey = _plate_is_grey(_render(widget, qt_app))
|
||||
|
||||
frame = RenderFrame(
|
||||
identity=FrameIdentity("poc", 1, 5, 0.5, generation=1),
|
||||
asset_hash=assets.asset_hash,
|
||||
geometry_id=assets.geometry_id,
|
||||
sensing_part_id=assets.sensing_part().part_id,
|
||||
positions_sensor_m=assets.sensing_part().positions_sensor_m,
|
||||
normals_sensor=assets.sensing_part().normals_sensor,
|
||||
scalar=freeze(np.full(count, 0.05, dtype=np.float32), dtype=np.float32),
|
||||
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
|
||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||
)
|
||||
widget.set_settings(
|
||||
DisplaySettings(
|
||||
housing_visible=False,
|
||||
color_scale=ColorScale(
|
||||
descriptor=frame.scalar_descriptor,
|
||||
lower=0.0,
|
||||
upper=1.0,
|
||||
lut_id="sequential",
|
||||
),
|
||||
)
|
||||
)
|
||||
widget.present(frame)
|
||||
measured_grey = _plate_is_grey(_render(widget, qt_app))
|
||||
|
||||
assert rest_grey < 6.0, f"the unmeasured surface is not neutral (r-g={rest_grey:.1f})"
|
||||
assert measured_grey > rest_grey + 10.0, (
|
||||
"the colour map made no difference: the invalid path is not being taken "
|
||||
f"(rest r-g={rest_grey:.1f}, measured r-g={measured_grey:.1f})"
|
||||
)
|
||||
widget.set_settings(DisplaySettings(housing_visible=True))
|
||||
|
||||
|
||||
def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app):
|
||||
widget, renderer, _ = gl_widget
|
||||
assets = synthetic.poc_scene()
|
||||
count = int(assets.sensing_part().positions_sensor_m.shape[0])
|
||||
positions = np.asarray(assets.sensing_part().positions_sensor_m).copy()
|
||||
positions[:, 2] = 0.004 # a visible bump
|
||||
frame = RenderFrame(
|
||||
identity=FrameIdentity("poc", 1, 6, 0.6, generation=1),
|
||||
asset_hash=assets.asset_hash,
|
||||
geometry_id=assets.geometry_id,
|
||||
sensing_part_id=assets.sensing_part().part_id,
|
||||
positions_sensor_m=freeze(positions, dtype=np.float32),
|
||||
normals_sensor=assets.sensing_part().normals_sensor,
|
||||
scalar=freeze(np.linspace(0.0, 1.0, count).astype(np.float32), dtype=np.float32),
|
||||
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
|
||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||
)
|
||||
widget.set_settings(
|
||||
DisplaySettings(
|
||||
color_scale=ColorScale(
|
||||
descriptor=frame.scalar_descriptor, lower=0.0, upper=1.0, lut_id="sequential"
|
||||
)
|
||||
)
|
||||
)
|
||||
# The housing's GPU buffers must be untouched by a surface update.
|
||||
before = _buffer_names(renderer, "housing")
|
||||
widget.present(frame)
|
||||
assert _plate_is_grey(_render(widget, qt_app)) > 2.0, "the scalar was not colour-mapped"
|
||||
assert _buffer_names(renderer, "housing") == before, (
|
||||
"a dynamic surface update rebuilt the static housing buffers"
|
||||
)
|
||||
|
||||
|
||||
def _buffer_names(renderer, part_id: str) -> tuple[int, int, int]:
|
||||
part = renderer._parts[part_id]
|
||||
return (part.vao, part.vbo, part.ebo)
|
||||
|
||||
|
||||
def test_a_frame_from_another_geometry_is_refused_without_losing_the_view(gl_widget, qt_app):
|
||||
"""A bad frame is refused; the scene stays usable and stops showing it."""
|
||||
widget, _, failures = gl_widget
|
||||
count = 9
|
||||
before = widget.rejected_frames
|
||||
frame = RenderFrame(
|
||||
identity=FrameIdentity("poc", 1, 2, 0.2, generation=1),
|
||||
asset_hash="other",
|
||||
geometry_id="some-other-geometry",
|
||||
sensing_part_id="sensing_surface",
|
||||
positions_sensor_m=freeze(np.zeros((count, 3), dtype=np.float32), dtype=np.float32),
|
||||
normals_sensor=freeze(
|
||||
np.tile([0.0, 0.0, 1.0], (count, 1)).astype(np.float32), dtype=np.float32
|
||||
),
|
||||
scalar=freeze(np.zeros(count, dtype=np.float32), dtype=np.float32),
|
||||
valid=freeze(np.zeros(count, dtype=np.bool_), dtype=np.bool_),
|
||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||
)
|
||||
widget.present(frame)
|
||||
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
|
||||
assert widget.rejected_frames == before + 1
|
||||
assert any(item.code == "geometry_mismatch" for item in failures), (
|
||||
"a frame from another geometry was accepted"
|
||||
)
|
||||
assert not widget.unavailable, "one bad frame must not take the whole view down"
|
||||
|
||||
|
||||
def test_resize_is_followed(gl_widget, qt_app):
|
||||
widget, _, _ = gl_widget
|
||||
widget.resize(320, 240)
|
||||
image = _render(widget, qt_app)
|
||||
assert (image.shape[1], image.shape[0]) == (320, 240), image.shape
|
||||
widget.resize(120, 90)
|
||||
image = _render(widget, qt_app)
|
||||
assert (image.shape[1], image.shape[0]) == (120, 90), image.shape
|
||||
widget.resize(240, 180)
|
||||
assert _drawn(_render(widget, qt_app)).size, "nothing was drawn after resizing back"
|
||||
|
||||
|
||||
def test_release_is_idempotent_and_a_new_context_gets_the_scene_back(gl_widget, qt_app):
|
||||
"""Closing must free everything; a rebuilt context must get the scene again.
|
||||
|
||||
A context rebuild is the case that matters: the GPU objects are gone while the
|
||||
CPU asset is still here, so "the parts are still uploaded" would be a lie and
|
||||
the window would come back empty. Qt calls ``initializeGL`` for a new context —
|
||||
re-parenting does *not* rebuild one on Qt 5.15 (measured), so the test drives
|
||||
that one call explicitly rather than pretending re-parenting did it.
|
||||
"""
|
||||
widget, renderer, _ = gl_widget
|
||||
widget.release_resources()
|
||||
assert not renderer.is_initialized
|
||||
widget.release_resources() # idempotent: no crash, still released
|
||||
assert not renderer.is_initialized
|
||||
assert not renderer.part_ids, "release left GPU buffers behind"
|
||||
|
||||
# What Qt does when it hands the widget a new context.
|
||||
widget.makeCurrent()
|
||||
widget.initializeGL()
|
||||
widget.doneCurrent()
|
||||
recovered = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
|
||||
assert recovered, "the scene did not come back for the new context"
|
||||
assert renderer.is_initialized
|
||||
assert _drawn(_render(widget, qt_app)).size, "the scene is not on screen again"
|
||||
|
||||
|
||||
def test_the_probe_reports_this_machine_truthfully():
|
||||
"""The probe's contract: exit 0 only for a GPU-driven context."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).resolve().parents[3] / "tools" / "scene_assets" / "probe_gl.py"
|
||||
spec = importlib.util.spec_from_file_location("probe_gl_tool", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
report = module.collect("desktop33", keep_open_s=0.3)
|
||||
context = report.get("context")
|
||||
assert context and context["valid"], report
|
||||
assert context["profile_used"] == "desktop33"
|
||||
assert not context["software_renderer"], "the dev machine has a real GPU"
|
||||
assert report["renderer_initialized"] and report["released"]
|
||||
@@ -89,53 +89,60 @@ def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
|
||||
|
||||
|
||||
def test_the_stubs_name_the_stage_that_fills_them_in(qt_app):
|
||||
"""What is implemented is exercised elsewhere; what is not says so.
|
||||
|
||||
Stage 1 landed the camera, the dispatcher and the renderer (see
|
||||
``test_scene3d_stage1.py`` and ``test_scene3d_gl_smoke.py``). The remaining
|
||||
placeholders must still fail loudly with the stage that owns them rather than
|
||||
quietly doing nothing.
|
||||
"""
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraPreset,
|
||||
DisplaySettings,
|
||||
ExpectedGeometry,
|
||||
FrameIdentity,
|
||||
GLCapabilities,
|
||||
RenderFrame,
|
||||
SceneAssets,
|
||||
)
|
||||
from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer
|
||||
from orisys_studio.scene3d.gl_backend import GLRenderer
|
||||
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
camera = OrbitCamera()
|
||||
renderer = GLRenderer()
|
||||
widget = Sensor3DWidget(renderer, camera)
|
||||
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
|
||||
panel = ThreeDPanel(coordinator, widget)
|
||||
try:
|
||||
assert coordinator.state == "disabled"
|
||||
assert coordinator.generation == 0
|
||||
assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub
|
||||
|
||||
with pytest.raises(NotImplementedError) as camera_error:
|
||||
camera.set_preset(CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7))
|
||||
assert "stage 1" in str(camera_error.value)
|
||||
# Stage 1 is real: the camera works with no context at all.
|
||||
camera.set_preset(
|
||||
CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7)
|
||||
)
|
||||
assert camera.matrices((100, 100)).view.shape == (4, 4)
|
||||
|
||||
with pytest.raises(NotImplementedError) as gl_error:
|
||||
GLDispatch().bind_current_context(object())
|
||||
assert "stage 1" in str(gl_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as activate_error:
|
||||
coordinator.activate(SceneAssets, "live-A") # type: ignore[arg-type]
|
||||
assert "stage 3" in str(activate_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as settings_error:
|
||||
coordinator.set_settings(DisplaySettings())
|
||||
assert "stage 3" in str(settings_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as initialize_error:
|
||||
renderer.initialize(GLCapabilities("desktop33", "", "", "", "", "", False, 0))
|
||||
assert "stage 1" in str(initialize_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as update_error:
|
||||
renderer.update_surface(None) # type: ignore[arg-type]
|
||||
assert "stage 1" in str(update_error.value)
|
||||
|
||||
assert FrameIdentity("live-A", 1, 0, 0.0, 1).generation == 1
|
||||
assert RenderFrame is not None and SceneAssets is not None
|
||||
for call, stage in (
|
||||
(lambda: coordinator.activate(None, "live-A"), "stage 3"),
|
||||
(lambda: coordinator.set_settings(DisplaySettings()), "stage 3"),
|
||||
(lambda: coordinator.set_visible(True), "stage 3"),
|
||||
(lambda: coordinator.close(), "stage 3"),
|
||||
(lambda: panel.set_state("ready"), "stage 4"),
|
||||
(lambda: BoxFrameAdapter().snapshot(None, FrameIdentity("a", 1, 0, 0.0, 1), None),
|
||||
"stage 3"),
|
||||
(
|
||||
lambda: FingerFrameAdapter().snapshot(
|
||||
None, None, FrameIdentity("a", 1, 0, 0.0, 1)
|
||||
),
|
||||
"stage 3",
|
||||
),
|
||||
):
|
||||
with pytest.raises(NotImplementedError) as excinfo:
|
||||
call()
|
||||
assert stage in str(excinfo.value), (stage, str(excinfo.value))
|
||||
del ExpectedGeometry
|
||||
finally:
|
||||
widget.deleteLater()
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Stage 1 interface tests: the camera, the synthetic scene, the CPU-side GL logic.
|
||||
|
||||
Everything here runs without a GPU — that is the point of splitting it out. The
|
||||
parts that need a real context are in ``test_scene3d_gl_smoke.py``, which skips
|
||||
itself on a platform that cannot create one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraPreset,
|
||||
FrameIdentity,
|
||||
SceneAssets,
|
||||
validate_assets,
|
||||
)
|
||||
from orisys_studio.scene3d.camera import (
|
||||
MAX_DISTANCE_M,
|
||||
MAX_PITCH_RAD,
|
||||
MIN_DISTANCE_M,
|
||||
OrbitCamera,
|
||||
)
|
||||
from orisys_studio.scene3d.gl_backend import (
|
||||
LUT_SIZE,
|
||||
STRIDE_FLOATS,
|
||||
estimate_gpu_bytes,
|
||||
is_software_renderer,
|
||||
lut_rgba,
|
||||
pack_part,
|
||||
pack_scene_part,
|
||||
profile_from_renderable_type,
|
||||
rest_surface,
|
||||
shader_source,
|
||||
)
|
||||
|
||||
|
||||
def _project(camera: OrbitCamera, points: np.ndarray, viewport=(240, 180)) -> np.ndarray:
|
||||
"""NDC coordinates of sensor-space points, through the real matrices."""
|
||||
matrices = camera.matrices(viewport)
|
||||
view = np.asarray(matrices.view, dtype=np.float64)
|
||||
projection = np.asarray(matrices.projection, dtype=np.float64)
|
||||
homogeneous = np.concatenate(
|
||||
[np.asarray(points, dtype=np.float64), np.ones((len(points), 1))], axis=1
|
||||
)
|
||||
camera_space = (view @ homogeneous.T).T
|
||||
clip = (projection @ camera_space.T).T
|
||||
return clip[:, :3] / clip[:, 3:4]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- camera
|
||||
def test_fit_frames_the_whole_scene_inside_the_viewport():
|
||||
assets = synthetic.poc_scene()
|
||||
camera = OrbitCamera()
|
||||
# What the widget does on load: the asset's default view, then framed.
|
||||
camera.set_preset(assets.camera_presets[0])
|
||||
camera.fit(assets.bounds_sensor_m, 240 / 180)
|
||||
for part in assets.parts:
|
||||
ndc = _project(camera, np.asarray(part.positions_sensor_m))
|
||||
assert np.all(np.abs(ndc[:, :2]) <= 1.0), (
|
||||
f"{part.part_id} is outside the viewport: {ndc[:, :2].min(axis=0)} .. "
|
||||
f"{ndc[:, :2].max(axis=0)}"
|
||||
)
|
||||
assert np.all(ndc[:, 2] <= 1.0), "the part is beyond the far plane"
|
||||
# ...and not so far away that it is a dot. The default view is a three-quarter
|
||||
# one, so the plate's face fills the frame in both axes.
|
||||
plate = np.asarray(assets.sensing_part().positions_sensor_m)
|
||||
span = np.ptp(_project(camera, plate)[:, :2], axis=0)
|
||||
assert span.min() > 0.25, f"the fitted scene is tiny on screen: {span}"
|
||||
# The scene sits inside the frustum, not against a plane: at NDC z ~ 1 there
|
||||
# is no usable depth resolution and the housing z-fights the plate.
|
||||
depth = _project(camera, plate)[:, 2]
|
||||
assert np.all(np.abs(depth) < 0.9), f"the scene is against a frustum plane: {depth}"
|
||||
|
||||
|
||||
def test_fit_respects_a_wide_and_a_tall_viewport():
|
||||
"""A wide window must not clip vertically, a tall one not horizontally."""
|
||||
assets = synthetic.poc_scene()
|
||||
for aspect in (0.5, 1.0, 2.5):
|
||||
camera = OrbitCamera()
|
||||
camera.fit(assets.bounds_sensor_m, aspect)
|
||||
points = np.concatenate([np.asarray(p.positions_sensor_m) for p in assets.parts])
|
||||
ndc = _project(camera, points, viewport=(int(200 * aspect), 200))
|
||||
assert np.all(np.abs(ndc[:, :2]) <= 1.0), (aspect, np.ptp(ndc[:, :2], axis=0))
|
||||
|
||||
|
||||
def test_orbit_keeps_the_target_and_the_distance():
|
||||
camera = OrbitCamera()
|
||||
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||
target = camera.target_sensor_m.copy()
|
||||
distance = camera.distance_m
|
||||
camera.orbit((40.0, -20.0), (240, 180))
|
||||
assert np.allclose(camera.target_sensor_m, target), "orbiting moved the target"
|
||||
assert camera.distance_m == pytest.approx(distance)
|
||||
assert camera.yaw_rad != 0.0 and camera.pitch_rad != 0.0
|
||||
|
||||
|
||||
def test_orbit_clamps_the_pitch_instead_of_flipping():
|
||||
camera = OrbitCamera()
|
||||
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||
camera.orbit((0.0, 100000.0), (240, 180))
|
||||
assert camera.pitch_rad <= MAX_PITCH_RAD + 1e-9
|
||||
camera.orbit((0.0, -100000.0), (240, 180))
|
||||
assert camera.pitch_rad >= -MAX_PITCH_RAD - 1e-9
|
||||
# The view stays well formed: an up vector that never collapses.
|
||||
up = camera.matrices((240, 180))
|
||||
assert np.isfinite(np.asarray(up.view)).all()
|
||||
|
||||
|
||||
def test_zoom_is_exponential_and_clamped():
|
||||
camera = OrbitCamera()
|
||||
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||
start = camera.distance_m
|
||||
camera.zoom(1.0)
|
||||
assert camera.distance_m < start
|
||||
inward = camera.distance_m
|
||||
camera.zoom(-1.0)
|
||||
assert camera.distance_m == pytest.approx(start, rel=1e-6), "zoom in then out must return"
|
||||
for _ in range(200):
|
||||
camera.zoom(10.0)
|
||||
assert camera.distance_m == pytest.approx(MIN_DISTANCE_M)
|
||||
for _ in range(400):
|
||||
camera.zoom(-10.0)
|
||||
assert camera.distance_m == pytest.approx(MAX_DISTANCE_M)
|
||||
del inward
|
||||
|
||||
|
||||
def test_pan_moves_the_target_in_the_view_plane():
|
||||
camera = OrbitCamera()
|
||||
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||
before = camera.target_sensor_m.copy()
|
||||
eye_before = camera.eye_sensor_m()
|
||||
camera.pan((10.0, 0.0), (240, 180))
|
||||
moved = camera.target_sensor_m - before
|
||||
assert float(np.linalg.norm(moved)) > 0.0, "panning did nothing"
|
||||
# The eye follows the target, so the model does not appear to rotate.
|
||||
assert np.allclose(camera.eye_sensor_m() - eye_before, moved, atol=1e-9)
|
||||
# A pan of half the viewport height moves the target by half the visible height.
|
||||
camera2 = OrbitCamera()
|
||||
camera2.fit(synthetic.poc_scene().bounds_sensor_m, 240 / 180)
|
||||
visible_height = 2.0 * camera2.distance_m * np.tan(0.5 * camera2.vertical_fov_rad)
|
||||
before2 = camera2.target_sensor_m.copy()
|
||||
camera2.pan((0.0, 90.0), (240, 180))
|
||||
moved2 = camera2.target_sensor_m - before2
|
||||
assert float(np.linalg.norm(moved2)) == pytest.approx(visible_height * 0.5, rel=1e-6), (
|
||||
"a half-viewport drag must move the target by half the visible height"
|
||||
)
|
||||
# ...and along the camera's up axis: a screen-vertical drag is not world-Z.
|
||||
direction = moved2 / float(np.linalg.norm(moved2))
|
||||
assert abs(float(np.dot(direction, camera2._direction()))) < 1e-6
|
||||
|
||||
|
||||
def test_presets_reproduce_their_eye_and_target():
|
||||
camera = OrbitCamera()
|
||||
preset = CameraPreset(
|
||||
"asset-default", (0.0, -0.05, 0.05), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.6
|
||||
)
|
||||
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||
camera.set_preset(preset)
|
||||
assert np.allclose(camera.target_sensor_m, preset.target_sensor_m)
|
||||
assert np.allclose(camera.eye_sensor_m(), preset.eye_sensor_m, atol=1e-9)
|
||||
assert camera.vertical_fov_rad == pytest.approx(preset.vertical_fov_rad)
|
||||
# A drag continues from the preset rather than jumping.
|
||||
eye = camera.eye_sensor_m()
|
||||
camera.orbit((5.0, 2.0), (240, 180))
|
||||
assert not np.allclose(camera.eye_sensor_m(), eye)
|
||||
|
||||
|
||||
def test_reset_returns_to_the_framed_view():
|
||||
camera = OrbitCamera()
|
||||
assets = synthetic.poc_scene()
|
||||
camera.fit(assets.bounds_sensor_m, 1.0)
|
||||
target = camera.target_sensor_m.copy()
|
||||
distance = camera.distance_m
|
||||
camera.orbit((30.0, 10.0), (240, 180))
|
||||
camera.zoom(3.0)
|
||||
camera.reset(assets.bounds_sensor_m)
|
||||
assert np.allclose(camera.target_sensor_m, target)
|
||||
assert camera.distance_m == pytest.approx(distance)
|
||||
assert camera.yaw_rad == 0.0 and camera.pitch_rad == 0.0
|
||||
|
||||
|
||||
def test_the_view_matrix_is_a_rigid_transform():
|
||||
camera = OrbitCamera()
|
||||
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.3)
|
||||
view = np.asarray(camera.matrices((320, 240)).view, dtype=np.float64)
|
||||
rotation = view[:3, :3]
|
||||
assert np.allclose(rotation @ rotation.T, np.eye(3), atol=1e-5), "the view is not rigid"
|
||||
assert np.linalg.det(rotation) == pytest.approx(1.0, abs=1e-5)
|
||||
# The camera sits in front of the scene: visible points have negative z.
|
||||
target = camera.target_sensor_m
|
||||
homogeneous = np.append(target, 1.0)
|
||||
assert float((view @ homogeneous)[2]) < 0.0
|
||||
|
||||
|
||||
def test_matrices_are_read_only_float32():
|
||||
camera = OrbitCamera()
|
||||
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||
matrices = camera.matrices((240, 180))
|
||||
for name, value in (("view", matrices.view), ("projection", matrices.projection)):
|
||||
assert value.dtype == np.float32 and value.shape == (4, 4)
|
||||
assert not value.flags["WRITEABLE"], f"{name} is writable"
|
||||
assert value.flags["C_CONTIGUOUS"]
|
||||
|
||||
|
||||
def test_fit_refuses_bounds_that_are_not_two_by_three():
|
||||
camera = OrbitCamera()
|
||||
with pytest.raises(ValueError):
|
||||
camera.fit(np.zeros((3, 3), dtype=np.float32), 1.0)
|
||||
|
||||
|
||||
def test_a_preset_with_the_eye_on_the_target_is_refused():
|
||||
camera = OrbitCamera()
|
||||
with pytest.raises(ValueError):
|
||||
camera.set_preset(
|
||||
CameraPreset("degenerate", (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.6)
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the POC scene
|
||||
def test_the_synthetic_scene_obeys_the_contracts():
|
||||
assets = synthetic.poc_scene()
|
||||
assert isinstance(assets, SceneAssets)
|
||||
assert validate_assets(assets, synthetic.poc_expected()) == ()
|
||||
roles = {part.role for part in assets.parts}
|
||||
assert roles == {"sensing", "housing"}, "a scene needs one of each for the PoC"
|
||||
# The plate faces +Z and is flat, so a normal assertion has an answer.
|
||||
plate = assets.sensing_part()
|
||||
assert np.allclose(np.asarray(plate.normals_sensor), [0.0, 0.0, 1.0])
|
||||
assert float(np.asarray(plate.positions_sensor_m)[:, 2].max()) == 0.0
|
||||
assert assets.bounds_sensor_m.shape == (2, 3)
|
||||
assert [preset.name for preset in assets.camera_presets][0] == "default"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ GL CPU helpers
|
||||
def test_shader_sources_are_two_dialects_of_one_body():
|
||||
for profile, marker in (("desktop33", "#version 330 core"), ("gles30", "#version 300 es")):
|
||||
for program in ("scalar", "diffuse"):
|
||||
vertex, fragment = shader_source(profile, program)
|
||||
assert vertex.startswith(marker), (profile, program, vertex[:40])
|
||||
assert fragment.startswith(marker)
|
||||
if profile == "gles30":
|
||||
# ES requires explicit precision for floats.
|
||||
assert "precision highp float;" in fragment
|
||||
# No fixed pipeline, ever.
|
||||
for banned in ("glBegin", "gl_End", "glMatrixMode", "gl_FragColor", "GL_QUADS"):
|
||||
assert banned not in vertex and banned not in fragment
|
||||
# The maths is shared: the bodies differ only by their header.
|
||||
from orisys_studio.scene3d.gl_backend import shader_header
|
||||
|
||||
desktop_vertex, desktop_fragment = shader_source("desktop33", "scalar")
|
||||
es_vertex, es_fragment = shader_source("gles30", "scalar")
|
||||
desktop_body = desktop_vertex[len(shader_header("desktop33")):]
|
||||
es_body = es_vertex[len(shader_header("gles30")):]
|
||||
assert desktop_body == es_body, "the vertex maths drifted between dialects"
|
||||
assert desktop_fragment[len(shader_header("desktop33")):] == es_fragment[
|
||||
len(shader_header("gles30")):
|
||||
]
|
||||
assert "texture(u_lut" in desktop_fragment and "texture(u_lut" in es_fragment
|
||||
assert "isnan" in desktop_fragment, "an unmeasured value must not be colour-mapped"
|
||||
|
||||
|
||||
def test_an_unknown_profile_or_program_is_refused():
|
||||
with pytest.raises(ValueError):
|
||||
shader_source("vulkan", "scalar") # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError):
|
||||
shader_source("desktop33", "wireframe")
|
||||
|
||||
|
||||
def test_the_lut_is_an_ordered_ramp_and_the_diverging_one_is_centred():
|
||||
sequential = lut_rgba("sequential")
|
||||
assert sequential.shape == (LUT_SIZE, 1, 4) and sequential.dtype == np.uint8
|
||||
assert (sequential[:, 0, 3] == 255).all(), "the LUT must be opaque"
|
||||
luminance = sequential[:, 0, :3].astype(float).mean(axis=1)
|
||||
assert luminance[0] < luminance[-1], "a sequential ramp must get lighter"
|
||||
assert (np.diff(luminance) >= -6.0).all(), "the ramp is not monotone"
|
||||
diverging = lut_rgba("diverging")
|
||||
assert diverging.shape == sequential.shape
|
||||
assert not np.array_equal(diverging, sequential)
|
||||
|
||||
|
||||
def test_software_renderers_are_recognised():
|
||||
assert is_software_renderer("Mesa", "llvmpipe (LLVM 15.0.7, 256 bits)")
|
||||
assert is_software_renderer("Microsoft", "Microsoft Basic Render Driver")
|
||||
assert is_software_renderer("", "swrast")
|
||||
assert not is_software_renderer("NVIDIA Corporation", "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2")
|
||||
assert not is_software_renderer("Intel", "Intel(R) UHD Graphics 620")
|
||||
|
||||
|
||||
def test_the_renderable_type_mapping_is_not_inverted():
|
||||
"""Qt: OpenGL = 1, OpenGLES = 2. Getting this backwards is invisible on a
|
||||
driver that accepts both dialects and fatal on one that does not."""
|
||||
assert profile_from_renderable_type(1) == "desktop33"
|
||||
assert profile_from_renderable_type(2) == "gles30"
|
||||
assert profile_from_renderable_type(0) == "desktop33", "the default is desktop"
|
||||
|
||||
|
||||
def _plate(**kwargs):
|
||||
return synthetic.poc_scene().sensing_part()
|
||||
|
||||
|
||||
def test_packing_lays_out_position_normal_scalar_valid():
|
||||
part = _plate()
|
||||
count = int(part.positions_sensor_m.shape[0])
|
||||
scalar = np.linspace(0.0, 1.0, count, dtype=np.float32)
|
||||
valid = np.ones(count, dtype=bool)
|
||||
rows = pack_part(part, scalar=scalar, valid=valid)
|
||||
assert rows.shape == (count, STRIDE_FLOATS)
|
||||
assert rows.dtype == np.float32 and rows.flags["C_CONTIGUOUS"]
|
||||
assert not rows.flags["WRITEABLE"], "the packed buffer must be read-only"
|
||||
assert np.allclose(rows[:, 0:3], part.positions_sensor_m)
|
||||
assert np.allclose(rows[:, 3:6], part.normals_sensor)
|
||||
assert np.allclose(rows[:, 6], scalar)
|
||||
assert np.allclose(rows[:, 7], 1.0)
|
||||
|
||||
|
||||
def test_packing_a_scene_without_a_measurement_marks_everything_invalid():
|
||||
rows = pack_part(_plate())
|
||||
assert np.isnan(rows[:, 6]).all(), "an unmeasured scalar must be NaN, not zero"
|
||||
assert np.allclose(rows[:, 7], 0.0), "an unmeasured vertex must be invalid"
|
||||
|
||||
|
||||
def test_packing_refuses_a_mismatched_length():
|
||||
part = _plate()
|
||||
with pytest.raises(Exception) as scalar_error:
|
||||
pack_part(part, scalar=np.zeros(3, dtype=np.float32))
|
||||
assert "scalars for" in str(scalar_error.value)
|
||||
with pytest.raises(Exception) as valid_error:
|
||||
pack_part(part, valid=np.zeros(2, dtype=bool))
|
||||
assert "validity flags for" in str(valid_error.value)
|
||||
|
||||
|
||||
def test_scene_part_indices_are_uint32_and_read_only():
|
||||
vertices, indices = pack_scene_part(_plate())
|
||||
assert indices.dtype == np.uint32 and indices.ndim == 1
|
||||
assert not indices.flags["WRITEABLE"] and not vertices.flags["WRITEABLE"]
|
||||
assert int(indices.max()) < vertices.shape[0]
|
||||
|
||||
|
||||
def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
|
||||
assets = synthetic.poc_scene()
|
||||
budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface")
|
||||
plate = assets.sensing_part()
|
||||
housing = assets.part("housing")
|
||||
assert housing is not None
|
||||
expected = (
|
||||
plate.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
|
||||
+ plate.triangles.size * 4
|
||||
+ plate.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4 # the in-flight copy
|
||||
+ housing.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
|
||||
+ housing.triangles.size * 4
|
||||
+ LUT_SIZE * 4
|
||||
)
|
||||
assert budget == expected
|
||||
assert budget < 1024 * 1024, "the PoC scene should be tiny"
|
||||
|
||||
|
||||
def test_the_rest_surface_claims_no_measurement():
|
||||
assets = synthetic.poc_scene()
|
||||
frame = rest_surface(assets, FrameIdentity("poc", 1, 0, 0.0, 1))
|
||||
count = int(assets.sensing_part().positions_sensor_m.shape[0])
|
||||
assert frame.scalar.shape == (count,) and frame.valid.shape == (count,)
|
||||
assert not frame.valid.any(), "a reference frame measures nothing"
|
||||
assert np.isnan(frame.scalar).all(), "an unavailable scalar is NaN, not zero"
|
||||
assert frame.scalar_descriptor.quantity == "unavailable"
|
||||
assert frame.scalar_descriptor.quality == "display_only"
|
||||
assert frame.geometry_id == assets.geometry_id
|
||||
assert frame.sensing_part_id == assets.sensing_part().part_id
|
||||
Reference in New Issue
Block a user