modify 3d view to sync / identical to 2d view

This commit is contained in:
ykcheungab
2026-09-27 10:42:38 +08:00
parent 3daee6d407
commit bcab2790bf
24 changed files with 1971 additions and 530 deletions
+32 -26
View File
@@ -132,7 +132,7 @@ def test_activating_a_scene_bumps_the_generation_and_shows_the_reference_model(s
assert coordinator.generation == 1
assert widget.scenes == [scene]
assert len(widget.frames) == 1, "the reference model must be shown immediately"
assert widget.frames[0].scalar_descriptor.quantity == "unavailable"
assert widget.frames[0].field.descriptor.quantity == "unavailable"
second = coordinator.activate(scene, "live-B")
assert second == 2 and coordinator.generation == 2
# A frame from the previous scene is now old news.
@@ -187,7 +187,7 @@ def test_a_tick_composes_and_presents(scene, pool):
_drain(coordinator)
assert len(widget.frames) >= 2
last = widget.frames[-1]
assert np.allclose(np.asarray(last.scalar), 3.0)
assert np.allclose(np.asarray(last.field.scalar), 3.0)
assert coordinator.state == "live"
coordinator.close()
@@ -200,8 +200,14 @@ def test_a_frame_with_nothing_valid_is_no_data_not_a_stale_picture(scene, pool):
coordinator.submit(_frame(scene, valid=False))
_drain(coordinator)
assert coordinator.state == "no_data"
assert not np.asarray(widget.frames[-1].valid).any()
assert np.isnan(np.asarray(widget.frames[-1].scalar)).all()
# Absence is the *validity mask*, not a rewrite of the measurement: the field
# travels exactly as the device published it, and the fragment reads the mask. The
# old per-vertex path expressed "no value" by putting NaN on the vertices, which is
# also what made one invalid texel grey a whole triangle.
assert not np.asarray(widget.frames[-1].field.valid).any()
assert np.isfinite(np.asarray(widget.frames[-1].field.scalar)).all(), (
"the measurement was rewritten to express absence; the mask is what says so"
)
coordinator.close()
@@ -253,7 +259,7 @@ def test_a_result_that_arrives_after_a_scene_change_is_dropped(scene, pool):
coordinator.activate(scene, "live-A") # generation 2 while it runs
before = len(widget.frames)
_drain(coordinator, ticks=20, sleep=0.02)
composed = [frame for frame in widget.frames[before:] if np.isfinite(np.asarray(frame.scalar)).any()]
composed = [frame for frame in widget.frames[before:] if np.isfinite(np.asarray(frame.field.scalar)).any()]
assert not composed, "a frame composed for the previous scene was presented"
assert coordinator.stale_frames >= 0
coordinator.close()
@@ -329,7 +335,7 @@ def test_a_new_session_is_refused_until_it_is_announced(scene, pool):
before = coordinator.generation
coordinator.begin_session()
assert coordinator.generation == before + 1
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable", (
assert widget.frames[-1].field.descriptor.quantity == "unavailable", (
"a new session must not leave the previous session's measurement on screen"
)
assert coordinator.submit(
@@ -386,12 +392,12 @@ def test_show_rest_puts_the_model_back_and_stops_claiming_a_measurement(scene, p
coordinator.submit(_frame(scene, source="replay-A"))
_drain(coordinator, ticks=10)
assert coordinator.state in ("live", "no_data")
assert widget.frames[-1].valid.any(), "the measurement never arrived"
assert widget.frames[-1].field.valid.any(), "the measurement never arrived"
coordinator.show_rest()
last = widget.frames[-1]
assert not last.valid.any(), "the old measurement is still on screen"
assert last.scalar_descriptor.quantity == "unavailable"
assert not last.field.valid.any(), "the old measurement is still on screen"
assert last.field.descriptor.quantity == "unavailable"
assert coordinator.state == "no_data"
# And it is a no-op when there is nothing to show or the view is off.
@@ -413,7 +419,7 @@ def test_a_frame_already_queued_is_dropped_when_the_model_comes_back(scene, pool
coordinator.submit(_frame(scene, source="replay-A", value=7.0))
coordinator.show_rest()
_drain(coordinator, ticks=4, sleep=0.01)
assert not widget.frames[-1].valid.any(), (
assert not widget.frames[-1].field.valid.any(), (
"a frame that was in flight when the pass stopped was presented anyway"
)
coordinator.close()
@@ -497,14 +503,14 @@ def test_a_compose_running_when_the_pass_stopped_can_never_come_back(scene, pool
coordinator.show_rest()
assert coordinator.state == "no_data"
assert not widget.frames[-1].valid.any(), "stop did not clear the measurement"
assert not widget.frames[-1].field.valid.any(), "stop did not clear the measurement"
composer.release.set()
_drain(coordinator, ticks=40, sleep=0.01)
assert coordinator.epoch == epoch + 1
assert coordinator._inflight is None, "the finished work still owns the slot"
assert not widget.frames[-1].valid.any(), (
assert not widget.frames[-1].field.valid.any(), (
"the stopped pass put its measurement back on screen"
)
assert coordinator.state != "live"
@@ -522,7 +528,7 @@ def test_hiding_while_a_compose_runs_does_not_let_it_paint_later(scene, pool):
time.sleep(0.005)
coordinator.set_visible(True)
_drain(coordinator, ticks=40, sleep=0.01)
assert not widget.frames[-1].valid.any(), (
assert not widget.frames[-1].field.valid.any(), (
"a compose started before the view was hidden painted after it came back"
)
assert coordinator.state != "live"
@@ -532,10 +538,10 @@ def test_hiding_while_a_compose_runs_does_not_let_it_paint_later(scene, pool):
def test_a_compose_running_for_the_old_session_is_dropped_by_begin_session(scene, pool):
composer, widget, coordinator = _start_blocked_compose(scene, pool)
coordinator.begin_session()
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable"
assert widget.frames[-1].field.descriptor.quantity == "unavailable"
composer.release.set()
_drain(coordinator, ticks=40, sleep=0.01)
assert not widget.frames[-1].valid.any(), (
assert not widget.frames[-1].field.valid.any(), (
"the previous session's compose was presented into the new session"
)
coordinator.close()
@@ -563,7 +569,7 @@ def test_a_compose_cancelled_before_it_started_is_not_an_error(scene):
executor.run_next()
coordinator.tick()
assert coordinator.state == "live"
assert np.allclose(np.asarray(widget.frames[-1].scalar), 4.0)
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 4.0)
coordinator.close()
@@ -586,9 +592,9 @@ def test_repeated_start_and_stop_never_leaves_an_old_measurement(scene, pool):
)
coordinator.tick()
coordinator.show_rest()
assert not widget.frames[-1].valid.any(), f"cycle {index} kept a measurement"
assert not widget.frames[-1].field.valid.any(), f"cycle {index} kept a measurement"
_drain(coordinator, ticks=60, sleep=0.01)
assert not widget.frames[-1].valid.any(), "a stopped pass painted after the fact"
assert not widget.frames[-1].field.valid.any(), "a stopped pass painted after the fact"
assert coordinator.state != "live"
assert composer.max_in_flight == 1
coordinator.close()
@@ -616,7 +622,7 @@ def test_an_older_request_id_cannot_move_the_picture_backwards(scene):
coordinator.tick()
coordinator.tick()
assert widget.frames[-1].identity.request_id == 2, "an older frame was shown"
assert np.allclose(np.asarray(widget.frames[-1].scalar), 2.0)
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 2.0)
assert coordinator.stale_frames == 1
coordinator.close()
@@ -637,7 +643,7 @@ def test_a_result_from_an_older_request_is_dropped_on_arrival(scene):
assert coordinator.submit(newer) == "accepted"
executor.run_next() # request 1's compose finishes late
_drain(coordinator, ticks=6, sleep=0.0)
assert not np.allclose(np.asarray(widget.frames[-1].scalar), 7.0), (
assert not np.allclose(np.asarray(widget.frames[-1].field.scalar), 7.0), (
"a result for a superseded request was presented"
)
coordinator.close()
@@ -688,7 +694,7 @@ def test_a_stale_array_is_never_presented_as_the_current_instant(scene):
coordinator.tick()
coordinator.tick()
assert not any(frame.valid.any() for frame in widget.frames[presented:]), (
assert not any(frame.field.valid.any() for frame in widget.frames[presented:]), (
"a stale array was painted as the current instant"
)
assert all(frame.identity.frame_id != 5 for frame in widget.frames[presented:])
@@ -711,22 +717,22 @@ def test_a_refused_field_expires_the_measurement_after_the_grace(scene):
coordinator.tick()
coordinator.tick()
assert coordinator.state == "live"
assert np.allclose(np.asarray(widget.frames[-1].scalar), 8.0)
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 8.0)
# The producer cannot build a field any more: the picture stays for the grace, but
# the pane must stop calling it live immediately.
coordinator.note_field_failure(_stale_diagnostic())
assert coordinator.state == "stale"
coordinator.tick()
assert np.allclose(np.asarray(widget.frames[-1].scalar), 8.0), (
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 8.0), (
"a single refused frame blanked the model"
)
assert coordinator.state == "stale"
clock.advance(1.5)
coordinator.tick()
assert not widget.frames[-1].valid.any(), "the expired measurement is still on screen"
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable"
assert not widget.frames[-1].field.valid.any(), "the expired measurement is still on screen"
assert widget.frames[-1].field.descriptor.quantity == "unavailable"
assert coordinator.state == "no_data"
coordinator.close()
@@ -746,7 +752,7 @@ def test_a_good_frame_after_a_failure_goes_back_to_live(scene):
coordinator.tick()
coordinator.tick()
assert coordinator.state == "live"
assert np.allclose(np.asarray(widget.frames[-1].scalar), 2.0)
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 2.0)
coordinator.close()
+99 -51
View File
@@ -27,6 +27,7 @@ import pytest
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplayField,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
@@ -44,6 +45,48 @@ pytestmark = pytest.mark.skipif(
BACKGROUND_RGB = np.array([15, 18, 23])
def _field(assets, values, valid=None, descriptor=None) -> DisplayField:
"""A display field on the scene's own domain, from a flat or gridded array.
The surface's colour and validity now come from the *field*, not from the vertices,
so a test that wants a particular picture builds one of these and hands it to the
widget. ``values`` is reshaped to the scene's ``[H,W]`` domain — which is what the
2D canvas would paint — and the fragment reads it through the mesh's UVs.
"""
shape = (int(assets.shape_hw[0]), int(assets.shape_hw[1]))
scalar = np.asarray(values, dtype=np.float32).reshape(shape)
mask = (
np.ones(shape, dtype=bool)
if valid is None
else np.asarray(valid, dtype=bool).reshape(shape)
)
return DisplayField(
domain_id=assets.field_domain_id,
shape_hw=shape,
scalar=freeze(np.array(scalar, dtype=np.float32), dtype=np.float32),
valid=freeze(mask, dtype=np.bool_),
descriptor=(
descriptor
or FieldDescriptor("flow_magnitude", "px", "approximate", False)
),
)
def _frame(assets, values, valid=None, *, frame_id=5, positions=None, normals=None,
descriptor=None, deformed=False) -> RenderFrame:
part = assets.sensing_part()
return RenderFrame(
identity=FrameIdentity("poc", 1, frame_id, 0.5, generation=1),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=part.part_id,
positions_sensor_m=part.positions_sensor_m if positions is None else positions,
normals_sensor=part.normals_sensor if normals is None else normals,
field=_field(assets, values, valid, descriptor),
deformed=deformed,
)
def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
"""Let Qt paint until ``predicate`` holds. Returns whether it did."""
import time
@@ -123,6 +166,13 @@ def _plate_is_grey(pixels: np.ndarray) -> float:
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean())
def _plate_mean_rgb(pixels: np.ndarray) -> np.ndarray:
"""The mean RGB of the drawn pixels: the plate's own colour, on a flat surface."""
drawn = _drawn(pixels)
assert drawn.size, "nothing was drawn"
return drawn.mean(axis=0)
def test_the_capabilities_come_from_a_real_context(gl_widget):
widget, _, _ = gl_widget
capabilities = widget.capabilities()
@@ -157,33 +207,30 @@ def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
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.
Asserted as a *colour identity*, not as a hue difference: the valid path must draw
the lookup table's own colour for the value it was given, and the neutral path must
draw the neutral colour. (The old form of this test compared mean ``|r-g|``, which
only worked because the 3D pane used a purple ramp whose low end is far from grey.
With one ramp serving both views the low end is the theme's own navy, where ``r≈g``,
so a hue test would have had to be loosened until it stopped meaning anything. A
colour-identity test is both stricter and independent of which ramp is in use.)
"""
from orisys_studio.scene3d.gl_backend import INVALID_COLOR, LUT_SIZE, lut_rgba
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))
rest_rgb = _plate_mean_rgb(_render(widget, qt_app))
neutral_8bit = np.array(INVALID_COLOR[:3]) * 255.0
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),
)
value = 0.05
frame = _frame(assets, np.full(assets.shape_hw, value, dtype=np.float32))
widget.set_settings(
DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=frame.scalar_descriptor,
descriptor=frame.field.descriptor,
lower=0.0,
upper=1.0,
lut_id="sequential",
@@ -191,12 +238,27 @@ def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_a
)
)
widget.present(frame)
measured_grey = _plate_is_grey(_render(widget, qt_app))
measured_rgb = _plate_mean_rgb(_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})"
# Where the fragment shader samples the table: ``vec2(t, 0.5)`` on a 256x1 texture
# with linear filtering, i.e. ``t * 256 - 0.5`` clamped into the table.
table = lut_rgba("sequential")[:, 0, :3].astype(float)
position = float(np.clip(value * LUT_SIZE - 0.5, 0.0, LUT_SIZE - 1.0))
low = int(np.floor(position))
high = min(low + 1, LUT_SIZE - 1)
fraction = position - low
expected = (1.0 - fraction) * table[low] + fraction * table[high]
assert np.abs(rest_rgb - neutral_8bit).max() < 6.0, (
f"the unmeasured surface is not the neutral colour: {rest_rgb} vs {neutral_8bit}"
)
assert np.abs(measured_rgb - expected).max() < 8.0, (
"the colour map's own colour did not reach the plate: "
f"drawn {measured_rgb}, the table gives {expected} for {value} — the valid path "
"is not being taken, or the pane is not using the shared ramp"
)
assert np.abs(expected - neutral_8bit).max() > 10.0, (
"this value is too close to the neutral colour to make the point; pick another"
)
widget.set_settings(DisplaySettings(housing_visible=True))
@@ -207,21 +269,18 @@ def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app):
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),
frame = _frame(
assets,
np.linspace(0.0, 1.0, count).reshape(assets.shape_hw),
frame_id=6,
positions=freeze(positions, dtype=np.float32),
deformed=True,
)
widget.set_settings(
DisplaySettings(
color_scale=ColorScale(
descriptor=frame.scalar_descriptor, lower=0.0, upper=1.0, lut_id="sequential"
descriptor=frame.field.descriptor, lower=0.0, upper=1.0,
lut_id="sequential",
)
)
)
@@ -242,21 +301,13 @@ def _buffer_names(renderer, part_id: str) -> tuple[int, int, int]:
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),
frame = _frame(
synthetic.poc_scene(), np.zeros(synthetic.poc_scene().shape_hw, dtype=np.float32),
frame_id=2,
)
object.__setattr__(frame, "asset_hash", "other")
object.__setattr__(frame, "geometry_id", "some-other-geometry")
widget.present(frame)
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
assert widget.rejected_frames == before + 1
@@ -525,7 +576,6 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
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(
@@ -535,9 +585,7 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
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),
field=_field(assets_frame, values),
)
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
@@ -553,7 +601,7 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
),
)
ramp = np.linspace(3.0, 7.0, count)
ramp = np.linspace(3.0, 7.0, int(assets_frame.shape_hw[0]) * int(assets_frame.shape_hw[1]))
widget.set_settings(fixed_wide)
widget.present(frame_with(ramp))
@@ -0,0 +1,620 @@
"""Region agreement: does the 3D pane paint exactly the 2D island?
The user-visible claim this file checks: the set of fragments the 3D surface paints as
*valid* must be the 2D island's inverse image under the mesh's UV map, to within one
texel of the field domain, with **no** disagreement in the island's interior.
Before the texture-first path it was not. The colour came from a per-vertex gather
(``sampling.SurfaceSampler``), one invalid tap made a vertex ``NaN``, the shader's
``isnan`` rule painted every triangle touching it grey, and on the shipped finger asset
that measured as 5763 of the 27227 mesh-covered island texels painted grey (**21.17 %**),
5476 of them strictly *inside* the island, with 26840 wrong pixels on screen. The same
measurement after the change is 0, 0 and 0.
How it is measured, and why this is not a tautology
---------------------------------------------------
The pane is drawn by a real GL context and the framebuffer is read back. The *reference*
is computed on the CPU from the same camera matrices, viewport, vertex positions and
UVs: every triangle is rasterised, ``a_uv`` is interpolated perspective-correctly exactly
as a GPU varying is, and the texel it addresses is the one the fragment shader's
``floor(v_uv + 0.5)`` selects. So the comparison is between the GPU's answer and an
independent CPU derivation of the same UV map. A wrong UV convention, a wrong v flip, an
off-by-one texel, a stale ``a_scalar`` attribute or a leftover per-vertex path all show
up as a large difference — which is exactly how the numbers above were produced.
The tolerance is the design's, and it is asserted in *domain* space, which is where
"one texel of the 300x280 domain" is defined:
* every island texel the mesh covers is painted valid, and no texel outside the island's
one-texel dilation is (``residual <= 1 texel``);
* the island's one-texel *erosion* is painted valid everywhere and invalid nowhere
(``zero interior disagreement``);
* no texel is painted both ways (a fragment-level disagreement).
The mesh's UV footprint is a sub-rectangle of the domain (the shipped finger mesh spans
x 0.03..279.0, y 42.6..256.4 of a 300x280 grid), so 5710 island texels have no triangle
over them at all. "The island's inverse image under the mesh UV map" is therefore
*island ∩ covered*, and every interior/outside assertion is restricted to that set: a
texel no triangle reaches cannot be evidence about the shader.
There is deliberately no ``pytest.skip`` for a missing context once a real platform is
in use: this is the only coverage of this property on actual hardware, and a silent skip
is how it stayed broken. The module is not collected under the offscreen plugin because
that plugin cannot create a platform GL context *at all* — a property of the plugin, not
of the machine — which is the same rule ``test_scene3d_gl_smoke.py`` follows.
"""
from __future__ import annotations
import os
import numpy as np
import pytest
os.environ.setdefault("QT_OPENGL", "desktop")
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
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 2D canvas's clear colour and the round-trip sentinels, in 8-bit.
BACKGROUND_RGB = (15, 18, 23)
INVALID_SENTINEL = (0, 255, 0) # patches gl_backend.INVALID_COLOR
#: The shipped domain, when the real package is the subject.
REAL_VIEWPORT = (900, 900)
#: A dense synthetic domain: one vertex per texel over a 320x240 viewport, so a texel is
#: about five pixels and a per-vertex path cannot look like a per-fragment one by luck.
DENSE_DOMAIN_HW = (40, 48)
DENSE_VIEWPORT = (320, 240)
DENSE_PLATE_M = 0.06
# ------------------------------------------------------------------------ helpers
def adjacent_to(mask: np.ndarray) -> np.ndarray:
"""Everything 8-adjacent to ``mask``: its one-texel dilation, without itself."""
out = np.zeros_like(mask)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dy == 0 and dx == 0:
continue
out |= np.roll(np.roll(mask, dy, axis=0), dx, axis=1)
return out
def dilate1(mask: np.ndarray) -> np.ndarray:
return mask | adjacent_to(mask)
def erode1(mask: np.ndarray) -> np.ndarray:
return mask & ~adjacent_to(~mask)
def reference_texels(positions, uvs, triangles, camera, viewport):
"""Which texel every framebuffer pixel reads, derived independently of the GPU."""
view = np.asarray(camera.view, dtype=np.float64).reshape(4, 4)
projection = np.asarray(camera.projection, dtype=np.float64).reshape(4, 4)
width, height = int(viewport[0]), int(viewport[1])
homo = np.hstack([np.asarray(positions, dtype=np.float64), np.ones((len(positions), 1))])
clip = (projection @ view @ homo.T).T
w = clip[:, 3]
ndc = clip[:, :3] / w[:, None]
x = (ndc[:, 0] * 0.5 + 0.5) * width
y = (ndc[:, 1] * 0.5 + 0.5) * height
depth = np.full((height, width), np.inf)
texel = np.full((height, width, 2), -1, dtype=np.int64)
for tri in np.asarray(triangles, dtype=np.int64):
i0, i1, i2 = int(tri[0]), int(tri[1]), int(tri[2])
xs = np.array([x[i0], x[i1], x[i2]])
ys = np.array([y[i0], y[i1], y[i2]])
area = (xs[1] - xs[0]) * (ys[2] - ys[0]) - (xs[2] - xs[0]) * (ys[1] - ys[0])
if area == 0.0:
continue
min_x = max(int(np.floor(xs.min() - 0.5)), 0)
max_x = min(int(np.ceil(xs.max() + 0.5)), width)
min_y = max(int(np.floor(ys.min() - 0.5)), 0)
max_y = min(int(np.ceil(ys.max() + 0.5)), height)
if min_x >= max_x or min_y >= max_y:
continue
gx, gy = np.meshgrid(
np.arange(min_x, max_x) + 0.5, np.arange(min_y, max_y) + 0.5
)
l0 = ((xs[1] - gx) * (ys[2] - gy) - (xs[2] - gx) * (ys[1] - gy)) / area
l1 = ((xs[2] - gx) * (ys[0] - gy) - (xs[0] - gx) * (ys[2] - gy)) / area
l2 = 1.0 - l0 - l1
inside = (l0 >= 0.0) & (l1 >= 0.0) & (l2 >= 0.0)
if not inside.any():
continue
zs = np.array([ndc[i0, 2], ndc[i1, 2], ndc[i2, 2]])
ws = np.array([w[i0], w[i1], w[i2]])
zi = l0 * zs[0] + l1 * zs[1] + l2 * zs[2]
window = depth[min_y:max_y, min_x:max_x]
take = inside & (zi < window)
if not take.any():
continue
inv_w = l0 / ws[0] + l1 / ws[1] + l2 / ws[2]
uu = (
l0 * uvs[i0, 0] / ws[0] + l1 * uvs[i1, 0] / ws[1] + l2 * uvs[i2, 0] / ws[2]
) / inv_w
vv = (
l0 * uvs[i0, 1] / ws[0] + l1 * uvs[i1, 1] / ws[1] + l2 * uvs[i2, 1] / ws[2]
) / inv_w
window[take] = zi[take]
rows, cols = np.nonzero(take)
view_x = texel[min_y:max_y, min_x:max_x, 0]
view_y = texel[min_y:max_y, min_x:max_x, 1]
view_x[rows, cols] = np.floor(uu[take] + 0.5)
view_y[rows, cols] = np.floor(vv[take] + 0.5)
return texel, np.isfinite(depth)
def classify(pixels, target, tolerance):
flat = pixels.reshape(-1, 3).astype(np.int16)
return (
np.abs(flat - np.asarray(target, dtype=np.int16)).max(axis=1) <= tolerance
).reshape(pixels.shape[:2])
def measure(island, texel, covered, painted_valid, painted_invalid):
"""The domain-space numbers, and the assertions, in one place."""
shape = island.shape
# The GPU's own coverage, not this rasteriser's: the reference's barycentric test
# includes triangle and silhhouette edge pixels that GL's fill rule rejects, and a
# texel only this reference reaches would otherwise read as "the pane failed to paint
# it". A texel is covered when a pixel the pane *did* paint resolves to it.
painted = painted_valid | painted_invalid
covered = covered & painted
height, width = int(shape[0]), int(shape[1])
flat = texel[..., 1] * width + texel[..., 0]
valid_texel = np.zeros(shape[0] * shape[1], dtype=bool)
invalid_texel = np.zeros(shape[0] * shape[1], dtype=bool)
covered_texel = np.zeros(shape[0] * shape[1], dtype=bool)
covered_texel[flat[covered]] = True
valid_texel[flat[covered & painted_valid]] = True
invalid_texel[flat[covered & painted_invalid]] = True
valid_texel = valid_texel.reshape(shape)
invalid_texel = invalid_texel.reshape(shape)
covered_texel = covered_texel.reshape(shape)
island_covered = island & covered_texel
grey = island_covered & ~valid_texel
extra = valid_texel & ~island
interior = erode1(island) & covered_texel
outside = ~dilate1(island) & covered_texel
mixed = valid_texel & invalid_texel
# The one-texel rim of the island, in screen terms: a pixel is in the band when its
# own texel has a 4-neighbour with the opposite validity.
tx = np.clip(texel[..., 0], 0, width - 1)
ty = np.clip(texel[..., 1], 0, height - 1)
here = island[ty, tx]
band = np.zeros_like(here)
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
band |= island[np.clip(ty + dy, 0, height - 1), np.clip(tx + dx, 0, width - 1)] != here
band &= covered
expected = np.zeros(here.shape, dtype=bool)
expected[covered] = here[covered]
missing_pixels = expected & ~painted_valid
extra_pixels = painted_valid & covered & ~expected
return {
"island_texels": int(island.sum()),
"rim_texels": int((island & adjacent_to(~island)).sum()),
"island_covered_texels": int(island_covered.sum()),
"island_uncovered_texels": int((island & ~covered_texel).sum()),
"painted_valid_texels": int(valid_texel.sum()),
"island_grey_texels": int(grey.sum()),
"island_grey_percent": round(
100.0 * float(grey.sum()) / max(int(island_covered.sum()), 1), 4
),
"extra_valid_texels": int(extra.sum()),
"mixed_texels": int(mixed.sum()),
"mixed_inside_interior": int((mixed & erode1(island)).sum()),
"covered_pixels": int(covered.sum()),
"interior_texels": int(interior.sum()),
"interior_not_painted_valid": int((interior & ~valid_texel).sum()),
"interior_painted_invalid": int((interior & invalid_texel).sum()),
"outside_dilated_painted_valid": int((outside & valid_texel).sum()),
"grey_more_than_one_texel_inside": int((grey & erode1(island)).sum()),
"grey_more_than_two_texels_inside": int((grey & erode1(erode1(island))).sum()),
"extra_more_than_one_texel_outside": int((extra & ~dilate1(island)).sum()),
"extra_more_than_two_texels_outside": int(
(extra & ~dilate1(dilate1(island))).sum()
),
"band_pixels": int(band.sum()),
"missing_pixels": int(missing_pixels.sum()),
"extra_pixels": int(extra_pixels.sum()),
"missing_pixels_outside_band": int((missing_pixels & ~band).sum()),
"extra_pixels_outside_band": int((extra_pixels & ~band).sum()),
}
def assert_agreement(numbers: dict, label: str) -> None:
"""Every assertion, with the label so a failure says which subject it was."""
assert numbers["covered_pixels"] > 5000, (label, numbers)
# A render that covers almost none of the island would make the rest vacuous.
assert numbers["island_covered_texels"] > 0.5 * numbers["island_texels"], (label, numbers)
# The island's inverse image: every covered island texel is a measurement.
#
# Two residues are bounded here rather than waved at, because they have different
# causes and only one of them is avoidable:
#
# * a *rim* texel can be missed when a fragment's interpolated UV sits within float32
# rounding of the neighbouring texel and the GPU rounds the other way — the
# "residual <= 1 texel" of the acceptance, and it may not reach deeper than the rim;
# * a texel can be *skipped entirely* when the plate is minified: NEAREST sampling of a
# 300x280 domain onto a plate of roughly half that many pixels cannot show every
# texel, and a validity channel deliberately cannot be averaged (a blended validity
# is the partly-valid triangle this change removed). One texel two steps inside the
# rim is the measured worst case at the shipped asset's own default view, so the
# band is bounded at two texels and the *fraction* at 0.05 %.
assert numbers["grey_more_than_one_texel_inside"] <= numbers["rim_texels"], (
f"{label}: the unpainted residue is deeper than the island's rim"
)
assert numbers["grey_more_than_two_texels_inside"] == 0, (label, numbers)
assert numbers["island_grey_percent"] <= 0.05, (
f"{label}: {numbers['island_grey_percent']} % of the island is painted as no "
"measurement; at most a hairline of the rim may be"
)
# The same tolerance in the other direction: a texel just outside the island may be
# resolved as valid when a fragment's UV rounds the other way, but never deeper than
# the rim and never in a way that grows with the island.
assert numbers["extra_valid_texels"] <= numbers["rim_texels"], (label, numbers)
assert numbers["extra_more_than_one_texel_outside"] == 0, (label, numbers)
# Zero interior disagreement, and no disagreement more than one texel out.
assert numbers["interior_not_painted_valid"] == 0, (label, numbers)
assert numbers["interior_painted_invalid"] == 0, (label, numbers)
assert numbers["outside_dilated_painted_valid"] == 0, (label, numbers)
assert numbers["grey_more_than_two_texels_inside"] == 0, (label, numbers)
assert numbers["extra_more_than_two_texels_outside"] == 0, (label, numbers)
# A texel painted both ways can only be a half-texel rounding tie at the rim: a
# fragment whose interpolated UV sits within float32 rounding of a texel boundary is
# rounded to a different texel by the GPU than by this float64 reference. That is the
# residue the one-texel tolerance exists for, and it may never reach the interior.
assert numbers["mixed_inside_interior"] == 0, (label, numbers)
# ...and every pixel that disagrees is inside that one-texel band.
assert numbers["missing_pixels_outside_band"] == 0, (label, numbers)
assert numbers["extra_pixels_outside_band"] == 0, (label, numbers)
# ---------------------------------------------------------------------- subjects
def _dense_scene():
"""A flat plate with one vertex per texel, and its island: the controlled case."""
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
Material,
MeshPart,
SceneAssets,
SurfaceBinding,
freeze,
)
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
height, width = DENSE_DOMAIN_HW
step = DENSE_PLATE_M / (max(height, width) - 1)
rows, cols = np.meshgrid(np.arange(height), np.arange(width), indexing="ij")
positions = np.stack(
[
(cols - (width - 1) / 2.0) * step,
-(rows - (height - 1) / 2.0) * step,
np.zeros_like(rows, dtype=np.float64),
],
axis=-1,
).astype(np.float32)
flat = positions.reshape(-1, 3)
index = (rows * width + cols).astype(np.int64)
a = index[:-1, :-1].reshape(-1)
b = index[:-1, 1:].reshape(-1)
c = index[1:, :-1].reshape(-1)
d = index[1:, 1:].reshape(-1)
triangles = np.concatenate(
[np.stack([a, b, d], axis=1), np.stack([a, d, c], axis=1)], axis=0
).astype(np.uint32)
part = MeshPart(
part_id="sensing_surface",
role="sensing",
positions_sensor_m=freeze(flat, dtype=np.float32),
normals_sensor=freeze(
np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (len(flat), 1)),
dtype=np.float32,
),
triangles=freeze(triangles, dtype=np.uint32),
material=Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True),
lod_id="region",
)
geom = geometry_id([part])
domain = field_domain_id(shape_hw=DENSE_DOMAIN_HW, flip_v=False)
uvs = np.stack(
[cols.reshape(-1).astype(np.float32), rows.reshape(-1).astype(np.float32)], axis=1
)
count = len(flat)
indices = np.full((count, 4), -1, dtype=np.int32)
weights = np.zeros((count, 4), dtype=np.float32)
indices[:, 0] = np.arange(count, dtype=np.int32)
weights[:, 0] = 1.0
assets = SceneAssets(
asset_id="region-dense",
asset_version="0.0.0",
asset_hash="region-hash",
geometry_id=geom,
parts=(part,),
binding=SurfaceBinding(
part_id="sensing_surface",
geometry_id=geom,
field_domain_id=domain,
shape_hw=DENSE_DOMAIN_HW,
uvs=freeze(uvs, dtype=np.float32),
sample_indices=freeze(indices, dtype=np.int32),
sample_weights=freeze(weights, dtype=np.float32),
),
camera_presets=(
CameraPreset(
"front", (0.0, 0.0, DENSE_PLATE_M * 2.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.6
),
),
bounds_sensor_m=freeze(
np.stack([flat.min(axis=0), flat.max(axis=0)]).astype(np.float32),
dtype=np.float32,
),
field_domain_id=domain,
shape_hw=DENSE_DOMAIN_HW,
)
island = np.zeros(DENSE_DOMAIN_HW, dtype=bool)
island[6:34, 8:40] = True
island[12:20, 20:26] = False # a hole: an interior the shader must still colour
island[24:30, 30:36] = False # a notch: a concave corner on the boundary
return assets, island, uvs, "front"
def _shipped_scene(preset_name: str = "front"):
"""The real finger package, with the island it carries.
``surface_valid.npy`` is byte-identical to the unroll map's ``valid_mask`` (measured:
32937 texels, 0 differing), so the island is the 2D canvas's own island and no path
outside this repository is needed to state it.
"""
from pathlib import Path
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
package = (
Path(__file__).resolve().parents[1]
/ "config" / "sensors" / "finger" / "scene" / "finger-display-1.0.0.oryscene"
)
assert package.is_file(), f"the shipped scene package is missing: {package}"
assets = scene_from_contents(read_package(package), None)
mapping = assets.surface_mapping
assert mapping is not None, "the shipped package carries no surface mapping"
names = {preset.name for preset in assets.camera_presets}
assert preset_name in names, f"the shipped package has no {preset_name!r} preset: {names}"
return (
assets,
np.asarray(mapping.valid, dtype=bool),
np.asarray(assets.binding.uvs, dtype=np.float32),
preset_name,
)
def _render_region(qt_app, assets, island, uvs, preset_name, viewport):
"""Draw the island through the real GL path and measure the painted region."""
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplayField,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
RenderFrame,
freeze,
)
from PySide6.QtGui import QImage
from orisys_studio.scene3d import gl_backend
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import (
GLDispatch,
GLRenderer,
configure_surface_format,
lut_rgba,
)
from orisys_studio.scene3d.widget import Sensor3DWidget
configure_surface_format("desktop33")
renderer = GLRenderer()
widget = Sensor3DWidget(renderer, OrbitCamera(), dispatch=GLDispatch())
seen: list = []
widget.diagnosticRaised.connect(seen.append)
widget.resize(*viewport)
widget.show()
widget.set_scene(assets)
def pump(predicate, timeout_s=12.0):
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
if not pump(lambda: bool(renderer.part_ids)):
widget.release_resources()
widget.close()
pytest.fail(
"no usable OpenGL context on a real platform: "
f"{widget.diagnostic() or seen or 'the widget never initialized'}"
)
preset = next(item for item in assets.camera_presets if item.name == preset_name)
widget.camera().set_preset(preset)
widget.camera().fit(assets.bounds_sensor_m, viewport[0] / max(viewport[1], 1))
widget.repaint()
qt_app.processEvents()
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
frame = RenderFrame(
identity=FrameIdentity("region", 1, 1, 0.0, 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,
field=DisplayField(
domain_id=assets.field_domain_id,
shape_hw=(int(island.shape[0]), int(island.shape[1])),
scalar=freeze(
np.where(island, 1.0, 0.0).astype(np.float32), dtype=np.float32
),
valid=freeze(np.array(island, dtype=bool), dtype=np.bool_),
descriptor=descriptor,
),
)
widget.set_settings(
DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential"
),
)
)
# The invalid colour is the sentinel the readback is classified by. ``draw`` reads it
# from the module, so patching the module is what the shader is given.
gl_backend.INVALID_COLOR = (0.0, 1.0, 0.0, 1.0)
# The colour a *valid* fragment gets comes from the tree's own lookup table, not from
# a hard-coded constant: the 2D canvas and the 3D surface must be coloured by one
# table, and a test that hard-coded the colour would stop noticing if they diverged.
valid_rgb = tuple(int(v) for v in lut_rgba("sequential")[-1, 0, :3])
widget.present(frame)
try:
assert pump(
lambda: getattr(renderer, "field_shape", None)
== (int(island.shape[0]), int(island.shape[1]))
), "the field texture was never uploaded"
for _ in range(8):
widget.repaint()
qt_app.processEvents()
image = widget.grabFramebuffer()
assert not image.isNull(), "Qt returned no framebuffer"
image = image.convertToFormat(QImage.Format.Format_RGB888)
width, height = image.width(), image.height()
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
pixels = (
np.frombuffer(buffer, dtype=np.uint8)
.reshape(height, image.bytesPerLine())[:, : width * 3]
.reshape(height, width, 3)
.copy()
)
background = classify(pixels, BACKGROUND_RGB, 6)
painted_valid = classify(pixels, valid_rgb, 14) & ~background
painted_invalid = classify(pixels, INVALID_SENTINEL, 14) & ~background
texel, covered = reference_texels(
np.asarray(frame.positions_sensor_m, dtype=np.float64),
uvs,
assets.sensing_part().triangles,
widget.camera().matrices(widget.viewport_pixels()),
widget.viewport_pixels(),
)
# The reference is built in GL window coordinates (y up); the grab is top-down.
return (
measure(
island,
texel[::-1],
covered[::-1],
painted_valid,
painted_invalid,
),
renderer,
)
finally:
widget.release_resources()
widget.close()
# ------------------------------------------------------------------------ the test
@pytest.fixture(scope="module")
def dense_region(qt_app):
assets, island, uvs, preset = _dense_scene()
numbers, _ = _render_region(
qt_app, assets, island, uvs, preset, DENSE_VIEWPORT
)
return numbers
@pytest.fixture(scope="module")
def shipped_region(qt_app):
"""The shipped asset, seen face-on and magnified: the exact case."""
assets, island, uvs, preset = _shipped_scene("front")
numbers, _ = _render_region(qt_app, assets, island, uvs, preset, REAL_VIEWPORT)
return numbers
@pytest.fixture(scope="module")
def shipped_default_region(qt_app):
"""The shipped asset at *its own* default preset: a three-quarter, minified view."""
assets, island, uvs, _ = _shipped_scene("front")
default = assets.camera_presets[0].name
numbers, _ = _render_region(qt_app, assets, island, uvs, default, REAL_VIEWPORT)
return numbers
def test_the_dense_scene_paints_exactly_the_island(dense_region):
"""The controlled case: one vertex per texel, a hole and a notch in the island.
Nothing is tolerated here. The plate is seen face-on and every texel is magnified,
so neither a rounding tie nor a skipped texel can arise: the painted region must be
the island exactly.
"""
assert_agreement(dense_region, "dense")
assert dense_region["island_grey_texels"] == 0, dense_region
assert dense_region["extra_valid_texels"] == 0, dense_region
assert dense_region["grey_more_than_one_texel_inside"] == 0, dense_region
assert dense_region["mixed_texels"] == 0, dense_region
assert dense_region["missing_pixels"] == 0, dense_region
assert dense_region["extra_pixels"] == 0, dense_region
# Nothing may be left uncovered here: the mesh spans the whole domain.
assert dense_region["island_uncovered_texels"] == 0, dense_region
assert dense_region["painted_valid_texels"] == dense_region["island_texels"]
def test_the_shipped_package_paints_exactly_the_island(shipped_region):
"""The real acceptance: the shipped asset, face-on, every texel magnified.
Zero tolerance, as the design asks: no island texel is grey, no texel outside the
island is painted as a measurement, and no pixel disagrees at all. Before the change
the same measurement was 5763 grey island texels — 5476 of them strictly inside the
island — and 26840 wrong pixels.
"""
numbers = shipped_region
assert_agreement(numbers, "shipped")
assert numbers["island_grey_texels"] == 0, numbers
assert numbers["extra_valid_texels"] == 0, numbers
assert numbers["mixed_texels"] == 0, numbers
assert numbers["missing_pixels"] == 0, numbers
assert numbers["extra_pixels"] == 0, numbers
assert numbers["painted_valid_texels"] == numbers["island_covered_texels"], numbers
# Reported, not asserted: the mesh's UV footprint is a sub-rectangle of the domain,
# so these island texels have no triangle over them at all.
assert numbers["island_uncovered_texels"] > 0, numbers
def test_the_shipped_default_view_keeps_the_residue_on_the_rim(shipped_default_region):
"""The view an operator actually opens: three-quarter, and minified.
Nothing may be grey except within two texels of the island's rim, and almost nothing
may be: measured as 1 texel of 25539 (0.0039 %) — the one texel the GPU never resolved
as itself, because NEAREST sampling of a 300x280 domain onto a plate of about half
that many pixels cannot show every texel, and a validity channel may not be averaged.
(The old per-vertex path, on this same view, left 5763 texels grey with 5476 of them
strictly inside the island.)
"""
numbers = shipped_default_region
assert_agreement(numbers, "shipped-default")
assert numbers["island_grey_percent"] < 0.05, numbers
assert numbers["interior_painted_invalid"] == 0, numbers
# The residual at this view is a rim rounding tie and nothing more: at most one texel
# of the island missed and at most one texel just outside it claimed, both adjacent to
# the boundary, and no disagreement anywhere but the one-texel band.
assert numbers["island_grey_texels"] <= 1, numbers
assert numbers["extra_valid_texels"] <= 1, numbers
assert numbers["grey_more_than_one_texel_inside"] == 0, numbers
assert numbers["extra_more_than_one_texel_outside"] == 0, numbers
+37 -25
View File
@@ -24,8 +24,10 @@ from orisys_studio.scene3d.camera import (
OrbitCamera,
)
from orisys_studio.scene3d.gl_backend import (
FIELD_BYTES_PER_TEXEL,
LUT_SIZE,
STRIDE_FLOATS,
VALID_BYTES_PER_TEXEL,
estimate_gpu_bytes,
is_software_renderer,
lut_rgba,
@@ -259,7 +261,14 @@ def test_shader_sources_are_two_dialects_of_one_body():
len(shader_header("gles30")):
]
assert "texture(u_lut" in desktop_fragment and "texture(u_lut" in es_fragment
assert "isnan" in desktop_fragment, "an unmeasured value must not be colour-mapped"
# The surface reads *two textures* and decides validity from one of them. There is
# no per-vertex scalar or validity attribute, and no ``isnan`` rule: the NaN that
# rule used to catch was produced by the CPU gather this path replaced.
for marker in ("uniform sampler2D u_field", "uniform sampler2D u_valid",
"u_inv_domain_size", "a_uv", "v_uv"):
assert marker in desktop_vertex or marker in desktop_fragment, marker
assert "a_scalar" not in desktop_vertex and "a_valid" not in desktop_vertex
assert "isnan" not in desktop_fragment
def test_an_unknown_profile_or_program_is_refused():
@@ -301,35 +310,33 @@ def _plate(**kwargs):
return synthetic.poc_scene().sensing_part()
def test_packing_lays_out_position_normal_scalar_valid():
def test_packing_lays_out_position_normal_and_uv():
"""Position, normal, UV: the measurement is not in the vertex buffer any more."""
part = _plate()
count = int(part.positions_sensor_m.shape[0])
scalar = np.linspace(0.0, 1.0, count, dtype=np.float32)
valid = np.ones(count, dtype=bool)
rows = pack_part(part, scalar=scalar, valid=valid)
uvs = synthetic.poc_scene().binding.uvs
rows = pack_part(part, uvs=uvs)
assert rows.shape == (count, STRIDE_FLOATS)
assert rows.dtype == np.float32 and rows.flags["C_CONTIGUOUS"]
assert not rows.flags["WRITEABLE"], "the packed buffer must be read-only"
assert np.allclose(rows[:, 0:3], part.positions_sensor_m)
assert np.allclose(rows[:, 3:6], part.normals_sensor)
assert np.allclose(rows[:, 6], scalar)
assert np.allclose(rows[:, 7], 1.0)
assert np.allclose(rows[:, 6:8], uvs)
# Slots 2 and 3 of the surface program are gone, not reused: the attributes that
# lived there were the per-vertex scalar and validity.
assert STRIDE_FLOATS == 8
def test_packing_a_scene_without_a_measurement_marks_everything_invalid():
def test_packing_a_part_without_a_uv_map_leaves_the_uv_slot_empty():
"""The housing is never coloured by the measurement, so it carries no mapping."""
rows = pack_part(_plate())
assert np.isnan(rows[:, 6]).all(), "an unmeasured scalar must be NaN, not zero"
assert np.allclose(rows[:, 7], 0.0), "an unmeasured vertex must be invalid"
assert np.allclose(rows[:, 6:8], 0.0)
def test_packing_refuses_a_mismatched_length():
part = _plate()
with pytest.raises(Exception) as scalar_error:
pack_part(part, scalar=np.zeros(3, dtype=np.float32))
assert "scalars for" in str(scalar_error.value)
with pytest.raises(Exception) as valid_error:
pack_part(part, valid=np.zeros(2, dtype=bool))
assert "validity flags for" in str(valid_error.value)
def test_packing_refuses_a_mismatched_uv_count():
with pytest.raises(Exception) as error_:
pack_part(_plate(), uvs=np.zeros((3, 2), dtype=np.float32))
assert "UVs for" in str(error_.value)
def test_scene_part_indices_are_uint32_and_read_only():
@@ -339,7 +346,7 @@ def test_scene_part_indices_are_uint32_and_read_only():
assert int(indices.max()) < vertices.shape[0]
def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
def test_the_gpu_budget_counts_both_parts_one_dynamic_copy_and_the_field_textures():
assets = synthetic.poc_scene()
budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface")
plate = assets.sensing_part()
@@ -352,6 +359,8 @@ def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
+ housing.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
+ housing.triangles.size * 4
+ LUT_SIZE * 4
# The two field textures: R16F scalar + R8 validity over the scene's domain.
+ assets.shape_hw[0] * assets.shape_hw[1] * (FIELD_BYTES_PER_TEXEL + VALID_BYTES_PER_TEXEL)
)
assert budget == expected
assert budget < 1024 * 1024, "the PoC scene should be tiny"
@@ -360,11 +369,14 @@ def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
def test_the_rest_surface_claims_no_measurement():
assets = synthetic.poc_scene()
frame = rest_surface(assets, FrameIdentity("poc", 1, 0, 0.0, 1))
count = int(assets.sensing_part().positions_sensor_m.shape[0])
assert frame.scalar.shape == (count,) and frame.valid.shape == (count,)
assert not frame.valid.any(), "a reference frame measures nothing"
assert np.isnan(frame.scalar).all(), "an unavailable scalar is NaN, not zero"
assert frame.scalar_descriptor.quantity == "unavailable"
assert frame.scalar_descriptor.quality == "display_only"
shape = assets.shape_hw
assert frame.field.scalar.shape == shape and frame.field.valid.shape == shape
assert frame.field.shape_hw == shape
assert frame.field.domain_id == assets.field_domain_id
assert not frame.field.valid.any(), "a reference frame measures nothing"
assert np.isnan(frame.field.scalar).all(), "an unavailable scalar is NaN, not zero"
assert frame.field.descriptor.quantity == "unavailable"
assert frame.field.descriptor.quality == "display_only"
assert not frame.deformed, "a reference frame is the asset's own geometry"
assert frame.geometry_id == assets.geometry_id
assert frame.sensing_part_id == assets.sensing_part().part_id