modify 3d view to sync / identical to 2d view
This commit is contained in:
Binary file not shown.
@@ -254,6 +254,11 @@ class StudioMainWindow(QMainWindow):
|
||||
|
||||
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||
# The island latch is *upstream of both views*: the 2D canvas paints the
|
||||
# latched boundary, and the 3D pane's validity texture is that same mask. Two
|
||||
# views, one decision about where the measurement is — see
|
||||
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
|
||||
self.mesh3d_panel.set_domain_latch(self._domain_mask)
|
||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||
# The charts browse the whole session, not just their rolling buffer.
|
||||
|
||||
@@ -142,13 +142,22 @@ def _computed_frame_id(packet: SdkPacket, field: str, frame_id: int) -> int:
|
||||
|
||||
|
||||
def _device_mask(
|
||||
packet: SdkPacket, shape_hw: tuple[int, int]
|
||||
packet: SdkPacket,
|
||||
shape_hw: tuple[int, int],
|
||||
latch: Any = None,
|
||||
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
|
||||
"""The device's own validity, or a diagnostic when it describes another grid.
|
||||
|
||||
Both silent choices are wrong: stretching a mask from another grid marks samples
|
||||
valid that the device never claimed, and ignoring it *widens* the trusted area to
|
||||
the whole UV domain. So a mismatch refuses the frame and names both shapes.
|
||||
|
||||
``latch`` is the session's ``DomainMaskLatch`` when the window has one. The mask it
|
||||
returns is the *session's* island — the same array the 2D canvas paints its island
|
||||
and its "no depth" grey from — so the surface's validity texture is that island and
|
||||
not a second, independently rebuilt copy of it. The latch is idempotent for a
|
||||
repeated mask (an IoU of 1 is always held), which is what makes it safe for both
|
||||
views to consult it from different threads and different frames.
|
||||
"""
|
||||
explicit = getattr(packet, "valid_mask", None)
|
||||
if explicit is None:
|
||||
@@ -161,6 +170,10 @@ def _device_mask(
|
||||
mask=_shape_of(array),
|
||||
domain=f"{int(shape_hw[0])}x{int(shape_hw[1])}",
|
||||
)
|
||||
if latch is not None:
|
||||
held = latch.update(array)
|
||||
if held is not None:
|
||||
array = np.asarray(held, dtype=bool)
|
||||
return array, None
|
||||
|
||||
|
||||
@@ -224,6 +237,11 @@ class FingerFrameAdapter:
|
||||
#: Set (and cleared) by every snapshot: the reason the picture is not the field
|
||||
#: the operator chose. ``None`` when the preferred field was the one drawn.
|
||||
self.last_substitution: Optional[Diagnostic] = None
|
||||
#: The session's island latch (``image_backend.DomainMaskLatch``), when the
|
||||
#: window has one. See ``_device_mask``: the surface's validity is the *latched*
|
||||
#: island — the same mask the 2D canvas paints its island and its "no depth"
|
||||
#: grey from — so the two views cannot disagree about where the measurement is.
|
||||
self.domain_latch: Any = None
|
||||
|
||||
def set_preferred_field(self, name: str) -> None:
|
||||
self.preferred_field = _field_name(name)
|
||||
@@ -387,7 +405,7 @@ class FingerFrameAdapter:
|
||||
"""Intersect with the device's own mask and build the frame."""
|
||||
# Inside the UV island is the only place a value means anything: the tracker is
|
||||
# looking at masked black outside it.
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw)
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||
if mask_problem is not None:
|
||||
refused.append(mask_problem)
|
||||
return None
|
||||
@@ -407,7 +425,7 @@ class FingerFrameAdapter:
|
||||
arr = np.asarray(flow, dtype=np.float32)
|
||||
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
|
||||
flow_uv = freeze(arr, dtype=np.float32)
|
||||
device_for_flow, _ = _device_mask(packet, self.shape_hw)
|
||||
device_for_flow, _ = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
|
||||
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
|
||||
if not np.isfinite(fn):
|
||||
@@ -452,6 +470,8 @@ class BoxFrameAdapter:
|
||||
self.field_domain_id = str(field_domain_id)
|
||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||
self.last_diagnostic: Optional[Diagnostic] = None
|
||||
#: See ``FingerFrameAdapter.domain_latch``: one island, both views.
|
||||
self.domain_latch: Any = None
|
||||
|
||||
def snapshot(
|
||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||
@@ -468,7 +488,7 @@ class BoxFrameAdapter:
|
||||
("depth", getattr(packet, "depth", None)),
|
||||
("flow", flow_magnitude(packet)),
|
||||
)
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw)
|
||||
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||
if mask_problem is not None:
|
||||
self.last_diagnostic = mask_problem
|
||||
return None
|
||||
|
||||
@@ -452,7 +452,7 @@ class ThreeDCoordinator:
|
||||
# A frame arrived with its own fields: whatever went stale is current
|
||||
# again, and the grace starts over next time it fails.
|
||||
self._stale_since = None
|
||||
self._set_state("live" if composed.valid.any() else "no_data")
|
||||
self._set_state("live" if composed.field.valid.any() else "no_data")
|
||||
elif self._state in ("ready", "loading"):
|
||||
# Nothing has arrived yet: show the reference model rather than a blank
|
||||
# window, and say that no measurement is being displayed. Only once per
|
||||
@@ -482,6 +482,19 @@ class ThreeDCoordinator:
|
||||
# held field frame so the operator sees the change without waiting.
|
||||
self._settings_dirty = True
|
||||
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
"""Hand the theme's colour ramp to the composer.
|
||||
|
||||
The composer stamps it on every ``DisplayField`` it builds, and the GL layer
|
||||
builds its lookup table from the field it is handed. That is how the 3D
|
||||
surface and the 2D canvas end up coloured by one table: the ramp is resolved
|
||||
once, from the theme, and travels with the measurement.
|
||||
"""
|
||||
try:
|
||||
self._composer.ramp = str(ramp or "")
|
||||
except AttributeError: # pragma: no cover - a composer stand-in without state
|
||||
pass
|
||||
|
||||
def show_rest(self) -> None:
|
||||
"""Put the reference model back on screen and stop claiming a measurement.
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ Import discipline, and why it is not negotiable:
|
||||
* :meth:`GLRenderer.release` is idempotent and requires the owning context to be
|
||||
current — resources are freed by the context that made them.
|
||||
|
||||
Two parts, two programs, one draw call per part. The sensing surface is re-uploaded
|
||||
per frame and its colour comes from ``scalar`` through a LUT texture with the range
|
||||
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded
|
||||
once and never touched again. A frame with no measurement (``valid == 0``) is drawn
|
||||
in the neutral colour rather than in colour zero — the reference model is not a
|
||||
measurement and must not look like one.
|
||||
Two parts, two programs, one draw call per part. The sensing surface's colour comes
|
||||
from the *field texture* — the acquisition frame's own ``[H,W]`` grid, uploaded with
|
||||
``glTexSubImage2D`` and read per fragment through the mesh's UVs — so Python never
|
||||
builds an RGB array per frame and never gathers the field onto the vertices; the
|
||||
vertex buffer is uploaded once and re-uploaded only when a display deformation
|
||||
actually moves a vertex. The housing is uploaded once and never touched again. A
|
||||
fragment whose texel is invalid is drawn in the neutral colour rather than in colour
|
||||
zero: the reference model is not a measurement and must not look like one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -61,6 +63,16 @@ INVALID_COLOR = (0.18, 0.18, 0.20, 1.0)
|
||||
#: LUT size. 256 is enough for a sequential ramp and cheap to rebuild.
|
||||
LUT_SIZE = 256
|
||||
|
||||
#: The ramp used when a field does not name one. It is the dark theme's own
|
||||
#: ``flow_ramp``: the 2D canvas and the 3D pane must be coloured by the same stops,
|
||||
#: and a default that differed from either would be the bug this replaces.
|
||||
DEFAULT_FIELD_RAMP = "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||
|
||||
#: Bytes per texel the GPU budget charges for the field textures: ``R16F`` scalar
|
||||
#: (nominal; the ``R32F`` fallback is 4) plus the ``R8`` validity.
|
||||
FIELD_BYTES_PER_TEXEL = 2
|
||||
VALID_BYTES_PER_TEXEL = 1
|
||||
|
||||
|
||||
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
||||
"""Select the default GL surface format. Call before ``QApplication``.
|
||||
@@ -112,33 +124,42 @@ def profile_from_renderable_type(value: int) -> Profile:
|
||||
# --------------------------------------------------------------------- shaders
|
||||
#: Shared bodies: the version header is the only difference, so the maths cannot
|
||||
#: drift between the desktop and the embedded build.
|
||||
#:
|
||||
#: The surface is **texture-first**. The vertex stage carries geometry and one UV
|
||||
#: pair per vertex — nothing about the measurement — and the fragment stage reads the
|
||||
#: field and its validity from two textures on the domain's own grid. There used to
|
||||
#: be ``a_scalar``/``a_valid`` attributes here, gathered per *vertex* on the CPU and
|
||||
#: interpolated across the triangles; a vertex whose taps touched one invalid pixel
|
||||
#: became ``NaN`` and the ``isnan`` rule below painted the whole triangle grey
|
||||
#: (measured: 30.15 % of the island). Sampling per fragment removes the interpolation
|
||||
#: step entirely: the fragment reads the same texel the 2D canvas paints at that
|
||||
#: pixel, so the two views agree by construction.
|
||||
_VERTEX_BODY = """
|
||||
layout(location = 0) in vec3 a_position;
|
||||
layout(location = 1) in vec3 a_normal;
|
||||
layout(location = 2) in float a_scalar;
|
||||
layout(location = 3) in float a_valid;
|
||||
layout(location = 4) in vec2 a_uv;
|
||||
|
||||
uniform mat4 u_view;
|
||||
uniform mat4 u_projection;
|
||||
|
||||
out vec3 v_normal;
|
||||
out float v_scalar;
|
||||
out float v_valid;
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
v_normal = a_normal;
|
||||
v_scalar = a_scalar;
|
||||
v_valid = a_valid;
|
||||
v_uv = a_uv;
|
||||
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
_SCALAR_FRAGMENT_BODY = """
|
||||
in vec3 v_normal;
|
||||
in float v_scalar;
|
||||
in float v_valid;
|
||||
in vec2 v_uv;
|
||||
|
||||
uniform sampler2D u_lut;
|
||||
uniform sampler2D u_field;
|
||||
uniform sampler2D u_valid;
|
||||
uniform vec2 u_inv_domain_size;
|
||||
uniform float u_lower;
|
||||
uniform float u_upper;
|
||||
uniform vec4 u_invalid_color;
|
||||
@@ -146,20 +167,25 @@ uniform vec4 u_invalid_color;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
if (v_valid < 0.5 || isnan(v_scalar)) {
|
||||
// ``v_uv`` is already on the domain's pixel grid (the compiler applied the field's
|
||||
// own flip and scale, once, in ``legacy.uvs_to_pixels``), so the texel this
|
||||
// fragment owns is the rounded pixel, centred by half a texel and normalised by
|
||||
// the domain size -- the only conversion left, and it is exact rather than a
|
||||
// resampling. NEAREST filtering on both textures then makes "valid" a property of
|
||||
// that one texel, which is what the 2D view paints.
|
||||
vec2 texel = (floor(v_uv + 0.5) + 0.5) * u_inv_domain_size;
|
||||
if (texture(u_valid, texel).r < 0.5) {
|
||||
frag_color = u_invalid_color;
|
||||
return;
|
||||
}
|
||||
float span = max(u_upper - u_lower, 1e-12);
|
||||
float t = clamp((v_scalar - u_lower) / span, 0.0, 1.0);
|
||||
float t = clamp((texture(u_field, texel).r - u_lower) / span, 0.0, 1.0);
|
||||
frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
_DIFFUSE_FRAGMENT_BODY = """
|
||||
in vec3 v_normal;
|
||||
in float v_scalar;
|
||||
in float v_valid;
|
||||
|
||||
uniform vec3 u_light_direction;
|
||||
uniform vec4 u_base_color;
|
||||
@@ -310,106 +336,134 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
|
||||
return lower, upper
|
||||
|
||||
|
||||
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
|
||||
"""A 256x1 RGBA8 lookup table.
|
||||
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
|
||||
"""A 256x1 RGBA8 lookup table, built from the **one** ramp both views share.
|
||||
|
||||
Hand-rolled rather than imported: the runtime must not grow a plotting
|
||||
dependency for one texture, and the ramp is part of what the colours *mean*.
|
||||
``sequential`` is perceptually increasing in lightness (safe for magnitude);
|
||||
``diverging`` is the same ramp mirrored through a neutral midpoint (safe for a
|
||||
signed quantity, and never used for an unsigned one).
|
||||
``ramp`` is the resolved ``#RRGGBB,#RRGGBB,...`` string the field carries
|
||||
(``acquisition.arrows.ramp_for_theme``); empty means this build's default ramp,
|
||||
which is the dark theme's own. Either way the table is the *same* array the 2D
|
||||
canvas colours its pixels with — ``arrows.ramp_rgb_table`` is the single
|
||||
interpolation and the single rounding, so the two legends and the two pictures
|
||||
cannot disagree.
|
||||
|
||||
That was not true before: this function had its own hand-written stop list
|
||||
(a viridis-like ramp) while the 2D side used the theme's ``flow_ramp``, and the
|
||||
two disagreed by up to 218 of 255 per channel. A measurement drawn purple in one
|
||||
pane and navy in the other is two different measurements as far as an operator is
|
||||
concerned.
|
||||
|
||||
``diverging`` is the same ramp mirrored through its midpoint: safe for a signed
|
||||
quantity, and never used for an unsigned one.
|
||||
"""
|
||||
stops = np.array(
|
||||
[
|
||||
[0.267, 0.005, 0.329],
|
||||
[0.283, 0.141, 0.458],
|
||||
[0.254, 0.265, 0.530],
|
||||
[0.207, 0.372, 0.553],
|
||||
[0.164, 0.471, 0.558],
|
||||
[0.128, 0.567, 0.551],
|
||||
[0.135, 0.659, 0.518],
|
||||
[0.267, 0.749, 0.441],
|
||||
[0.478, 0.821, 0.318],
|
||||
[0.741, 0.873, 0.150],
|
||||
[0.993, 0.906, 0.144],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
if lut_id == "diverging":
|
||||
stops = np.concatenate([stops[::-1], stops], axis=0)
|
||||
positions = np.linspace(0.0, 1.0, stops.shape[0], dtype=np.float32)
|
||||
grid = np.linspace(0.0, 1.0, LUT_SIZE, dtype=np.float32)
|
||||
rgba = np.empty((LUT_SIZE, 1, 4), dtype=np.uint8)
|
||||
for channel in range(3):
|
||||
rgba[:, 0, channel] = np.clip(
|
||||
np.interp(grid, positions, stops[:, channel]) * 255.0, 0, 255
|
||||
).astype(np.uint8)
|
||||
rgba[:, 0, 3] = 255
|
||||
return rgba
|
||||
from orisys_app_shared.acquisition.arrows import ramp_rgba_table
|
||||
|
||||
return ramp_rgba_table(str(ramp or DEFAULT_FIELD_RAMP), diverging=lut_id == "diverging")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- vertex packing
|
||||
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + scalar(1)
|
||||
#: + valid(1). One buffer per part, so a per-frame upload is one glBufferSubData.
|
||||
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + uv(2). One
|
||||
#: buffer per part, uploaded once. The stride is the same 8 floats it was when it
|
||||
#: carried a per-vertex scalar and validity — the measurement moved to its own
|
||||
#: texture, and the UV that addresses it took their place.
|
||||
STRIDE_FLOATS = 8
|
||||
|
||||
#: Where in a vertex row the UV starts.
|
||||
UV_OFFSET_FLOATS = 6
|
||||
|
||||
def pack_part(part: MeshPart, *, scalar: Optional[np.ndarray] = None,
|
||||
valid: Optional[np.ndarray] = None) -> np.ndarray:
|
||||
#: Texture units. The LUT keeps unit 0; the field and its validity get their own so a
|
||||
#: driver cannot alias them.
|
||||
LUT_TEXTURE_UNIT = 0
|
||||
FIELD_TEXTURE_UNIT = 1
|
||||
VALID_TEXTURE_UNIT = 2
|
||||
|
||||
|
||||
def _extend_edges(values: np.ndarray, usable: np.ndarray) -> np.ndarray:
|
||||
"""Every unusable texel's one-texel edge extension: the mean of its usable neighbours.
|
||||
|
||||
Slicing rather than ``np.roll``: a wrapped neighbour would carry a measurement in
|
||||
from the opposite edge of the domain, which is a value from somewhere else entirely.
|
||||
A texel with no usable neighbour — the whole field is a hole — keeps 0.0, and the
|
||||
validity channel is what decides whether anything is drawn there.
|
||||
"""
|
||||
height, width = values.shape
|
||||
total = np.zeros_like(values)
|
||||
counts = np.zeros_like(values)
|
||||
for dy in (-1, 0, 1):
|
||||
for dx in (-1, 0, 1):
|
||||
if dy == 0 and dx == 0:
|
||||
continue
|
||||
source_y = slice(max(dy, 0), height + min(dy, 0))
|
||||
source_x = slice(max(dx, 0), width + min(dx, 0))
|
||||
target_y = slice(max(-dy, 0), height + min(-dy, 0))
|
||||
target_x = slice(max(-dx, 0), width + min(-dx, 0))
|
||||
neighbour_values = values[source_y, source_x]
|
||||
neighbour_usable = usable[source_y, source_x]
|
||||
total[target_y, target_x] += np.where(neighbour_usable, neighbour_values, 0.0)
|
||||
counts[target_y, target_x] += neighbour_usable
|
||||
filled = np.divide(total, counts, out=np.zeros_like(values), where=counts > 0)
|
||||
return np.where(usable, values, filled)
|
||||
|
||||
|
||||
def pack_part(part: MeshPart, *, uvs: Optional[np.ndarray] = None) -> np.ndarray:
|
||||
"""Interleave one part into the layout the shaders read.
|
||||
|
||||
The static case (no scalar yet) marks every vertex invalid, which the shader
|
||||
draws as the neutral colour: a scene that has never received a measurement
|
||||
must not be coloured as if it had.
|
||||
``uvs`` are the render-vertex UVs on the domain's pixel grid
|
||||
(``SurfaceBinding.uvs``), in the same order as ``part.positions_sensor_m``. A part
|
||||
with no UV mapping (the housing) is never coloured by the measurement, so it gets
|
||||
zeros and its own diffuse program; the attribute exists in the shared layout but
|
||||
nothing reads it there.
|
||||
"""
|
||||
count = int(part.positions_sensor_m.shape[0])
|
||||
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
|
||||
rows[:, 0:3] = np.asarray(part.positions_sensor_m, dtype=np.float32)
|
||||
rows[:, 3:6] = np.asarray(part.normals_sensor, dtype=np.float32)
|
||||
if scalar is None:
|
||||
rows[:, 6] = np.nan
|
||||
if uvs is None:
|
||||
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 0.0
|
||||
else:
|
||||
values = np.asarray(scalar, dtype=np.float32).reshape(-1)
|
||||
if values.size != count:
|
||||
values = np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
|
||||
if values.shape[0] != count:
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
f"{part.part_id}: {values.size} scalars for {count} vertices",
|
||||
f"{part.part_id}: {values.shape[0]} UVs for {count} vertices",
|
||||
)
|
||||
rows[:, 6] = values
|
||||
if valid is None:
|
||||
rows[:, 7] = 0.0
|
||||
else:
|
||||
flags = np.asarray(valid, dtype=bool).reshape(-1)
|
||||
if flags.size != count:
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
f"{part.part_id}: {flags.size} validity flags for {count} vertices",
|
||||
)
|
||||
rows[:, 7] = flags.astype(np.float32)
|
||||
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = values
|
||||
return freeze(rows, dtype=np.float32)
|
||||
|
||||
|
||||
def pack_scene_part(part: MeshPart) -> tuple[np.ndarray, np.ndarray]:
|
||||
def pack_scene_part(
|
||||
part: MeshPart, *, uvs: Optional[np.ndarray] = None
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""``(vertices, indices)`` as the GPU wants them, both read-only."""
|
||||
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1)
|
||||
return pack_part(part), freeze(indices, dtype=np.uint32)
|
||||
return pack_part(part, uvs=uvs), freeze(indices, dtype=np.uint32)
|
||||
|
||||
|
||||
def estimate_gpu_bytes(assets: SceneAssets, *, dynamic_part_id: str) -> int:
|
||||
"""A rough static-plus-one-dynamic budget, for the stats line."""
|
||||
"""A rough static-plus-one-dynamic budget, for the stats line.
|
||||
|
||||
The two field textures are part of it now: the scalar as 16-bit floats and the
|
||||
validity as one byte per texel, over the scene's own UV domain. They are counted
|
||||
at their nominal size (``R16F``), and the ``R32F`` fallback a driver without
|
||||
``R16F`` forces is twice the scalar half — noted rather than guessed at, because
|
||||
the budget is a *report*, and the code that picks the format is in
|
||||
:meth:`GLRenderer._ensure_field_textures`.
|
||||
"""
|
||||
total = 0
|
||||
for part in assets.parts:
|
||||
vertices = int(part.positions_sensor_m.shape[0])
|
||||
indices = int(part.triangles.size)
|
||||
total += vertices * STRIDE_FLOATS * 4 + indices * 4
|
||||
if part.part_id == dynamic_part_id:
|
||||
# A second copy for the frame in flight.
|
||||
# A second copy for the frame in flight. Only ever re-written when a display
|
||||
# deformation actually moves the vertices (``RenderFrame.deformed``).
|
||||
total += vertices * STRIDE_FLOATS * 4
|
||||
total += LUT_SIZE * 4
|
||||
height, width = int(assets.shape_hw[0]), int(assets.shape_hw[1])
|
||||
total += max(height, 0) * max(width, 0) * (FIELD_BYTES_PER_TEXEL + VALID_BYTES_PER_TEXEL)
|
||||
return total
|
||||
|
||||
|
||||
def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
||||
def rest_surface(assets: SceneAssets, identity: Any, *, ramp: str = "") -> RenderFrame:
|
||||
"""The asset's own geometry, with nothing measured.
|
||||
|
||||
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
||||
@@ -419,7 +473,7 @@ def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
||||
"""
|
||||
from orisys_app_shared.scene3d.composer import build_rest_frame
|
||||
|
||||
return build_rest_frame(assets, identity)
|
||||
return build_rest_frame(assets, identity, ramp=ramp)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- dispatch
|
||||
@@ -543,9 +597,13 @@ def _leading_int(text: str) -> int:
|
||||
class _PartBuffers:
|
||||
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
|
||||
|
||||
__slots__ = ("part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices")
|
||||
__slots__ = (
|
||||
"part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices", "rows"
|
||||
)
|
||||
|
||||
def __init__(self, part: MeshPart, vao: int, vbo: int, ebo: int) -> None:
|
||||
def __init__(
|
||||
self, part: MeshPart, vao: int, vbo: int, ebo: int, rows: np.ndarray
|
||||
) -> None:
|
||||
self.part_id = part.part_id
|
||||
self.role = part.role
|
||||
self.vao = vao
|
||||
@@ -553,6 +611,9 @@ class _PartBuffers:
|
||||
self.ebo = ebo
|
||||
self.triangles = int(part.triangles.shape[0])
|
||||
self.vertices = int(part.positions_sensor_m.shape[0])
|
||||
#: The interleaved rows as uploaded. A display deformation rewrites positions
|
||||
#: and normals into a copy of this; nothing else ever re-uploads a vertex.
|
||||
self.rows = rows
|
||||
|
||||
|
||||
class GLRenderer:
|
||||
@@ -565,7 +626,15 @@ class GLRenderer:
|
||||
self._parts: dict[str, _PartBuffers] = {}
|
||||
self._lut: int = 0
|
||||
self._lut_id: str = ""
|
||||
#: Range of the valid samples of the frame currently uploaded, for the
|
||||
#: The ramp the LUT was built from, so a theme change rebuilds it once.
|
||||
self._lut_ramp: str = ""
|
||||
#: The two field textures. Created once, sub-uploaded per frame: no per-frame
|
||||
#: allocation and no per-frame vertex traffic.
|
||||
self._field_texture: int = 0
|
||||
self._valid_texture: int = 0
|
||||
self._field_shape: Optional[tuple[int, int]] = None
|
||||
self._field_internal_format: int = 0
|
||||
#: Range of the valid samples of the field currently uploaded, for the
|
||||
#: automatic colour scale. ``None`` until a frame carries valid data.
|
||||
self._scalar_range: Optional[tuple[float, float]] = None
|
||||
#: The colour bounds of the last draw and how they were chosen.
|
||||
@@ -621,8 +690,11 @@ class GLRenderer:
|
||||
self._scalar_range = None
|
||||
self._last_scale = None
|
||||
self._last_scale_mode = ""
|
||||
uvs = np.asarray(assets.binding.uvs, dtype=np.float32)
|
||||
for part in assets.parts:
|
||||
vertices, indices = pack_scene_part(part)
|
||||
vertices, indices = pack_scene_part(
|
||||
part, uvs=uvs if part.role == "sensing" else None
|
||||
)
|
||||
vao = int(GL.glGenVertexArrays(1))
|
||||
vbo = int(GL.glGenBuffers(1))
|
||||
ebo = int(GL.glGenBuffers(1))
|
||||
@@ -639,13 +711,24 @@ class GLRenderer:
|
||||
GL.glBindVertexArray(0)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
|
||||
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo)
|
||||
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo, vertices)
|
||||
self._estimated_gpu_bytes = estimate_gpu_bytes(
|
||||
assets, dynamic_part_id=self._sensing_part_id
|
||||
)
|
||||
|
||||
def update_surface(self, frame: RenderFrame) -> None:
|
||||
"""Update the sensing part's vertices in place: no index rebuild, ever."""
|
||||
def update_field(self, frame: RenderFrame) -> None:
|
||||
"""Upload this frame's field as two textures. No vertex traffic.
|
||||
|
||||
This replaced ``update_surface``, which interleaved a per-vertex scalar and
|
||||
validity flag and pushed the whole vertex buffer through
|
||||
``glBufferSubData`` on every frame. The measurement no longer has anything to
|
||||
do with the vertices: the field goes into two textures on the domain's own
|
||||
grid and the fragment reads it through the UV that was already in the buffer.
|
||||
|
||||
A *display deformation* still moves vertices, so that case — and only that
|
||||
case, ``frame.deformed`` — re-uploads the sensing part's rows. No adapter in
|
||||
this build publishes a displacement, so the application never takes it.
|
||||
"""
|
||||
from OpenGL import GL
|
||||
|
||||
self._require_initialized()
|
||||
@@ -655,26 +738,132 @@ class GLRenderer:
|
||||
"geometry_mismatch",
|
||||
f"the scene has no part {frame.sensing_part_id!r} to update",
|
||||
)
|
||||
rows = pack_part(
|
||||
_PartLayout(frame.sensing_part_id, frame.positions_sensor_m, frame.normals_sensor),
|
||||
scalar=frame.scalar,
|
||||
valid=frame.valid,
|
||||
)
|
||||
if int(rows.shape[0]) != buffers.vertices:
|
||||
field = frame.field
|
||||
if int(buffers.vertices) != int(frame.positions_sensor_m.shape[0]):
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
f"{buffers.part_id}: {rows.shape[0]} vertices for "
|
||||
f"{buffers.part_id}: {frame.positions_sensor_m.shape[0]} vertices for "
|
||||
f"{buffers.vertices} uploaded",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
|
||||
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||
# The field carries the theme's ramp, so the lookup table follows the same
|
||||
# string the 2D canvas built its table from. Checked here rather than plumbed
|
||||
# through the view: whatever field this renderer is handed is what it draws.
|
||||
self.set_ramp(getattr(field, "ramp", "") or "")
|
||||
if frame.deformed:
|
||||
rows = np.array(buffers.rows, copy=True)
|
||||
rows[:, 0:3] = np.asarray(frame.positions_sensor_m, dtype=np.float32)
|
||||
rows[:, 3:6] = np.asarray(frame.normals_sensor, dtype=np.float32)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
|
||||
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||
self._upload_field_textures(field)
|
||||
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
||||
measured = _valid_scalar_range(frame.scalar, frame.valid)
|
||||
measured = _valid_scalar_range(field.scalar, field.valid)
|
||||
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
|
||||
self._upload_overlays(frame)
|
||||
|
||||
def _upload_field_textures(self, field: Any) -> None:
|
||||
"""``glTexSubImage2D`` of the scalar and validity channels.
|
||||
|
||||
The scalar is ``R16F`` (``R32F`` when the driver will not take a half float) and
|
||||
is filtered linearly, so the picture is smooth rather than blocky when the plate
|
||||
is magnified. The validity is ``R8`` with ``NEAREST`` filtering, because a
|
||||
validity that a linear filter could blend is exactly the "partly valid triangle"
|
||||
that used to be painted grey.
|
||||
|
||||
**A texel with no measurement must not contribute to a filtered sample.** Linear
|
||||
filtering reaches one texel in each direction, so every invalid texel adjacent to
|
||||
the island is given the mean of its valid neighbours (a one-texel edge extension).
|
||||
Without it a valid fragment on the island's rim blended its own measurement with
|
||||
a placeholder's zero and was drawn in a colour that is neither the 2D pixel's nor
|
||||
the invalid colour — measured on the shipped asset as a texel painted both valid
|
||||
and not, depending on which fragment landed on it. One texel of margin is exactly
|
||||
enough for a 2x2 filter, and the validity channel is untouched, so the painted
|
||||
*region* is still decided per texel.
|
||||
"""
|
||||
from OpenGL import GL
|
||||
|
||||
shape = (int(field.shape_hw[0]), int(field.shape_hw[1]))
|
||||
self._ensure_field_textures(shape)
|
||||
measured = np.asarray(field.scalar, dtype=np.float32)
|
||||
usable = np.asarray(field.valid, dtype=bool) & np.isfinite(measured)
|
||||
scalars = np.where(usable, measured, 0.0).astype(np.float32)
|
||||
if usable.any() and not usable.all():
|
||||
scalars = np.where(usable, scalars, _extend_edges(scalars, usable))
|
||||
valid = np.ascontiguousarray(np.asarray(field.valid, dtype=bool).astype(np.uint8) * 255)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||
GL.glTexSubImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
|
||||
GL.GL_RED, GL.GL_FLOAT, np.ascontiguousarray(scalars),
|
||||
)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||
GL.glTexSubImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
|
||||
GL.GL_RED, GL.GL_UNSIGNED_BYTE, valid,
|
||||
)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
|
||||
def _ensure_field_textures(self, shape: tuple[int, int]) -> None:
|
||||
"""Allocate the two field textures once, for one domain shape."""
|
||||
from OpenGL import GL
|
||||
|
||||
if self._field_texture and self._field_shape == shape:
|
||||
return
|
||||
self._release_field_textures()
|
||||
height, width = int(shape[0]), int(shape[1])
|
||||
if height <= 0 or width <= 0:
|
||||
raise error(
|
||||
"field_domain_mismatch", f"the field has no extent: {height}x{width}"
|
||||
)
|
||||
capabilities = self._capabilities
|
||||
if capabilities is not None and capabilities.max_texture_size:
|
||||
if width > capabilities.max_texture_size or height > capabilities.max_texture_size:
|
||||
raise error(
|
||||
"gpu_budget_exceeded",
|
||||
f"the field is {width}x{height} but this GPU takes at most "
|
||||
f"{capabilities.max_texture_size} texels per side",
|
||||
)
|
||||
self._field_texture = int(GL.glGenTextures(1))
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
|
||||
# ``R16F`` is the size the design asks for and every 3.3/ES3 context has it;
|
||||
# a driver that refuses it raises, and ``R32F`` is the documented fallback
|
||||
# rather than a silent no-texture render.
|
||||
for internal_format in (GL.GL_R16F, GL.GL_R32F):
|
||||
try:
|
||||
GL.glTexImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, internal_format, width, height, 0,
|
||||
GL.GL_RED, GL.GL_FLOAT, None,
|
||||
)
|
||||
self._field_internal_format = internal_format
|
||||
break
|
||||
except Exception: # noqa: BLE001 - a refused internal format
|
||||
while GL.glGetError() != GL.GL_NO_ERROR:
|
||||
pass
|
||||
if not self._field_internal_format:
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
self._release_field_textures()
|
||||
raise error(
|
||||
"gl_unsupported",
|
||||
"this context accepts neither R16F nor R32F for the field texture",
|
||||
)
|
||||
self._valid_texture = int(GL.glGenTextures(1))
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
|
||||
GL.glTexImage2D(
|
||||
GL.GL_TEXTURE_2D, 0, GL.GL_R8, width, height, 0,
|
||||
GL.GL_RED, GL.GL_UNSIGNED_BYTE, None,
|
||||
)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
self._field_shape = (height, width)
|
||||
|
||||
def _upload_overlays(self, frame: RenderFrame) -> None:
|
||||
"""Upload merged flow / contact arrow meshes for this frame."""
|
||||
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
|
||||
@@ -768,7 +957,9 @@ class GLRenderer:
|
||||
# panel has to be able to ask what the picture means.
|
||||
self._last_scale = (float(lower), float(upper))
|
||||
self._last_scale_mode = str(getattr(scale, "mode", "") or "")
|
||||
self._ensure_lut("diverging" if (scale and scale.descriptor.signed) else "sequential")
|
||||
self._ensure_lut(
|
||||
"diverging" if (scale and scale.descriptor.signed) else "sequential"
|
||||
)
|
||||
for part in self._parts.values():
|
||||
if part.role == "housing" and not settings.housing_visible:
|
||||
continue
|
||||
@@ -781,10 +972,7 @@ class GLRenderer:
|
||||
_set_float(GL, locations.get("u_lower"), lower)
|
||||
_set_float(GL, locations.get("u_upper"), upper)
|
||||
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
|
||||
if locations.get("u_lut") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||
GL.glUniform1i(locations["u_lut"], 0)
|
||||
self._bind_field_textures(GL, locations)
|
||||
else:
|
||||
_set_vec3(GL, locations.get("u_light_direction"), (0.3, -0.5, 0.8))
|
||||
_set_vec4(GL, locations.get("u_base_color"), _base_color(part.part_id))
|
||||
@@ -837,6 +1025,49 @@ class GLRenderer:
|
||||
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
||||
return self._last_scale
|
||||
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
"""Adopt the theme's ramp. Rebuilds the LUT at the next draw, once.
|
||||
|
||||
The ramp is the *only* thing the 3D pane needs from the theme: it is what
|
||||
makes its colour table the same table the 2D canvas paints with.
|
||||
"""
|
||||
wanted = str(ramp or "")
|
||||
if wanted != self._lut_ramp:
|
||||
self._lut_ramp = wanted
|
||||
self._lut_id = ""
|
||||
|
||||
def _bind_field_textures(self, GL: Any, locations: dict[str, int]) -> None:
|
||||
if locations.get("u_lut") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0 + LUT_TEXTURE_UNIT)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||
GL.glUniform1i(locations["u_lut"], LUT_TEXTURE_UNIT)
|
||||
if locations.get("u_field") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0 + FIELD_TEXTURE_UNIT)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||
GL.glUniform1i(locations["u_field"], FIELD_TEXTURE_UNIT)
|
||||
if locations.get("u_valid") is not None:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0 + VALID_TEXTURE_UNIT)
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||
GL.glUniform1i(locations["u_valid"], VALID_TEXTURE_UNIT)
|
||||
if locations.get("u_inv_domain_size") is not None:
|
||||
height, width = self._field_shape or (1, 1)
|
||||
GL.glUniform2f(
|
||||
locations["u_inv_domain_size"],
|
||||
1.0 / max(float(width), 1.0),
|
||||
1.0 / max(float(height), 1.0),
|
||||
)
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0)
|
||||
|
||||
@property
|
||||
def field_shape(self) -> Optional[tuple[int, int]]:
|
||||
"""The allocated field texture's ``(H, W)``, or ``None`` before the first frame."""
|
||||
return self._field_shape
|
||||
|
||||
@property
|
||||
def field_internal_format(self) -> int:
|
||||
"""The internal format the scalar texture actually got (``R16F`` or ``R32F``)."""
|
||||
return self._field_internal_format
|
||||
|
||||
def release(self) -> None:
|
||||
"""Delete every GPU resource. Idempotent; the owning context must be current."""
|
||||
from OpenGL import GL
|
||||
@@ -858,6 +1089,7 @@ class GLRenderer:
|
||||
pass
|
||||
self._lut = 0
|
||||
self._lut_id = ""
|
||||
self._release_field_textures()
|
||||
self._initialized = False
|
||||
self._estimated_gpu_bytes = 0
|
||||
self._scalar_range = None
|
||||
@@ -865,6 +1097,20 @@ class GLRenderer:
|
||||
self._last_scale_mode = ""
|
||||
self._paint_calls = 0
|
||||
|
||||
def _release_field_textures(self) -> None:
|
||||
from OpenGL import GL
|
||||
|
||||
for name in ("_field_texture", "_valid_texture"):
|
||||
handle = int(getattr(self, name))
|
||||
if handle:
|
||||
try:
|
||||
GL.glDeleteTextures([handle])
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
setattr(self, name, 0)
|
||||
self._field_shape = None
|
||||
self._field_internal_format = 0
|
||||
|
||||
def release_parts(self) -> None:
|
||||
from OpenGL import GL
|
||||
|
||||
@@ -890,7 +1136,7 @@ class GLRenderer:
|
||||
if self._lut:
|
||||
GL.glDeleteTextures([int(self._lut)])
|
||||
self._lut = 0
|
||||
data = lut_rgba(lut_id)
|
||||
data = lut_rgba(lut_id, self._lut_ramp)
|
||||
self._lut = int(GL.glGenTextures(1))
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
||||
@@ -916,17 +1162,6 @@ class GLRenderer:
|
||||
return self._initialized
|
||||
|
||||
|
||||
class _PartLayout:
|
||||
"""A minimal stand-in for :class:`MeshPart` when updating an existing buffer."""
|
||||
|
||||
__slots__ = ("part_id", "positions_sensor_m", "normals_sensor")
|
||||
|
||||
def __init__(self, part_id: str, positions: np.ndarray, normals: np.ndarray) -> None:
|
||||
self.part_id = part_id
|
||||
self.positions_sensor_m = positions
|
||||
self.normals_sensor = normals
|
||||
|
||||
|
||||
#: Base colours for non-sensing parts, by part id: the housing is grey/graphite so
|
||||
#: it never competes with the measurement colour scale.
|
||||
_PART_COLORS: dict[str, tuple[float, float, float, float]] = {
|
||||
@@ -939,15 +1174,23 @@ def _base_color(part_id: str) -> tuple[float, float, float, float]:
|
||||
|
||||
|
||||
def _bind_attributes(GL: Any) -> None:
|
||||
"""Position, normal and the UV the field texture is addressed by.
|
||||
|
||||
Attribute locations 2 and 3 are deliberately left unbound: they were the
|
||||
per-vertex scalar and validity flags, and nothing in the surface program declares
|
||||
them any more. Re-using 2 or 3 for something else later would be a silent
|
||||
reinterpretation of a slot an old shader variant could still expect, so the UV
|
||||
goes to 4 instead.
|
||||
"""
|
||||
stride = STRIDE_FLOATS * 4
|
||||
GL.glEnableVertexAttribArray(0)
|
||||
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
|
||||
GL.glEnableVertexAttribArray(1)
|
||||
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
|
||||
GL.glEnableVertexAttribArray(2)
|
||||
GL.glVertexAttribPointer(2, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(6))
|
||||
GL.glEnableVertexAttribArray(3)
|
||||
GL.glVertexAttribPointer(3, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(7))
|
||||
GL.glEnableVertexAttribArray(4)
|
||||
GL.glVertexAttribPointer(
|
||||
4, 2, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(UV_OFFSET_FLOATS)
|
||||
)
|
||||
|
||||
|
||||
def _offset(floats: int) -> Any:
|
||||
@@ -985,8 +1228,9 @@ def _compile_program(GL: Any, profile: Profile, name: str) -> int:
|
||||
|
||||
def _uniform_locations(GL: Any, program: int) -> dict[str, int]:
|
||||
names = (
|
||||
"u_view", "u_projection", "u_scalar", "u_lower", "u_upper",
|
||||
"u_invalid_color", "u_lut", "u_light_direction", "u_base_color",
|
||||
"u_view", "u_projection", "u_lower", "u_upper",
|
||||
"u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size",
|
||||
"u_light_direction", "u_base_color",
|
||||
)
|
||||
out: dict[str, int] = {}
|
||||
for name in names:
|
||||
|
||||
@@ -90,6 +90,9 @@ class ColorScaleBar(QLabel):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.setVisible(False)
|
||||
self._lut_id = "sequential"
|
||||
#: The theme's own ramp, so this legend is drawn from the same table the 2D
|
||||
#: canvas and the 3D surface are coloured by.
|
||||
self._ramp = ""
|
||||
|
||||
def set_lut(self, lut_id: str) -> None:
|
||||
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
|
||||
@@ -98,6 +101,13 @@ class ColorScaleBar(QLabel):
|
||||
self._lut_id = wanted
|
||||
self.update()
|
||||
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
wanted = str(ramp or "")
|
||||
if wanted == self._ramp:
|
||||
return
|
||||
self._ramp = wanted
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
from PySide6.QtGui import QPainter
|
||||
|
||||
@@ -105,7 +115,9 @@ class ColorScaleBar(QLabel):
|
||||
|
||||
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
||||
# which is what QImage's borrowed buffer needs.
|
||||
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
|
||||
rgba = np.ascontiguousarray(
|
||||
lut_rgba(self._lut_id, self._ramp)[:, 0, :]
|
||||
).tobytes()
|
||||
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
|
||||
painter = QPainter(self)
|
||||
painter.drawImage(self.rect(), image)
|
||||
@@ -393,6 +405,18 @@ class ThreeDPanel(QFrame):
|
||||
self._render_scale_bounds()
|
||||
self._render_legend()
|
||||
|
||||
def set_ramp(self, ramp: str) -> None:
|
||||
"""Adopt the theme's resolved colour ramp for the legend.
|
||||
|
||||
The legend and the picture are drawn from one table
|
||||
(``gl_backend.lut_rgba``), and that table is built from this ramp — the same
|
||||
string the 2D canvas colours its pixels from. Without this the 3D pane's
|
||||
legend would keep the default theme's colours after a theme switch while the
|
||||
surface (which gets the ramp on the frame it is handed) changed, which is the
|
||||
disagreement the one-ramp change exists to remove.
|
||||
"""
|
||||
self.scale_bar.set_ramp(ramp)
|
||||
|
||||
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
|
||||
"""Offer only the controls this data source can actually feed.
|
||||
|
||||
|
||||
@@ -99,6 +99,13 @@ class Scene3DView(QWidget):
|
||||
self._camera = OrbitCamera()
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._composer: Optional[SceneComposer] = None
|
||||
#: The session's island latch, shared with the 2D canvas when the window hands
|
||||
#: one over (``image_backend.DomainMaskLatch``). One latch, two views: the
|
||||
#: surface's validity texture and the canvas's island are the same mask, so
|
||||
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
|
||||
#: each frame's own mask, which is what the tests and the PoC want.
|
||||
self._domain_latch: Any = None
|
||||
self._expected: Optional[ExpectedGeometry] = None
|
||||
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
|
||||
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
|
||||
@@ -136,8 +143,9 @@ class Scene3DView(QWidget):
|
||||
|
||||
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
|
||||
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
|
||||
self._composer = SceneComposer()
|
||||
self._coordinator = ThreeDCoordinator(
|
||||
self._widget, SceneComposer(), self._executor, max_fps=30
|
||||
self._widget, self._composer, self._executor, max_fps=30
|
||||
)
|
||||
self._panel = ThreeDPanel(self._coordinator, self._widget)
|
||||
# The picture must not be squeezed out of existence by the controls above it:
|
||||
@@ -253,8 +261,7 @@ class Scene3DView(QWidget):
|
||||
asset_id = str(getattr(assets, "asset_id", "") or "")
|
||||
self._adapter = None
|
||||
device = expected
|
||||
common = {
|
||||
"geometry_id": str(
|
||||
common = { "geometry_id": str(
|
||||
device.geometry_id if device is not None and device.geometry_id
|
||||
else assets.geometry_id
|
||||
),
|
||||
@@ -285,9 +292,44 @@ class Scene3DView(QWidget):
|
||||
# (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
|
||||
# passed here. Remapping it again would index the flow at source pixels.
|
||||
adapter = FingerFrameAdapter(**common)
|
||||
if self._domain_latch is not None:
|
||||
# The window's own latch, so the island the 2D canvas paints and the island
|
||||
# the surface's validity texture carries are one decision, taken once.
|
||||
adapter.domain_latch = self._domain_latch
|
||||
self._adapter = adapter
|
||||
return lambda packet, identity: adapter.snapshot(packet, None, identity)
|
||||
|
||||
def set_domain_latch(self, latch: Any) -> None:
|
||||
"""Share the 2D canvas's island latch with this pane's adapter.
|
||||
|
||||
``image_backend.DomainMaskLatch`` is *upstream of both views*: the canvas paints
|
||||
the latched boundary and the adapter marks exactly that boundary valid, so
|
||||
"which pixels carry a measurement" is one answer rather than two that happen to
|
||||
agree. Called before the scene is resolved in practice (the window builds the
|
||||
latch first); re-binding an already-built adapter updates it in place.
|
||||
"""
|
||||
self._domain_latch = latch
|
||||
if self._adapter is not None and hasattr(self._adapter, "domain_latch"):
|
||||
self._adapter.domain_latch = latch
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""Adopt the application theme's colour ramp.
|
||||
|
||||
The 2D canvas colours its pixels from ``arrows.flow_colormap(theme)``; the 3D
|
||||
surface is coloured from whatever ramp the field it draws carries. Setting both
|
||||
from this one call is what makes them the *same* table rather than two tables
|
||||
that were written to look alike.
|
||||
"""
|
||||
from orisys_app_shared.acquisition.arrows import ramp_for_theme
|
||||
|
||||
ramp = ramp_for_theme(theme)
|
||||
if self._composer is not None:
|
||||
self._composer.ramp = ramp
|
||||
if self._panel is not None:
|
||||
self._panel.set_ramp(ramp)
|
||||
if self._coordinator is not None:
|
||||
self._coordinator.set_ramp(ramp)
|
||||
|
||||
# --------------------------------------------------------------- producer
|
||||
def submit_packet(self, packet: Any) -> None:
|
||||
"""Called from the acquisition thread. Must stay O(1) and Qt-free."""
|
||||
|
||||
@@ -109,7 +109,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
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.field.descriptor)
|
||||
self.update()
|
||||
|
||||
def set_settings(self, settings: DisplaySettings) -> None:
|
||||
@@ -216,11 +216,11 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
)
|
||||
)
|
||||
if self._assets is not None:
|
||||
self._renderer.update_surface(
|
||||
self._renderer.update_field(
|
||||
rest_surface(self._assets, frame.identity)
|
||||
)
|
||||
else:
|
||||
self._renderer.update_surface(frame)
|
||||
self._renderer.update_field(frame)
|
||||
stats = self._renderer.draw(
|
||||
self._camera.matrices(self.viewport_pixels()),
|
||||
self._settings,
|
||||
|
||||
@@ -111,6 +111,9 @@ class Mesh3dPanel(QFrame):
|
||||
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
|
||||
#: until the view exists (see ``set_diagnostics_visible``).
|
||||
self._show_diagnostics = False
|
||||
#: The session's island latch, shared with the 2D canvas (see
|
||||
#: ``set_domain_latch``). Held until the view exists.
|
||||
self._domain_latch = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -189,9 +192,26 @@ class Mesh3dPanel(QFrame):
|
||||
panel.set_diagnostics_visible(self._show_diagnostics)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""Re-tint the header icons: they are painted from the theme's own colours."""
|
||||
"""Re-tint the header icons, and colour the picture from the theme's ramp.
|
||||
|
||||
The ramp half is not cosmetic: the 2D canvas is coloured from
|
||||
``arrows.flow_colormap(theme)``, so the 3D surface has to be coloured from the
|
||||
same string or the two panes show one measurement in two colours.
|
||||
"""
|
||||
self._theme = str(theme)
|
||||
self._apply_icons()
|
||||
if self._view is not None and hasattr(self._view, "set_theme"):
|
||||
self._view.set_theme(self._theme)
|
||||
|
||||
def set_domain_latch(self, latch) -> None:
|
||||
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
|
||||
|
||||
Held here as well as pushed, because the view is built on first use: a latch
|
||||
handed over before then must still reach the adapter when the view appears.
|
||||
"""
|
||||
self._domain_latch = latch
|
||||
if self._view is not None and hasattr(self._view, "set_domain_latch"):
|
||||
self._view.set_domain_latch(latch)
|
||||
|
||||
def _apply_icons(self) -> None:
|
||||
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
||||
@@ -267,6 +287,16 @@ class Mesh3dPanel(QFrame):
|
||||
self._stack.addWidget(view)
|
||||
view.diagnosticRaised.connect(self._on_diagnostic)
|
||||
view.enabledChanged.connect(self._on_view_enabled)
|
||||
# The theme and the session's island latch were decided before the view existed
|
||||
# (the view is built on first use), so they are pushed again here rather than
|
||||
# lost — exactly the treatment ``_pending_settings`` and ``_source_kind`` get,
|
||||
# and for the same reason: the picture must be coloured by the same ramp, and
|
||||
# bounded by the same island, as the 2D canvas from its first frame. Guarded the
|
||||
# same way as ``panel`` above, because a test double is a view too.
|
||||
if hasattr(view, "set_theme"):
|
||||
view.set_theme(self._theme)
|
||||
if self._domain_latch is not None and hasattr(view, "set_domain_latch"):
|
||||
view.set_domain_latch(self._domain_latch)
|
||||
if self._pending_settings is not None:
|
||||
view.restore_settings(self._pending_settings)
|
||||
# The kind was decided before the view existed (the window sets it when the
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user