modify 3d view to sync / identical to 2d view
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user