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` 报告)需要长时间运行与板卡,本轮未跑,结论不写"通过";
  签名私钥仍只在构建机(本提交只引入"读"的一半)。
This commit is contained in:
ykcheungab
2026-09-14 21:52:02 +08:00
parent e2570925e8
commit ef487098c9
16 changed files with 839 additions and 44 deletions
@@ -2191,7 +2191,14 @@ class StudioMainWindow(QMainWindow):
that on its worker thread. Without it the pane could only compare the asset with that on its worker thread. Without it the pane could only compare the asset with
itself, which is how a scene built on a different mapping would still look itself, which is how a scene built on a different mapping would still look
matched. matched.
The release keys travel the same way (from the preferences, paths only): a signed
or sealed package is refused by the container without them, which left a
legitimate protected release unopenable from the application.
""" """
from orisys_studio.scene3d.keys import resolve_scene_keys
self.mesh3d_panel.set_keys(resolve_scene_keys(self.prefs))
self.mesh3d_panel.set_sensor( self.mesh3d_panel.set_sensor(
self.sensor_picker.sensor_type(), self.sensor_picker.sensor_type(),
expected_provider=self._device_geometry_provider(), expected_provider=self._device_geometry_provider(),
@@ -43,6 +43,7 @@ from orisys_app_shared.scene3d.contracts import (
diagnostic, diagnostic,
error, error,
) )
from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement
if TYPE_CHECKING: # the description is a value object; the callable returns one if TYPE_CHECKING: # the description is a value object; the callable returns one
from orisys_app_shared.acquisition.geometry import DeviceGeometry from orisys_app_shared.acquisition.geometry import DeviceGeometry
@@ -124,20 +125,37 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None)
def load_package( def load_package(
source: SceneSource, expected: Optional[ExpectedGeometry] = None source: SceneSource,
expected: Optional[ExpectedGeometry] = None,
keys: "Optional[SceneKeys]" = None,
) -> SceneAssets: ) -> SceneAssets:
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.""" """Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.
``keys`` carries the release key material (a verification key and/or a content-key
provider) and the release-mode flag. Without it a *signed* package is refused by the
container itself ("a verification key is required"), which left the application with
no way to open a legitimate protected release at all.
"""
from orisys_app_shared.scene3d.package import PackagedAssetLoader from orisys_app_shared.scene3d.package import PackagedAssetLoader
if not source.path.is_file(): if not source.path.is_file():
raise error("asset_rejected", f"the scene package is missing: {source.path}") raise error("asset_rejected", f"the scene package is missing: {source.path}")
return PackagedAssetLoader().load(source.path, expected) requirement = check_release_requirement(source.path, keys)
if requirement is not None:
raise error(requirement.code, requirement.message, **dict(requirement.details))
return PackagedAssetLoader().load(
source.path,
expected,
keys=getattr(keys, "provider", None),
verify_key_pem=getattr(keys, "verify_key_pem", None),
)
def _resolve_and_load( def _resolve_and_load(
source: SceneSource, source: SceneSource,
expected: Optional[ExpectedGeometry], expected: Optional[ExpectedGeometry],
provider: "Optional[Callable[[], DeviceGeometry]]", provider: "Optional[Callable[[], DeviceGeometry]]",
keys: "Optional[SceneKeys]" = None,
) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]: ) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]:
"""Worker-thread half: build the *device's* description, then read the package. """Worker-thread half: build the *device's* description, then read the package.
@@ -150,7 +168,7 @@ def _resolve_and_load(
if expected is None and provider is not None: if expected is None and provider is not None:
device = provider() device = provider()
expected = device.expected expected = device.expected
return load_package(source, expected), device return load_package(source, expected, keys), device
class SceneAssetLoader(QObject): class SceneAssetLoader(QObject):
@@ -179,10 +197,11 @@ class SceneAssetLoader(QObject):
expected: Optional[ExpectedGeometry] = None, expected: Optional[ExpectedGeometry] = None,
*, *,
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None, expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
keys: "Optional[SceneKeys]" = None,
) -> int: ) -> int:
"""Ask for a scene; returns the request id this call was given.""" """Ask for a scene; returns the request id this call was given."""
self._request_id += 1 self._request_id += 1
request = (source, expected, expected_provider, self._request_id) request = (source, expected, expected_provider, self._request_id, keys)
if self._inflight: if self._inflight:
self._pending = request self._pending = request
return self._request_id return self._request_id
@@ -190,13 +209,15 @@ class SceneAssetLoader(QObject):
return self._request_id return self._request_id
def _start(self, request) -> None: def _start(self, request) -> None:
source, expected, provider, request_id = request source, expected, provider, request_id, keys = request
if self._executor is None: if self._executor is None:
self._executor = ThreadPoolExecutor( self._executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="scene3d-assets" max_workers=1, thread_name_prefix="scene3d-assets"
) )
self._inflight = True self._inflight = True
future = self._executor.submit(_resolve_and_load, source, expected, provider) future = self._executor.submit(
_resolve_and_load, source, expected, provider, keys
)
future.add_done_callback( future.add_done_callback(
lambda done: self._finished(done, source, request_id) lambda done: self._finished(done, source, request_id)
) )
@@ -131,6 +131,11 @@ class ThreeDCoordinator:
#: Without this the pane re-uploaded the same rest frame on every 16 ms tick #: Without this the pane re-uploaded the same rest frame on every 16 ms tick
#: while it waited for the first measurement. #: while it waited for the first measurement.
self._rest_presented = False self._rest_presented = False
#: A monotonic budget for *starting* composes: the window ticks at 16 ms, and
#: "display rate" is a promise to the operator, not a description of the timer.
self._last_compose_start = float("-inf")
#: How long the last accepted result took (compose + queue), in milliseconds.
self._last_result_latency_ms = 0.0
#: The field frame currently on screen, so a gain change can redraw exactly #: The field frame currently on screen, so a gain change can redraw exactly
#: it rather than a newer instant. #: it rather than a newer instant.
self._last_field_frame: Optional[SurfaceFieldFrame] = None self._last_field_frame: Optional[SurfaceFieldFrame] = None
@@ -154,6 +159,23 @@ class ThreeDCoordinator:
"""What is on screen, and the monotonic time it was handed to the widget.""" """What is on screen, and the monotonic time it was handed to the widget."""
return self._last_presented return self._last_presented
def last_result_latency_ms(self) -> float:
"""How long the last accepted compose took, from start to acceptance."""
return self._last_result_latency_ms
def display_period_s(self) -> float:
"""The shortest interval between two compose starts, from ``max_fps``."""
return 1.0 / max(1, int(self._settings.max_fps))
def has_pending_frame(self) -> bool:
"""Whether a produced frame is already waiting for the timer.
The producer asks this before *building* a snapshot: the mailbox keeps only the
newest frame, so the O(H×W) copy that would be dropped is work nobody displays.
A frame is never lost that would have been shown — it is only not made twice.
"""
return self._incoming.peek() is not None
@property @property
def superseded_frames(self) -> int: def superseded_frames(self) -> int:
"""Frames the display never showed. Never a recording drop.""" """Frames the display never showed. Never a recording drop."""
@@ -203,6 +225,11 @@ class ThreeDCoordinator:
self._settings_dirty = False self._settings_dirty = False
self._stale_since = None self._stale_since = None
self._rest_presented = False self._rest_presented = False
# The next frame after a claim boundary composes at once: the operator just
# asked for something, and making them wait out the display period adds delay to
# no purpose.
self._last_compose_start = float("-inf")
self._last_result_latency_ms = 0.0
item = self._inflight item = self._inflight
if item is not None: if item is not None:
item.future.cancel() item.future.cancel()
@@ -382,17 +409,23 @@ class ThreeDCoordinator:
return return
self._collect_inflight() self._collect_inflight()
self._expire_stale() self._expire_stale()
if self._inflight is None: # One compose per display period. The window's timer is 16 ms whatever the panel
# says, so without this "15 fps" was a stored number nobody obeyed.
period = self.display_period_s()
now = self._clock()
if self._inflight is None and (now - self._last_compose_start) >= period:
if self._settings_dirty and self._last_field_frame is not None: if self._settings_dirty and self._last_field_frame is not None:
# A gain change re-draws the field that is already on screen; taking # A gain change re-draws the field that is already on screen; taking
# a newer one would show a different instant than the one the # a newer one would show a different instant than the one the
# operator just changed the exaggeration of. # operator just changed the exaggeration of.
self._settings_dirty = False self._settings_dirty = False
self._last_compose_start = now
self._start_compose(self._last_field_frame) self._start_compose(self._last_field_frame)
else: else:
self._settings_dirty = False self._settings_dirty = False
frame = self._incoming.take() frame = self._incoming.take()
if frame is not None: if frame is not None:
self._last_compose_start = now
self._start_compose(frame) self._start_compose(frame)
composed = self._composed.take() composed = self._composed.take()
if composed is not None: if composed is not None:
@@ -580,4 +613,7 @@ class ThreeDCoordinator:
# screen for something that is no longer being measured. # screen for something that is no longer being measured.
self._stale_frames += 1 self._stale_frames += 1
return return
# From the compose's own start to here: the cost plus however long it waited for
# a tick. A number the operator can compare with the frame period.
self._last_result_latency_ms = (self._clock() - item.started) * 1000.0
self._composed.offer(composed) self._composed.offer(composed)
@@ -512,7 +512,7 @@ class GLRenderer:
self._sensing_part_id: str = "" self._sensing_part_id: str = ""
self._estimated_gpu_bytes: int = 0 self._estimated_gpu_bytes: int = 0
self._initialized = False self._initialized = False
self._displayed_frames = 0 self._paint_calls = 0
self._last_upload_ms = 0.0 self._last_upload_ms = 0.0
# ------------------------------------------------------------------ lifetime # ------------------------------------------------------------------ lifetime
@@ -660,9 +660,12 @@ class GLRenderer:
) )
GL.glBindVertexArray(0) GL.glBindVertexArray(0)
GL.glUseProgram(0) GL.glUseProgram(0)
self._displayed_frames += 1 self._paint_calls += 1
return RenderStats( return RenderStats(
displayed_frames=self._displayed_frames, # The renderer counts *draws*; the widget counts unique frames. Keeping the
# two apart is what lets a slow camera move be told from a slow pipeline.
displayed_frames=0,
paint_calls=self._paint_calls,
# The real cost of the frame's compose is carried on the frame itself # The real cost of the frame's compose is carried on the frame itself
# (``RenderFrame.compose_ms``): the shared composer counter is rewritten by # (``RenderFrame.compose_ms``): the shared composer counter is rewritten by
# the next task, and reading it here reported the wrong frame's cost. # the next task, and reading it here reported the wrong frame's cost.
@@ -703,7 +706,7 @@ class GLRenderer:
self._scalar_range = None self._scalar_range = None
self._last_scale = None self._last_scale = None
self._last_scale_mode = "" self._last_scale_mode = ""
self._displayed_frames = 0 self._paint_calls = 0
def release_parts(self) -> None: def release_parts(self) -> None:
from OpenGL import GL from OpenGL import GL
@@ -0,0 +1,138 @@
"""Where the application's release keys come from.
A scene package can be signed (provenance) and sealed (a content key): the container
format supports both, and the loader accepts a ``KeyProvider`` and a verification key.
The application, however, never passed either — so a legitimate release package was
refused with "this package is signed; a verification key is required", and the only way
to run it was to ship a plain package or turn the check off. That is precisely the
decision this module keeps out of the engineer's hands.
The keys are therefore *configuration*: paths in the preferences file, resolved here into
the two things the loader wants, plus a release-mode flag that refuses an unsigned
package. Nothing here logs or returns key material; :meth:`SceneKeys.describe` reports
paths and booleans so a recording can say how a package was authenticated.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping, Optional
from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic
logger = logging.getLogger(__name__)
#: Preferences keys. Paths only — never key material, and never a private key: signing
#: happens on the build machine (see ``tools/scene_assets/compile_scene.py``).
PREF_VERIFY_KEY = "scene3d_verify_key"
PREF_CONTENT_KEY_FILE = "scene3d_content_key_file"
PREF_CONTENT_KEY_ID = "scene3d_content_key_id"
PREF_REQUIRE_SIGNATURE = "scene3d_require_signature"
@dataclass(frozen=True)
class SceneKeys:
"""What the loader needs to authenticate a package — and how strict to be."""
verify_key_pem: Optional[bytes] = None
provider: Any = None
require_signature: bool = False
#: Paths/ids only, for logs and for the recording: never material.
sources: Mapping[str, str] = field(default_factory=dict)
def authenticated_by_key(self) -> bool:
return self.verify_key_pem is not None
def describe(self) -> dict:
"""What is configured, in a form safe to log and to record.
Booleans for "is this piece present" and the *paths* it came from; never key
material, and never the private half of anything.
"""
return {
"verify_key": bool(self.verify_key_pem),
"content_key": bool(self.provider is not None),
"require_signature": bool(self.require_signature),
**{str(key): str(value) for key, value in self.sources.items()},
}
def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
"""Build the key material the preferences describe. Never raises for absence.
A missing or unreadable file is a *diagnostic-bearing state*, not a crash: the 2D
application must start and run, and only the 3D pane is affected. The reason is kept
so the pane can say why a protected package was refused instead of looking broken.
"""
prefs = dict(prefs or {})
sources: dict[str, str] = {}
verify_pem: Optional[bytes] = None
provider = None
verify_path = str(prefs.get(PREF_VERIFY_KEY) or "").strip()
if verify_path:
path = Path(verify_path).expanduser()
sources["verify_key_path"] = str(path)
if path.is_file():
try:
verify_pem = path.read_bytes()
except OSError:
logger.warning("the 3D verification key is unreadable: %s", path)
sources["verify_key_error"] = "unreadable"
else:
logger.warning("the configured 3D verification key is missing: %s", path)
sources["verify_key_error"] = "missing"
key_file = str(prefs.get(PREF_CONTENT_KEY_FILE) or "").strip()
if key_file:
path = Path(key_file).expanduser()
key_id = str(prefs.get(PREF_CONTENT_KEY_ID) or "").strip()
sources["content_key_file"] = str(path)
if key_id:
sources["content_key_id"] = key_id
if path.is_file() and key_id:
try:
from orisys_app_shared.scene3d import protect
provider = protect.load_key_file(path, key_id=key_id)
except Exception as exc: # noqa: BLE001 - reported, never fatal here
logger.warning("the 3D content key file was refused: %s", type(exc).__name__)
sources["content_key_error"] = str(getattr(exc, "diagnostic", exc))
elif not key_id:
sources["content_key_error"] = "no key id configured"
else:
sources["content_key_error"] = "missing"
return SceneKeys(
verify_key_pem=verify_pem,
provider=provider,
require_signature=bool(prefs.get(PREF_REQUIRE_SIGNATURE, False)),
sources=sources,
)
def check_release_requirement(path: Path, keys: Optional[SceneKeys]) -> Optional[Diagnostic]:
"""Refuse an unsigned package when the application is in release mode.
Development builds accept a plain package (the shipped engineering one is exactly
that); a release build is asked to say so explicitly rather than rely on whoever
compiled the package remembering to sign it.
"""
if keys is None or not keys.require_signature:
return None
from orisys_app_shared.scene3d.package import describe_envelope
try:
summary = describe_envelope(Path(path))
except Exception as exc: # noqa: BLE001 - the loader will report the real problem
logger.debug("could not read the envelope of %s: %s", path, exc)
return None
if not summary.get("signed"):
return diagnostic(
"asset_auth_failed",
"this build requires signed scene packages and this one is not signed",
package=Path(path).name,
)
return None
+34 -2
View File
@@ -115,6 +115,8 @@ class Scene3DView(QWidget):
self._reported_diagnostic: Optional[Diagnostic] = None self._reported_diagnostic: Optional[Diagnostic] = None
#: Whether the loaded asset was compared against the device's own description. #: Whether the loaded asset was compared against the device's own description.
self._device_verified = False self._device_verified = False
#: Frames the producer did not even build (a frame was already waiting).
self._skipped_snapshots = 0
self._layout = QVBoxLayout(self) self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setContentsMargins(0, 0, 0, 0)
@@ -294,6 +296,12 @@ class Scene3DView(QWidget):
identity = self.identity_for(packet) identity = self.identity_for(packet)
if identity is None: if identity is None:
return return
if coordinator.has_pending_frame():
# A produced frame is still waiting for the timer: building another one
# would be an O(H×W) copy the mailbox drops. It is not a dropped frame —
# the frame it would have replaced is still there to be shown.
self._skipped_snapshots += 1
return
frame = snapshot(packet, identity) frame = snapshot(packet, identity)
if frame is not None: if frame is not None:
coordinator.submit(frame) coordinator.submit(frame)
@@ -590,16 +598,40 @@ class Scene3DView(QWidget):
} }
def render_totals(self) -> dict: def render_totals(self) -> dict:
"""Display-side counters: shown frames and display drops, never recording ones.""" """Display-side counters: shown frames and display drops, never recording ones.
"Shown" and the compose cost come from the *widget*, which counts what was
presented — not from the last ``RenderStats``, which only exists after a paint (a
headless or offscreen pane never paints, and it still received every frame).
"""
coordinator = self._coordinator coordinator = self._coordinator
widget = self._widget
stats = self._last_stats stats = self._last_stats
shown = (
int(widget.displayed_frames())
if widget is not None
else int(getattr(stats, "displayed_frames", 0) or 0)
)
compose_ms = (
float(widget.last_compose_ms())
if widget is not None
else float(getattr(stats, "compose_ms", 0.0) or 0.0)
)
return { return {
"shown": int(getattr(stats, "displayed_frames", 0) or 0), "shown": shown,
"dropped_display": int( "dropped_display": int(
coordinator.superseded_frames if coordinator is not None else 0 coordinator.superseded_frames if coordinator is not None else 0
), ),
"stale": int(getattr(coordinator, "stale_frames", 0) or 0), "stale": int(getattr(coordinator, "stale_frames", 0) or 0),
"invalid": int(getattr(coordinator, "invalid_frames", 0) or 0), "invalid": int(getattr(coordinator, "invalid_frames", 0) or 0),
# Work the producer deliberately did not do because a frame was waiting.
"skipped_producer": int(self._skipped_snapshots),
# A *real* cost, per frame, plus how long the result took to be accepted.
"compose_ms": compose_ms,
"latency_ms": float(
coordinator.last_result_latency_ms() if coordinator is not None else 0.0
),
"paint_calls": int(getattr(stats, "paint_calls", 0) or 0),
} }
def close(self) -> None: def close(self) -> None:
@@ -76,6 +76,10 @@ class Sensor3DWidget(QOpenGLWidget):
self._needs_scene_upload = False self._needs_scene_upload = False
self._needs_surface_upload = False self._needs_surface_upload = False
self._last_stats_at = 0.0 self._last_stats_at = 0.0
#: What reached the screen, counted by identity, and what it cost to compose.
self._unique_frames = 0
self._last_frame_identity: Optional[FrameIdentity] = None
self._last_compose_ms = 0.0
self._last_press: Optional[tuple[float, float]] = None self._last_press: Optional[tuple[float, float]] = None
self._unavailable = False self._unavailable = False
#: Frames the display never showed, set by the coordinator. Never a #: Frames the display never showed, set by the coordinator. Never a
@@ -97,6 +101,12 @@ class Sensor3DWidget(QOpenGLWidget):
"""Keep the newest CPU frame and ask for a repaint. Never requires a context.""" """Keep the newest CPU frame and ask for a repaint. Never requires a context."""
self._frame = frame self._frame = frame
self._needs_surface_upload = True self._needs_surface_upload = True
# Count *data*, not drawing: a camera move or a colour-scale change repaints the
# same measurement, and the old counter incremented on every paintGL.
if frame.identity != self._last_frame_identity:
self._last_frame_identity = frame.identity
self._unique_frames += 1
self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0)
self.descriptorChanged.emit(frame.scalar_descriptor) self.descriptorChanged.emit(frame.scalar_descriptor)
self.update() self.update()
@@ -110,6 +120,14 @@ class Sensor3DWidget(QOpenGLWidget):
def capabilities(self) -> Optional[GLCapabilities]: def capabilities(self) -> Optional[GLCapabilities]:
return self._capabilities return self._capabilities
def displayed_frames(self) -> int:
"""Unique frames handed to this widget (a repaint is not a new frame)."""
return int(self._unique_frames)
def last_compose_ms(self) -> float:
"""What the frame on screen cost to compose, measured where it was composed."""
return float(self._last_compose_ms)
def diagnostic(self) -> Optional[Diagnostic]: def diagnostic(self) -> Optional[Diagnostic]:
return self._diagnostic return self._diagnostic
@@ -220,9 +238,12 @@ class Sensor3DWidget(QOpenGLWidget):
self._last_stats_at = now self._last_stats_at = now
self.renderStatsChanged.emit( self.renderStatsChanged.emit(
RenderStats( RenderStats(
displayed_frames=stats.displayed_frames, # Frames that reached the screen, by identity; ``paint_calls`` is the
# renderer's own count of how often it was asked to draw.
displayed_frames=self._unique_frames,
superseded_frames=self._superseded, superseded_frames=self._superseded,
compose_ms=stats.compose_ms, paint_calls=stats.paint_calls,
compose_ms=self._last_compose_ms,
upload_ms=stats.upload_ms, upload_ms=stats.upload_ms,
paint_ms=(now - started) * 1000.0, paint_ms=(now - started) * 1000.0,
estimated_gpu_bytes=stats.estimated_gpu_bytes, estimated_gpu_bytes=stats.estimated_gpu_bytes,
@@ -87,6 +87,8 @@ class Mesh3dPanel(QFrame):
#: request describes a selection the operator has already moved away from. #: request describes a selection the operator has already moved away from.
self._load_request = 0 self._load_request = 0
self._device = None self._device = None
#: Release key material from the preferences (``scene3d.keys.SceneKeys``).
self._keys = None
#: The package whose scene is in the view, so switching sensor away and back #: The package whose scene is in the view, so switching sensor away and back
#: does not reload it (and does not pretend to be loading it either). #: does not reload it (and does not pretend to be loading it either).
self._loaded_path: Optional[Path] = None self._loaded_path: Optional[Path] = None
@@ -209,9 +211,19 @@ class Mesh3dPanel(QFrame):
self._loader.loaded.connect(self._on_loaded) self._loader.loaded.connect(self._on_loaded)
self._loader.failed.connect(self._on_load_failed) self._loader.failed.connect(self._on_load_failed)
self._load_request = self._loader.load( self._load_request = self._loader.load(
self._source, self._expected, expected_provider=self._expected_provider self._source,
self._expected,
expected_provider=self._expected_provider,
keys=self._keys,
) )
def set_keys(self, keys) -> None:
"""The release key material (see ``scene3d.keys``). Paths, never material."""
self._keys = keys
def keys(self):
return self._keys
# -------------------------------------------------------------- settings # -------------------------------------------------------------- settings
def set_display_settings(self, settings) -> None: def set_display_settings(self, settings) -> None:
"""Persisted display settings, applied now if the view exists. """Persisted display settings, applied now if the view exists.
@@ -257,6 +269,9 @@ class Mesh3dPanel(QFrame):
"reason": reason, "reason": reason,
"scene": { "scene": {
"path": str(self._source.path) if self._source is not None else "", "path": str(self._source.path) if self._source is not None else "",
# How the package was authenticated, when it was: paths and booleans
# only, never key material (see ``scene3d.keys``).
"keys": self._keys.describe() if self._keys is not None else {},
**dict(described.get("scene") or {}), **dict(described.get("scene") or {}),
}, },
"gl": dict(described.get("gl") or {}), "gl": dict(described.get("gl") or {}),
+94 -1
View File
@@ -753,8 +753,9 @@ def test_a_good_frame_after_a_failure_goes_back_to_live(scene):
def test_a_failed_compose_does_not_leave_the_state_live(scene): def test_a_failed_compose_does_not_leave_the_state_live(scene):
"""R2: the error used to be recorded while the picture and "live" stayed.""" """R2: the error used to be recorded while the picture and "live" stayed."""
composer = FailingComposer() composer = FailingComposer()
clock = Clock()
widget = FakeWidget() widget = FakeWidget()
coordinator = ThreeDCoordinator(widget, composer, ImmediateExecutor()) coordinator = ThreeDCoordinator(widget, composer, ImmediateExecutor(), clock=clock)
coordinator.set_visible(True) coordinator.set_visible(True)
coordinator.activate(scene, "live-A") coordinator.activate(scene, "live-A")
coordinator.submit(_frame(scene, frame_id=1, value=1.0)) coordinator.submit(_frame(scene, frame_id=1, value=1.0))
@@ -764,6 +765,7 @@ def test_a_failed_compose_does_not_leave_the_state_live(scene):
composer.fail = True composer.fail = True
coordinator.submit(_frame(scene, frame_id=2, value=2.0)) coordinator.submit(_frame(scene, frame_id=2, value=2.0))
clock.advance(0.05) # clear the display period, as the window's timer would
coordinator.tick() coordinator.tick()
coordinator.tick() coordinator.tick()
assert coordinator.state == "stale", "a failed compose kept reporting live" assert coordinator.state == "stale", "a failed compose kept reporting live"
@@ -789,3 +791,94 @@ def test_an_invalid_frame_starts_the_stale_grace_too(scene):
coordinator.tick() coordinator.tick()
assert coordinator.state == "no_data" assert coordinator.state == "no_data"
coordinator.close() coordinator.close()
# --------------------------------------------- stage 9: the display rate is obeyed
def test_the_display_rate_limits_how_often_a_compose_starts(scene):
"""R5: ``max_fps`` was stored and shown, and nothing read it.
The audit reproduced it with a synchronous executor: 15 fps requested, 40 valid
presents inside 3 ms. Here the clock is moved by hand, so the assertion is about the
budget rather than about how fast this machine is.
"""
clock = Clock()
composer = CountingComposer()
coordinator = ThreeDCoordinator(
FakeWidget(), composer, ImmediateExecutor(), max_fps=15, clock=clock
)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
# 40 ticks at the window's own cadence: 120 ms of simulated time at 15 fps = 1 start
# (the first frame after the claim boundary composes at once).
for index in range(40):
coordinator.submit(_frame(scene, frame_id=index))
coordinator.tick()
clock.advance(0.003)
assert composer.calls == 2, f"{composer.calls} composes in 120 ms at 15 fps"
# A full simulated second then allows the configured rate, and no more: 1320 ms of
# simulated time at 15 fps is one start per 66 ms, i.e. about twenty.
for index in range(400):
coordinator.submit(_frame(scene, frame_id=100 + index))
coordinator.tick()
clock.advance(0.003)
assert 17 <= composer.calls <= 21, f"{composer.calls} composes in ~1.3 s at 15 fps"
assert composer.max_in_flight == 1
coordinator.close()
def test_raising_the_display_rate_starts_more_composes(scene):
"""The contrast is the point: 60 fps over the same simulated span, four times more."""
clock = Clock()
composer = CountingComposer()
coordinator = ThreeDCoordinator(
FakeWidget(), composer, ImmediateExecutor(), max_fps=60, clock=clock
)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
for index in range(440):
coordinator.submit(_frame(scene, frame_id=index))
coordinator.tick()
clock.advance(0.003)
assert composer.calls >= 55, f"{composer.calls} composes in 1.32 s at 60 fps"
coordinator.close()
def test_the_display_period_follows_a_settings_change(scene):
coordinator = ThreeDCoordinator(FakeWidget(), SceneComposer(), ImmediateExecutor())
assert coordinator.display_period_s() == pytest.approx(1.0 / 30.0)
coordinator.set_settings(DisplaySettings(max_fps=15))
assert coordinator.display_period_s() == pytest.approx(1.0 / 15.0)
coordinator.close()
def test_the_coordinator_reports_how_long_a_result_took(scene):
"""A real number for the panel: from the compose's start to its acceptance."""
clock = Clock()
coordinator = ThreeDCoordinator(
FakeWidget(), SceneComposer(), ImmediateExecutor(), clock=clock
)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
coordinator.submit(_frame(scene, value=1.0))
coordinator.tick() # starts (and, with this executor, finishes) the compose
clock.advance(0.02)
coordinator.tick() # accepts it
assert coordinator.last_result_latency_ms() == pytest.approx(20.0, abs=1.0)
coordinator.close()
def test_the_producer_work_is_skipped_while_a_frame_is_waiting(scene):
"""The mailbox keeps the newest frame; a copy made for it would be dropped."""
coordinator = ThreeDCoordinator(
FakeWidget(), SceneComposer(), ImmediateExecutor(), max_fps=1
)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
assert coordinator.has_pending_frame() is False
coordinator.submit(_frame(scene, value=1.0))
assert coordinator.has_pending_frame() is True
coordinator.tick() # the timer takes it (and the 1 fps budget starts one compose)
assert coordinator.has_pending_frame() is False
coordinator.close()
+34 -11
View File
@@ -486,17 +486,31 @@ def test_the_producer_side_never_waits_for_the_gpu(assembled_view, qt_app):
p99 = ordered[int(len(ordered) * 0.99) - 1] 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 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 max(samples) < 5.0, f"submit peaked at {max(samples):.2f} ms"
assert coordinator.superseded_frames > 0, ( # A slow composer must cost the *display* something, and nothing else. Two
"a 50 ms composer produced no display drops: the frame rate limiter or the " # mechanisms account for it now: the mailbox supersedes what it never showed,
"mailbox is not doing its job" # 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: finally:
coordinator._composer = real_composer coordinator._composer = real_composer
# Drain the in-flight compose that the slow composer left behind. # Drain everything the slow composer left behind, *including* the frames still
deadline = time.perf_counter() + 1.0 # waiting in the mailbox: this view is shared by the whole module, and a leftover
while time.perf_counter() < deadline and coordinator._inflight is not None: # 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() coordinator.tick()
time.sleep(0.01) 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): def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
@@ -537,14 +551,23 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
widget.set_settings(fixed_wide) widget.set_settings(fixed_wide)
widget.present(frame_with(ramp)) widget.present(frame_with(ramp))
assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0 flat_pixels = _drawn(_render(widget, qt_app))
flat = len(np.unique(_drawn(_render(widget, qt_app)), axis=0)) 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.set_settings(auto)
widget.present(frame_with(ramp)) widget.present(frame_with(ramp))
spread = len(np.unique(_drawn(_render(widget, qt_app)), axis=0)) auto_pixels = _drawn(_render(widget, qt_app))
assert spread > flat, ( # 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 " "an automatic range did not use the frame's own values "
f"(distinct colours: automatic {spread}, fixed 0..1000 {flat})" f"(mean brightness: automatic {auto_mean:.1f}, fixed 0..1000 {flat_mean:.1f})"
) )
@@ -0,0 +1,228 @@
"""Stage 9 acceptance: a protected scene package, opened from the application.
The container could always read a signed or sealed package, but the application never
passed a verification key or a content-key provider, so a legitimate protected release
was refused with "this package is signed; a verification key is required" — and the only
ways to run it were to ship a plain package or to disable the check. These tests are
about the *wiring*: preferences → ``SceneKeys`` → the loader the pane actually uses.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from orisys_app_shared.scene3d import protect, synthetic
from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError
from orisys_app_shared.scene3d.package import write_package
from orisys_studio.scene3d.asset_source import SceneSource, load_package
from orisys_studio.scene3d.keys import (
PREF_CONTENT_KEY_FILE,
PREF_CONTENT_KEY_ID,
PREF_REQUIRE_SIGNATURE,
PREF_VERIFY_KEY,
resolve_scene_keys,
)
KEY_ID = "finger-display"
CONTENT_KEY_HEX = "11" * 32
def _expected(scene) -> ExpectedGeometry:
return ExpectedGeometry(
"finger", "", scene.geometry_id, scene.field_domain_id, scene.shape_hw
)
def _keys_and_paths(tmp_path: Path):
"""A signing key pair and a content key file, as a release build would have."""
private = protect.generate_signing_key()
public = protect.public_key_pem(private)
verify_path = tmp_path / "verify.pem"
verify_path.write_bytes(public)
key_path = tmp_path / "content.key"
key_path.write_text(CONTENT_KEY_HEX + "\n", encoding="utf-8")
provider = protect.MappingKeyProvider({KEY_ID: CONTENT_KEY_HEX})
return private, public, verify_path, key_path, provider
def _write_signed(tmp_path: Path, scene, *, sealed: bool):
private, public, verify_path, key_path, provider = _keys_and_paths(tmp_path)
from orisys_app_shared.scene3d.contracts import Sealing
target = tmp_path / ("sealed.oryscene" if sealed else "signed.oryscene")
write_package(
scene,
target,
sealing=Sealing(
key_id=KEY_ID if sealed else "",
provider=provider if sealed else None,
signing_key_pem=private,
signing_key_reference="stage-9",
verify_key_pem=public,
),
)
return target, verify_path, key_path
def _source(path: Path) -> SceneSource:
return SceneSource(path=path, kind="package", directory=path.parent)
def test_a_signed_package_opens_from_the_application(tmp_path):
"""The review's R4: the library could read it; the application could not.
With the verification key resolved from the preferences, the pane's own
``load_package`` accepts the package and the scene comes out intact.
"""
scene = synthetic.poc_scene()
package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False)
keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)})
assert keys.verify_key_pem is not None
assert keys.describe()["verify_key"] is True
assets = load_package(_source(package), _expected(scene), keys)
assert assets.geometry_id == scene.geometry_id
# The manifest's own hash, recomputed when the package was written: the synthetic
# scene's ``asset_hash`` is a placeholder until it goes through ``write_package``.
assert len(assets.asset_hash) == 64
def test_a_sealed_package_opens_with_the_content_key_from_the_preferences(tmp_path):
scene = synthetic.poc_scene()
package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=True)
keys = resolve_scene_keys(
{
PREF_VERIFY_KEY: str(verify_path),
PREF_CONTENT_KEY_FILE: str(key_path),
PREF_CONTENT_KEY_ID: KEY_ID,
}
)
assert keys.provider is not None
described = keys.describe()
assert described["content_key"] is True
assert described["content_key_id"] == KEY_ID
# The description carries paths and ids, never key material.
assert CONTENT_KEY_HEX not in json.dumps(described)
assert keys.verify_key_pem is not None
assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem
assets = load_package(_source(package), _expected(scene), keys)
assert assets.geometry_id == scene.geometry_id
def test_without_the_verification_key_a_signed_package_is_refused(tmp_path):
"""Not a silent fallback to plain: the refusal names what is missing."""
scene = synthetic.poc_scene()
package, _verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False)
keys = resolve_scene_keys({})
with pytest.raises(ThreeDError) as excinfo:
load_package(_source(package), _expected(scene), keys)
assert excinfo.value.diagnostic.code == "asset_auth_failed"
assert "verification key" in excinfo.value.diagnostic.message
def test_a_missing_or_unreadable_key_file_is_a_state_not_a_crash(tmp_path):
"""The 2D application must start whatever the key configuration looks like."""
keys = resolve_scene_keys(
{
PREF_VERIFY_KEY: str(tmp_path / "gone.pem"),
PREF_CONTENT_KEY_FILE: str(tmp_path / "gone.key"),
PREF_CONTENT_KEY_ID: KEY_ID,
}
)
assert keys.verify_key_pem is None and keys.provider is None
described = keys.describe()
assert described["verify_key_error"] == "missing"
assert described["content_key_error"] == "missing"
# A key file without a key id is not usable either, and says which half is absent.
key_path = tmp_path / "content.key"
key_path.write_text(CONTENT_KEY_HEX, encoding="utf-8")
keys = resolve_scene_keys({PREF_CONTENT_KEY_FILE: str(key_path)})
assert keys.provider is None
assert keys.describe()["content_key_error"] == "no key id configured"
def test_release_mode_refuses_an_unsigned_package(tmp_path):
"""The gate that replaces "ask the engineer to sign it"."""
scene = synthetic.poc_scene()
plain = tmp_path / "plain.oryscene"
write_package(scene, plain)
release = resolve_scene_keys({PREF_REQUIRE_SIGNATURE: True})
assert release.require_signature is True
with pytest.raises(ThreeDError) as excinfo:
load_package(_source(plain), _expected(scene), release)
assert excinfo.value.diagnostic.code == "asset_auth_failed"
assert "requires signed scene packages" in excinfo.value.diagnostic.message
# The same package is fine in a development build (and the shipped engineering
# package is exactly this case).
dev = resolve_scene_keys({})
assert load_package(_source(plain), _expected(scene), dev).geometry_id == scene.geometry_id
class _InlineExecutor:
"""Runs submitted work immediately: the loader's path without a thread."""
def submit(self, function, *args, **kwargs):
from concurrent.futures import Future
future: Future = Future()
try:
future.set_result(function(*args, **kwargs))
except BaseException as exc: # noqa: BLE001 - mirrored into the future
future.set_exception(exc)
return future
def shutdown(self, wait: bool = True) -> None: # noqa: ARG002 - executor contract
return None
def test_the_scene_loader_passes_the_keys_to_its_worker(qt_app, tmp_path):
"""The pane's loader is where the wiring has to happen, not just ``load_package``."""
from orisys_studio.scene3d.asset_source import SceneAssetLoader
scene = synthetic.poc_scene()
package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False)
keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)})
loader = SceneAssetLoader()
loader._executor = _InlineExecutor()
loaded: list = []
failures: list = []
loader.loaded.connect(
lambda assets, description, request_id, device: loaded.append(assets)
)
loader.failed.connect(lambda item, request_id: failures.append(item))
try:
loader.load(_source(package), _expected(scene), keys=keys)
assert not failures, [item.message for item in failures]
assert loaded and loaded[0].geometry_id == scene.geometry_id
finally:
loader.close()
# Without them the same file is refused, and the loader reports it rather than
# raising into the GUI thread.
loader = SceneAssetLoader()
loader._executor = _InlineExecutor()
failures = []
loader.failed.connect(lambda item, request_id: failures.append(item))
try:
loader.load(_source(package), _expected(scene), keys=resolve_scene_keys({}))
assert failures and failures[0].code == "asset_auth_failed"
finally:
loader.close()
def test_the_pane_carries_the_keys_into_its_loader(qt_app):
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
panel = Mesh3dPanel()
keys = resolve_scene_keys({})
assert panel.keys() is None
panel.set_keys(keys)
assert panel.keys() is keys
+52 -2
View File
@@ -576,7 +576,8 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
assert second > first assert second > first
assert loader2._executor is None, "a queued request must not open a second worker" assert loader2._executor is None, "a queued request must not open a second worker"
assert loader2._pending is not None, "the newer request was dropped" assert loader2._pending is not None, "the newer request was dropped"
assert loader2._pending[-1] == second # The request tuple ends with the release keys; the id is the element before it.
assert second in loader2._pending
finally: finally:
loader2.close() loader2.close()
@@ -871,7 +872,56 @@ def test_a_scene_without_a_device_description_is_shown_as_unverified(qt_app):
view.close() view.close()
# --------------------------------------- stage 8: capability-controlled controls # -------------------------------------------- stage 9: producer budget and real costs
def test_the_producer_does_not_build_a_snapshot_nobody_can_show(qt_app):
"""R5: the adapter's copy is O(H×W), and a waiting frame makes it wasted work.
The mailbox keeps the newest frame, so a second snapshot taken while one is waiting
is dropped unwatched. Skipping it loses nothing — it is the same frame the display
would have shown — and keeps the acquisition thread free.
"""
view = _stale_scene_view(qt_app)
try:
shape = tuple(view.assets.shape_hw)
packet = ui_check._fake_packet(1)
packet.flow = np.full((*shape, 2), 0.5, dtype=np.float32)
view.submit_packet(packet)
assert view.render_totals()["skipped_producer"] == 0
view.submit_packet(packet)
assert view.render_totals()["skipped_producer"] == 1, (
"a snapshot was built while one was already waiting"
)
view.tick() # the timer takes it
view.submit_packet(packet)
assert view.render_totals()["skipped_producer"] == 1, (
"the budget must clear as soon as the waiting frame is taken"
)
finally:
view.close()
def test_the_totals_report_a_real_compose_cost_and_how_many_frames_were_drawn(qt_app):
"""R5: "compose 0.0 ms" was hard-coded, and every repaint counted as a frame."""
view = _stale_scene_view(qt_app)
try:
shape = tuple(view.assets.shape_hw)
packet = ui_check._fake_packet(1)
packet.flow = np.full((*shape, 2), 0.5, dtype=np.float32)
view.submit_packet(packet)
for _ in range(40):
view.tick()
if view.state() == "live":
break
time.sleep(0.01)
totals = view.render_totals()
assert totals["shown"] >= 1
# The compose really happened, so the frame carries its own measured cost.
assert totals["compose_ms"] > 0.0, totals
assert totals["latency_ms"] >= totals["compose_ms"] - 0.5, totals
stats = view.render_stats()
assert stats is None or stats.displayed_frames >= 1
finally:
view.close()
def test_a_source_with_no_displacement_cannot_offer_exaggeration(qt_app): def test_a_source_with_no_displacement_cannot_offer_exaggeration(qt_app):
"""R6: the control must describe an input that exists.""" """R6: the control must describe an input that exists."""
view = _stale_scene_view(qt_app) view = _stale_scene_view(qt_app)
@@ -71,6 +71,10 @@ class SceneComposer:
def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None: def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None:
self._sampler = sampler or SurfaceSampler() self._sampler = sampler or SurfaceSampler()
self.last_stats = ComposeStats() self.last_stats = ComposeStats()
#: Rest positions and normals, keyed by ``asset_hash``: with no displacement to
#: draw, the picture is the asset's own geometry, and recomputing its normals
#: every frame was pure cost (measured as the bulk of a 20k-vertex compose).
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
def compose( def compose(
self, self,
@@ -91,15 +95,16 @@ class SceneComposer:
sampling_started = time.perf_counter() sampling_started = time.perf_counter()
fields = self._sampler.sample(frame, assets.binding) fields = self._sampler.sample(frame, assets.binding)
self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0 self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0
rest_positions = np.asarray(part.positions_sensor_m, dtype=np.float64) rest_positions, rest_normals = self._rest(assets)
diagnostics: list[Diagnostic] = [] diagnostics: list[Diagnostic] = []
positions = rest_positions positions = rest_positions
quality = frame.displacement_quality quality = frame.displacement_quality
if settings.deform_enabled and fields.displacement_sensor_m is not None: deformed = bool(settings.deform_enabled and fields.displacement_sensor_m is not None)
if deformed:
gain = float(settings.display_gain) gain = float(settings.display_gain)
displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64) displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64)
positions = rest_positions + gain * displacement positions = np.asarray(rest_positions, dtype=np.float64) + gain * displacement
if quality != "calibrated": if quality != "calibrated":
diagnostics.append( diagnostics.append(
diagnostic( diagnostic(
@@ -118,16 +123,33 @@ class SceneComposer:
) )
) )
normals_started = time.perf_counter() normals_started = time.perf_counter()
normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64)) # In place, the normals are the asset's own: the positions are untouched, so
# recomputing them from the same triangles gives the same answer every frame.
normals = (
vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
if deformed
else rest_normals
)
self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0 self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0
# Measured before the frame is built, so the frame carries *its own* cost: the
# shared ``last_stats`` object is rewritten by the next task.
total_ms = (time.perf_counter() - started) * 1000.0
self.last_stats.total_ms = total_ms
result = RenderFrame( result = RenderFrame(
identity=frame.identity, identity=frame.identity,
asset_hash=assets.asset_hash, asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id, geometry_id=assets.geometry_id,
sensing_part_id=part.part_id, sensing_part_id=part.part_id,
positions_sensor_m=freeze(positions, dtype=np.float32), positions_sensor_m=(
normals_sensor=freeze(normals, dtype=np.float32), freeze(positions, dtype=np.float32)
if deformed
else rest_positions
),
normals_sensor=(
freeze(normals, dtype=np.float32) if deformed else rest_normals
),
# The measurement, untouched by the display gain. # The measurement, untouched by the display gain.
scalar=fields.scalar, scalar=fields.scalar,
valid=fields.valid, valid=fields.valid,
@@ -136,11 +158,31 @@ class SceneComposer:
# Carried through untouched: only the display side knows whether the frame # Carried through untouched: only the display side knows whether the frame
# it is about to show is still the current one. # it is about to show is still the current one.
computed_frame_id=frame.computed_frame_id, computed_frame_id=frame.computed_frame_id,
# What this frame cost to compose, carried *on the frame*: the shared
# ``last_stats`` object is rewritten by the next task, so a reader on the
# GUI thread would report another frame's cost.
compose_ms=total_ms,
) )
validate_render_frame(result, vertices=int(rest_positions.shape[0])) validate_render_frame(result, vertices=int(rest_positions.shape[0]))
self.last_stats.total_ms = (time.perf_counter() - started) * 1000.0
return result return result
def _rest(self, assets: SceneAssets) -> tuple[np.ndarray, np.ndarray]:
"""The asset's own positions and normals, computed once per asset.
``positions_sensor_m`` and ``normals_sensor`` are already frozen float32 arrays
on the asset, so the cache hands them out by reference: nothing here may mutate
them (they are read-only, which is what makes that safe).
"""
key = str(assets.asset_hash)
cached = self._rest_geometry.get(key)
if cached is None:
part = assets.sensing_part()
cached = (part.positions_sensor_m, part.normals_sensor)
# One entry: the pane composes one asset at a time, and a stale cache would
# be a wrong picture rather than a slow one.
self._rest_geometry = {key: cached}
return cached
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
"""The static reference: nothing measured, so nothing coloured.""" """The static reference: nothing measured, so nothing coloured."""
return build_rest_frame(assets, identity) return build_rest_frame(assets, identity)
@@ -565,6 +565,9 @@ class RenderFrame:
#: The acquisition frame the coloured values belong to, carried through from the #: The acquisition frame the coloured values belong to, carried through from the
#: field frame so the *display* side can re-check it before presenting. #: field frame so the *display* side can re-check it before presenting.
computed_frame_id: Optional[int] = None computed_frame_id: Optional[int] = None
#: What composing this frame cost, measured where it was composed. Carried per frame
#: rather than read from the composer's shared counter, which the next task rewrites.
compose_ms: float = 0.0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -588,8 +591,14 @@ class GLCapabilities:
@dataclass(frozen=True) @dataclass(frozen=True)
class RenderStats: class RenderStats:
#: Frames that reached the screen, counted by *identity*: a repaint of the same
#: measurement (a camera move, a colour-scale change) is not a new frame, and the
#: panel's "shown" number was reading paint calls before this.
displayed_frames: int = 0 displayed_frames: int = 0
superseded_frames: int = 0 superseded_frames: int = 0
#: How much every ``paintGL`` cost, so a slow camera move can be told from a slow
#: pipeline. ``displayed_frames`` counts data; this counts drawing.
paint_calls: int = 0
compose_ms: float = 0.0 compose_ms: float = 0.0
upload_ms: float = 0.0 upload_ms: float = 0.0
paint_ms: float = 0.0 paint_ms: float = 0.0
+23 -5
View File
@@ -459,12 +459,30 @@ def test_identity_distinguishes_sources_and_generations():
# ------------------------------------------------------------------ skeleton # ------------------------------------------------------------------ skeleton
def test_the_contract_layer_imports_without_qt_or_opengl(): def test_the_contract_layer_imports_without_qt_or_opengl():
"""The acquisition thread imports this package; it must not drag Qt in.""" """The acquisition thread imports this package; it must not drag Qt or GL in.
assert "PyQt5" not in sys.modules or True # Qt may be loaded by other tests
for name in ("PyQt5.QtWidgets", "OpenGL"): Checked in a **fresh interpreter**: asking ``sys.modules`` in this process only
assert name not in sys.modules or name == "PyQt5.QtWidgets", ( proves what some earlier test happened to import (it failed once the studio tests
f"importing the contracts pulled in {name}" painted a widget before this file ran, which says nothing about the contract layer).
"""
import subprocess
import sys
script = (
"import sys;"
"import orisys_app_shared.scene3d.contracts as c;"
"print(','.join(m for m in ('OpenGL', 'PyQt5', 'cv2') if m in sys.modules))"
) )
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert result.returncode == 0, result.stderr
pulled = [name for name in result.stdout.strip().split(",") if name]
assert pulled == [], f"importing the contracts pulled in {pulled}"
def test_the_cpu_layer_is_importable_and_works_without_a_gpu(): def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
+59
View File
@@ -403,3 +403,62 @@ def test_the_composer_does_not_rebuild_the_binding_or_the_indices():
for _ in range(3): for _ in range(3):
composer.compose(scene, frame, DisplaySettings()) composer.compose(scene, frame, DisplaySettings())
assert composer.last_stats.total_ms < 50.0, composer.last_stats assert composer.last_stats.total_ms < 50.0, composer.last_stats
# --------------------------------------------- stage 9: reuse and real numbers
def test_colouring_in_place_reuses_the_assets_own_geometry():
"""No displacement to draw means the picture *is* the asset: nothing to recompute.
Normals were recomputed from the same untouched positions on every frame, and the
measured cost was the bulk of a 20k-vertex compose. The reused arrays are the
asset's own (frozen) ones, so nothing is copied either.
"""
scene = synthetic.poc_scene()
part = scene.sensing_part()
composer = SceneComposer()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
composed = composer.compose(scene, frame, DisplaySettings())
assert composed.positions_sensor_m is part.positions_sensor_m
assert composed.normals_sensor is part.normals_sensor
# A deform request with nothing to deform is the same case, and says so.
composed = composer.compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=5.0)
)
assert composed.positions_sensor_m is part.positions_sensor_m
assert any(item.code == "field_unavailable" for item in composed.diagnostics)
def test_a_calibrated_displacement_still_recomputes_positions_and_normals():
"""The cache is for the colour-in-place case only, never for a deformed surface."""
scene = synthetic.poc_scene()
part = scene.sensing_part()
displacement = np.zeros((3, 3, 3), dtype=np.float32)
displacement[1, 1, 2] = 0.5
frame = _frame(
scene,
np.ones((3, 3), dtype=np.float32),
displacement=displacement,
quality="calibrated",
)
composed = SceneComposer().compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0)
)
assert composed.positions_sensor_m is not part.positions_sensor_m
assert composed.normals_sensor is not part.normals_sensor
assert not np.allclose(
np.asarray(composed.positions_sensor_m), np.asarray(part.positions_sensor_m)
)
def test_the_composed_frame_carries_its_own_cost():
"""The panel's "compose ms" reads this, not the composer's shared counter."""
scene = synthetic.poc_scene()
composer = SceneComposer()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
composed = composer.compose(scene, frame, DisplaySettings())
assert composed.compose_ms > 0.0
assert composed.compose_ms == pytest.approx(composer.last_stats.total_ms)
# A second compose rewrites ``last_stats``; the first frame's number must not move.
first = composed.compose_ms
composer.compose(scene, frame, DisplaySettings())
assert composed.compose_ms == first