Files
orisys-app/apps/studio/tests/test_scene3d_gl_smoke.py
T
ykcheungab 5767b4bf11 阶段 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 已可在板上直接运行)。
2026-09-14 14:55:45 +08:00

317 lines
13 KiB
Python

"""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"]