317 lines
13 KiB
Python
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"]
|