7d1fca4c93
- What:
1. **采集侧声明每个派生数组的归属**(`acquisition/models.py`):`SdkPacket` 新增
`flow_valid/flow_frame_id/depth_valid/depth_frame_id/forces_valid`(`None` = 未声明)。两个真实
生产者都填写:`finger_worker`/`sdk_worker` 逐帧记录"该数组实际算出来的 frame_id",链失败时
`*_valid=False` 并把上一次成功帧号带出来。光流与形变链分开判定(力读取失败不等于光流失败),
但算法链失败时本帧的一切派生量都不可声明。
2. **adapter 按声明收数据**(`scene3d/adapters.py`):`_field_validity()` 三态——未声明 → 拒绝并报
`field_unverified`(没声明就不算数);声明为假 → `field_stale`(把 `invalid_reason` 带进诊断);
声明帧号不是本帧 → `field_stale`(两个帧号都写进 details)。`_intersect_device_mask` 换成
`_device_mask`:设备 mask 形状不符**拒绝该帧**(`field_domain_mismatch`,两个形状都写进诊断),
既不拉伸也不静默忽略。发布的帧带 `computed_frame_id`,身份一路带到显示侧。
3. **契约**(`scene3d/contracts.py`):`SceneState` 加 `"stale"`;诊断码加 `field_stale`/`field_unverified`
(warning);`SurfaceFieldFrame.computed_frame_id` 与 `RenderFrame.computed_frame_id`(由 composer
原样透传);`validate_surface_frame` 校验帧号非负且不晚于所属帧。新增 locale:
`scene3d.state_stale`(中英各一条)。
4. **协调器不再"边失败边报 live"**(`scene3d/coordinator.py`):新增 `_stale_since` 宽限状态机与
`note_field_failure()`——字段被拒、帧被判 invalid、compose 抛错、或合成结果的 `computed_frame_id`
落后于本帧,都立即把状态改为 `stale` 并保留最后一帧;宽限(`stale_grace_s`,默认 1.0 s)到期后
回参考模型并转 `no_data`;收到好帧即回 `live` 并清掉宽限。顺带修掉"等待首帧时每个 tick 都把参考
模型重新 present 一次"(`_rest_presented`,16 ms 一次的无谓上传)。
5. **诊断真正到达界面**(`scene3d/view.py`):`submit_packet()` 在 adapter 拒绝时把
`adapter.last_diagnostic` 交给 `note_field_failure()`;`tick()` 把协调器诊断的变化经已有
`diagnosticRaised` 冒泡(协调器自身仍然零 Qt 依赖)。新增 `_snapshot_diagnostic()` 与 `_adapter`
引用(后续"按能力禁用控件"也要问它)。
- Why: 审查报告 P1/R2:`FingerFrameAdapter.snapshot()` 从不读 `valid`/`stale_forces`/`invalid_reason`,
只看到有限的 `flow` 就当作新数据上色(复现:`valid=False, stale_forces=True` + 有限 flow → 仍有有效
着色点、diagnostic=None);compose 抛错只写 `last_diagnostic`,画面与 `state` 保持不变;
`snapshot()` 返回 None 时诊断无人读取;mask 域不符时静默忽略会扩大可信范围。力读数失败不该机械地
判死三维,但算法失败也不能因为旧数组是有限值就当成新数据——所以按**字段级来源**处理,并让失效有
明确的、可见的状态而不是继续保持 live。
- Verification:
· `test_scene3d_adapters.py` 23 通过(+6 新增):stale flow 被拒且带原因、未声明 flow → `field_unverified`、
帧号不符 → `field_stale`(details 两个帧号)、新帧记录 `computed_frame_id`、box 侧"形变链失败仍用光流"、
box 全部字段不可声明时拒绝;mask 域不符由"被忽略"改为"拒绝该帧"(原断言已改)。
· `test_scene3d_coordinator.py` 30 通过(+6 新增):落后帧号的合成结果永不上屏且状态 stale、
字段被拒 → 宽限内保留画面/宽限后回参考模型/好帧回 live、compose 抛错不再保持 live、invalid 帧也计入
宽限。
· `test_scene3d_view.py`(+3 新增,offscreen 可跑):adapter 拒绝 → 协调器 stale 且诊断经
`diagnosticRaised` 回到面板、`scene3d.state_stale` 不是裸键;未声明字段 → `field_unverified`;
新鲜字段照常上屏回到 live。
· 全量:apps/studio 228 项 0 失败 14 跳过;shared 301 项 0 失败 1 跳过(`--junitxml` 计数)。
- Not in this change: `docs/architecture/studio-3d-contracts.pyi` 与新契约的同步放在阶段 6 的文档提交里
一起做(本阶段契约改动较多,避免每提交改一次文档);设备几何身份(R3)、资产加载请求号(R7)、
二维后端统一、限速与真实统计均未动;真机上"链失败时 VRAW 是否也过期"这一条仍需实测(若证明链失败
不影响 VRAW,`flow_valid` 放宽为只看读取结果是一行改动,已写进计划的风险项)。
575 lines
23 KiB
Python
575 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.
|
|
|
|
The provenance flags are part of the contract now: a producer has to say that the
|
|
array it hands over belongs to this frame, and the adapter refuses one that does
|
|
not say (see ``adapters._field_validity``).
|
|
"""
|
|
|
|
def __init__(self, frame_id: int, shape_hw: tuple[int, int], value: float = 0.5) -> None:
|
|
self.session_id = 1
|
|
self.frame_id = int(frame_id)
|
|
self.mono_t = float(frame_id) / 30.0
|
|
self.flow = np.full((*shape_hw, 2), float(value), dtype=np.float32)
|
|
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
|
|
self.valid = True
|
|
self.invalid_reason = ""
|
|
self.flow_valid = True
|
|
self.flow_frame_id = int(frame_id)
|
|
self.depth = None
|
|
self.depth_valid = None
|
|
self.depth_frame_id = None
|
|
self.forces_valid = True
|
|
self.valid_mask = None
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def assembled_view(qt_app):
|
|
"""A real ``Scene3DView``: pane, coordinator, composer, widget, real context."""
|
|
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
|
from orisys_studio.scene3d.view import Scene3DView
|
|
|
|
configure_surface_format("desktop33")
|
|
view = Scene3DView()
|
|
if not view.available:
|
|
pytest.skip(f"no GL view here: {view.reason}")
|
|
assets = synthetic.poc_scene()
|
|
if not view.use_scene(assets, source_id="poc-live"):
|
|
pytest.skip("the synthetic scene was refused")
|
|
view.resize(360, 300)
|
|
view.show()
|
|
view.set_enabled(True)
|
|
renderer = view.gl_widget._renderer
|
|
painted = _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
|
|
if not painted:
|
|
view.close()
|
|
pytest.skip(f"no usable GL context here: {view.gl_widget.diagnostic()}")
|
|
yield view, renderer, assets
|
|
view.close()
|
|
view.close()
|
|
qt_app.processEvents()
|
|
|
|
|
|
def test_the_assembled_view_draws_what_the_acquisition_thread_hands_it(assembled_view, qt_app):
|
|
"""The whole stage-4 path: packet in, pixels out, and no recording involved."""
|
|
view, renderer, assets = assembled_view
|
|
widget = view.gl_widget
|
|
for index in range(30):
|
|
view.submit_packet(_Packet(index, assets.shape_hw, value=0.4 + index * 0.05))
|
|
view.tick()
|
|
qt_app.processEvents()
|
|
assert _pump(qt_app, widget, lambda: view.state() in ("live", "no_data"), timeout_s=5.0)
|
|
assert view.state() in ("live", "no_data"), view.state()
|
|
stats = widget._last_stats_at and None # stats are published on a timer
|
|
del stats
|
|
assert view.last_descriptor is not None, "the pane never learned what it is showing"
|
|
assert view.last_descriptor.quantity == "flow_magnitude"
|
|
assert view.last_descriptor.quality == "approximate"
|
|
drawn = _drawn(_render(widget, qt_app))
|
|
assert drawn.size, "the live frame was not drawn"
|
|
|
|
|
|
def test_the_producer_side_never_waits_for_the_gpu(assembled_view, qt_app):
|
|
"""The measured budget: the acquisition thread's own cost, with a slow composer.
|
|
|
|
This is the stage-3 property asserted through the *application's* entry point, so
|
|
it includes the adapter (the flow magnitude over the whole grid) and not just the
|
|
mailbox. The composer is slowed to 50 ms, which is far below any real frame rate:
|
|
frames must drop in the display and nowhere else.
|
|
"""
|
|
import time
|
|
|
|
view, _, assets = assembled_view
|
|
coordinator = view.coordinator
|
|
real_composer = coordinator._composer
|
|
|
|
class Slow:
|
|
def compose(self, *args, **kwargs):
|
|
time.sleep(0.05)
|
|
return real_composer.compose(*args, **kwargs)
|
|
|
|
def rest_frame(self, *args, **kwargs):
|
|
return real_composer.rest_frame(*args, **kwargs)
|
|
|
|
coordinator._composer = Slow()
|
|
try:
|
|
samples: list[float] = []
|
|
for index in range(200):
|
|
packet = _Packet(index, assets.shape_hw, value=0.5)
|
|
started = time.perf_counter()
|
|
view.submit_packet(packet)
|
|
samples.append((time.perf_counter() - started) * 1000.0)
|
|
view.tick()
|
|
ordered = sorted(samples)
|
|
p99 = ordered[int(len(ordered) * 0.99) - 1]
|
|
assert p99 < 1.0, f"submit p99 was {p99:.2f} ms with a 50 ms composer"
|
|
assert max(samples) < 5.0, f"submit peaked at {max(samples):.2f} ms"
|
|
assert coordinator.superseded_frames > 0, (
|
|
"a 50 ms composer produced no display drops: the frame rate limiter or the "
|
|
"mailbox is not doing its job"
|
|
)
|
|
finally:
|
|
coordinator._composer = real_composer
|
|
# Drain the in-flight compose that the slow composer left behind.
|
|
deadline = time.perf_counter() + 1.0
|
|
while time.perf_counter() < deadline and coordinator._inflight is not None:
|
|
coordinator.tick()
|
|
time.sleep(0.01)
|
|
|
|
|
|
def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
|
|
"""A range taken from the data, and a fixed range that ignores it."""
|
|
view, renderer, assets = assembled_view
|
|
widget = view.gl_widget
|
|
assets_frame = view.assets
|
|
assert assets_frame is not None
|
|
count = int(assets_frame.sensing_part().positions_sensor_m.shape[0])
|
|
|
|
def frame_with(values: np.ndarray) -> RenderFrame:
|
|
return RenderFrame(
|
|
identity=FrameIdentity("poc-live", 1, 1, 0.0, generation=view.coordinator.generation),
|
|
asset_hash=assets.asset_hash,
|
|
geometry_id=assets.geometry_id,
|
|
sensing_part_id=assets_frame.sensing_part().part_id,
|
|
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
|
|
normals_sensor=assets_frame.sensing_part().normals_sensor,
|
|
scalar=freeze(np.asarray(values, dtype=np.float32), dtype=np.float32),
|
|
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
|
|
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
|
)
|
|
|
|
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
|
auto = DisplaySettings(
|
|
housing_visible=False,
|
|
color_scale=ColorScale(
|
|
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential", mode="automatic"
|
|
),
|
|
)
|
|
fixed_wide = DisplaySettings(
|
|
housing_visible=False,
|
|
color_scale=ColorScale(
|
|
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
|
|
),
|
|
)
|
|
ramp = np.linspace(3.0, 7.0, count)
|
|
|
|
widget.set_settings(fixed_wide)
|
|
widget.present(frame_with(ramp))
|
|
assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0
|
|
flat = len(np.unique(_drawn(_render(widget, qt_app)), axis=0))
|
|
widget.set_settings(auto)
|
|
widget.present(frame_with(ramp))
|
|
spread = len(np.unique(_drawn(_render(widget, qt_app)), axis=0))
|
|
assert spread > flat, (
|
|
"an automatic range did not use the frame's own values "
|
|
f"(distinct colours: automatic {spread}, fixed 0..1000 {flat})"
|
|
)
|
|
|
|
|
|
def test_the_pane_releases_its_context_and_survives_a_second_close(qt_app):
|
|
"""`closeEvent` calls this; a second close must be a no-op, not a crash."""
|
|
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
|
from orisys_studio.scene3d.view import Scene3DView
|
|
|
|
configure_surface_format("desktop33")
|
|
view = Scene3DView()
|
|
if not view.available:
|
|
pytest.skip("no GL view here")
|
|
if not view.use_scene(synthetic.poc_scene(), source_id="poc-close"):
|
|
pytest.skip("the synthetic scene was refused")
|
|
view.resize(200, 160)
|
|
view.show()
|
|
view.set_enabled(True)
|
|
renderer = view.gl_widget._renderer
|
|
if not _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0):
|
|
view.close()
|
|
pytest.skip("no usable GL context here")
|
|
view.close()
|
|
assert not renderer.is_initialized
|
|
assert not renderer.part_ids, "closing the pane left GPU buffers behind"
|
|
view.close()
|
|
view.close()
|
|
qt_app.processEvents()
|