Files
orisys-app/apps/studio/tests/test_scene3d_gl_smoke.py
T
ykcheungab ef487098c9 feat(studio-3d): 限速、真实耗时统计、无位移复用资产几何,以及保护包密钥注入(阶段 9 上半)
- What:
  1. **显示限速真的生效**(R5):`ThreeDCoordinator.tick()` 用注入的单调时钟与
     `display_period_s() = 1/max_fps` 限制**合成启动**频率(窗口定时器仍是 16 ms,但"显示帧率"是给操作者的
     承诺,不是定时器的描述);声明边界(activate/begin_session/show_rest/隐藏)把预算清零,所以按下开始后的
     第一帧立刻合成。生产者侧改为**按需**跳过:`Scene3DView.submit_packet()` 在
     `coordinator.has_pending_frame()`(mailbox 里已有一帧等待)时不再构造快照——那是 O(H×W) 的拷贝,
     而 mailbox 只保留最新一帧,两次拷贝里必然丢一个;被跳过的数量计入新的 `skipped_producer`,
     与"显示跳帧"分开。**不丢任何本该显示的帧**(等待中的那一帧仍在)。
  2. **真实统计**:`RenderFrame.compose_ms`(composer 在构造帧之前测量并写入,不再读会被下一任务改写的
     `last_stats`);`GLRenderer` 的绘制计数改名为 `paint_calls`,`RenderStats.displayed_frames` 由
     **widget 按帧身份**统计(相机旋转/色标改动只是重画,不再是"多显示了一帧");`render_totals()` 增
     `compose_ms`/`latency_ms`(合成开始→被接受的单调耗时)/`paint_calls`/`skipped_producer`,并且
     `shown`/`compose_ms` 改从 widget 读(无 GL 的机器也能报告,之前只有画过才有数)。
  3. **无位移时复用资产几何**(R5 建议):`SceneComposer` 缓存 `asset_hash → (positions, normals)`;
     变形时照旧从形变后的位置重算法线,不变形时直接用资产自己的(只读、零拷贝)数组。20k 顶点实测:
     compose p50 **6.15 ms → 1.03 ms**(法线段 4.9 ms → 0.0001 ms,剩余是采样 1.01 ms);
     真实发布包(1032 顶点)compose p50 **0.50 ms → 0.17 ms**。
  4. **保护包密钥注入**(R4):新增 `scene3d/keys.py`——从 preference 读取
     `scene3d_verify_key`(验签公钥 PEM)/`scene3d_content_key_file` + `scene3d_content_key_id`
     (内容密钥,经 `protect.load_key_file`)/`scene3d_require_signature`(发布模式,拒绝无签名包),
     组装成 `SceneKeys` 交给 `SceneAssetLoader` → `load_package` → `PackagedAssetLoader.load(keys=…,
     verify_key_pem=…)`;`AppWindow._sync_3d_source()` 解析它并 `Mesh3dPanel.set_keys()`。
     缺失/不可读的密钥是**状态而不是崩溃**(`describe()` 里带原因),密钥材料永不进日志;
     `Mesh3dPanel.describe()` 的 scene 块记录 `keys`(布尔 + 路径)。
- Why: 审查报告 R5(`max_fps` 只被存储与显示、`draw()` 硬编码 `compose_ms=0.0`、`_displayed_frames` 把
  重画也算新帧、`SceneComposer.last_stats` 被显示链路完全未读)与 R4(底层支持签名/加密包,Studio 加载
  入口却从不传验签公钥与 KeyProvider,合法保护包打不开,只能发明文包或关掉校验)。R5 的另一半是
  "当前只有曲面着色、没有 displacement 时,composer 仍每帧重算法线并重新打包位置"——缓存后 20k 顶点
  降低到六分之一。
- Verification:
  · `test_scene3d_coordinator.py` +6:15 fps 的假时钟下 1.32 s 内只启动约 20 次合成(60 fps 对照 ≥55 次)、
  `display_period_s()` 跟随设置、结果延迟是真实单调差值、`has_pending_frame()` 随 mailbox 变化;
  `test_scene3d_live.py` +3:无位移时发布帧直接**就是**资产自己的(只读)数组、force_deform 但仍无位移时
  给出 `field_unavailable` 诊断、有标定位移时仍重算、`compose_ms` 属于该帧且不被下一次合成改写;
  `test_scene3d_view.py` +2:等待中的帧让生产者跳过第二个快照且取出后立即恢复、`render_totals()` 的
  compose 成本与延迟是真实值;新增 `test_scene3d_protected_package.py` 7 项:Studio 入口打开签名包、
  用 preference 里的内容密钥打开加密包、缺验签公钥 → `asset_auth_failed`、密钥文件缺失/无 key id →
  状态而非崩溃、发布模式拒绝无签名包而开发模式接受、`SceneAssetLoader` 把密钥交给 worker、面板携带密钥。
  · 全量:shared+apps 同一次运行 **588 项 0 失败 15 跳过**;真 GL 冒烟(`QT_QPA_PLATFORM=windows`)
  **14 项 0 失败**;ui_check 587 项 0 失败;locale_check 0 缺失。
  · 顺带修掉两个**既有的顺序依赖**真 GL 用例(不是本次引入:在上一提交上单跑同样失败):
  `test_the_producer_side_never_waits_for_the_gpu` 的收尾没有排空 mailbox(留下的一帧会被下一个测试
  上传),`test_an_automatic_colour_scale_follows_the_frame` 在渲染之前就断言"范围来自本帧",
  且用"不同颜色数"当自动量程的代理(对可见面积敏感)。现在前者排空并断言排空完成,后者先渲染再断言,
  判据改为**平均亮度**(固定 0..1000 把 3..7 压进 LUT 最暗的一小段,自动量程铺满整条色带)。
  · 性能报告(`.repro/perf-cpu-*-after-9.json`,`bench_scene.py --cpu-only`):合成 p50
  合成 poc 0.016 ms / 20k 顶点 1.03 ms / 真实包 0.17 ms;对比已提交的 `perf-report-windows-dev.json`
  (20k 6.15 ms、真实包 0.50 ms)。
- Not in this change: **画布布局第二批**(2D/3D 页签容器、参数抽屉、窄窗口回退、诊断抽屉)与曲线量程开关
  仍未做;阶段 9 的门禁部分(`soak_scene.py --minutes 120` GUI 模式、Jetson Orin Nano 与 QCS6490 上的
  `probe_gl/bench_scene/soak_scene` 报告)需要长时间运行与板卡,本轮未跑,结论不写"通过";
  签名私钥仍只在构建机(本提交只引入"读"的一半)。
2026-09-14 21:52:02 +08:00

598 lines
25 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.
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
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))
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()