Files
orisys-app/apps/studio/tests/test_scene3d_gl_smoke.py
T
ykcheungab ac4dcd643f feat(studio): 三维视图接入产品(阶段 4:界面、生命周期与记录隔离)
- What: `Scene3DView` 装配(widget/相机/渲染器/合成器/协调器/适配器),`asset_source`
  只查找并后台加载已编译的 `.oryscene` 包,`scene3d/prefs` 持久化并**修复**显示设置,
  `ui/mesh3d_panel` 用真实容器替换占位标签(无 GL/无包/包被拒/传感器不匹配各显示一行原因),
  `main.py` 在 `QApplication` 之前选定 GL profile,`app_window` 接线:3D 作为采集线程上的
  第二个 sink、`_on_run_tick` 驱动 `tick()`、视图切换与 `closeEvent` 释放、preferences 落盘。
- Why: 阶段 4 的验收要求"三维开关不影响采集/记录/力曲线",并把阶段 1 留下的
  "开/关三维 CSV 一致"这条补上;同时把"没有 context"从"看起来像在工作"变成可显示的终态。
- Verification: app 侧 test_scene3d_view 28 项、test_scene3d_gl_smoke 14 项(真实 GPU)、
  adapters 17 项、skeleton 4 项、stage1 24 项、coordinator 13 项、ui_smoke 86 项;
  shared 侧 contract 26 / assets 33 / package 19 / live 25;`ui_check.py` 566 项检查 0 失败
  (新增 "3D pane" 段 12 项);`locale_check.py` 302 个字面 key 0 缺失、中英各 377 key 对齐;
  记录隔离:同一 96 帧会话挂/不挂 3D 消费者,`forces.csv` 逐字节相同、有效样本数与记录问题数相同;
  生产者预算:合成器放慢到 50 ms 时 `submit_packet` p99 < 1 ms。
- Not in this change: 示意位移(`displacement_sensor_m`)仍未接入(需 SDK lift 与已确认尺度);
  ARM 板卡适配(按用户要求暂缓);包签名/AES-GCM 与长时性能报告(阶段 5);
  仓库中仍未提供 `.oryscene` 包(发布产物,且其单位与变换必须由人声明)。
2026-09-14 15:47:32 +08:00

561 lines
23 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_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."""
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)
@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()