"""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_a_software_renderer_is_refused_with_its_capabilities(gl_widget, monkeypatch): """A board must not be signed off on llvmpipe, and the report has to say so.""" from orisys_studio.scene3d import gl_backend widget, _, _ = gl_widget monkeypatch.setattr(gl_backend, "is_software_renderer", lambda vendor, renderer: True) with pytest.raises(gl_backend.ThreeDUnsupported) as excinfo: gl_backend.GLDispatch().bind_current_context(widget.context()) capabilities = excinfo.value.capabilities assert capabilities.software_renderer is True assert any(item.code == "gl_unsupported" for item in capabilities.diagnostics) assert "software" in excinfo.value.diagnostic.message.lower() def test_a_broken_shader_takes_the_view_down_not_the_application(gl_widget, qt_app, monkeypatch): """Shader compilation failure is a diagnostic and an empty view, nothing more.""" from orisys_studio.scene3d import gl_backend from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.widget import Sensor3DWidget widget, _, _ = gl_widget capabilities = widget.capabilities() assert capabilities is not None # The renderer refuses to come up, and says why. def broken_source(profile, program): return "#version 330 core\nvoid main() { this is not glsl }\n", "#version 330 core\n" monkeypatch.setattr(gl_backend, "shader_source", broken_source) renderer = gl_backend.GLRenderer() widget.makeCurrent() try: with pytest.raises(Exception) as excinfo: renderer.initialize(capabilities) finally: widget.doneCurrent() item = getattr(excinfo.value, "diagnostic", None) assert item is not None and item.code == "shader_compile_failed", excinfo.value assert not renderer.is_initialized, "a failed initialize must not claim to be up" # The widget turns the same failure into a state: a diagnostic, an empty view, # and no exception on the way out. monkeypatch.undo() class FailingRenderer(gl_backend.GLRenderer): def initialize(self, capabilities): raise gl_backend.error("shader_compile_failed", "forced for this test") failing = Sensor3DWidget(FailingRenderer(), OrbitCamera()) seen: list = [] failing.diagnosticRaised.connect(seen.append) failing.resize(120, 90) failing.show() failing.repaint() qt_app.processEvents() failing.repaint() # a second paint of a failed view must be a no-op qt_app.processEvents() assert failing.unavailable assert seen and seen[0].code == "shader_compile_failed" failing.close() failing.deleteLater() 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"] # ------------------------------------------------- stage 4: the assembled view class _Packet: """The parts of an ``SdkPacket`` the finger adapter and the identity need. The provenance flags are part of the contract now: a producer has to say that the array it hands over belongs to this frame, and the adapter refuses one that does not say (see ``adapters._field_validity``). """ def __init__(self, frame_id: int, shape_hw: tuple[int, int], value: float = 0.5) -> None: self.session_id = 1 self.frame_id = int(frame_id) self.mono_t = float(frame_id) / 30.0 self.flow = np.full((*shape_hw, 2), float(value), dtype=np.float32) self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8) self.valid = True self.invalid_reason = "" self.flow_valid = True self.flow_frame_id = int(frame_id) self.depth = None self.depth_valid = None self.depth_frame_id = None self.forces_valid = True self.valid_mask = None @pytest.fixture(scope="module") def assembled_view(qt_app): """A real ``Scene3DView``: pane, coordinator, composer, widget, real context.""" from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.view import Scene3DView configure_surface_format("desktop33") view = Scene3DView() if not view.available: pytest.skip(f"no GL view here: {view.reason}") assets = synthetic.poc_scene() if not view.use_scene(assets, source_id="poc-live"): pytest.skip("the synthetic scene was refused") view.resize(360, 300) view.show() view.set_enabled(True) renderer = view.gl_widget._renderer painted = _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0) if not painted: view.close() pytest.skip(f"no usable GL context here: {view.gl_widget.diagnostic()}") yield view, renderer, assets view.close() view.close() qt_app.processEvents() def test_the_assembled_view_draws_what_the_acquisition_thread_hands_it(assembled_view, qt_app): """The whole stage-4 path: packet in, pixels out, and no recording involved.""" view, renderer, assets = assembled_view widget = view.gl_widget for index in range(30): view.submit_packet(_Packet(index, assets.shape_hw, value=0.4 + index * 0.05)) view.tick() qt_app.processEvents() assert _pump(qt_app, widget, lambda: view.state() in ("live", "no_data"), timeout_s=5.0) assert view.state() in ("live", "no_data"), view.state() stats = widget._last_stats_at and None # stats are published on a timer del stats assert view.last_descriptor is not None, "the pane never learned what it is showing" assert view.last_descriptor.quantity == "flow_magnitude" assert view.last_descriptor.quality == "approximate" drawn = _drawn(_render(widget, qt_app)) assert drawn.size, "the live frame was not drawn" def test_the_producer_side_never_waits_for_the_gpu(assembled_view, qt_app): """The measured budget: the acquisition thread's own cost, with a slow composer. This is the stage-3 property asserted through the *application's* entry point, so it includes the adapter (the flow magnitude over the whole grid) and not just the mailbox. The composer is slowed to 50 ms, which is far below any real frame rate: frames must drop in the display and nowhere else. """ import time view, _, assets = assembled_view coordinator = view.coordinator real_composer = coordinator._composer class Slow: def compose(self, *args, **kwargs): time.sleep(0.05) return real_composer.compose(*args, **kwargs) def rest_frame(self, *args, **kwargs): return real_composer.rest_frame(*args, **kwargs) coordinator._composer = Slow() try: samples: list[float] = [] for index in range(200): packet = _Packet(index, assets.shape_hw, value=0.5) started = time.perf_counter() view.submit_packet(packet) samples.append((time.perf_counter() - started) * 1000.0) view.tick() ordered = sorted(samples) p99 = ordered[int(len(ordered) * 0.99) - 1] assert p99 < 1.0, f"submit p99 was {p99:.2f} ms with a 50 ms composer" assert max(samples) < 5.0, f"submit peaked at {max(samples):.2f} ms" assert coordinator.superseded_frames > 0, ( "a 50 ms composer produced no display drops: the frame rate limiter or the " "mailbox is not doing its job" ) finally: coordinator._composer = real_composer # Drain the in-flight compose that the slow composer left behind. deadline = time.perf_counter() + 1.0 while time.perf_counter() < deadline and coordinator._inflight is not None: coordinator.tick() time.sleep(0.01) def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app): """A range taken from the data, and a fixed range that ignores it.""" view, renderer, assets = assembled_view widget = view.gl_widget assets_frame = view.assets assert assets_frame is not None count = int(assets_frame.sensing_part().positions_sensor_m.shape[0]) def frame_with(values: np.ndarray) -> RenderFrame: return RenderFrame( identity=FrameIdentity("poc-live", 1, 1, 0.0, generation=view.coordinator.generation), asset_hash=assets.asset_hash, geometry_id=assets.geometry_id, sensing_part_id=assets_frame.sensing_part().part_id, positions_sensor_m=assets_frame.sensing_part().positions_sensor_m, normals_sensor=assets_frame.sensing_part().normals_sensor, scalar=freeze(np.asarray(values, 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), ) descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False) auto = DisplaySettings( housing_visible=False, color_scale=ColorScale( descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential", mode="automatic" ), ) fixed_wide = DisplaySettings( housing_visible=False, color_scale=ColorScale( descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed" ), ) ramp = np.linspace(3.0, 7.0, count) widget.set_settings(fixed_wide) widget.present(frame_with(ramp)) assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0 flat = len(np.unique(_drawn(_render(widget, qt_app)), axis=0)) widget.set_settings(auto) widget.present(frame_with(ramp)) spread = len(np.unique(_drawn(_render(widget, qt_app)), axis=0)) assert spread > flat, ( "an automatic range did not use the frame's own values " f"(distinct colours: automatic {spread}, fixed 0..1000 {flat})" ) def test_the_pane_releases_its_context_and_survives_a_second_close(qt_app): """`closeEvent` calls this; a second close must be a no-op, not a crash.""" from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.view import Scene3DView configure_surface_format("desktop33") view = Scene3DView() if not view.available: pytest.skip("no GL view here") if not view.use_scene(synthetic.poc_scene(), source_id="poc-close"): pytest.skip("the synthetic scene was refused") view.resize(200, 160) view.show() view.set_enabled(True) renderer = view.gl_widget._renderer if not _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0): view.close() pytest.skip("no usable GL context here") view.close() assert not renderer.is_initialized assert not renderer.part_ids, "closing the pane left GPU buffers behind" view.close() view.close() qt_app.processEvents()