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

This commit is contained in:
ykcheungab
2026-09-27 10:42:38 +08:00
parent 3daee6d407
commit bcab2790bf
24 changed files with 1971 additions and 530 deletions
@@ -254,6 +254,11 @@ class StudioMainWindow(QMainWindow):
self.content_splitter = QSplitter(Qt.Orientation.Horizontal) self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
self.mesh3d_panel = Mesh3dPanel(theme=self._theme) 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.arrow_panel = ArrowPanel(theme=self._theme)
self.force_plots = ForcePlotsPanel(theme=self._theme) self.force_plots = ForcePlotsPanel(theme=self._theme)
# The charts browse the whole session, not just their rolling buffer. # 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( 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]]: ) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
"""The device's own validity, or a diagnostic when it describes another grid. """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 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 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. 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) explicit = getattr(packet, "valid_mask", None)
if explicit is None: if explicit is None:
@@ -161,6 +170,10 @@ def _device_mask(
mask=_shape_of(array), mask=_shape_of(array),
domain=f"{int(shape_hw[0])}x{int(shape_hw[1])}", 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 return array, None
@@ -224,6 +237,11 @@ class FingerFrameAdapter:
#: Set (and cleared) by every snapshot: the reason the picture is not the field #: 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. #: the operator chose. ``None`` when the preferred field was the one drawn.
self.last_substitution: Optional[Diagnostic] = None 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: def set_preferred_field(self, name: str) -> None:
self.preferred_field = _field_name(name) self.preferred_field = _field_name(name)
@@ -387,7 +405,7 @@ class FingerFrameAdapter:
"""Intersect with the device's own mask and build the frame.""" """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 # Inside the UV island is the only place a value means anything: the tracker is
# looking at masked black outside it. # 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: if mask_problem is not None:
refused.append(mask_problem) refused.append(mask_problem)
return None return None
@@ -407,7 +425,7 @@ class FingerFrameAdapter:
arr = np.asarray(flow, dtype=np.float32) arr = np.asarray(flow, dtype=np.float32)
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw: if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
flow_uv = freeze(arr, dtype=np.float32) 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) 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) fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
if not np.isfinite(fn): if not np.isfinite(fn):
@@ -452,6 +470,8 @@ class BoxFrameAdapter:
self.field_domain_id = str(field_domain_id) self.field_domain_id = str(field_domain_id)
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1])) self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
self.last_diagnostic: Optional[Diagnostic] = None self.last_diagnostic: Optional[Diagnostic] = None
#: See ``FingerFrameAdapter.domain_latch``: one island, both views.
self.domain_latch: Any = None
def snapshot( def snapshot(
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
@@ -468,7 +488,7 @@ class BoxFrameAdapter:
("depth", getattr(packet, "depth", None)), ("depth", getattr(packet, "depth", None)),
("flow", flow_magnitude(packet)), ("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: if mask_problem is not None:
self.last_diagnostic = mask_problem self.last_diagnostic = mask_problem
return None return None
@@ -452,7 +452,7 @@ class ThreeDCoordinator:
# A frame arrived with its own fields: whatever went stale is current # A frame arrived with its own fields: whatever went stale is current
# again, and the grace starts over next time it fails. # again, and the grace starts over next time it fails.
self._stale_since = None 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"): elif self._state in ("ready", "loading"):
# Nothing has arrived yet: show the reference model rather than a blank # Nothing has arrived yet: show the reference model rather than a blank
# window, and say that no measurement is being displayed. Only once per # 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. # held field frame so the operator sees the change without waiting.
self._settings_dirty = True 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: def show_rest(self) -> None:
"""Put the reference model back on screen and stop claiming a measurement. """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 * :meth:`GLRenderer.release` is idempotent and requires the owning context to be
current — resources are freed by the context that made them. 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 Two parts, two programs, one draw call per part. The sensing surface's colour comes
per frame and its colour comes from ``scalar`` through a LUT texture with the range from the *field texture* — the acquisition frame's own ``[H,W]`` grid, uploaded with
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded ``glTexSubImage2D`` and read per fragment through the mesh's UVs — so Python never
once and never touched again. A frame with no measurement (``valid == 0``) is drawn builds an RGB array per frame and never gathers the field onto the vertices; the
in the neutral colour rather than in colour zero — the reference model is not a vertex buffer is uploaded once and re-uploaded only when a display deformation
measurement and must not look like one. 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 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 is enough for a sequential ramp and cheap to rebuild.
LUT_SIZE = 256 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: def configure_surface_format(profile: Profile = "desktop33") -> None:
"""Select the default GL surface format. Call before ``QApplication``. """Select the default GL surface format. Call before ``QApplication``.
@@ -112,33 +124,42 @@ def profile_from_renderable_type(value: int) -> Profile:
# --------------------------------------------------------------------- shaders # --------------------------------------------------------------------- shaders
#: Shared bodies: the version header is the only difference, so the maths cannot #: Shared bodies: the version header is the only difference, so the maths cannot
#: drift between the desktop and the embedded build. #: 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 = """ _VERTEX_BODY = """
layout(location = 0) in vec3 a_position; layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal; layout(location = 1) in vec3 a_normal;
layout(location = 2) in float a_scalar; layout(location = 4) in vec2 a_uv;
layout(location = 3) in float a_valid;
uniform mat4 u_view; uniform mat4 u_view;
uniform mat4 u_projection; uniform mat4 u_projection;
out vec3 v_normal; out vec3 v_normal;
out float v_scalar; out vec2 v_uv;
out float v_valid;
void main() { void main() {
v_normal = a_normal; v_normal = a_normal;
v_scalar = a_scalar; v_uv = a_uv;
v_valid = a_valid;
gl_Position = u_projection * u_view * vec4(a_position, 1.0); gl_Position = u_projection * u_view * vec4(a_position, 1.0);
} }
""" """
_SCALAR_FRAGMENT_BODY = """ _SCALAR_FRAGMENT_BODY = """
in vec3 v_normal; in vec3 v_normal;
in float v_scalar; in vec2 v_uv;
in float v_valid;
uniform sampler2D u_lut; 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_lower;
uniform float u_upper; uniform float u_upper;
uniform vec4 u_invalid_color; uniform vec4 u_invalid_color;
@@ -146,20 +167,25 @@ uniform vec4 u_invalid_color;
out vec4 frag_color; out vec4 frag_color;
void main() { 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; frag_color = u_invalid_color;
return; return;
} }
float span = max(u_upper - u_lower, 1e-12); 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); frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0);
} }
""" """
_DIFFUSE_FRAGMENT_BODY = """ _DIFFUSE_FRAGMENT_BODY = """
in vec3 v_normal; in vec3 v_normal;
in float v_scalar;
in float v_valid;
uniform vec3 u_light_direction; uniform vec3 u_light_direction;
uniform vec4 u_base_color; uniform vec4 u_base_color;
@@ -310,106 +336,134 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
return lower, upper return lower, upper
def lut_rgba(lut_id: str = "sequential") -> np.ndarray: def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
"""A 256x1 RGBA8 lookup table. """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 ``ramp`` is the resolved ``#RRGGBB,#RRGGBB,...`` string the field carries
dependency for one texture, and the ramp is part of what the colours *mean*. (``acquisition.arrows.ramp_for_theme``); empty means this build's default ramp,
``sequential`` is perceptually increasing in lightness (safe for magnitude); which is the dark theme's own. Either way the table is the *same* array the 2D
``diverging`` is the same ramp mirrored through a neutral midpoint (safe for a canvas colours its pixels with — ``arrows.ramp_rgb_table`` is the single
signed quantity, and never used for an unsigned one). 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( from orisys_app_shared.acquisition.arrows import ramp_rgba_table
[
[0.267, 0.005, 0.329], return ramp_rgba_table(str(ramp or DEFAULT_FIELD_RAMP), diverging=lut_id == "diverging")
[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
# ---------------------------------------------------------------- vertex packing # ---------------------------------------------------------------- vertex packing
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + scalar(1) #: Floats per vertex in the interleaved buffer: position(3) + normal(3) + uv(2). One
#: + valid(1). One buffer per part, so a per-frame upload is one glBufferSubData. #: 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 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, #: Texture units. The LUT keeps unit 0; the field and its validity get their own so a
valid: Optional[np.ndarray] = None) -> np.ndarray: #: 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. """Interleave one part into the layout the shaders read.
The static case (no scalar yet) marks every vertex invalid, which the shader ``uvs`` are the render-vertex UVs on the domain's pixel grid
draws as the neutral colour: a scene that has never received a measurement (``SurfaceBinding.uvs``), in the same order as ``part.positions_sensor_m``. A part
must not be coloured as if it had. 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]) count = int(part.positions_sensor_m.shape[0])
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32) rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
rows[:, 0:3] = np.asarray(part.positions_sensor_m, 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) rows[:, 3:6] = np.asarray(part.normals_sensor, dtype=np.float32)
if scalar is None: if uvs is None:
rows[:, 6] = np.nan rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 0.0
else: else:
values = np.asarray(scalar, dtype=np.float32).reshape(-1) values = np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
if values.size != count: if values.shape[0] != count:
raise error( raise error(
"geometry_mismatch", "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 rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 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)
return freeze(rows, dtype=np.float32) 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.""" """``(vertices, indices)`` as the GPU wants them, both read-only."""
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1) 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: 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 total = 0
for part in assets.parts: for part in assets.parts:
vertices = int(part.positions_sensor_m.shape[0]) vertices = int(part.positions_sensor_m.shape[0])
indices = int(part.triangles.size) indices = int(part.triangles.size)
total += vertices * STRIDE_FLOATS * 4 + indices * 4 total += vertices * STRIDE_FLOATS * 4 + indices * 4
if part.part_id == dynamic_part_id: 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 += vertices * STRIDE_FLOATS * 4
total += LUT_SIZE * 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 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. """The asset's own geometry, with nothing measured.
Used to *clear* a measurement — after a refused frame, on connect, or on a 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 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 # ------------------------------------------------------------------- dispatch
@@ -543,9 +597,13 @@ def _leading_int(text: str) -> int:
class _PartBuffers: class _PartBuffers:
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games.""" """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.part_id = part.part_id
self.role = part.role self.role = part.role
self.vao = vao self.vao = vao
@@ -553,6 +611,9 @@ class _PartBuffers:
self.ebo = ebo self.ebo = ebo
self.triangles = int(part.triangles.shape[0]) self.triangles = int(part.triangles.shape[0])
self.vertices = int(part.positions_sensor_m.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: class GLRenderer:
@@ -565,7 +626,15 @@ class GLRenderer:
self._parts: dict[str, _PartBuffers] = {} self._parts: dict[str, _PartBuffers] = {}
self._lut: int = 0 self._lut: int = 0
self._lut_id: str = "" 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. #: automatic colour scale. ``None`` until a frame carries valid data.
self._scalar_range: Optional[tuple[float, float]] = None self._scalar_range: Optional[tuple[float, float]] = None
#: The colour bounds of the last draw and how they were chosen. #: The colour bounds of the last draw and how they were chosen.
@@ -621,8 +690,11 @@ class GLRenderer:
self._scalar_range = None self._scalar_range = None
self._last_scale = None self._last_scale = None
self._last_scale_mode = "" self._last_scale_mode = ""
uvs = np.asarray(assets.binding.uvs, dtype=np.float32)
for part in assets.parts: 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)) vao = int(GL.glGenVertexArrays(1))
vbo = int(GL.glGenBuffers(1)) vbo = int(GL.glGenBuffers(1))
ebo = int(GL.glGenBuffers(1)) ebo = int(GL.glGenBuffers(1))
@@ -639,13 +711,24 @@ class GLRenderer:
GL.glBindVertexArray(0) GL.glBindVertexArray(0)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ELEMENT_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( self._estimated_gpu_bytes = estimate_gpu_bytes(
assets, dynamic_part_id=self._sensing_part_id assets, dynamic_part_id=self._sensing_part_id
) )
def update_surface(self, frame: RenderFrame) -> None: def update_field(self, frame: RenderFrame) -> None:
"""Update the sensing part's vertices in place: no index rebuild, ever.""" """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 from OpenGL import GL
self._require_initialized() self._require_initialized()
@@ -655,26 +738,132 @@ class GLRenderer:
"geometry_mismatch", "geometry_mismatch",
f"the scene has no part {frame.sensing_part_id!r} to update", f"the scene has no part {frame.sensing_part_id!r} to update",
) )
rows = pack_part( field = frame.field
_PartLayout(frame.sensing_part_id, frame.positions_sensor_m, frame.normals_sensor), if int(buffers.vertices) != int(frame.positions_sensor_m.shape[0]):
scalar=frame.scalar,
valid=frame.valid,
)
if int(rows.shape[0]) != buffers.vertices:
raise error( raise error(
"geometry_mismatch", "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", f"{buffers.vertices} uploaded",
) )
started = time.perf_counter() started = time.perf_counter()
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo) # The field carries the theme's ramp, so the lookup table follows the same
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows) # string the 2D canvas built its table from. Checked here rather than plumbed
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) # 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 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._scalar_range = _blend_scale_range(self._scalar_range, measured)
self._upload_overlays(frame) 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: def _upload_overlays(self, frame: RenderFrame) -> None:
"""Upload merged flow / contact arrow meshes for this frame.""" """Upload merged flow / contact arrow meshes for this frame."""
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh 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. # panel has to be able to ask what the picture means.
self._last_scale = (float(lower), float(upper)) self._last_scale = (float(lower), float(upper))
self._last_scale_mode = str(getattr(scale, "mode", "") or "") 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(): for part in self._parts.values():
if part.role == "housing" and not settings.housing_visible: if part.role == "housing" and not settings.housing_visible:
continue continue
@@ -781,10 +972,7 @@ class GLRenderer:
_set_float(GL, locations.get("u_lower"), lower) _set_float(GL, locations.get("u_lower"), lower)
_set_float(GL, locations.get("u_upper"), upper) _set_float(GL, locations.get("u_upper"), upper)
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR) _set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
if locations.get("u_lut") is not None: self._bind_field_textures(GL, locations)
GL.glActiveTexture(GL.GL_TEXTURE0)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
GL.glUniform1i(locations["u_lut"], 0)
else: else:
_set_vec3(GL, locations.get("u_light_direction"), (0.3, -0.5, 0.8)) _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)) _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).""" """The colour bounds the last draw actually used (``None`` before any draw)."""
return self._last_scale 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: def release(self) -> None:
"""Delete every GPU resource. Idempotent; the owning context must be current.""" """Delete every GPU resource. Idempotent; the owning context must be current."""
from OpenGL import GL from OpenGL import GL
@@ -858,6 +1089,7 @@ class GLRenderer:
pass pass
self._lut = 0 self._lut = 0
self._lut_id = "" self._lut_id = ""
self._release_field_textures()
self._initialized = False self._initialized = False
self._estimated_gpu_bytes = 0 self._estimated_gpu_bytes = 0
self._scalar_range = None self._scalar_range = None
@@ -865,6 +1097,20 @@ class GLRenderer:
self._last_scale_mode = "" self._last_scale_mode = ""
self._paint_calls = 0 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: def release_parts(self) -> None:
from OpenGL import GL from OpenGL import GL
@@ -890,7 +1136,7 @@ class GLRenderer:
if self._lut: if self._lut:
GL.glDeleteTextures([int(self._lut)]) GL.glDeleteTextures([int(self._lut)])
self._lut = 0 self._lut = 0
data = lut_rgba(lut_id) data = lut_rgba(lut_id, self._lut_ramp)
self._lut = int(GL.glGenTextures(1)) self._lut = int(GL.glGenTextures(1))
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut) GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR) GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
@@ -916,17 +1162,6 @@ class GLRenderer:
return self._initialized 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 #: Base colours for non-sensing parts, by part id: the housing is grey/graphite so
#: it never competes with the measurement colour scale. #: it never competes with the measurement colour scale.
_PART_COLORS: dict[str, tuple[float, float, float, float]] = { _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: 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 stride = STRIDE_FLOATS * 4
GL.glEnableVertexAttribArray(0) GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None) GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
GL.glEnableVertexAttribArray(1) GL.glEnableVertexAttribArray(1)
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3)) GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
GL.glEnableVertexAttribArray(2) GL.glEnableVertexAttribArray(4)
GL.glVertexAttribPointer(2, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(6)) GL.glVertexAttribPointer(
GL.glEnableVertexAttribArray(3) 4, 2, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(UV_OFFSET_FLOATS)
GL.glVertexAttribPointer(3, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(7)) )
def _offset(floats: int) -> Any: 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]: def _uniform_locations(GL: Any, program: int) -> dict[str, int]:
names = ( names = (
"u_view", "u_projection", "u_scalar", "u_lower", "u_upper", "u_view", "u_projection", "u_lower", "u_upper",
"u_invalid_color", "u_lut", "u_light_direction", "u_base_color", "u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size",
"u_light_direction", "u_base_color",
) )
out: dict[str, int] = {} out: dict[str, int] = {}
for name in names: for name in names:
+25 -1
View File
@@ -90,6 +90,9 @@ class ColorScaleBar(QLabel):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setVisible(False) self.setVisible(False)
self._lut_id = "sequential" 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: def set_lut(self, lut_id: str) -> None:
wanted = "diverging" if str(lut_id) == "diverging" else "sequential" wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
@@ -98,6 +101,13 @@ class ColorScaleBar(QLabel):
self._lut_id = wanted self._lut_id = wanted
self.update() 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 def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
from PySide6.QtGui import QPainter 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, # (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
# which is what QImage's borrowed buffer needs. # 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) image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
painter = QPainter(self) painter = QPainter(self)
painter.drawImage(self.rect(), image) painter.drawImage(self.rect(), image)
@@ -393,6 +405,18 @@ class ThreeDPanel(QFrame):
self._render_scale_bounds() self._render_scale_bounds()
self._render_legend() 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: def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
"""Offer only the controls this data source can actually feed. """Offer only the controls this data source can actually feed.
+45 -3
View File
@@ -99,6 +99,13 @@ class Scene3DView(QWidget):
self._camera = OrbitCamera() self._camera = OrbitCamera()
self._executor: Optional[ThreadPoolExecutor] = None self._executor: Optional[ThreadPoolExecutor] = None
self._assets: Optional[SceneAssets] = 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._expected: Optional[ExpectedGeometry] = None
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused, #: 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._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
self._widget = Sensor3DWidget(GLRenderer(), self._camera) self._widget = Sensor3DWidget(GLRenderer(), self._camera)
self._composer = SceneComposer()
self._coordinator = ThreeDCoordinator( 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) self._panel = ThreeDPanel(self._coordinator, self._widget)
# The picture must not be squeezed out of existence by the controls above it: # 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 "") asset_id = str(getattr(assets, "asset_id", "") or "")
self._adapter = None self._adapter = None
device = expected device = expected
common = { common = { "geometry_id": str(
"geometry_id": str(
device.geometry_id if device is not None and device.geometry_id device.geometry_id if device is not None and device.geometry_id
else assets.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 # (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
# passed here. Remapping it again would index the flow at source pixels. # passed here. Remapping it again would index the flow at source pixels.
adapter = FingerFrameAdapter(**common) 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 self._adapter = adapter
return lambda packet, identity: adapter.snapshot(packet, None, identity) 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 # --------------------------------------------------------------- producer
def submit_packet(self, packet: Any) -> None: def submit_packet(self, packet: Any) -> None:
"""Called from the acquisition thread. Must stay O(1) and Qt-free.""" """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._last_frame_identity = frame.identity
self._unique_frames += 1 self._unique_frames += 1
self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0) 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() self.update()
def set_settings(self, settings: DisplaySettings) -> None: def set_settings(self, settings: DisplaySettings) -> None:
@@ -216,11 +216,11 @@ class Sensor3DWidget(QOpenGLWidget):
) )
) )
if self._assets is not None: if self._assets is not None:
self._renderer.update_surface( self._renderer.update_field(
rest_surface(self._assets, frame.identity) rest_surface(self._assets, frame.identity)
) )
else: else:
self._renderer.update_surface(frame) self._renderer.update_field(frame)
stats = self._renderer.draw( stats = self._renderer.draw(
self._camera.matrices(self.viewport_pixels()), self._camera.matrices(self.viewport_pixels()),
self._settings, self._settings,
@@ -111,6 +111,9 @@ class Mesh3dPanel(QFrame):
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here #: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
#: until the view exists (see ``set_diagnostics_visible``). #: until the view exists (see ``set_diagnostics_visible``).
self._show_diagnostics = False 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 = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
@@ -189,9 +192,26 @@ class Mesh3dPanel(QFrame):
panel.set_diagnostics_visible(self._show_diagnostics) panel.set_diagnostics_visible(self._show_diagnostics)
def set_theme(self, theme: str) -> None: 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._theme = str(theme)
self._apply_icons() 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: def _apply_icons(self) -> None:
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE) size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
@@ -267,6 +287,16 @@ class Mesh3dPanel(QFrame):
self._stack.addWidget(view) self._stack.addWidget(view)
view.diagnosticRaised.connect(self._on_diagnostic) view.diagnosticRaised.connect(self._on_diagnostic)
view.enabledChanged.connect(self._on_view_enabled) 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: if self._pending_settings is not None:
view.restore_settings(self._pending_settings) view.restore_settings(self._pending_settings)
# The kind was decided before the view existed (the window sets it when the # The kind was decided before the view existed (the window sets it when the
+32 -26
View File
@@ -132,7 +132,7 @@ def test_activating_a_scene_bumps_the_generation_and_shows_the_reference_model(s
assert coordinator.generation == 1 assert coordinator.generation == 1
assert widget.scenes == [scene] assert widget.scenes == [scene]
assert len(widget.frames) == 1, "the reference model must be shown immediately" 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") second = coordinator.activate(scene, "live-B")
assert second == 2 and coordinator.generation == 2 assert second == 2 and coordinator.generation == 2
# A frame from the previous scene is now old news. # 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) _drain(coordinator)
assert len(widget.frames) >= 2 assert len(widget.frames) >= 2
last = widget.frames[-1] 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" assert coordinator.state == "live"
coordinator.close() 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)) coordinator.submit(_frame(scene, valid=False))
_drain(coordinator) _drain(coordinator)
assert coordinator.state == "no_data" assert coordinator.state == "no_data"
assert not np.asarray(widget.frames[-1].valid).any() # Absence is the *validity mask*, not a rewrite of the measurement: the field
assert np.isnan(np.asarray(widget.frames[-1].scalar)).all() # 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() 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 coordinator.activate(scene, "live-A") # generation 2 while it runs
before = len(widget.frames) before = len(widget.frames)
_drain(coordinator, ticks=20, sleep=0.02) _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 not composed, "a frame composed for the previous scene was presented"
assert coordinator.stale_frames >= 0 assert coordinator.stale_frames >= 0
coordinator.close() coordinator.close()
@@ -329,7 +335,7 @@ def test_a_new_session_is_refused_until_it_is_announced(scene, pool):
before = coordinator.generation before = coordinator.generation
coordinator.begin_session() coordinator.begin_session()
assert coordinator.generation == before + 1 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" "a new session must not leave the previous session's measurement on screen"
) )
assert coordinator.submit( 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")) coordinator.submit(_frame(scene, source="replay-A"))
_drain(coordinator, ticks=10) _drain(coordinator, ticks=10)
assert coordinator.state in ("live", "no_data") 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() coordinator.show_rest()
last = widget.frames[-1] last = widget.frames[-1]
assert not last.valid.any(), "the old measurement is still on screen" assert not last.field.valid.any(), "the old measurement is still on screen"
assert last.scalar_descriptor.quantity == "unavailable" assert last.field.descriptor.quantity == "unavailable"
assert coordinator.state == "no_data" assert coordinator.state == "no_data"
# And it is a no-op when there is nothing to show or the view is off. # 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.submit(_frame(scene, source="replay-A", value=7.0))
coordinator.show_rest() coordinator.show_rest()
_drain(coordinator, ticks=4, sleep=0.01) _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" "a frame that was in flight when the pass stopped was presented anyway"
) )
coordinator.close() coordinator.close()
@@ -497,14 +503,14 @@ def test_a_compose_running_when_the_pass_stopped_can_never_come_back(scene, pool
coordinator.show_rest() coordinator.show_rest()
assert coordinator.state == "no_data" 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() composer.release.set()
_drain(coordinator, ticks=40, sleep=0.01) _drain(coordinator, ticks=40, sleep=0.01)
assert coordinator.epoch == epoch + 1 assert coordinator.epoch == epoch + 1
assert coordinator._inflight is None, "the finished work still owns the slot" 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" "the stopped pass put its measurement back on screen"
) )
assert coordinator.state != "live" 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) time.sleep(0.005)
coordinator.set_visible(True) coordinator.set_visible(True)
_drain(coordinator, ticks=40, sleep=0.01) _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" "a compose started before the view was hidden painted after it came back"
) )
assert coordinator.state != "live" 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): def test_a_compose_running_for_the_old_session_is_dropped_by_begin_session(scene, pool):
composer, widget, coordinator = _start_blocked_compose(scene, pool) composer, widget, coordinator = _start_blocked_compose(scene, pool)
coordinator.begin_session() coordinator.begin_session()
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable" assert widget.frames[-1].field.descriptor.quantity == "unavailable"
composer.release.set() composer.release.set()
_drain(coordinator, ticks=40, sleep=0.01) _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" "the previous session's compose was presented into the new session"
) )
coordinator.close() coordinator.close()
@@ -563,7 +569,7 @@ def test_a_compose_cancelled_before_it_started_is_not_an_error(scene):
executor.run_next() executor.run_next()
coordinator.tick() coordinator.tick()
assert coordinator.state == "live" 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() coordinator.close()
@@ -586,9 +592,9 @@ def test_repeated_start_and_stop_never_leaves_an_old_measurement(scene, pool):
) )
coordinator.tick() coordinator.tick()
coordinator.show_rest() 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) _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 coordinator.state != "live"
assert composer.max_in_flight == 1 assert composer.max_in_flight == 1
coordinator.close() coordinator.close()
@@ -616,7 +622,7 @@ def test_an_older_request_id_cannot_move_the_picture_backwards(scene):
coordinator.tick() coordinator.tick()
coordinator.tick() coordinator.tick()
assert widget.frames[-1].identity.request_id == 2, "an older frame was shown" 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 assert coordinator.stale_frames == 1
coordinator.close() coordinator.close()
@@ -637,7 +643,7 @@ def test_a_result_from_an_older_request_is_dropped_on_arrival(scene):
assert coordinator.submit(newer) == "accepted" assert coordinator.submit(newer) == "accepted"
executor.run_next() # request 1's compose finishes late executor.run_next() # request 1's compose finishes late
_drain(coordinator, ticks=6, sleep=0.0) _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" "a result for a superseded request was presented"
) )
coordinator.close() coordinator.close()
@@ -688,7 +694,7 @@ def test_a_stale_array_is_never_presented_as_the_current_instant(scene):
coordinator.tick() coordinator.tick()
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" "a stale array was painted as the current instant"
) )
assert all(frame.identity.frame_id != 5 for frame in widget.frames[presented:]) 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()
coordinator.tick() coordinator.tick()
assert coordinator.state == "live" 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 producer cannot build a field any more: the picture stays for the grace, but
# the pane must stop calling it live immediately. # the pane must stop calling it live immediately.
coordinator.note_field_failure(_stale_diagnostic()) coordinator.note_field_failure(_stale_diagnostic())
assert coordinator.state == "stale" assert coordinator.state == "stale"
coordinator.tick() 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" "a single refused frame blanked the model"
) )
assert coordinator.state == "stale" assert coordinator.state == "stale"
clock.advance(1.5) clock.advance(1.5)
coordinator.tick() coordinator.tick()
assert not widget.frames[-1].valid.any(), "the expired measurement is still on screen" assert not widget.frames[-1].field.valid.any(), "the expired measurement is still on screen"
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable" assert widget.frames[-1].field.descriptor.quantity == "unavailable"
assert coordinator.state == "no_data" assert coordinator.state == "no_data"
coordinator.close() coordinator.close()
@@ -746,7 +752,7 @@ def test_a_good_frame_after_a_failure_goes_back_to_live(scene):
coordinator.tick() coordinator.tick()
coordinator.tick() coordinator.tick()
assert coordinator.state == "live" 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() coordinator.close()
+99 -51
View File
@@ -27,6 +27,7 @@ import pytest
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
ColorScale, ColorScale,
DisplayField,
DisplaySettings, DisplaySettings,
FieldDescriptor, FieldDescriptor,
FrameIdentity, FrameIdentity,
@@ -44,6 +45,48 @@ pytestmark = pytest.mark.skipif(
BACKGROUND_RGB = np.array([15, 18, 23]) 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: def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
"""Let Qt paint until ``predicate`` holds. Returns whether it did.""" """Let Qt paint until ``predicate`` holds. Returns whether it did."""
import time import time
@@ -123,6 +166,13 @@ def _plate_is_grey(pixels: np.ndarray) -> float:
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean()) 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): def test_the_capabilities_come_from_a_real_context(gl_widget):
widget, _, _ = gl_widget widget, _, _ = gl_widget
capabilities = widget.capabilities() 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): 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. """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 Asserted as a *colour identity*, not as a hue difference: the valid path must draw
end is not. Counting "grey pixels" over the whole frame would just be counting the lookup table's own colour for the value it was given, and the neutral path must
the background. 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 widget, _, _ = gl_widget
assets = synthetic.poc_scene() assets = synthetic.poc_scene()
count = int(assets.sensing_part().positions_sensor_m.shape[0])
widget.set_settings(DisplaySettings(housing_visible=False)) 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( value = 0.05
identity=FrameIdentity("poc", 1, 5, 0.5, generation=1), frame = _frame(assets, np.full(assets.shape_hw, value, dtype=np.float32))
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=assets.sensing_part().part_id,
positions_sensor_m=assets.sensing_part().positions_sensor_m,
normals_sensor=assets.sensing_part().normals_sensor,
scalar=freeze(np.full(count, 0.05, dtype=np.float32), dtype=np.float32),
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
)
widget.set_settings( widget.set_settings(
DisplaySettings( DisplaySettings(
housing_visible=False, housing_visible=False,
color_scale=ColorScale( color_scale=ColorScale(
descriptor=frame.scalar_descriptor, descriptor=frame.field.descriptor,
lower=0.0, lower=0.0,
upper=1.0, upper=1.0,
lut_id="sequential", 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) 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})" # Where the fragment shader samples the table: ``vec2(t, 0.5)`` on a 256x1 texture
assert measured_grey > rest_grey + 10.0, ( # with linear filtering, i.e. ``t * 256 - 0.5`` clamped into the table.
"the colour map made no difference: the invalid path is not being taken " table = lut_rgba("sequential")[:, 0, :3].astype(float)
f"(rest r-g={rest_grey:.1f}, measured r-g={measured_grey:.1f})" 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)) 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]) count = int(assets.sensing_part().positions_sensor_m.shape[0])
positions = np.asarray(assets.sensing_part().positions_sensor_m).copy() positions = np.asarray(assets.sensing_part().positions_sensor_m).copy()
positions[:, 2] = 0.004 # a visible bump positions[:, 2] = 0.004 # a visible bump
frame = RenderFrame( frame = _frame(
identity=FrameIdentity("poc", 1, 6, 0.6, generation=1), assets,
asset_hash=assets.asset_hash, np.linspace(0.0, 1.0, count).reshape(assets.shape_hw),
geometry_id=assets.geometry_id, frame_id=6,
sensing_part_id=assets.sensing_part().part_id, positions=freeze(positions, dtype=np.float32),
positions_sensor_m=freeze(positions, dtype=np.float32), deformed=True,
normals_sensor=assets.sensing_part().normals_sensor,
scalar=freeze(np.linspace(0.0, 1.0, count).astype(np.float32), dtype=np.float32),
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
) )
widget.set_settings( widget.set_settings(
DisplaySettings( DisplaySettings(
color_scale=ColorScale( 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): 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.""" """A bad frame is refused; the scene stays usable and stops showing it."""
widget, _, failures = gl_widget widget, _, failures = gl_widget
count = 9
before = widget.rejected_frames before = widget.rejected_frames
frame = RenderFrame( frame = _frame(
identity=FrameIdentity("poc", 1, 2, 0.2, generation=1), synthetic.poc_scene(), np.zeros(synthetic.poc_scene().shape_hw, dtype=np.float32),
asset_hash="other", frame_id=2,
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),
) )
object.__setattr__(frame, "asset_hash", "other")
object.__setattr__(frame, "geometry_id", "some-other-geometry")
widget.present(frame) widget.present(frame)
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame" assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
assert widget.rejected_frames == before + 1 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 widget = view.gl_widget
assets_frame = view.assets assets_frame = view.assets
assert assets_frame is not None assert assets_frame is not None
count = int(assets_frame.sensing_part().positions_sensor_m.shape[0])
def frame_with(values: np.ndarray) -> RenderFrame: def frame_with(values: np.ndarray) -> RenderFrame:
return 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, sensing_part_id=assets_frame.sensing_part().part_id,
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m, positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
normals_sensor=assets_frame.sensing_part().normals_sensor, normals_sensor=assets_frame.sensing_part().normals_sensor,
scalar=freeze(np.asarray(values, dtype=np.float32), dtype=np.float32), field=_field(assets_frame, values),
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
) )
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False) 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" 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.set_settings(fixed_wide)
widget.present(frame_with(ramp)) widget.present(frame_with(ramp))
@@ -0,0 +1,620 @@
"""Region agreement: does the 3D pane paint exactly the 2D island?
The user-visible claim this file checks: the set of fragments the 3D surface paints as
*valid* must be the 2D island's inverse image under the mesh's UV map, to within one
texel of the field domain, with **no** disagreement in the island's interior.
Before the texture-first path it was not. The colour came from a per-vertex gather
(``sampling.SurfaceSampler``), one invalid tap made a vertex ``NaN``, the shader's
``isnan`` rule painted every triangle touching it grey, and on the shipped finger asset
that measured as 5763 of the 27227 mesh-covered island texels painted grey (**21.17 %**),
5476 of them strictly *inside* the island, with 26840 wrong pixels on screen. The same
measurement after the change is 0, 0 and 0.
How it is measured, and why this is not a tautology
---------------------------------------------------
The pane is drawn by a real GL context and the framebuffer is read back. The *reference*
is computed on the CPU from the same camera matrices, viewport, vertex positions and
UVs: every triangle is rasterised, ``a_uv`` is interpolated perspective-correctly exactly
as a GPU varying is, and the texel it addresses is the one the fragment shader's
``floor(v_uv + 0.5)`` selects. So the comparison is between the GPU's answer and an
independent CPU derivation of the same UV map. A wrong UV convention, a wrong v flip, an
off-by-one texel, a stale ``a_scalar`` attribute or a leftover per-vertex path all show
up as a large difference — which is exactly how the numbers above were produced.
The tolerance is the design's, and it is asserted in *domain* space, which is where
"one texel of the 300x280 domain" is defined:
* every island texel the mesh covers is painted valid, and no texel outside the island's
one-texel dilation is (``residual <= 1 texel``);
* the island's one-texel *erosion* is painted valid everywhere and invalid nowhere
(``zero interior disagreement``);
* no texel is painted both ways (a fragment-level disagreement).
The mesh's UV footprint is a sub-rectangle of the domain (the shipped finger mesh spans
x 0.03..279.0, y 42.6..256.4 of a 300x280 grid), so 5710 island texels have no triangle
over them at all. "The island's inverse image under the mesh UV map" is therefore
*island ∩ covered*, and every interior/outside assertion is restricted to that set: a
texel no triangle reaches cannot be evidence about the shader.
There is deliberately no ``pytest.skip`` for a missing context once a real platform is
in use: this is the only coverage of this property on actual hardware, and a silent skip
is how it stayed broken. The module is not collected under the offscreen plugin because
that plugin cannot create a platform GL context *at all* — a property of the plugin, not
of the machine — which is the same rule ``test_scene3d_gl_smoke.py`` follows.
"""
from __future__ import annotations
import os
import numpy as np
import pytest
os.environ.setdefault("QT_OPENGL", "desktop")
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
pytestmark = pytest.mark.skipif(
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
reason="the offscreen/minimal plugin cannot create a platform GL context",
)
#: The 2D canvas's clear colour and the round-trip sentinels, in 8-bit.
BACKGROUND_RGB = (15, 18, 23)
INVALID_SENTINEL = (0, 255, 0) # patches gl_backend.INVALID_COLOR
#: The shipped domain, when the real package is the subject.
REAL_VIEWPORT = (900, 900)
#: A dense synthetic domain: one vertex per texel over a 320x240 viewport, so a texel is
#: about five pixels and a per-vertex path cannot look like a per-fragment one by luck.
DENSE_DOMAIN_HW = (40, 48)
DENSE_VIEWPORT = (320, 240)
DENSE_PLATE_M = 0.06
# ------------------------------------------------------------------------ helpers
def adjacent_to(mask: np.ndarray) -> np.ndarray:
"""Everything 8-adjacent to ``mask``: its one-texel dilation, without itself."""
out = np.zeros_like(mask)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dy == 0 and dx == 0:
continue
out |= np.roll(np.roll(mask, dy, axis=0), dx, axis=1)
return out
def dilate1(mask: np.ndarray) -> np.ndarray:
return mask | adjacent_to(mask)
def erode1(mask: np.ndarray) -> np.ndarray:
return mask & ~adjacent_to(~mask)
def reference_texels(positions, uvs, triangles, camera, viewport):
"""Which texel every framebuffer pixel reads, derived independently of the GPU."""
view = np.asarray(camera.view, dtype=np.float64).reshape(4, 4)
projection = np.asarray(camera.projection, dtype=np.float64).reshape(4, 4)
width, height = int(viewport[0]), int(viewport[1])
homo = np.hstack([np.asarray(positions, dtype=np.float64), np.ones((len(positions), 1))])
clip = (projection @ view @ homo.T).T
w = clip[:, 3]
ndc = clip[:, :3] / w[:, None]
x = (ndc[:, 0] * 0.5 + 0.5) * width
y = (ndc[:, 1] * 0.5 + 0.5) * height
depth = np.full((height, width), np.inf)
texel = np.full((height, width, 2), -1, dtype=np.int64)
for tri in np.asarray(triangles, dtype=np.int64):
i0, i1, i2 = int(tri[0]), int(tri[1]), int(tri[2])
xs = np.array([x[i0], x[i1], x[i2]])
ys = np.array([y[i0], y[i1], y[i2]])
area = (xs[1] - xs[0]) * (ys[2] - ys[0]) - (xs[2] - xs[0]) * (ys[1] - ys[0])
if area == 0.0:
continue
min_x = max(int(np.floor(xs.min() - 0.5)), 0)
max_x = min(int(np.ceil(xs.max() + 0.5)), width)
min_y = max(int(np.floor(ys.min() - 0.5)), 0)
max_y = min(int(np.ceil(ys.max() + 0.5)), height)
if min_x >= max_x or min_y >= max_y:
continue
gx, gy = np.meshgrid(
np.arange(min_x, max_x) + 0.5, np.arange(min_y, max_y) + 0.5
)
l0 = ((xs[1] - gx) * (ys[2] - gy) - (xs[2] - gx) * (ys[1] - gy)) / area
l1 = ((xs[2] - gx) * (ys[0] - gy) - (xs[0] - gx) * (ys[2] - gy)) / area
l2 = 1.0 - l0 - l1
inside = (l0 >= 0.0) & (l1 >= 0.0) & (l2 >= 0.0)
if not inside.any():
continue
zs = np.array([ndc[i0, 2], ndc[i1, 2], ndc[i2, 2]])
ws = np.array([w[i0], w[i1], w[i2]])
zi = l0 * zs[0] + l1 * zs[1] + l2 * zs[2]
window = depth[min_y:max_y, min_x:max_x]
take = inside & (zi < window)
if not take.any():
continue
inv_w = l0 / ws[0] + l1 / ws[1] + l2 / ws[2]
uu = (
l0 * uvs[i0, 0] / ws[0] + l1 * uvs[i1, 0] / ws[1] + l2 * uvs[i2, 0] / ws[2]
) / inv_w
vv = (
l0 * uvs[i0, 1] / ws[0] + l1 * uvs[i1, 1] / ws[1] + l2 * uvs[i2, 1] / ws[2]
) / inv_w
window[take] = zi[take]
rows, cols = np.nonzero(take)
view_x = texel[min_y:max_y, min_x:max_x, 0]
view_y = texel[min_y:max_y, min_x:max_x, 1]
view_x[rows, cols] = np.floor(uu[take] + 0.5)
view_y[rows, cols] = np.floor(vv[take] + 0.5)
return texel, np.isfinite(depth)
def classify(pixels, target, tolerance):
flat = pixels.reshape(-1, 3).astype(np.int16)
return (
np.abs(flat - np.asarray(target, dtype=np.int16)).max(axis=1) <= tolerance
).reshape(pixels.shape[:2])
def measure(island, texel, covered, painted_valid, painted_invalid):
"""The domain-space numbers, and the assertions, in one place."""
shape = island.shape
# The GPU's own coverage, not this rasteriser's: the reference's barycentric test
# includes triangle and silhhouette edge pixels that GL's fill rule rejects, and a
# texel only this reference reaches would otherwise read as "the pane failed to paint
# it". A texel is covered when a pixel the pane *did* paint resolves to it.
painted = painted_valid | painted_invalid
covered = covered & painted
height, width = int(shape[0]), int(shape[1])
flat = texel[..., 1] * width + texel[..., 0]
valid_texel = np.zeros(shape[0] * shape[1], dtype=bool)
invalid_texel = np.zeros(shape[0] * shape[1], dtype=bool)
covered_texel = np.zeros(shape[0] * shape[1], dtype=bool)
covered_texel[flat[covered]] = True
valid_texel[flat[covered & painted_valid]] = True
invalid_texel[flat[covered & painted_invalid]] = True
valid_texel = valid_texel.reshape(shape)
invalid_texel = invalid_texel.reshape(shape)
covered_texel = covered_texel.reshape(shape)
island_covered = island & covered_texel
grey = island_covered & ~valid_texel
extra = valid_texel & ~island
interior = erode1(island) & covered_texel
outside = ~dilate1(island) & covered_texel
mixed = valid_texel & invalid_texel
# The one-texel rim of the island, in screen terms: a pixel is in the band when its
# own texel has a 4-neighbour with the opposite validity.
tx = np.clip(texel[..., 0], 0, width - 1)
ty = np.clip(texel[..., 1], 0, height - 1)
here = island[ty, tx]
band = np.zeros_like(here)
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
band |= island[np.clip(ty + dy, 0, height - 1), np.clip(tx + dx, 0, width - 1)] != here
band &= covered
expected = np.zeros(here.shape, dtype=bool)
expected[covered] = here[covered]
missing_pixels = expected & ~painted_valid
extra_pixels = painted_valid & covered & ~expected
return {
"island_texels": int(island.sum()),
"rim_texels": int((island & adjacent_to(~island)).sum()),
"island_covered_texels": int(island_covered.sum()),
"island_uncovered_texels": int((island & ~covered_texel).sum()),
"painted_valid_texels": int(valid_texel.sum()),
"island_grey_texels": int(grey.sum()),
"island_grey_percent": round(
100.0 * float(grey.sum()) / max(int(island_covered.sum()), 1), 4
),
"extra_valid_texels": int(extra.sum()),
"mixed_texels": int(mixed.sum()),
"mixed_inside_interior": int((mixed & erode1(island)).sum()),
"covered_pixels": int(covered.sum()),
"interior_texels": int(interior.sum()),
"interior_not_painted_valid": int((interior & ~valid_texel).sum()),
"interior_painted_invalid": int((interior & invalid_texel).sum()),
"outside_dilated_painted_valid": int((outside & valid_texel).sum()),
"grey_more_than_one_texel_inside": int((grey & erode1(island)).sum()),
"grey_more_than_two_texels_inside": int((grey & erode1(erode1(island))).sum()),
"extra_more_than_one_texel_outside": int((extra & ~dilate1(island)).sum()),
"extra_more_than_two_texels_outside": int(
(extra & ~dilate1(dilate1(island))).sum()
),
"band_pixels": int(band.sum()),
"missing_pixels": int(missing_pixels.sum()),
"extra_pixels": int(extra_pixels.sum()),
"missing_pixels_outside_band": int((missing_pixels & ~band).sum()),
"extra_pixels_outside_band": int((extra_pixels & ~band).sum()),
}
def assert_agreement(numbers: dict, label: str) -> None:
"""Every assertion, with the label so a failure says which subject it was."""
assert numbers["covered_pixels"] > 5000, (label, numbers)
# A render that covers almost none of the island would make the rest vacuous.
assert numbers["island_covered_texels"] > 0.5 * numbers["island_texels"], (label, numbers)
# The island's inverse image: every covered island texel is a measurement.
#
# Two residues are bounded here rather than waved at, because they have different
# causes and only one of them is avoidable:
#
# * a *rim* texel can be missed when a fragment's interpolated UV sits within float32
# rounding of the neighbouring texel and the GPU rounds the other way — the
# "residual <= 1 texel" of the acceptance, and it may not reach deeper than the rim;
# * a texel can be *skipped entirely* when the plate is minified: NEAREST sampling of a
# 300x280 domain onto a plate of roughly half that many pixels cannot show every
# texel, and a validity channel deliberately cannot be averaged (a blended validity
# is the partly-valid triangle this change removed). One texel two steps inside the
# rim is the measured worst case at the shipped asset's own default view, so the
# band is bounded at two texels and the *fraction* at 0.05 %.
assert numbers["grey_more_than_one_texel_inside"] <= numbers["rim_texels"], (
f"{label}: the unpainted residue is deeper than the island's rim"
)
assert numbers["grey_more_than_two_texels_inside"] == 0, (label, numbers)
assert numbers["island_grey_percent"] <= 0.05, (
f"{label}: {numbers['island_grey_percent']} % of the island is painted as no "
"measurement; at most a hairline of the rim may be"
)
# The same tolerance in the other direction: a texel just outside the island may be
# resolved as valid when a fragment's UV rounds the other way, but never deeper than
# the rim and never in a way that grows with the island.
assert numbers["extra_valid_texels"] <= numbers["rim_texels"], (label, numbers)
assert numbers["extra_more_than_one_texel_outside"] == 0, (label, numbers)
# Zero interior disagreement, and no disagreement more than one texel out.
assert numbers["interior_not_painted_valid"] == 0, (label, numbers)
assert numbers["interior_painted_invalid"] == 0, (label, numbers)
assert numbers["outside_dilated_painted_valid"] == 0, (label, numbers)
assert numbers["grey_more_than_two_texels_inside"] == 0, (label, numbers)
assert numbers["extra_more_than_two_texels_outside"] == 0, (label, numbers)
# A texel painted both ways can only be a half-texel rounding tie at the rim: a
# fragment whose interpolated UV sits within float32 rounding of a texel boundary is
# rounded to a different texel by the GPU than by this float64 reference. That is the
# residue the one-texel tolerance exists for, and it may never reach the interior.
assert numbers["mixed_inside_interior"] == 0, (label, numbers)
# ...and every pixel that disagrees is inside that one-texel band.
assert numbers["missing_pixels_outside_band"] == 0, (label, numbers)
assert numbers["extra_pixels_outside_band"] == 0, (label, numbers)
# ---------------------------------------------------------------------- subjects
def _dense_scene():
"""A flat plate with one vertex per texel, and its island: the controlled case."""
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
Material,
MeshPart,
SceneAssets,
SurfaceBinding,
freeze,
)
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
height, width = DENSE_DOMAIN_HW
step = DENSE_PLATE_M / (max(height, width) - 1)
rows, cols = np.meshgrid(np.arange(height), np.arange(width), indexing="ij")
positions = np.stack(
[
(cols - (width - 1) / 2.0) * step,
-(rows - (height - 1) / 2.0) * step,
np.zeros_like(rows, dtype=np.float64),
],
axis=-1,
).astype(np.float32)
flat = positions.reshape(-1, 3)
index = (rows * width + cols).astype(np.int64)
a = index[:-1, :-1].reshape(-1)
b = index[:-1, 1:].reshape(-1)
c = index[1:, :-1].reshape(-1)
d = index[1:, 1:].reshape(-1)
triangles = np.concatenate(
[np.stack([a, b, d], axis=1), np.stack([a, d, c], axis=1)], axis=0
).astype(np.uint32)
part = MeshPart(
part_id="sensing_surface",
role="sensing",
positions_sensor_m=freeze(flat, dtype=np.float32),
normals_sensor=freeze(
np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (len(flat), 1)),
dtype=np.float32,
),
triangles=freeze(triangles, dtype=np.uint32),
material=Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True),
lod_id="region",
)
geom = geometry_id([part])
domain = field_domain_id(shape_hw=DENSE_DOMAIN_HW, flip_v=False)
uvs = np.stack(
[cols.reshape(-1).astype(np.float32), rows.reshape(-1).astype(np.float32)], axis=1
)
count = len(flat)
indices = np.full((count, 4), -1, dtype=np.int32)
weights = np.zeros((count, 4), dtype=np.float32)
indices[:, 0] = np.arange(count, dtype=np.int32)
weights[:, 0] = 1.0
assets = SceneAssets(
asset_id="region-dense",
asset_version="0.0.0",
asset_hash="region-hash",
geometry_id=geom,
parts=(part,),
binding=SurfaceBinding(
part_id="sensing_surface",
geometry_id=geom,
field_domain_id=domain,
shape_hw=DENSE_DOMAIN_HW,
uvs=freeze(uvs, dtype=np.float32),
sample_indices=freeze(indices, dtype=np.int32),
sample_weights=freeze(weights, dtype=np.float32),
),
camera_presets=(
CameraPreset(
"front", (0.0, 0.0, DENSE_PLATE_M * 2.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.6
),
),
bounds_sensor_m=freeze(
np.stack([flat.min(axis=0), flat.max(axis=0)]).astype(np.float32),
dtype=np.float32,
),
field_domain_id=domain,
shape_hw=DENSE_DOMAIN_HW,
)
island = np.zeros(DENSE_DOMAIN_HW, dtype=bool)
island[6:34, 8:40] = True
island[12:20, 20:26] = False # a hole: an interior the shader must still colour
island[24:30, 30:36] = False # a notch: a concave corner on the boundary
return assets, island, uvs, "front"
def _shipped_scene(preset_name: str = "front"):
"""The real finger package, with the island it carries.
``surface_valid.npy`` is byte-identical to the unroll map's ``valid_mask`` (measured:
32937 texels, 0 differing), so the island is the 2D canvas's own island and no path
outside this repository is needed to state it.
"""
from pathlib import Path
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
package = (
Path(__file__).resolve().parents[1]
/ "config" / "sensors" / "finger" / "scene" / "finger-display-1.0.0.oryscene"
)
assert package.is_file(), f"the shipped scene package is missing: {package}"
assets = scene_from_contents(read_package(package), None)
mapping = assets.surface_mapping
assert mapping is not None, "the shipped package carries no surface mapping"
names = {preset.name for preset in assets.camera_presets}
assert preset_name in names, f"the shipped package has no {preset_name!r} preset: {names}"
return (
assets,
np.asarray(mapping.valid, dtype=bool),
np.asarray(assets.binding.uvs, dtype=np.float32),
preset_name,
)
def _render_region(qt_app, assets, island, uvs, preset_name, viewport):
"""Draw the island through the real GL path and measure the painted region."""
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplayField,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
RenderFrame,
freeze,
)
from PySide6.QtGui import QImage
from orisys_studio.scene3d import gl_backend
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import (
GLDispatch,
GLRenderer,
configure_surface_format,
lut_rgba,
)
from orisys_studio.scene3d.widget import Sensor3DWidget
configure_surface_format("desktop33")
renderer = GLRenderer()
widget = Sensor3DWidget(renderer, OrbitCamera(), dispatch=GLDispatch())
seen: list = []
widget.diagnosticRaised.connect(seen.append)
widget.resize(*viewport)
widget.show()
widget.set_scene(assets)
def pump(predicate, timeout_s=12.0):
import time
deadline = time.perf_counter() + timeout_s
while time.perf_counter() < deadline:
widget.repaint()
qt_app.processEvents()
if predicate():
return True
time.sleep(0.01)
return False
if not pump(lambda: bool(renderer.part_ids)):
widget.release_resources()
widget.close()
pytest.fail(
"no usable OpenGL context on a real platform: "
f"{widget.diagnostic() or seen or 'the widget never initialized'}"
)
preset = next(item for item in assets.camera_presets if item.name == preset_name)
widget.camera().set_preset(preset)
widget.camera().fit(assets.bounds_sensor_m, viewport[0] / max(viewport[1], 1))
widget.repaint()
qt_app.processEvents()
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
frame = RenderFrame(
identity=FrameIdentity("region", 1, 1, 0.0, generation=1),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=assets.sensing_part().part_id,
positions_sensor_m=assets.sensing_part().positions_sensor_m,
normals_sensor=assets.sensing_part().normals_sensor,
field=DisplayField(
domain_id=assets.field_domain_id,
shape_hw=(int(island.shape[0]), int(island.shape[1])),
scalar=freeze(
np.where(island, 1.0, 0.0).astype(np.float32), dtype=np.float32
),
valid=freeze(np.array(island, dtype=bool), dtype=np.bool_),
descriptor=descriptor,
),
)
widget.set_settings(
DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential"
),
)
)
# The invalid colour is the sentinel the readback is classified by. ``draw`` reads it
# from the module, so patching the module is what the shader is given.
gl_backend.INVALID_COLOR = (0.0, 1.0, 0.0, 1.0)
# The colour a *valid* fragment gets comes from the tree's own lookup table, not from
# a hard-coded constant: the 2D canvas and the 3D surface must be coloured by one
# table, and a test that hard-coded the colour would stop noticing if they diverged.
valid_rgb = tuple(int(v) for v in lut_rgba("sequential")[-1, 0, :3])
widget.present(frame)
try:
assert pump(
lambda: getattr(renderer, "field_shape", None)
== (int(island.shape[0]), int(island.shape[1]))
), "the field texture was never uploaded"
for _ in range(8):
widget.repaint()
qt_app.processEvents()
image = widget.grabFramebuffer()
assert not image.isNull(), "Qt returned no framebuffer"
image = image.convertToFormat(QImage.Format.Format_RGB888)
width, height = image.width(), image.height()
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
pixels = (
np.frombuffer(buffer, dtype=np.uint8)
.reshape(height, image.bytesPerLine())[:, : width * 3]
.reshape(height, width, 3)
.copy()
)
background = classify(pixels, BACKGROUND_RGB, 6)
painted_valid = classify(pixels, valid_rgb, 14) & ~background
painted_invalid = classify(pixels, INVALID_SENTINEL, 14) & ~background
texel, covered = reference_texels(
np.asarray(frame.positions_sensor_m, dtype=np.float64),
uvs,
assets.sensing_part().triangles,
widget.camera().matrices(widget.viewport_pixels()),
widget.viewport_pixels(),
)
# The reference is built in GL window coordinates (y up); the grab is top-down.
return (
measure(
island,
texel[::-1],
covered[::-1],
painted_valid,
painted_invalid,
),
renderer,
)
finally:
widget.release_resources()
widget.close()
# ------------------------------------------------------------------------ the test
@pytest.fixture(scope="module")
def dense_region(qt_app):
assets, island, uvs, preset = _dense_scene()
numbers, _ = _render_region(
qt_app, assets, island, uvs, preset, DENSE_VIEWPORT
)
return numbers
@pytest.fixture(scope="module")
def shipped_region(qt_app):
"""The shipped asset, seen face-on and magnified: the exact case."""
assets, island, uvs, preset = _shipped_scene("front")
numbers, _ = _render_region(qt_app, assets, island, uvs, preset, REAL_VIEWPORT)
return numbers
@pytest.fixture(scope="module")
def shipped_default_region(qt_app):
"""The shipped asset at *its own* default preset: a three-quarter, minified view."""
assets, island, uvs, _ = _shipped_scene("front")
default = assets.camera_presets[0].name
numbers, _ = _render_region(qt_app, assets, island, uvs, default, REAL_VIEWPORT)
return numbers
def test_the_dense_scene_paints_exactly_the_island(dense_region):
"""The controlled case: one vertex per texel, a hole and a notch in the island.
Nothing is tolerated here. The plate is seen face-on and every texel is magnified,
so neither a rounding tie nor a skipped texel can arise: the painted region must be
the island exactly.
"""
assert_agreement(dense_region, "dense")
assert dense_region["island_grey_texels"] == 0, dense_region
assert dense_region["extra_valid_texels"] == 0, dense_region
assert dense_region["grey_more_than_one_texel_inside"] == 0, dense_region
assert dense_region["mixed_texels"] == 0, dense_region
assert dense_region["missing_pixels"] == 0, dense_region
assert dense_region["extra_pixels"] == 0, dense_region
# Nothing may be left uncovered here: the mesh spans the whole domain.
assert dense_region["island_uncovered_texels"] == 0, dense_region
assert dense_region["painted_valid_texels"] == dense_region["island_texels"]
def test_the_shipped_package_paints_exactly_the_island(shipped_region):
"""The real acceptance: the shipped asset, face-on, every texel magnified.
Zero tolerance, as the design asks: no island texel is grey, no texel outside the
island is painted as a measurement, and no pixel disagrees at all. Before the change
the same measurement was 5763 grey island texels — 5476 of them strictly inside the
island — and 26840 wrong pixels.
"""
numbers = shipped_region
assert_agreement(numbers, "shipped")
assert numbers["island_grey_texels"] == 0, numbers
assert numbers["extra_valid_texels"] == 0, numbers
assert numbers["mixed_texels"] == 0, numbers
assert numbers["missing_pixels"] == 0, numbers
assert numbers["extra_pixels"] == 0, numbers
assert numbers["painted_valid_texels"] == numbers["island_covered_texels"], numbers
# Reported, not asserted: the mesh's UV footprint is a sub-rectangle of the domain,
# so these island texels have no triangle over them at all.
assert numbers["island_uncovered_texels"] > 0, numbers
def test_the_shipped_default_view_keeps_the_residue_on_the_rim(shipped_default_region):
"""The view an operator actually opens: three-quarter, and minified.
Nothing may be grey except within two texels of the island's rim, and almost nothing
may be: measured as 1 texel of 25539 (0.0039 %) — the one texel the GPU never resolved
as itself, because NEAREST sampling of a 300x280 domain onto a plate of about half
that many pixels cannot show every texel, and a validity channel may not be averaged.
(The old per-vertex path, on this same view, left 5763 texels grey with 5476 of them
strictly inside the island.)
"""
numbers = shipped_default_region
assert_agreement(numbers, "shipped-default")
assert numbers["island_grey_percent"] < 0.05, numbers
assert numbers["interior_painted_invalid"] == 0, numbers
# The residual at this view is a rim rounding tie and nothing more: at most one texel
# of the island missed and at most one texel just outside it claimed, both adjacent to
# the boundary, and no disagreement anywhere but the one-texel band.
assert numbers["island_grey_texels"] <= 1, numbers
assert numbers["extra_valid_texels"] <= 1, numbers
assert numbers["grey_more_than_one_texel_inside"] == 0, numbers
assert numbers["extra_more_than_one_texel_outside"] == 0, numbers
+37 -25
View File
@@ -24,8 +24,10 @@ from orisys_studio.scene3d.camera import (
OrbitCamera, OrbitCamera,
) )
from orisys_studio.scene3d.gl_backend import ( from orisys_studio.scene3d.gl_backend import (
FIELD_BYTES_PER_TEXEL,
LUT_SIZE, LUT_SIZE,
STRIDE_FLOATS, STRIDE_FLOATS,
VALID_BYTES_PER_TEXEL,
estimate_gpu_bytes, estimate_gpu_bytes,
is_software_renderer, is_software_renderer,
lut_rgba, lut_rgba,
@@ -259,7 +261,14 @@ def test_shader_sources_are_two_dialects_of_one_body():
len(shader_header("gles30")): len(shader_header("gles30")):
] ]
assert "texture(u_lut" in desktop_fragment and "texture(u_lut" in es_fragment 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(): def test_an_unknown_profile_or_program_is_refused():
@@ -301,35 +310,33 @@ def _plate(**kwargs):
return synthetic.poc_scene().sensing_part() 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() part = _plate()
count = int(part.positions_sensor_m.shape[0]) count = int(part.positions_sensor_m.shape[0])
scalar = np.linspace(0.0, 1.0, count, dtype=np.float32) uvs = synthetic.poc_scene().binding.uvs
valid = np.ones(count, dtype=bool) rows = pack_part(part, uvs=uvs)
rows = pack_part(part, scalar=scalar, valid=valid)
assert rows.shape == (count, STRIDE_FLOATS) assert rows.shape == (count, STRIDE_FLOATS)
assert rows.dtype == np.float32 and rows.flags["C_CONTIGUOUS"] assert rows.dtype == np.float32 and rows.flags["C_CONTIGUOUS"]
assert not rows.flags["WRITEABLE"], "the packed buffer must be read-only" 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[:, 0:3], part.positions_sensor_m)
assert np.allclose(rows[:, 3:6], part.normals_sensor) assert np.allclose(rows[:, 3:6], part.normals_sensor)
assert np.allclose(rows[:, 6], scalar) assert np.allclose(rows[:, 6:8], uvs)
assert np.allclose(rows[:, 7], 1.0) # 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()) rows = pack_part(_plate())
assert np.isnan(rows[:, 6]).all(), "an unmeasured scalar must be NaN, not zero" assert np.allclose(rows[:, 6:8], 0.0)
assert np.allclose(rows[:, 7], 0.0), "an unmeasured vertex must be invalid"
def test_packing_refuses_a_mismatched_length(): def test_packing_refuses_a_mismatched_uv_count():
part = _plate() with pytest.raises(Exception) as error_:
with pytest.raises(Exception) as scalar_error: pack_part(_plate(), uvs=np.zeros((3, 2), dtype=np.float32))
pack_part(part, scalar=np.zeros(3, dtype=np.float32)) assert "UVs for" in str(error_.value)
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_scene_part_indices_are_uint32_and_read_only(): 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] 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() assets = synthetic.poc_scene()
budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface") budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface")
plate = assets.sensing_part() 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.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
+ housing.triangles.size * 4 + housing.triangles.size * 4
+ LUT_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 == expected
assert budget < 1024 * 1024, "the PoC scene should be tiny" 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(): def test_the_rest_surface_claims_no_measurement():
assets = synthetic.poc_scene() assets = synthetic.poc_scene()
frame = rest_surface(assets, FrameIdentity("poc", 1, 0, 0.0, 1)) frame = rest_surface(assets, FrameIdentity("poc", 1, 0, 0.0, 1))
count = int(assets.sensing_part().positions_sensor_m.shape[0]) shape = assets.shape_hw
assert frame.scalar.shape == (count,) and frame.valid.shape == (count,) assert frame.field.scalar.shape == shape and frame.field.valid.shape == shape
assert not frame.valid.any(), "a reference frame measures nothing" assert frame.field.shape_hw == shape
assert np.isnan(frame.scalar).all(), "an unavailable scalar is NaN, not zero" assert frame.field.domain_id == assets.field_domain_id
assert frame.scalar_descriptor.quantity == "unavailable" assert not frame.field.valid.any(), "a reference frame measures nothing"
assert frame.scalar_descriptor.quality == "display_only" 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.geometry_id == assets.geometry_id
assert frame.sensing_part_id == assets.sensing_part().part_id assert frame.sensing_part_id == assets.sensing_part().part_id
@@ -67,24 +67,71 @@ def _parse_ramp(ramp: str) -> list[tuple[int, int, int]]:
return stops return stops
@lru_cache(maxsize=8) def ramp_for_theme(theme: str = "dark") -> str:
def _lut_from_ramp(ramp: str) -> np.ndarray: """The theme's own ``flow_ramp`` string, resolved and never empty.
"""Build a 256-entry user colour table from ``#RRGGBB`` ramp stops.
The table is BGR, matching how :func:`cv2.applyColorMap` consumes user This is the **single** ramp both views build their lookup table from: the 2D
colormaps. Invalid input never raises: it falls back to canvas through :func:`flow_colormap`, the 3D surface through the field it is
:data:`FLOW_RAMP_FALLBACK`. handed (``scene3d.contracts.DisplayField.ramp``). One string, two tables, so the
two pictures cannot be coloured by different ramps — which they were, until the
texture-first path: the 3D pane had its own hand-rolled stop list and the two
disagreed by up to 218/255 per channel.
"""
return str(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK) or FLOW_RAMP_FALLBACK)
@lru_cache(maxsize=8)
def ramp_rgb_table(ramp: str) -> np.ndarray:
"""A 256x3 **RGB** table from ``#RRGGBB`` ramp stops.
One interpolation, one rounding, one table shape: every consumer of the ramp
(the BGR table ``cv2`` wants, the RGBA texture the shader samples) is a view of
this array, so "the legend cannot disagree with the picture" holds for the 3D
pane too and not only for the 2D one. Invalid input never raises: it falls back
to :data:`FLOW_RAMP_FALLBACK`.
""" """
stops = _parse_ramp(ramp) stops = _parse_ramp(ramp)
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS: if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
stops = _parse_ramp(FLOW_RAMP_FALLBACK) stops = _parse_ramp(FLOW_RAMP_FALLBACK)
positions = np.linspace(0.0, 1.0, _LUT_STEPS) positions = np.linspace(0.0, 1.0, _LUT_STEPS)
span = np.linspace(0.0, 1.0, len(stops)) span = np.linspace(0.0, 1.0, len(stops))
lut = np.empty((_LUT_STEPS, 1, 3), dtype=np.uint8) table = np.empty((_LUT_STEPS, 3), dtype=np.uint8)
for channel in range(3): # source order: R, G, B for channel in range(3): # source order: R, G, B
values = np.interp(positions, span, [stop[channel] for stop in stops]) values = np.interp(positions, span, [stop[channel] for stop in stops])
lut[:, 0, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8) table[:, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8)
return lut[:, :, ::-1].copy() return table
def ramp_rgba_table(ramp: str, *, diverging: bool = False) -> np.ndarray:
"""A 256x1 RGBA8 texture table for the shader, from the same stops.
``diverging`` mirrors the ramp through its own midpoint: the safe choice for a
signed quantity (a deformation response), and never used for an unsigned one. The
mirroring happens on the *stops* rather than on the interpolated table, so the
result is still exactly one interpolation of a legal ramp.
"""
stops = _parse_ramp(ramp)
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
stops = _parse_ramp(FLOW_RAMP_FALLBACK)
if diverging:
ramp = ",".join(
"#%02X%02X%02X" % stop for stop in [*stops, *reversed(stops)]
)
table = ramp_rgb_table(ramp)
rgba = np.empty((_LUT_STEPS, 1, 4), dtype=np.uint8)
rgba[:, 0, :3] = table
rgba[:, 0, 3] = 255
return rgba
@lru_cache(maxsize=8)
def _lut_from_ramp(ramp: str) -> np.ndarray:
"""Build a 256-entry user colour table from ``#RRGGBB`` ramp stops.
The table is BGR, matching how :func:`cv2.applyColorMap` consumes user
colormaps.
"""
return ramp_rgb_table(ramp)[:, None, ::-1].copy()
def flow_colormap(theme: str = "dark") -> np.ndarray: def flow_colormap(theme: str = "dark") -> np.ndarray:
@@ -93,7 +140,7 @@ def flow_colormap(theme: str = "dark") -> np.ndarray:
Cached on the resolved ramp string rather than the theme name, so a theme Cached on the resolved ramp string rather than the theme name, so a theme
switch is picked up as soon as :func:`build_qss` reloads the tokens. switch is picked up as soon as :func:`build_qss` reloads the tokens.
""" """
return _lut_from_ramp(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK)) return _lut_from_ramp(ramp_for_theme(theme))
def arrow_display_scale(draw_style: str = "box") -> float: def arrow_display_scale(draw_style: str = "box") -> float:
@@ -22,6 +22,7 @@ from orisys_app_shared.scene3d.contracts import (
CompileOptions, CompileOptions,
CompileReport, CompileReport,
Diagnostic, Diagnostic,
DisplayField,
DisplaySettings, DisplaySettings,
ExpectedGeometry, ExpectedGeometry,
F32, F32,
@@ -45,7 +46,7 @@ from orisys_app_shared.scene3d.contracts import (
SurfaceMapping, SurfaceMapping,
ThreeDError, ThreeDError,
U32, U32,
VertexFields,
diagnostic, diagnostic,
error, error,
freeze, freeze,
@@ -72,6 +73,7 @@ __all__ = [
"CompileOptions", "CompileOptions",
"CompileReport", "CompileReport",
"Diagnostic", "Diagnostic",
"DisplayField",
"DisplaySettings", "DisplaySettings",
"ExpectedGeometry", "ExpectedGeometry",
"F32", "F32",
@@ -95,7 +97,6 @@ __all__ = [
"SurfaceMapping", "SurfaceMapping",
"ThreeDError", "ThreeDError",
"U32", "U32",
"VertexFields",
"diagnostic", "diagnostic",
"error", "error",
"freeze", "freeze",
+106 -35
View File
@@ -1,9 +1,18 @@
"""Composing a render frame: sample, deform, update normals. Pure NumPy. """Composing a render frame: validate, (optionally) deform, hand the field on. NumPy.
Stage 3 in ``docs/architecture/studio-3d-stages.md``. Stage 3 in ``docs/architecture/studio-3d-stages.md``.
This module decides what a colour and a vertex position *mean*, so the rules from The composer no longer *samples*. It used to gather the field onto every sensing
§3.2 of the design document are its reason to exist: vertex and put the result on the frame, and that is what made the 3D pane disagree
with the 2D one: a vertex whose four taps touched one invalid pixel became ``NaN``,
and the shader's ``isnan`` rule then painted the whole triangle grey. The field now
travels whole — :class:`~orisys_app_shared.scene3d.contracts.DisplayField` carries the
frame's own ``[H,W]`` grid, the same array the 2D canvas paints, and the surface
samples it per *fragment* through the mesh's UVs. Same array, same validity, two
views: they agree because there is only one of them.
This module still decides what a colour and a vertex position *mean*, so the rules
from §3.2 of the design document remain its reason to exist:
* ``scalar`` is never multiplied by the display gain. The colour scale describes the * ``scalar`` is never multiplied by the display gain. The colour scale describes the
measurement; the gain describes only how far the drawing is exaggerated. A gain measurement; the gain describes only how far the drawing is exaggerated. A gain
@@ -13,9 +22,8 @@ This module decides what a colour and a vertex position *mean*, so the rules fro
instead of leaving the UI to guess. instead of leaving the UI to guess.
* without a trustworthy displacement the surface is coloured in place, and the total * without a trustworthy displacement the surface is coloured in place, and the total
force is never turned into a per-vertex height. force is never turned into a per-vertex height.
* a reference frame measures nothing: every validity flag is false and the scalar is * a reference frame measures nothing: the field arrives with every validity flag
absent, so the UI hides the colour bar rather than leaving the previous one on false and no scalar.
screen.
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,8 +35,8 @@ import numpy as np
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
Diagnostic, Diagnostic,
DisplayField,
DisplaySettings, DisplaySettings,
FieldDescriptor,
FrameIdentity, FrameIdentity,
Quality, Quality,
RenderFrame, RenderFrame,
@@ -47,7 +55,10 @@ from orisys_app_shared.scene3d.overlays import (
build_normal_overlay, build_normal_overlay,
mesh_span_from_mapping, mesh_span_from_mapping,
) )
from orisys_app_shared.scene3d.sampling import SurfaceSampler from orisys_app_shared.scene3d.sampling import (
check_field_domain,
sample_vertex_displacement,
)
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
#: Reported when a display gain is applied to a displacement that is only #: Reported when a display gain is applied to a displacement that is only
@@ -74,8 +85,7 @@ class SceneComposer:
looking at. looking at.
""" """
def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None: def __init__(self, *, ramp: str = "") -> None:
self._sampler = sampler or SurfaceSampler()
self.last_stats = ComposeStats() self.last_stats = ComposeStats()
#: Rest positions and normals, keyed by ``asset_hash``: with no displacement to #: Rest positions and normals, keyed by ``asset_hash``: with no displacement to
#: draw, the picture is the asset's own geometry, and recomputing its normals #: draw, the picture is the asset's own geometry, and recomputing its normals
@@ -83,6 +93,9 @@ class SceneComposer:
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {} self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
#: Last UV grid of "arrow was on" samples for flow hysteresis. #: Last UV grid of "arrow was on" samples for flow hysteresis.
self._flow_previously_on: Optional[np.ndarray] = None self._flow_previously_on: Optional[np.ndarray] = None
#: The resolved ``#RRGGBB,...`` ramp both views build their lookup table from.
#: Set by the view when the theme changes; empty means this build's default.
self.ramp = str(ramp)
def compose( def compose(
self, self,
@@ -96,23 +109,67 @@ class SceneComposer:
self._check(assets, frame) self._check(assets, frame)
for item in validate_surface_frame(frame): for item in validate_surface_frame(frame):
# A frame with, say, non-finite scalars marked valid is still usable: # A frame with, say, non-finite scalars marked valid is still usable:
# the vertex ends up invalid. Reported rather than refused. # the fragment ends up invalid. Reported rather than refused.
if item.severity == "error": if item.severity == "error":
raise error(item.code, item.message, **dict(item.details)) raise error(item.code, item.message, **dict(item.details))
part = assets.sensing_part() part = assets.sensing_part()
# The field, whole. No gather, no NaN repair, no per-vertex copy: the render
# frame carries the acquisition frame's own arrays, which is what makes the
# 2D and 3D pictures the same picture. ``validate_surface_frame`` above has
# already checked shapes and dtypes; ``check_field_domain`` repeats the
# geometry/domain identity against the binding, which is the identity the
# *texture* is addressed by.
sampling_started = time.perf_counter() sampling_started = time.perf_counter()
fields = self._sampler.sample(frame, assets.binding) check_field_domain(frame, assets.binding)
field = DisplayField(
domain_id=frame.field_domain_id,
shape_hw=(int(frame.scalar.shape[0]), int(frame.scalar.shape[1])),
scalar=frame.scalar,
valid=frame.valid,
descriptor=frame.scalar_descriptor,
ramp=self.ramp,
)
self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0 self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0
rest_positions, rest_normals = self._rest(assets) rest_positions, rest_normals = self._rest(assets)
diagnostics: list[Diagnostic] = [] diagnostics: list[Diagnostic] = []
positions = rest_positions positions = rest_positions
normals = rest_normals
quality = frame.displacement_quality quality = frame.displacement_quality
deformed = bool(settings.deform_enabled and fields.displacement_sensor_m is not None) # Deformation is a *vertex-stage* quantity: it moves the mesh, so it is the one
# thing that still has to reach the CPU. It is gathered only when it is asked
# for — never for colour — and it is what ``RenderFrame.deformed`` reports, so
# the GPU knows whether the vertex buffer needs re-uploading at all.
displaced = (
sample_vertex_displacement(frame, assets.binding)
if settings.deform_enabled and frame.displacement_sensor_m is not None
else None
)
deformed = bool(settings.deform_enabled and displaced is not None)
if deformed: if deformed:
gain = float(settings.display_gain) gain = float(settings.display_gain)
displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64) # A tap that was invalid leaves ``NaN`` here, and a vertex with no measured
positions = np.asarray(rest_positions, dtype=np.float64) + gain * displacement # displacement does not move: ``nan_to_num`` is the honest reading of
# "absent", and it is *not* the colour path — it cannot make a fragment
# grey, because the colour no longer comes from the vertices at all.
displacement = np.nan_to_num(
np.asarray(displaced, dtype=np.float64), nan=0.0, posinf=0.0, neginf=0.0
)
moved = np.asarray(rest_positions, dtype=np.float64) + gain * displacement
if bool(np.isfinite(moved).all()):
positions = moved
else:
# A gain large enough to overflow is a display request that cannot be
# drawn, and drawing something else would be a different picture.
diagnostics.append(
diagnostic(
"field_unavailable",
"the display gain overflows the surface coordinates: "
"colouring in place",
gain=f"{gain:g}",
)
)
deformed = False
if quality != "calibrated": if quality != "calibrated":
diagnostics.append( diagnostics.append(
diagnostic( diagnostic(
@@ -123,7 +180,7 @@ class SceneComposer:
gain=f"{gain:g}", gain=f"{gain:g}",
) )
) )
elif settings.deform_enabled and fields.displacement_sensor_m is None: elif settings.deform_enabled and frame.displacement_sensor_m is None:
diagnostics.append( diagnostics.append(
diagnostic( diagnostic(
"field_unavailable", "field_unavailable",
@@ -133,11 +190,8 @@ class SceneComposer:
normals_started = time.perf_counter() normals_started = time.perf_counter()
# In place, the normals are the asset's own: the positions are untouched, so # In place, the normals are the asset's own: the positions are untouched, so
# recomputing them from the same triangles gives the same answer every frame. # recomputing them from the same triangles gives the same answer every frame.
normals = ( if deformed:
vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64)) normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
if deformed
else rest_normals
)
self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0 self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0
# Measured before the frame is built, so the frame carries *its own* cost: the # Measured before the frame is built, so the frame carries *its own* cost: the
@@ -154,21 +208,18 @@ class SceneComposer:
geometry_id=assets.geometry_id, geometry_id=assets.geometry_id,
sensing_part_id=part.part_id, sensing_part_id=part.part_id,
positions_sensor_m=( positions_sensor_m=(
freeze(positions, dtype=np.float32) freeze(positions, dtype=np.float32) if deformed else rest_positions
if deformed
else rest_positions
), ),
normals_sensor=( normals_sensor=(
freeze(normals, dtype=np.float32) if deformed else rest_normals freeze(normals, dtype=np.float32) if deformed else rest_normals
), ),
# The measurement, untouched by the display gain. # The measurement, untouched by the display gain.
scalar=fields.scalar, field=field,
valid=fields.valid,
scalar_descriptor=frame.scalar_descriptor,
diagnostics=tuple(diagnostics), diagnostics=tuple(diagnostics),
# Carried through untouched: only the display side knows whether the frame # Carried through untouched: only the display side knows whether the frame
# it is about to show is still the current one. # it is about to show is still the current one.
computed_frame_id=frame.computed_frame_id, computed_frame_id=frame.computed_frame_id,
deformed=deformed,
# What this frame cost to compose, carried *on the frame*: the shared # What this frame cost to compose, carried *on the frame*: the shared
# ``last_stats`` object is rewritten by the next task, so a reader on the # ``last_stats`` object is rewritten by the next task, so a reader on the
# GUI thread would report another frame's cost. # GUI thread would report another frame's cost.
@@ -176,7 +227,11 @@ class SceneComposer:
flow_arrows=flow_arrows, flow_arrows=flow_arrows,
contact_arrow=contact_arrow, contact_arrow=contact_arrow,
) )
validate_render_frame(result, vertices=int(rest_positions.shape[0])) validate_render_frame(
result,
vertices=int(rest_positions.shape[0]),
domain_shape=tuple(assets.shape_hw),
)
return result return result
def _build_overlays( def _build_overlays(
@@ -275,7 +330,7 @@ class SceneComposer:
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
"""The static reference: nothing measured, so nothing coloured.""" """The static reference: nothing measured, so nothing coloured."""
return build_rest_frame(assets, identity) return build_rest_frame(assets, identity, ramp=self.ramp)
@staticmethod @staticmethod
def _check(assets: SceneAssets, frame: SurfaceFieldFrame) -> None: def _check(assets: SceneAssets, frame: SurfaceFieldFrame) -> None:
@@ -302,14 +357,23 @@ class SceneComposer:
) )
def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: def build_rest_frame(
assets: SceneAssets, identity: FrameIdentity, *, ramp: str = ""
) -> RenderFrame:
"""The asset's own geometry at rest, with no measurement attached. """The asset's own geometry at rest, with no measurement attached.
Shared by the composer and by the GL layer, which needs it to *clear* a stale Shared by the composer and by the GL layer, which needs it to *clear* a stale
measurement after a refused frame or a disconnect. measurement after a refused frame or a disconnect. The field is a full-size grid
of "no measurement": a reference model is not an observation, and the shader has
to be able to tell (every texel invalid) rather than being handed an empty frame.
""" """
part = assets.sensing_part() part = assets.sensing_part()
count = int(part.positions_sensor_m.shape[0]) shape = (int(assets.shape_hw[0]), int(assets.shape_hw[1]))
if shape[0] <= 0 or shape[1] <= 0:
raise error(
"field_domain_mismatch",
"the scene declares no UV domain, so there is no field to clear to",
)
return RenderFrame( return RenderFrame(
identity=identity, identity=identity,
asset_hash=assets.asset_hash, asset_hash=assets.asset_hash,
@@ -317,7 +381,14 @@ def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFram
sensing_part_id=part.part_id, sensing_part_id=part.part_id,
positions_sensor_m=part.positions_sensor_m, positions_sensor_m=part.positions_sensor_m,
normals_sensor=part.normals_sensor, normals_sensor=part.normals_sensor,
scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32), field=DisplayField(
valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_), domain_id=assets.field_domain_id,
scalar_descriptor=rest_descriptor(), shape_hw=shape,
scalar=freeze(
np.full(shape, np.nan, dtype=np.float32), dtype=np.float32
),
valid=freeze(np.zeros(shape, dtype=bool), dtype=np.bool_),
descriptor=rest_descriptor(),
ramp=str(ramp),
),
) )
+118 -26
View File
@@ -268,12 +268,26 @@ class MeshPart:
@dataclass(frozen=True) @dataclass(frozen=True)
class SurfaceBinding: class SurfaceBinding:
"""How sensing vertices sample a UV field: up to four bilinear taps each.""" """How the sensing mesh addresses the field: one UV per vertex, plus its taps.
Two things travel together, because they are two views of the same
correspondence — the mesh's own UV map onto the field domain:
* ``uvs`` is the **render-vertex** UV, one pair per vertex of
``MeshPart.positions_sensor_m``, already converted onto the domain's pixel grid
by ``legacy.uv_to_pixel`` (``x = u*(W-1)``, ``y = (1-v)*(H-1)`` when the map is
v-flipped). Converting once, here, is what keeps the flip and the scale in one
place: the fragment shader only normalises by the domain size.
* ``sample_indices``/``sample_weights`` are the bilinear taps of the same UVs.
Since the texture-first path they carry only the *geometry* input (a display
deformation), never the surface colour — the colour is the field texture.
"""
part_id: str part_id: str
geometry_id: str geometry_id: str
field_domain_id: str field_domain_id: str
shape_hw: tuple[int, int] shape_hw: tuple[int, int]
uvs: F32 # [N,2], domain pixel coordinates, same order as the render vertices
sample_indices: I32 # [N,4], y*W+x, unused = -1 sample_indices: I32 # [N,4], y*W+x, unused = -1
sample_weights: F32 # [N,4], non-negative, used weights sum to 1 sample_weights: F32 # [N,4], non-negative, used weights sum to 1
@@ -627,12 +641,37 @@ class DisplaySettings:
@dataclass(frozen=True) @dataclass(frozen=True)
class VertexFields: class DisplayField:
"""A UV field sampled onto the sensing vertices.""" """The one field both views draw, on the domain's own grid.
scalar: F32 # [N] This object exists so that the two-dimensional canvas and the three-dimensional
valid: Mask # [N] surface cannot disagree about what was measured. Before it, the 3D pane had a
displacement_sensor_m: Optional[F32] = None # [N,3] *per-vertex* field: ``sampling.SurfaceSampler`` gathered each of the mesh's 1032
vertices from up to four domain pixels, and the shader interpolated those 1032
values across 1974 triangles. A vertex whose taps touched one invalid pixel
became ``NaN``, the shader's ``isnan`` rule then made it neutral grey, and a
triangle with two valid corners and one invalid one was drawn entirely grey —
measured on the shipped finger asset as **30.15 % of the island painted grey**
and a painted region that was p50 6.7 px / max 25.8 px wrong against the 2D view.
So the field travels whole. ``scalar``/``valid`` are the frame's own
``[H,W]`` grid — the same array the 2D canvas paints — and the surface samples
them per *fragment* through the mesh's UVs. There is no resampling step to get
wrong and no fallback: a scene whose package carries no UVs is refused at load
(see ``package.scene_from_contents``) rather than drawn untextured.
"""
#: The UV domain this field lives on. Both views check it against the scene's.
domain_id: str
shape_hw: tuple[int, int]
scalar: F32 # [H,W], the measurement before the display gain; NaN where invalid
valid: Mask # [H,W]
descriptor: FieldDescriptor
#: The resolved ``#RRGGBB,#RRGGBB,...`` ramp both views build their lookup table
#: from (``acquisition.arrows.ramp_for_theme``). One ramp, one range, one meaning:
#: the 2D canvas and the 3D surface are coloured by the same table because they are
#: handed the same string. Empty means "this build's default ramp".
ramp: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -645,13 +684,17 @@ class RenderFrame:
sensing_part_id: str sensing_part_id: str
positions_sensor_m: F32 # [N,3], display displacement already applied positions_sensor_m: F32 # [N,3], display displacement already applied
normals_sensor: F32 # [N,3] normals_sensor: F32 # [N,3]
scalar: F32 # [N], before display gain #: The measurement, on the domain's grid. Not per vertex: see :class:`DisplayField`.
valid: Mask # [N] field: DisplayField
scalar_descriptor: FieldDescriptor
diagnostics: tuple[Diagnostic, ...] = () diagnostics: tuple[Diagnostic, ...] = ()
#: The acquisition frame the coloured values belong to, carried through from the #: The acquisition frame the coloured values belong to, carried through from the
#: field frame so the *display* side can re-check it before presenting. #: field frame so the *display* side can re-check it before presenting.
computed_frame_id: Optional[int] = None computed_frame_id: Optional[int] = None
#: Whether ``positions_sensor_m``/``normals_sensor`` differ from the asset's own
#: rest geometry. ``False`` for every path the application can take today (no
#: adapter publishes a displacement), and the GPU then uploads the vertex buffer
#: once and never again: the per-frame work is the field texture and nothing else.
deformed: bool = False
#: What composing this frame cost, measured where it was composed. Carried per frame #: What composing this frame cost, measured where it was composed. Carried per frame
#: rather than read from the composer's shared counter, which the next task rewrites. #: rather than read from the composer's shared counter, which the next task rewrites.
compose_ms: float = 0.0 compose_ms: float = 0.0
@@ -864,8 +907,9 @@ def validate_surface_frame(frame: SurfaceFieldFrame) -> tuple[Diagnostic, ...]:
def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnostic, ...]: def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnostic, ...]:
"""The sampling table: shapes, ranges, weights and the domain it belongs to.""" """The UV map and the sampling table: shapes, ranges, weights and the domain."""
for name, array in ( for name, array in (
("uvs", binding.uvs),
("sample_indices", binding.sample_indices), ("sample_indices", binding.sample_indices),
("sample_weights", binding.sample_weights), ("sample_weights", binding.sample_weights),
): ):
@@ -874,6 +918,7 @@ def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnos
# binding was built for a different mesh (1032 rows against a 1005-vertex # binding was built for a different mesh (1032 rows against a 1005-vertex
# export), not a differently shaped UV field. # export), not a differently shaped UV field.
for name, array in ( for name, array in (
("uvs", binding.uvs),
("sample_indices", binding.sample_indices), ("sample_indices", binding.sample_indices),
("sample_weights", binding.sample_weights), ("sample_weights", binding.sample_weights),
): ):
@@ -883,15 +928,35 @@ def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnos
f"{name} has {int(array.shape[0])} rows for {int(vertices)} sensing " f"{name} has {int(array.shape[0])} rows for {int(vertices)} sensing "
"vertices: this binding belongs to a different mesh", "vertices: this binding belongs to a different mesh",
) )
_shape("uvs", binding.uvs, (vertices, 2))
_shape("sample_indices", binding.sample_indices, (vertices, 4)) _shape("sample_indices", binding.sample_indices, (vertices, 4))
_shape("sample_weights", binding.sample_weights, (vertices, 4)) _shape("sample_weights", binding.sample_weights, (vertices, 4))
if binding.uvs.dtype != np.float32:
raise error("field_domain_mismatch", "uvs must be float32")
if not bool(np.isfinite(binding.uvs).all()):
# A non-finite UV is not a UV: it would put a fragment's sample somewhere no
# domain pixel exists, and the texture lookup would be undefined.
raise error("field_domain_mismatch", "the render UVs must be finite")
height, width = binding.shape_hw
if height <= 0 or width <= 0:
raise error("field_domain_mismatch", f"invalid field shape {binding.shape_hw}")
# The UVs are already on the pixel grid, so "inside the domain" is a range check
# with a small tolerance: a compiler that stored u/v instead of pixel coordinates
# lands in [0,1] and fails here rather than sampling one texel in the corner.
if bool((binding.uvs < -0.5).any()) or bool(
(binding.uvs[:, 0] > float(width - 1) + 0.5).any()
) or bool((binding.uvs[:, 1] > float(height - 1) + 0.5).any()):
raise error(
"field_domain_mismatch",
"the render UVs are not on this binding's pixel grid "
f"(expected x in [0,{int(width) - 1}], y in [0,{int(height) - 1}])",
u_min=f"{float(binding.uvs.min()):.4f}",
u_max=f"{float(binding.uvs.max()):.4f}",
)
if binding.sample_indices.dtype != np.int32: if binding.sample_indices.dtype != np.int32:
raise error("field_domain_mismatch", "sample_indices must be int32") raise error("field_domain_mismatch", "sample_indices must be int32")
if binding.sample_weights.dtype != np.float32: if binding.sample_weights.dtype != np.float32:
raise error("field_domain_mismatch", "sample_weights must be float32") raise error("field_domain_mismatch", "sample_weights must be float32")
height, width = binding.shape_hw
if height <= 0 or width <= 0:
raise error("field_domain_mismatch", f"invalid field shape {binding.shape_hw}")
domain = height * width domain = height * width
indices = binding.sample_indices indices = binding.sample_indices
weights = binding.sample_weights weights = binding.sample_weights
@@ -1156,20 +1221,47 @@ def validate_display_settings(settings: DisplaySettings) -> tuple[Diagnostic, ..
return tuple(out) return tuple(out)
def validate_render_frame(frame: RenderFrame, *, vertices: int) -> None: def validate_render_frame(
frame: RenderFrame, *, vertices: int, domain_shape: tuple[int, int] = (0, 0)
) -> None:
for name, array in ( for name, array in (
("positions_sensor_m", frame.positions_sensor_m), ("positions_sensor_m", frame.positions_sensor_m),
("normals_sensor", frame.normals_sensor), ("normals_sensor", frame.normals_sensor),
("scalar", frame.scalar),
("valid", frame.valid),
): ):
_readonly_c_contiguous(name, array) _readonly_c_contiguous(name, array)
_shape("positions_sensor_m", frame.positions_sensor_m, (vertices, 3)) _shape("positions_sensor_m", frame.positions_sensor_m, (vertices, 3))
_shape("normals_sensor", frame.normals_sensor, (vertices, 3)) _shape("normals_sensor", frame.normals_sensor, (vertices, 3))
_shape("scalar", frame.scalar, (vertices,)) field = frame.field
_shape("valid", frame.valid, (vertices,)) for name, array in (("field.scalar", field.scalar), ("field.valid", field.valid)):
if frame.scalar.dtype != np.float32 or frame.valid.dtype != np.bool_: _readonly_c_contiguous(name, array)
raise error("field_domain_mismatch", "render scalar is float32 and valid is bool") if field.scalar.dtype != np.float32 or field.valid.dtype != np.bool_:
raise error("field_domain_mismatch", "the display field is float32 and bool")
if field.scalar.shape != field.valid.shape:
raise error(
"field_domain_mismatch",
"the display field's scalar and validity disagree in shape",
)
if field.scalar.ndim != 2:
raise error(
"field_domain_mismatch",
f"the display field must be [H,W], got {tuple(field.scalar.shape)}",
)
if tuple(field.shape_hw) != tuple(field.scalar.shape):
raise error(
"field_domain_mismatch",
"the display field's declared shape is not its array's shape",
declared=f"{int(field.shape_hw[0])}x{int(field.shape_hw[1])}",
array=f"{int(field.scalar.shape[0])}x{int(field.scalar.shape[1])}",
)
if domain_shape and tuple(field.shape_hw) != tuple(domain_shape):
raise error(
"field_domain_mismatch",
"the display field is not on the scene's UV domain",
field=f"{int(field.shape_hw[0])}x{int(field.shape_hw[1])}",
scene=f"{int(domain_shape[0])}x{int(domain_shape[1])}",
)
if not field.domain_id.strip():
raise error("field_domain_mismatch", "a display field must name its domain")
if not frame.asset_hash.strip() or not frame.geometry_id.strip(): if not frame.asset_hash.strip() or not frame.geometry_id.strip():
raise error("geometry_mismatch", "a render frame must name its asset and geometry") raise error("geometry_mismatch", "a render frame must name its asset and geometry")
for label, arrows in ( for label, arrows in (
@@ -1315,15 +1407,15 @@ class BoxFrameAdapter(Protocol):
) -> Optional[SurfaceFieldFrame]: ... ) -> Optional[SurfaceFieldFrame]: ...
class SurfaceSampler(Protocol):
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ...
class SceneComposer(Protocol): class SceneComposer(Protocol):
def compose( def compose(
self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings
) -> RenderFrame: ) -> RenderFrame:
"""CPU worker: validate identity/units, sample, deform, update normals.""" """CPU worker: validate identity/units, deform, update normals.
It no longer *samples*: the frame's own ``[H,W]`` grid is what the surface
draws (``DisplayField``), so there is no per-vertex gather to get wrong.
"""
... ...
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
@@ -1349,7 +1441,7 @@ class GLDispatch(Protocol):
class GLRenderer(Protocol): class GLRenderer(Protocol):
def initialize(self, capabilities: GLCapabilities) -> None: ... def initialize(self, capabilities: GLCapabilities) -> None: ...
def upload_scene(self, assets: SceneAssets) -> None: ... def upload_scene(self, assets: SceneAssets) -> None: ...
def update_surface(self, frame: RenderFrame) -> None: ... def update_field(self, frame: RenderFrame) -> None: ...
def draw( def draw(
self, self,
camera: CameraMatrices, camera: CameraMatrices,
@@ -30,6 +30,38 @@ from orisys_app_shared.scene3d.contracts import MeshPart, SurfaceBinding, error
#: else is worse than no digest at all. #: else is worse than no digest at all.
DIGEST_SCHEMA = "orisys-scene-digest/1" DIGEST_SCHEMA = "orisys-scene-digest/1"
#: Which digest covers which array. Written down because the texture-first change had
#: to answer exactly this question before it could delete anything, and a table is a
#: better answer than a grep:
#:
#: ``geometry_id``
#: ``MeshPart.positions_sensor_m`` and ``MeshPart.triangles`` of the sensing part.
#: Normals are *not* covered (they are derived), which is what lets the same sensor
#: be shipped with better shading and the same identity.
#: ``binding_id``
#: ``SurfaceBinding.sample_indices``/``sample_weights``, plus the part id, the
#: geometry id and the domain shape. The render UVs are deliberately **not** here:
#: they are the field *correspondence*, and they are covered by the package's own
#: per-entry sha256 instead, so adding them did not move this digest.
#: ``source_geometry_digest``
#: the map's ``world_xyz``, in the source's own units — the one geometry identity a
#: device can reproduce without the metre statement.
#: ``field_domain_id``
#: the domain's shape, flip, scale, origin and the *content digest of the mapping*
#: (``valid_mask``, ``map_x``, ``map_y``). Not any per-vertex array.
#: ``asset_hash``
#: every packaged entry's ``content_digest`` plus the canonical manifest. This is
#: the only digest that covers ``sensing/surface_positions.npy``,
#: ``sensing/surface_normals.npy``, ``sensing/surface_valid.npy``,
#: ``sensing/source_vertex.npy`` and ``sensing/uv.npy``.
DIGEST_COVERAGE: Mapping[str, str] = {
"geometry_id": "sensing positions + triangles",
"binding_id": "sample_indices + sample_weights",
"source_geometry_digest": "the map's world_xyz",
"field_domain_id": "shape, flip, scale, origin, mapping content",
"asset_hash": "every packaged entry (including uvs and surface_*) + the manifest",
}
def _array_bytes(array: Any) -> bytes: def _array_bytes(array: Any) -> bytes:
"""C-contiguous little-endian bytes: the same on every machine.""" """C-contiguous little-endian bytes: the same on every machine."""
+41 -6
View File
@@ -219,6 +219,28 @@ def uv_to_pixel(u: float, v: float, domain: LegacyFieldDomain) -> tuple[float, f
return x, y return x, y
def uvs_to_pixels(render_uv: np.ndarray, domain: LegacyFieldDomain) -> np.ndarray:
"""Every render vertex's UV, once, on the domain's pixel grid.
This is the conversion the *shader* used to have to redo per fragment (and could
not: it has no flip flag), and it is why ``SurfaceBinding.uvs`` holds pixel
coordinates rather than u/v. ``x = u*(W-1)`` and ``y = (1-v)*(H-1)`` when the map
is v-flipped — the same convention as :func:`uv_to_pixel` and
:func:`build_binding`, so the texture the fragment reads and the tap the sampler
would have read are the same pixel by construction.
"""
values = np.asarray(render_uv, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != 2:
raise error("geometry_mismatch", f"render UVs must be [N,2], got {values.shape}")
height, width = domain.shape_hw
x = values[:, 0] * float(width - 1)
if domain.flip_v:
y = (1.0 - values[:, 1]) * float(height - 1)
else:
y = values[:, 1] * float(height - 1)
return freeze(np.stack([x, y], axis=1), dtype=np.float32)
def split_at_uv_seams( def split_at_uv_seams(
vertices: np.ndarray, vertices: np.ndarray,
faces: np.ndarray, faces: np.ndarray,
@@ -540,16 +562,27 @@ def build_binding(
part_id: str, part_id: str,
geometry_id_value: str, geometry_id_value: str,
) -> SurfaceBinding: ) -> SurfaceBinding:
"""Bilinear taps for every render vertex, inside the declared domain. """The render vertices' UVs, on the domain's pixel grid, plus their bilinear taps.
Each row holds up to four ``y*W+x`` indices and their weights. A neighbour that ``uvs`` is the map the *shader* reads: one pixel coordinate per render vertex, in
would fall outside the domain contributes nothing and its weight is the same order as the mesh's positions, already flipped and scaled, so the
redistributed, so the used weights always sum to exactly one and the unused fragment program only normalises by the domain size. Each row of
entries are ``-1``/``0`` — the sampling table can never point outside the map ``sample_indices``/``sample_weights`` holds up to four ``y*W+x`` indices and their
it was built for. weights for the same UV. A neighbour that would fall outside the domain
contributes nothing and its weight is redistributed, so the used weights always
sum to exactly one and the unused entries are ``-1``/``0`` — the sampling table
can never point outside the map it was built for.
The taps are computed from the **unrounded** UV (``uv_to_pixel`` on the float64
u/v) rather than from ``uvs``, and deliberately so: the sampling table is part of
``binding_id``, and deriving it from the float32 pixel coordinates would move that
digest for every existing package even though the correspondence had not changed
by even 1e-5 of a pixel. The two agree to within float32 rounding, which is far
below the distance between neighbouring texels.
""" """
height, width = domain.shape_hw height, width = domain.shape_hw
count = len(render_uv) count = len(render_uv)
pixels = uvs_to_pixels(render_uv, domain)
indices = np.full((count, 4), -1, dtype=np.int32) indices = np.full((count, 4), -1, dtype=np.int32)
weights = np.zeros((count, 4), dtype=np.float32) weights = np.zeros((count, 4), dtype=np.float32)
for row in range(count): for row in range(count):
@@ -584,6 +617,7 @@ def build_binding(
geometry_id=geometry_id_value, geometry_id=geometry_id_value,
field_domain_id=domain.domain_id, field_domain_id=domain.domain_id,
shape_hw=domain.shape_hw, shape_hw=domain.shape_hw,
uvs=pixels,
sample_indices=freeze(indices, dtype=np.int32), sample_indices=freeze(indices, dtype=np.int32),
sample_weights=freeze(weights, dtype=np.float32), sample_weights=freeze(weights, dtype=np.float32),
) )
@@ -766,6 +800,7 @@ class LegacyFingerImporter:
hash_arrays: dict[str, Any] = { hash_arrays: dict[str, Any] = {
"positions": sensing.positions_sensor_m, "positions": sensing.positions_sensor_m,
"triangles": sensing.triangles, "triangles": sensing.triangles,
"uv": binding.uvs,
"indices": binding.sample_indices, "indices": binding.sample_indices,
"weights": binding.sample_weights, "weights": binding.sample_weights,
} }
@@ -99,10 +99,18 @@ MAX_COMPRESSION_RATIO = 200
RATIO_FLOOR_BYTES = 4 << 20 RATIO_FLOOR_BYTES = 4 << 20
#: The arrays a sensing part is made of, and the role each belongs to. #: The arrays a sensing part is made of, and the role each belongs to.
#:
#: ``sensing/uv.npy`` is **required**, not optional: the surface's colour is the field
#: texture sampled through these UVs, so a package without them cannot be drawn at all.
#: A reader that fell back to "no UVs" would have to paint the surface untextured, and
#: an untextured measurement is a picture that claims to be a measurement and is not —
#: see :func:`scene_from_contents`, which refuses such a package by name and says the
#: repair is a recompile.
SENSING_ARRAYS = { SENSING_ARRAYS = {
"sensing/positions.npy": ("f4", 2), "sensing/positions.npy": ("f4", 2),
"sensing/normals.npy": ("f4", 2), "sensing/normals.npy": ("f4", 2),
"sensing/triangles.npy": ("u4", 2), "sensing/triangles.npy": ("u4", 2),
"sensing/uv.npy": ("f4", 2),
"sensing/sample_indices.npy": ("i4", 2), "sensing/sample_indices.npy": ("i4", 2),
"sensing/sample_weights.npy": ("f4", 2), "sensing/sample_weights.npy": ("f4", 2),
} }
@@ -233,6 +241,7 @@ def _arrays_of(assets: SceneAssets) -> dict[str, np.ndarray]:
arrays["sensing/positions.npy"] = np.ascontiguousarray(sensing.positions_sensor_m) arrays["sensing/positions.npy"] = np.ascontiguousarray(sensing.positions_sensor_m)
arrays["sensing/normals.npy"] = np.ascontiguousarray(sensing.normals_sensor) arrays["sensing/normals.npy"] = np.ascontiguousarray(sensing.normals_sensor)
arrays["sensing/triangles.npy"] = np.ascontiguousarray(sensing.triangles) arrays["sensing/triangles.npy"] = np.ascontiguousarray(sensing.triangles)
arrays["sensing/uv.npy"] = np.ascontiguousarray(assets.binding.uvs)
arrays["sensing/sample_indices.npy"] = np.ascontiguousarray(assets.binding.sample_indices) arrays["sensing/sample_indices.npy"] = np.ascontiguousarray(assets.binding.sample_indices)
arrays["sensing/sample_weights.npy"] = np.ascontiguousarray(assets.binding.sample_weights) arrays["sensing/sample_weights.npy"] = np.ascontiguousarray(assets.binding.sample_weights)
if assets.source_vertex_of_render is not None: if assets.source_vertex_of_render is not None:
@@ -688,11 +697,37 @@ def scene_from_contents(
) )
if not parts: if not parts:
raise error("geometry_mismatch", "the package has no mesh parts") raise error("geometry_mismatch", "the package has no mesh parts")
# The texture-first path has no fallback, so the absence of the UV map is a
# refusal with a named repair rather than a scene that renders untextured. The
# check is here, ahead of the binding, so the message names the *entry* an engineer
# has to add rather than a KeyError from a dictionary lookup.
if "sensing/uv.npy" not in arrays:
raise error(
"asset_rejected",
"this scene package carries no sensing/uv.npy, so the sensing surface has "
"no UV map to sample the field through: it was compiled before the "
"texture-first surface and must be recompiled "
"(tools/scene_assets/compile_scene.py)",
entry="sensing/uv.npy",
found="absent",
required="the render-vertex UVs, [N,2] on the field domain's pixel grid",
repair="recompile the package",
)
for required_entry in SENSING_ARRAYS:
if required_entry not in arrays:
raise error(
"asset_rejected",
f"this scene package is missing {required_entry}, which every sensing "
"surface needs: recompile the package",
entry=required_entry,
repair="recompile the package",
)
binding = SurfaceBinding( binding = SurfaceBinding(
part_id="sensing_surface", part_id="sensing_surface",
geometry_id=str(manifest.get("geometry_id") or ""), geometry_id=str(manifest.get("geometry_id") or ""),
field_domain_id=str(domain.get("id") or ""), field_domain_id=str(domain.get("id") or ""),
shape_hw=(shape[0], shape[1]), shape_hw=(shape[0], shape[1]),
uvs=freeze(arrays["sensing/uv.npy"], dtype=np.float32),
sample_indices=freeze(arrays["sensing/sample_indices.npy"], dtype=np.int32), sample_indices=freeze(arrays["sensing/sample_indices.npy"], dtype=np.int32),
sample_weights=freeze(arrays["sensing/sample_weights.npy"], dtype=np.float32), sample_weights=freeze(arrays["sensing/sample_weights.npy"], dtype=np.float32),
) )
+102 -102
View File
@@ -1,33 +1,43 @@
"""Sampling a UV field onto the sensing vertices. Pure NumPy, no Qt, no GL. """Field plumbing that is still CPU work. Pure NumPy, no Qt, no GL.
Stage 3 in ``docs/architecture/studio-3d-stages.md``. Stage 3 in ``docs/architecture/studio-3d-stages.md``.
The sampling table is built once, at load time, from the field's own domain. It is **The per-vertex colour gather is gone.** This module used to hold
never rebuilt per frame, and a vertex is never repaired by searching for the nearest ``SurfaceSampler.sample``: a vectorised bilinear gather of the field onto every
triangle: if any tap that carries weight is invalid, the vertex is invalid. sensing vertex, with a ``NaN`` for a vertex whose taps touched one invalid pixel. The
fragment shader then had to interpret that ``NaN``, and the rule it used —
``if (v_valid < 0.5 || isnan(v_scalar))`` — made a whole triangle grey as soon as one
of its corners was invalid. On the shipped finger asset that painted **30.15 % of the
island grey** and put the painted region p50 6.7 px / max 25.8 px away from the 2D
view.
Three details that decide whether a measurement is honest: The field now reaches the surface whole, as a texture sampled per *fragment*
(``contracts.DisplayField``), so there is nothing here to gather for colour and no
fallback for a caller to reach for.
* **an invalid tap is not zero.** Weighting a gap with weight 0 would still poison Two things remain, and they are not the colour path:
the sum, because ``0 * NaN`` is ``NaN``; invalid taps are therefore excluded from
the sum and the *vertex* is marked invalid, which is what the shader draws as the * :func:`remap_to_domain` — a gather, but of a *source* field onto the scene's domain,
neutral colour. done once in the acquisition adapter before any frame is published. It is how a
* **validity is a separate array from the value.** A vertex whose scalar happens to tracker field that arrives in the camera's grid reaches the UV domain.
be 0.0 is a measurement of zero; a vertex that is invalid has no value at all, and * :func:`sample_vertex_displacement` — the display **deformation**, which moves
the value is left as ``NaN`` so nobody can mistake one for the other. vertices and is therefore a vertex-stage quantity by definition. It runs only when
* **the domain is checked, not assumed.** A frame whose domain or shape does not ``DisplaySettings.deform_enabled`` is set, and no adapter in this build publishes a
match the binding is refused with ``field_domain_mismatch`` rather than sampled displacement, so the application never executes it.
into a plausible-looking but wrong picture.
Neither of them reads the scalar field: a unit test asserts that the render path
touches the frame's own arrays and never copies them (``tests/test_scene3d_live.py``).
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional
import numpy as np import numpy as np
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
SurfaceBinding, SurfaceBinding,
SurfaceFieldFrame, SurfaceFieldFrame,
VertexFields,
error, error,
freeze, freeze,
) )
@@ -37,97 +47,87 @@ from orisys_app_shared.scene3d.contracts import (
WEIGHT_EPS = 1e-7 WEIGHT_EPS = 1e-7
class SurfaceSampler: def check_field_domain(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None:
"""Vectorised bilinear sampling through a precomputed binding.""" """Refuse a frame that is not on this binding's geometry, domain and shape.
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: Kept as a function rather than a method so the *texture* path can make exactly the
self._check_domains(frame, binding) same check without importing a sampler: a frame from another domain would be
indices = np.asarray(binding.sample_indices, dtype=np.int64) # [N,4] uploaded as a texture of the wrong size and quietly rescaled by the sampler, which
weights = np.asarray(binding.sample_weights, dtype=np.float64) # [N,4] is the class of bug this check exists to prevent.
used = indices >= 0 """
if not used.any() and len(indices): if binding.geometry_id and frame.geometry_id != binding.geometry_id:
raise error("field_domain_mismatch", "the binding samples nothing at all") raise error(
# ``-1`` -> index 0 for the gather; the weight there is 0 by contract and the "geometry_mismatch",
# ``used`` mask keeps it out of both the sum and the validity test. "the frame belongs to a different geometry than the binding",
safe = np.where(used, indices, 0) frame=frame.geometry_id,
height, width = frame.scalar.shape binding=binding.geometry_id,
flat_scalar = np.asarray(frame.scalar, dtype=np.float64).reshape(-1) )
flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1) if binding.field_domain_id and frame.field_domain_id != binding.field_domain_id:
if safe.size and int(safe.max()) >= flat_scalar.size: raise error(
raise error( "field_domain_mismatch",
"field_domain_mismatch", "the frame is on a different field domain than the binding",
"a binding index falls outside the frame's field", frame=frame.field_domain_id,
index=int(safe.max()), binding=binding.field_domain_id,
size=int(flat_scalar.size), )
) if tuple(frame.scalar.shape) != tuple(binding.shape_hw):
gathered_scalar = flat_scalar[safe] raise error(
gathered_valid = flat_valid[safe] "field_domain_mismatch",
finite = np.isfinite(gathered_scalar) "the frame's shape does not match the binding's domain",
# A tap counts only where it is used, in range, finite and valid. frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}",
contributed = used & gathered_valid & finite binding=f"{binding.shape_hw[0]}x{binding.shape_hw[1]}",
valid = contributed | ~used )
valid = valid.all(axis=1) if frame.scalar.shape != frame.valid.shape:
contribution = np.where(contributed, gathered_scalar, 0.0) * weights raise error(
scalar = contribution.sum(axis=1) "field_domain_mismatch", "the frame's scalar and validity disagree"
# No measurement: the value is absent, not zero.
scalar = np.where(valid, scalar, np.nan)
displacement = None
if frame.displacement_sensor_m is not None:
flat_displacement = np.asarray(
frame.displacement_sensor_m, dtype=np.float64
).reshape(-1, 3)
if flat_displacement.shape[0] != flat_scalar.shape[0]:
raise error(
"field_domain_mismatch",
"the displacement field does not match the scalar field",
)
gathered = flat_displacement[safe] # [N,4,3]
finite_disp = np.isfinite(gathered).all(axis=2)
contributed_disp = used & gathered_valid & finite_disp
displacement = np.where(contributed_disp[:, :, None], gathered, 0.0)
displacement = (displacement * weights[:, :, None]).sum(axis=1)
displacement = np.where(valid[:, None], displacement, np.nan)
displacement = freeze(displacement, dtype=np.float32)
del height, width
return VertexFields(
scalar=freeze(scalar, dtype=np.float32),
valid=freeze(valid, dtype=np.bool_),
displacement_sensor_m=displacement,
) )
@staticmethod
def _check_domains(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None:
if binding.geometry_id and frame.geometry_id != binding.geometry_id:
raise error(
"geometry_mismatch",
"the frame belongs to a different geometry than the binding",
frame=frame.geometry_id,
binding=binding.geometry_id,
)
if binding.field_domain_id and frame.field_domain_id != binding.field_domain_id:
raise error(
"field_domain_mismatch",
"the frame is on a different field domain than the binding",
frame=frame.field_domain_id,
binding=binding.field_domain_id,
)
if tuple(frame.scalar.shape) != tuple(binding.shape_hw):
raise error(
"field_domain_mismatch",
"the frame's shape does not match the binding's domain",
frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}",
binding=f"{binding.shape_hw[0]}x{binding.shape_hw[1]}",
)
if frame.scalar.shape != frame.valid.shape:
raise error(
"field_domain_mismatch", "the frame's scalar and validity disagree"
)
def sample_vertex_displacement(
frame: SurfaceFieldFrame, binding: SurfaceBinding
) -> Optional[np.ndarray]:
"""The frame's displacement, gathered onto the render vertices.
def sample_field(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ``None`` when the frame carries no displacement — the common case, and the only
"""Convenience wrapper: one sampler, no state to keep.""" case this build's adapters can produce. A tap that is unused or invalid makes the
return SurfaceSampler().sample(frame, binding) vertex's displacement ``NaN``, which the composer reads as "do not move this
vertex": an absent displacement must not be read as a zero-length one, because
that would move a vertex by exactly nothing and call it measured.
This is geometry, not colour. It does **not** touch ``frame.scalar``.
"""
displacement = frame.displacement_sensor_m
if displacement is None:
return None
check_field_domain(frame, binding)
indices = np.asarray(binding.sample_indices, dtype=np.int64) # [N,4]
weights = np.asarray(binding.sample_weights, dtype=np.float64) # [N,4]
used = indices >= 0
if not used.any() and len(indices):
raise error("field_domain_mismatch", "the binding samples nothing at all")
safe = np.where(used, indices, 0)
flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1)
flat_displacement = np.asarray(displacement, dtype=np.float64).reshape(-1, 3)
if flat_displacement.shape[0] != flat_valid.size:
raise error(
"field_domain_mismatch",
"the displacement field does not match the scalar field",
)
if safe.size and int(safe.max()) >= flat_valid.size:
raise error(
"field_domain_mismatch",
"a binding index falls outside the frame's field",
index=int(safe.max()),
size=int(flat_valid.size),
)
gathered = flat_displacement[safe] # [N,4,3]
gathered_valid = flat_valid[safe]
finite = np.isfinite(gathered).all(axis=2)
contributed = used & gathered_valid & finite
valid = (contributed | ~used).all(axis=1)
values = np.where(contributed[:, :, None], gathered, 0.0)
values = (values * weights[:, :, None]).sum(axis=1)
values = np.where(valid[:, None], values, np.nan)
return freeze(values, dtype=np.float32)
def remap_to_domain( def remap_to_domain(
@@ -26,6 +26,7 @@ from orisys_app_shared.scene3d.contracts import (
freeze, freeze,
) )
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
from orisys_app_shared.scene3d.legacy import LegacyFieldDomain, uvs_to_pixels
#: The plate is 20 mm x 20 mm, sampled 3x3: small enough to read in a test, big #: The plate is 20 mm x 20 mm, sampled 3x3: small enough to read in a test, big
#: enough to have an interior vertex that a bilinear tap can move. #: enough to have an interior vertex that a bilinear tap can move.
@@ -117,8 +118,29 @@ POC_DOMAIN_ID = field_domain_id(shape_hw=(GRID, GRID), flip_v=False)
def _binding() -> SurfaceBinding: def _binding() -> SurfaceBinding:
"""Identity binding: each plate vertex samples its own field cell.""" """Identity binding: each plate vertex samples its own field cell.
The UVs are the plate's own parameterisation, converted to the domain's pixel
grid by the *same* function the importer uses (``legacy.uvs_to_pixels``) rather
than by a second, hand-written convention. With a 3x3 domain and no v flip that
lands exactly on the identity — vertex ``row*GRID+col`` owns texel ``(col,row)``
— so a fragment's texel is a fact the tests can state without re-deriving it.
That is what makes this scene able to pin the texture-first path: the surface's
colour and validity now come from the field texture, and the PoC's UVs say
precisely which texel every fragment must read.
"""
count = GRID * GRID count = GRID * GRID
domain = LegacyFieldDomain(
shape_hw=(GRID, GRID),
flip_v=False,
source_scale=None,
valid_mask=freeze(np.ones((GRID, GRID), dtype=bool), dtype=np.bool_),
domain_id=POC_DOMAIN_ID,
)
uvs = np.zeros((count, 2), dtype=np.float32)
for row in range(GRID):
for col in range(GRID):
uvs[row * GRID + col] = (col / (GRID - 1), row / (GRID - 1))
indices = np.full((count, 4), -1, dtype=np.int32) indices = np.full((count, 4), -1, dtype=np.int32)
weights = np.zeros((count, 4), dtype=np.float32) weights = np.zeros((count, 4), dtype=np.float32)
for index in range(count): for index in range(count):
@@ -129,6 +151,7 @@ def _binding() -> SurfaceBinding:
geometry_id=POC_GEOMETRY_ID, geometry_id=POC_GEOMETRY_ID,
field_domain_id=POC_DOMAIN_ID, field_domain_id=POC_DOMAIN_ID,
shape_hw=(GRID, GRID), shape_hw=(GRID, GRID),
uvs=uvs_to_pixels(uvs, domain),
sample_indices=freeze(indices, dtype=np.int32), sample_indices=freeze(indices, dtype=np.int32),
sample_weights=freeze(weights, dtype=np.float32), sample_weights=freeze(weights, dtype=np.float32),
) )
+19 -5
View File
@@ -39,11 +39,16 @@ def _binding(vertices: int = 4):
[[0, 1, 5, 6], [1, 2, 6, 7], [4, 5, 9, 10], [5, 6, 10, 11]], dtype=np.int32 [[0, 1, 5, 6], [1, 2, 6, 7], [4, 5, 9, 10], [5, 6, 10, 11]], dtype=np.int32
)[:vertices] )[:vertices]
weights = np.full((vertices, 4), 0.25, dtype=np.float32) weights = np.full((vertices, 4), 0.25, dtype=np.float32)
# The domain is 3x4, so its pixel grid is x in [0,3], y in [0,2].
uvs = np.array(
[[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]], dtype=np.float32
)[:vertices]
return c.SurfaceBinding( return c.SurfaceBinding(
part_id="sensing_surface", part_id="sensing_surface",
geometry_id="geom-abc", geometry_id="geom-abc",
field_domain_id="domain-xyz", field_domain_id="domain-xyz",
shape_hw=(3, 4), shape_hw=(3, 4),
uvs=c.freeze(uvs, dtype=np.float32),
sample_indices=c.freeze(indices, dtype=np.int32), sample_indices=c.freeze(indices, dtype=np.int32),
sample_weights=c.freeze(weights, dtype=np.float32), sample_weights=c.freeze(weights, dtype=np.float32),
) )
@@ -175,6 +180,7 @@ def test_a_binding_with_an_out_of_domain_index_is_refused():
geometry_id=binding.geometry_id, geometry_id=binding.geometry_id,
field_domain_id=binding.field_domain_id, field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw, shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=c.freeze(indices, dtype=np.int32), sample_indices=c.freeze(indices, dtype=np.int32),
sample_weights=binding.sample_weights, sample_weights=binding.sample_weights,
) )
@@ -191,6 +197,7 @@ def test_unused_samples_must_be_minus_one_with_zero_weight():
geometry_id=binding.geometry_id, geometry_id=binding.geometry_id,
field_domain_id=binding.field_domain_id, field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw, shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=c.freeze(indices, dtype=np.int32), sample_indices=c.freeze(indices, dtype=np.int32),
sample_weights=binding.sample_weights, sample_weights=binding.sample_weights,
) )
@@ -207,6 +214,7 @@ def test_weights_must_sum_to_one():
geometry_id=binding.geometry_id, geometry_id=binding.geometry_id,
field_domain_id=binding.field_domain_id, field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw, shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=binding.sample_indices, sample_indices=binding.sample_indices,
sample_weights=c.freeze(weights, dtype=np.float32), sample_weights=c.freeze(weights, dtype=np.float32),
) )
@@ -263,6 +271,7 @@ def test_the_binding_must_belong_to_the_sensing_part():
geometry_id=binding.geometry_id, geometry_id=binding.geometry_id,
field_domain_id=binding.field_domain_id, field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw, shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=binding.sample_indices, sample_indices=binding.sample_indices,
sample_weights=binding.sample_weights, sample_weights=binding.sample_weights,
) )
@@ -491,15 +500,20 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
"""The acquisition thread imports this package; it must not drag Qt or GL in. """The acquisition thread imports this package; it must not drag Qt or GL in.
Stages 2 and 3 filled in the CPU pieces, so this checks they are reachable and Stages 2 and 3 filled in the CPU pieces, so this checks they are reachable and
usable here — the sampler and the composer run on a machine with no GPU, which usable here — the composer and the field domain checks run on a machine with no
is the whole reason the contracts layer is separate from the view. GPU, which is the whole reason the contracts layer is separate from the view.
""" """
from orisys_app_shared.scene3d import assets, composer, sampling, synthetic from orisys_app_shared.scene3d import assets, composer, sampling, synthetic
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
frame = _frame() frame = _frame()
fields = sampling.sample_field(frame, _binding()) # The colour path makes no gather at all; the only sampling left is the field
assert np.asarray(fields.scalar).shape == (4,) # remap and the vertex displacement, and both are pure NumPy.
assert callable(sampling.remap_to_domain)
assert callable(sampling.sample_vertex_displacement)
assert not hasattr(sampling, "sample_field"), (
"the per-vertex colour gather is back; the texture-first path has no fallback"
)
composed = composer.SceneComposer().compose( composed = composer.SceneComposer().compose(
scene, c.SurfaceFieldFrame( scene, c.SurfaceFieldFrame(
identity=frame.identity, identity=frame.identity,
@@ -511,7 +525,7 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
), ),
c.DisplaySettings(), c.DisplaySettings(),
) )
assert composed.scalar.shape == (9,) assert composed.field.scalar.shape == (3, 3)
# The loading facade is real too (stage 2), and the compiler refuses what it # The loading facade is real too (stage 2), and the compiler refuses what it
# cannot honestly do: asking for encryption without key material, or without the # cannot honestly do: asking for encryption without key material, or without the
# crypto backend, must not produce a plain package that looks protected. # crypto backend, must not produce a plain package that looks protected.
+133 -106
View File
@@ -1,9 +1,10 @@
"""Stage 3 acceptance: sampling, composition, and the frame schedule. """Stage 3 acceptance: the field path, composition, and the frame schedule.
The three rows that matter most are the ones that are easy to get subtly wrong and The three rows that matter most are the ones that are easy to get subtly wrong and
impossible to notice on screen: an invalid tap must invalidate the *vertex* rather impossible to notice on screen: the *colour* path must never resample the field onto
than read as zero, the display gain must not touch the measurement, and a producer the vertices (that is what painted 30.15 % of the island grey), the display gain must
must never be able to make the display queue grow. not touch the measurement, and a producer must never be able to make the display queue
grow.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,9 +31,8 @@ from orisys_app_shared.scene3d.contracts import (
rest_descriptor, rest_descriptor,
) )
from orisys_app_shared.scene3d.sampling import ( from orisys_app_shared.scene3d.sampling import (
SurfaceSampler,
remap_to_domain, remap_to_domain,
sample_field, sample_vertex_displacement,
) )
@@ -70,133 +70,155 @@ def _binding(shape=(3, 3), indices=None, weights=None):
from orisys_app_shared.scene3d.contracts import SurfaceBinding from orisys_app_shared.scene3d.contracts import SurfaceBinding
count = int(indices.shape[0]) if indices is not None else shape[0] * shape[1] count = int(indices.shape[0]) if indices is not None else shape[0] * shape[1]
uvs = np.zeros((count, 2), dtype=np.float32)
if count == shape[0] * shape[1]:
grid = np.stack(
np.meshgrid(np.arange(shape[1]), np.arange(shape[0])), axis=-1
).reshape(-1, 2)
uvs = grid.astype(np.float32)
return SurfaceBinding( return SurfaceBinding(
part_id="sensing_surface", part_id="sensing_surface",
geometry_id=scene.geometry_id, geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id, field_domain_id=scene.field_domain_id,
shape_hw=shape, shape_hw=shape,
uvs=freeze(uvs, dtype=np.float32),
sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32), sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32),
sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32), sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32),
) )
# --------------------------------------------------------------------- sampling # -------------------------------------------------------- the colour field path
def test_a_constant_field_samples_to_the_same_constant(): def test_the_colour_path_never_gathers_the_field_onto_the_vertices():
"""The property the whole texture-first change exists for.
The render frame must carry the acquisition frame's *own* ``[H,W]`` grid — not a
per-vertex resampling of it. This is asserted by identity, which is the strongest
form available: a gather cannot produce the same object.
"""
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
fields = sample_field(_frame(scene, np.full((3, 3), 7.5)), scene.binding) frame = _frame(scene, np.arange(9, dtype=np.float32).reshape(3, 3))
assert np.allclose(np.asarray(fields.scalar), 7.5) composed = SceneComposer().compose(scene, frame, DisplaySettings())
assert np.asarray(fields.valid).all() assert composed.field.scalar is frame.scalar
assert composed.field.valid is frame.valid
assert composed.field.shape_hw == (3, 3)
assert composed.field.domain_id == scene.field_domain_id
assert composed.field.descriptor is frame.scalar_descriptor
# ...and there is no per-vertex array left to read by mistake.
assert not hasattr(composed, "scalar")
assert not hasattr(composed, "valid")
def test_a_ramp_field_samples_exactly_like_the_reference(): def test_a_hole_in_the_field_stays_a_hole_and_is_never_a_vertex_nan():
"""Every vertex samples its own cell, so the sampled value is the cell value.""" """A gap is not a measurement of zero, and it is not a grey *triangle* either.
Under the old sampler one invalid tap made a whole vertex ``NaN``, and the shader
painted every triangle touching it grey. Now the hole travels as a hole: exactly
the pixels the device called invalid are invalid, and a value of 0.0 inside them
is not read at all.
"""
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
ramp = np.arange(9, dtype=np.float32).reshape(3, 3)
fields = sample_field(_frame(scene, ramp), scene.binding)
assert np.array_equal(np.asarray(fields.scalar), ramp.reshape(-1))
def test_a_single_sample_spike_reaches_exactly_one_vertex():
scene = synthetic.poc_scene()
field = np.zeros((3, 3), dtype=np.float32)
field[1, 1] = 5.0
fields = sample_field(_frame(scene, field), scene.binding)
scalar = np.asarray(fields.scalar)
assert int((scalar > 0).sum()) == 1
assert scalar[4] == 5.0
def test_bilinear_sampling_averages_the_four_taps():
"""A vertex between pixels gets the weighted average, computed by hand."""
scene = synthetic.poc_scene()
indices = np.array([[0, 1, 3, 4]], dtype=np.int32)
weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32)
binding = _binding(indices=indices, weights=weights)
field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32)
fields = sample_field(_frame(scene, field), binding)
assert np.asarray(fields.scalar)[0] == pytest.approx((1 + 2 + 3 + 4) / 4)
assert bool(np.asarray(fields.valid)[0])
def test_one_invalid_tap_invalidates_the_vertex_and_is_not_read_as_zero():
"""A gap is not a measurement of zero: the value must be absent."""
scene = synthetic.poc_scene()
indices = np.array([[0, 1, 3, 4]], dtype=np.int32)
weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32)
binding = _binding(indices=indices, weights=weights)
field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32)
valid = np.ones((3, 3), dtype=bool) valid = np.ones((3, 3), dtype=bool)
valid[1, 1] = False # index 4 is a positive-weight tap valid[1, 1] = False
fields = sample_field(_frame(scene, field, valid), binding)
assert not bool(np.asarray(fields.valid)[0])
assert np.isnan(np.asarray(fields.scalar)[0]), (
"an invalid vertex must carry no value at all, not a sum with a zero in it"
)
def test_an_unused_tap_is_neither_a_contribution_nor_a_reason_to_invalidate():
scene = synthetic.poc_scene()
indices = np.array([[4, -1, -1, -1]], dtype=np.int32)
weights = np.array([[1.0, 0.0, 0.0, 0.0]], dtype=np.float32)
binding = _binding(indices=indices, weights=weights)
field = np.zeros((3, 3), dtype=np.float32) field = np.zeros((3, 3), dtype=np.float32)
field[1, 1] = 3.0 field[1, 1] = 99.0
valid = np.ones((3, 3), dtype=bool) composed = SceneComposer().compose(scene, _frame(scene, field, valid), DisplaySettings())
valid[0, 0] = False # unused, so it must not matter assert composed.field.valid.tolist() == valid.tolist()
fields = sample_field(_frame(scene, field, valid), binding) assert int(composed.field.valid.sum()) == 8
assert bool(np.asarray(fields.valid)[0]) # The scalar is carried through untouched, `NaN` or not: nothing rewrites it into
assert np.asarray(fields.scalar)[0] == pytest.approx(3.0) # a per-vertex absence, so the fragment reads the *device's* answer.
assert np.array_equal(np.asarray(composed.field.scalar), field)
def test_a_non_finite_scalar_invalidates_rather_than_colouring_nan():
scene = synthetic.poc_scene()
field = np.full((3, 3), 1.0, dtype=np.float32)
field[2, 2] = np.nan
fields = sample_field(_frame(scene, field), scene.binding)
assert not bool(np.asarray(fields.valid)[8])
assert np.isnan(np.asarray(fields.scalar)[8])
def test_a_zero_measurement_is_valid_and_stays_zero(): def test_a_zero_measurement_is_valid_and_stays_zero():
"""Zero is a value. Only `valid` decides whether it means anything.""" """Zero is a value. Only `valid` decides whether it means anything."""
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
fields = sample_field(_frame(scene, np.zeros((3, 3), dtype=np.float32)), scene.binding) composed = SceneComposer().compose(
assert np.asarray(fields.valid).all() scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings()
assert np.all(np.asarray(fields.scalar) == 0.0) )
assert not np.isnan(np.asarray(fields.scalar)).any() assert composed.field.valid.all()
assert np.all(np.asarray(composed.field.scalar) == 0.0)
assert not np.isnan(np.asarray(composed.field.scalar)).any()
def test_the_sampler_refuses_a_frame_from_another_domain(): def test_the_composer_refuses_a_frame_from_another_domain():
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain") frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain")
with pytest.raises(ThreeDError) as excinfo: with pytest.raises(ThreeDError) as excinfo:
sample_field(frame, scene.binding) SceneComposer().compose(scene, frame, DisplaySettings())
assert excinfo.value.diagnostic.code == "field_domain_mismatch" assert excinfo.value.diagnostic.code == "field_domain_mismatch"
def test_the_sampler_refuses_a_frame_of_the_wrong_shape(): def test_the_composer_refuses_a_frame_of_the_wrong_shape():
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32)) frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32))
with pytest.raises(ThreeDError) as excinfo: with pytest.raises(ThreeDError) as excinfo:
sample_field(frame, scene.binding) SceneComposer().compose(scene, frame, DisplaySettings())
assert "shape does not match" in excinfo.value.diagnostic.message assert excinfo.value.diagnostic.code == "field_domain_mismatch"
def test_sampling_is_vectorised_enough_for_a_large_mesh(): def test_composing_a_frame_costs_no_per_vertex_work():
"""A full-resolution binding is ~10^5 vertices; sampling must not crawl.""" """The measured CPU cost of the colour path, over a realistic grid.
The old path gathered 1032 vertices x 4 taps every frame *and* the panel's own
tests measured it as the bulk of a compose. The field path is a shape check and a
dataclass: the number below is the evidence.
"""
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
count = 20_000 composer = SceneComposer()
rng = np.random.default_rng(7) frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
indices = rng.integers(0, 9, size=(count, 4)).astype(np.int32) composer.compose(scene, frame, DisplaySettings())
weights = rng.random((count, 4)).astype(np.float32) samples = []
weights /= weights.sum(axis=1, keepdims=True) for _ in range(20):
binding = _binding(indices=indices, weights=weights) composer.compose(scene, frame, DisplaySettings())
frame = _frame(scene, rng.random((3, 3)).astype(np.float32)) samples.append(composer.last_stats.sample_ms)
started = time.perf_counter() assert max(samples) < 2.0, f"the colour path still does per-vertex work: {samples}"
fields = sample_field(frame, binding)
elapsed = (time.perf_counter() - started) * 1000.0
assert np.asarray(fields.scalar).shape == (count,) # -------------------------------------------- the vertex displacement (geometry)
assert elapsed < 150.0, f"sampling {count} vertices took {elapsed:.1f} ms" def test_a_constant_displacement_gathers_to_the_same_constant():
scene = synthetic.poc_scene()
displacement = np.full((3, 3, 3), 0.25, dtype=np.float32)
frame = _frame(
scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement
)
values = sample_vertex_displacement(frame, scene.binding)
assert values is not None
assert np.allclose(np.asarray(values), 0.25)
def test_an_invalid_tap_leaves_that_vertex_with_no_displacement():
"""Absent is absent: the vertex does not move at all, and the composer reads NaN."""
scene = synthetic.poc_scene()
displacement = np.ones((3, 3, 3), dtype=np.float32) * 0.5
valid = np.ones((3, 3), dtype=bool)
valid[1, 1] = False
frame = _frame(
scene, np.zeros((3, 3), dtype=np.float32), valid, displacement=displacement
)
values = np.asarray(sample_vertex_displacement(frame, scene.binding))
assert np.isnan(values[4]).all()
assert np.allclose(values[0], 0.5)
def test_a_frame_without_a_displacement_gathers_nothing():
scene = synthetic.poc_scene()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
assert sample_vertex_displacement(frame, scene.binding) is None
def test_the_displacement_path_never_reads_the_scalar_field():
"""It is geometry, not colour: the gathered value may not depend on the scalar."""
scene = synthetic.poc_scene()
displacement = np.ones((3, 3, 3), dtype=np.float32) * 0.125
first = sample_vertex_displacement(
_frame(scene, np.full((3, 3), 1.0, dtype=np.float32), displacement=displacement),
scene.binding,
)
second = sample_vertex_displacement(
_frame(scene, np.full((3, 3), 900.0, dtype=np.float32), displacement=displacement),
scene.binding,
)
assert np.array_equal(np.asarray(first), np.asarray(second))
# ----------------------------------------------------------------------- remap # ----------------------------------------------------------------------- remap
@@ -267,9 +289,9 @@ def test_the_gain_moves_positions_and_never_the_measurement():
scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0) scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0)
) )
# The measurement is identical in all three. # The measurement is identical in all three.
assert np.array_equal(np.asarray(quiet.scalar), np.asarray(small.scalar)) assert np.array_equal(np.asarray(quiet.field.scalar), np.asarray(small.field.scalar))
assert np.array_equal(np.asarray(small.scalar), np.asarray(large.scalar)) assert np.array_equal(np.asarray(small.field.scalar), np.asarray(large.field.scalar))
assert np.array_equal(np.asarray(quiet.valid), np.asarray(large.valid)) assert np.array_equal(np.asarray(quiet.field.valid), np.asarray(large.field.valid))
# The positions are not. # The positions are not.
rest = np.asarray(small.positions_sensor_m) rest = np.asarray(small.positions_sensor_m)
moved = np.asarray(large.positions_sensor_m) moved = np.asarray(large.positions_sensor_m)
@@ -374,10 +396,10 @@ def test_the_composer_refuses_a_frame_from_another_geometry():
def test_a_reference_frame_measures_nothing(): def test_a_reference_frame_measures_nothing():
scene = synthetic.poc_scene() scene = synthetic.poc_scene()
rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1)) rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1))
assert not np.asarray(rest.valid).any() assert not np.asarray(rest.field.valid).any()
assert np.isnan(np.asarray(rest.scalar)).all() assert np.isnan(np.asarray(rest.field.scalar)).all()
assert rest.scalar_descriptor.quantity == "unavailable" assert rest.field.descriptor.quantity == "unavailable"
assert rest.scalar_descriptor.quality == "display_only" assert rest.field.descriptor.quality == "display_only"
assert np.allclose( assert np.allclose(
np.asarray(rest.positions_sensor_m), np.asarray(rest.positions_sensor_m),
np.asarray(scene.sensing_part().positions_sensor_m), np.asarray(scene.sensing_part().positions_sensor_m),
@@ -389,10 +411,14 @@ def test_the_composed_frame_carries_read_only_arrays():
composed = SceneComposer().compose( composed = SceneComposer().compose(
scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings() scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings()
) )
for name in ("positions_sensor_m", "normals_sensor", "scalar", "valid"): for name in ("positions_sensor_m", "normals_sensor"):
value = getattr(composed, name) value = getattr(composed, name)
assert not value.flags["WRITEABLE"], f"{name} is writable" assert not value.flags["WRITEABLE"], f"{name} is writable"
assert value.flags["C_CONTIGUOUS"] assert value.flags["C_CONTIGUOUS"]
for name in ("scalar", "valid"):
value = getattr(composed.field, name)
assert not value.flags["WRITEABLE"], f"field.{name} is writable"
assert value.flags["C_CONTIGUOUS"]
def test_the_composer_does_not_rebuild_the_binding_or_the_indices(): def test_the_composer_does_not_rebuild_the_binding_or_the_indices():
@@ -443,6 +469,7 @@ def test_a_calibrated_displacement_still_recomputes_positions_and_normals():
composed = SceneComposer().compose( composed = SceneComposer().compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0) scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0)
) )
assert composed.deformed
assert composed.positions_sensor_m is not part.positions_sensor_m assert composed.positions_sensor_m is not part.positions_sensor_m
assert composed.normals_sensor is not part.normals_sensor assert composed.normals_sensor is not part.normals_sensor
assert not np.allclose( assert not np.allclose(