ef487098c9
- 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` 报告)需要长时间运行与板卡,本轮未跑,结论不写"通过";
签名私钥仍只在构建机(本提交只引入"读"的一半)。
598 lines
25 KiB
Python
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()
|