766 lines
32 KiB
Python
766 lines
32 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,
|
|
DisplayField,
|
|
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 _field(assets, values, valid=None, descriptor=None) -> DisplayField:
|
|
"""A display field on the scene's own domain, from a flat or gridded array.
|
|
|
|
The surface's colour and validity now come from the *field*, not from the vertices,
|
|
so a test that wants a particular picture builds one of these and hands it to the
|
|
widget. ``values`` is reshaped to the scene's ``[H,W]`` domain — which is what the
|
|
2D canvas would paint — and the fragment reads it through the mesh's UVs.
|
|
"""
|
|
shape = (int(assets.shape_hw[0]), int(assets.shape_hw[1]))
|
|
scalar = np.asarray(values, dtype=np.float32).reshape(shape)
|
|
mask = (
|
|
np.ones(shape, dtype=bool)
|
|
if valid is None
|
|
else np.asarray(valid, dtype=bool).reshape(shape)
|
|
)
|
|
return DisplayField(
|
|
domain_id=assets.field_domain_id,
|
|
shape_hw=shape,
|
|
scalar=freeze(np.array(scalar, dtype=np.float32), dtype=np.float32),
|
|
valid=freeze(mask, dtype=np.bool_),
|
|
descriptor=(
|
|
descriptor
|
|
or FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
|
),
|
|
)
|
|
|
|
|
|
def _frame(assets, values, valid=None, *, frame_id=5, positions=None, normals=None,
|
|
descriptor=None, deformed=False) -> RenderFrame:
|
|
part = assets.sensing_part()
|
|
return RenderFrame(
|
|
identity=FrameIdentity("poc", 1, frame_id, 0.5, generation=1),
|
|
asset_hash=assets.asset_hash,
|
|
geometry_id=assets.geometry_id,
|
|
sensing_part_id=part.part_id,
|
|
positions_sensor_m=part.positions_sensor_m if positions is None else positions,
|
|
normals_sensor=part.normals_sensor if normals is None else normals,
|
|
field=_field(assets, values, valid, descriptor),
|
|
deformed=deformed,
|
|
)
|
|
|
|
|
|
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 PySide6.QtGui import QImage
|
|
|
|
widget.repaint()
|
|
qt_app.processEvents()
|
|
image = widget.grabFramebuffer()
|
|
assert not image.isNull(), "Qt returned no framebuffer"
|
|
image = image.convertToFormat(QImage.Format.Format_RGB888)
|
|
width, height = image.width(), image.height()
|
|
# PySide6 returns a `memoryview` from constBits(), not a QByteArray: wrap it directly.
|
|
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
|
|
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 _plate_mean_rgb(pixels: np.ndarray) -> np.ndarray:
|
|
"""The mean RGB of the drawn pixels: the plate's own colour, on a flat surface."""
|
|
drawn = _drawn(pixels)
|
|
assert drawn.size, "nothing was drawn"
|
|
return drawn.mean(axis=0)
|
|
|
|
|
|
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"}
|
|
|
|
# ``DisplaySettings.housing_visible`` ships False (the frame dominated the
|
|
# picture), so the visible case has to be asked for explicitly rather than
|
|
# relying on the default — otherwise both renders below are the hidden one and
|
|
# the comparison cannot mean anything.
|
|
widget.set_settings(DisplaySettings(housing_visible=True))
|
|
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.
|
|
|
|
Asserted as a *colour identity*, not as a hue difference: the valid path must draw
|
|
the lookup table's own colour for the value it was given, and the neutral path must
|
|
draw the neutral colour. (The old form of this test compared mean ``|r-g|``, which
|
|
only worked because the 3D pane used a purple ramp whose low end is far from grey.
|
|
With one ramp serving both views the low end is the theme's own navy, where ``r≈g``,
|
|
so a hue test would have had to be loosened until it stopped meaning anything. A
|
|
colour-identity test is both stricter and independent of which ramp is in use.)
|
|
"""
|
|
from orisys_studio.scene3d.gl_backend import INVALID_COLOR, LUT_SIZE, lut_rgba
|
|
|
|
widget, _, _ = gl_widget
|
|
assets = synthetic.poc_scene()
|
|
widget.set_settings(DisplaySettings(housing_visible=False))
|
|
|
|
rest_rgb = _plate_mean_rgb(_render(widget, qt_app))
|
|
neutral_8bit = np.array(INVALID_COLOR[:3]) * 255.0
|
|
|
|
value = 0.05
|
|
frame = _frame(assets, np.full(assets.shape_hw, value, dtype=np.float32))
|
|
widget.set_settings(
|
|
DisplaySettings(
|
|
housing_visible=False,
|
|
color_scale=ColorScale(
|
|
descriptor=frame.field.descriptor,
|
|
lower=0.0,
|
|
upper=1.0,
|
|
lut_id="sequential",
|
|
),
|
|
)
|
|
)
|
|
widget.present(frame)
|
|
measured_rgb = _plate_mean_rgb(_render(widget, qt_app))
|
|
|
|
# Where the fragment shader samples the table: ``vec2(t, 0.5)`` on a 256x1 texture
|
|
# with linear filtering, i.e. ``t * 256 - 0.5`` clamped into the table.
|
|
table = lut_rgba("sequential")[:, 0, :3].astype(float)
|
|
position = float(np.clip(value * LUT_SIZE - 0.5, 0.0, LUT_SIZE - 1.0))
|
|
low = int(np.floor(position))
|
|
high = min(low + 1, LUT_SIZE - 1)
|
|
fraction = position - low
|
|
expected = (1.0 - fraction) * table[low] + fraction * table[high]
|
|
|
|
assert np.abs(rest_rgb - neutral_8bit).max() < 6.0, (
|
|
f"the unmeasured surface is not the neutral colour: {rest_rgb} vs {neutral_8bit}"
|
|
)
|
|
assert np.abs(measured_rgb - expected).max() < 8.0, (
|
|
"the colour map's own colour did not reach the plate: "
|
|
f"drawn {measured_rgb}, the table gives {expected} for {value} — the valid path "
|
|
"is not being taken, or the pane is not using the shared ramp"
|
|
)
|
|
assert np.abs(expected - neutral_8bit).max() > 10.0, (
|
|
"this value is too close to the neutral colour to make the point; pick another"
|
|
)
|
|
widget.set_settings(DisplaySettings(housing_visible=True))
|
|
|
|
|
|
def _plate_mode_rgb(pixels: np.ndarray) -> np.ndarray:
|
|
"""The most common colour of the drawn surface: its *base* colour.
|
|
|
|
The resting area is the largest uniform region of a depth picture, so the modal
|
|
colour is the colour the ramp gives "no response" — the thing that must not move
|
|
when the operator presses.
|
|
"""
|
|
drawn = _drawn(pixels)
|
|
assert drawn.size, "nothing was drawn"
|
|
quantised = (drawn // 4) * 4
|
|
values, counts = np.unique(quantised, axis=0, return_counts=True)
|
|
return values[int(np.argmax(counts))].astype(float)
|
|
|
|
|
|
def test_depth_threshold_only_changes_depth_colours(gl_widget, qt_app):
|
|
from orisys_studio.scene3d.gl_backend import lut_rgba
|
|
|
|
widget, _, _ = gl_widget
|
|
assets = synthetic.poc_scene()
|
|
values = np.full(assets.shape_hw, 0.05, dtype=np.float32)
|
|
depth = FieldDescriptor("depth_response", "1", "approximate", False)
|
|
flow = FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
|
|
|
def settings(descriptor, threshold):
|
|
return DisplaySettings(
|
|
housing_visible=False,
|
|
depth_threshold=threshold,
|
|
color_scale=ColorScale(
|
|
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential"
|
|
),
|
|
)
|
|
|
|
widget.set_settings(settings(depth, 0.0))
|
|
widget.present(_frame(assets, values, descriptor=depth, frame_id=30))
|
|
unfiltered = _plate_mean_rgb(_render(widget, qt_app))
|
|
widget.set_settings(settings(depth, 0.1))
|
|
filtered = _plate_mean_rgb(_render(widget, qt_app))
|
|
base = lut_rgba("sequential")[0, 0, :3].astype(float)
|
|
assert np.abs(filtered - base).max() < 8.0
|
|
assert np.abs(unfiltered - filtered).max() > 10.0
|
|
|
|
widget.set_settings(settings(flow, 0.1))
|
|
widget.present(_frame(assets, values, descriptor=flow, frame_id=31))
|
|
assert np.abs(_plate_mean_rgb(_render(widget, qt_app)) - unfiltered).max() < 8.0
|
|
|
|
|
|
def test_a_press_does_not_move_the_surfaces_base_colour(gl_widget, qt_app):
|
|
"""The base colour follows the field, not the frame's current response.
|
|
|
|
Reported as "the base color of 3d depth view will change upon pressing". Measured
|
|
before the fix on a real recording, with the automatic colour scale: the resting
|
|
plate was drawn RGB [248, 0, 240] (the middle of the ramp, because a flat field was
|
|
centred on its own midpoint) and the first press moved it to [8, 24, 48] (the first
|
|
colour), where it stayed after the release. The automatic range is now anchored at
|
|
the field's own zero, so the base colour is the ramp's first colour throughout and
|
|
only the upper bound follows the press.
|
|
"""
|
|
widget, renderer, _ = gl_widget
|
|
from orisys_studio.scene3d.gl_backend import lut_rgba
|
|
|
|
assets = synthetic.poc_scene()
|
|
descriptor = FieldDescriptor("depth_response", "1", "approximate", False)
|
|
automatic = DisplaySettings(
|
|
housing_visible=False,
|
|
color_scale=ColorScale(
|
|
descriptor=descriptor,
|
|
lower=0.0,
|
|
upper=1.0,
|
|
lut_id="sequential",
|
|
mode="automatic",
|
|
),
|
|
)
|
|
widget.set_settings(automatic)
|
|
|
|
rest = np.zeros(assets.shape_hw, dtype=np.float32)
|
|
widget.present(_frame(assets, rest, frame_id=21))
|
|
rest_base = _plate_mode_rgb(_render(widget, qt_app))
|
|
rest_range = renderer._scalar_range
|
|
|
|
# A press on one cell of the 3x3 PoC plate: the rest of the plate is still "no
|
|
# response", so the modal colour is still the base colour.
|
|
pressed = rest.copy()
|
|
pressed[1, 1] = 1.0
|
|
widget.present(_frame(assets, pressed, frame_id=22))
|
|
pressed_base = _plate_mode_rgb(_render(widget, qt_app))
|
|
|
|
table = lut_rgba("sequential")[:, 0, :3].astype(float)
|
|
first_colour = table[0]
|
|
assert np.abs(rest_base - first_colour).max() <= 6.0, (
|
|
f"the resting plate is not drawn in the ramp's first colour: {rest_base} vs "
|
|
f"{first_colour}"
|
|
)
|
|
assert np.abs(pressed_base - rest_base).max() <= 6.0, (
|
|
"pressing changed the surface's base colour: "
|
|
f"{rest_base} at rest against {pressed_base} pressed"
|
|
)
|
|
# The press still has to show: the range's upper bound follows the response.
|
|
scale = renderer._last_scale
|
|
assert scale is not None and scale[0] == 0.0, (
|
|
f"the automatic range is not anchored at the field's zero: {scale}"
|
|
)
|
|
assert (
|
|
renderer._scalar_range is not None
|
|
and rest_range is not None
|
|
and renderer._scalar_range[1] > rest_range[1]
|
|
), (
|
|
"the press did not reach the colour scale: "
|
|
f"{rest_range} at rest against {renderer._scalar_range} pressed"
|
|
)
|
|
|
|
|
|
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 = _frame(
|
|
assets,
|
|
np.linspace(0.0, 1.0, count).reshape(assets.shape_hw),
|
|
frame_id=6,
|
|
positions=freeze(positions, dtype=np.float32),
|
|
deformed=True,
|
|
)
|
|
widget.set_settings(
|
|
DisplaySettings(
|
|
color_scale=ColorScale(
|
|
descriptor=frame.field.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
|
|
before = widget.rejected_frames
|
|
frame = _frame(
|
|
synthetic.poc_scene(), np.zeros(synthetic.poc_scene().shape_hw, dtype=np.float32),
|
|
frame_id=2,
|
|
)
|
|
object.__setattr__(frame, "asset_hash", "other")
|
|
object.__setattr__(frame, "geometry_id", "some-other-geometry")
|
|
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"
|
|
# A slow composer must cost the *display* something, and nothing else. Two
|
|
# mechanisms account for it now: the mailbox supersedes what it never showed,
|
|
# and the producer declines to build a snapshot while one is already waiting
|
|
# (which is cheaper and loses nothing — the waiting frame is still the newest).
|
|
accounted = (
|
|
coordinator.superseded_frames + view.render_totals()["skipped_producer"]
|
|
)
|
|
assert accounted > 0, (
|
|
"a 50 ms composer produced no display-side drops at all: neither the mailbox "
|
|
"nor the producer budget is doing its job"
|
|
)
|
|
finally:
|
|
coordinator._composer = real_composer
|
|
# Drain everything the slow composer left behind, *including* the frames still
|
|
# waiting in the mailbox: this view is shared by the whole module, and a leftover
|
|
# frame would be uploaded by whichever test paints next.
|
|
deadline = time.perf_counter() + 8.0
|
|
while time.perf_counter() < deadline and (
|
|
coordinator._inflight is not None or coordinator.has_pending_frame()
|
|
):
|
|
coordinator.tick()
|
|
time.sleep(0.01)
|
|
assert coordinator._inflight is None and not coordinator.has_pending_frame(), (
|
|
"the slow-composer test did not drain before the next test"
|
|
)
|
|
|
|
|
|
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
|
|
|
|
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,
|
|
field=_field(assets_frame, values),
|
|
)
|
|
|
|
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, int(assets_frame.shape_hw[0]) * int(assets_frame.shape_hw[1]))
|
|
|
|
widget.set_settings(fixed_wide)
|
|
widget.present(frame_with(ramp))
|
|
flat_pixels = _drawn(_render(widget, qt_app))
|
|
assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0, (
|
|
"the frame's own values never reached the renderer"
|
|
)
|
|
widget.set_settings(auto)
|
|
widget.present(frame_with(ramp))
|
|
auto_pixels = _drawn(_render(widget, qt_app))
|
|
# The data spans 3..7. A fixed 0..1000 range squeezes all of it into the first
|
|
# fraction of a percent of the LUT (the ramp starts dark, so the picture is nearly
|
|
# black); an automatic range spreads it over the whole ramp. Mean brightness is the
|
|
# property that follows from that, and unlike "how many distinct colours" it does
|
|
# not depend on how much of the plate happens to be visible in this window.
|
|
flat_mean = float(flat_pixels.mean())
|
|
auto_mean = float(auto_pixels.mean())
|
|
assert auto_mean > flat_mean + 20.0, (
|
|
"an automatic range did not use the frame's own values "
|
|
f"(mean brightness: automatic {auto_mean:.1f}, fixed 0..1000 {flat_mean:.1f})"
|
|
)
|
|
|
|
|
|
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()
|