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
@@ -67,24 +67,71 @@ def _parse_ramp(ramp: str) -> list[tuple[int, int, int]]:
return stops
@lru_cache(maxsize=8)
def _lut_from_ramp(ramp: str) -> np.ndarray:
"""Build a 256-entry user colour table from ``#RRGGBB`` ramp stops.
def ramp_for_theme(theme: str = "dark") -> str:
"""The theme's own ``flow_ramp`` string, resolved and never empty.
The table is BGR, matching how :func:`cv2.applyColorMap` consumes user
colormaps. Invalid input never raises: it falls back to
:data:`FLOW_RAMP_FALLBACK`.
This is the **single** ramp both views build their lookup table from: the 2D
canvas through :func:`flow_colormap`, the 3D surface through the field it is
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)
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
stops = _parse_ramp(FLOW_RAMP_FALLBACK)
positions = np.linspace(0.0, 1.0, _LUT_STEPS)
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
values = np.interp(positions, span, [stop[channel] for stop in stops])
lut[:, 0, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8)
return lut[:, :, ::-1].copy()
table[:, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8)
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:
@@ -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
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:
@@ -22,6 +22,7 @@ from orisys_app_shared.scene3d.contracts import (
CompileOptions,
CompileReport,
Diagnostic,
DisplayField,
DisplaySettings,
ExpectedGeometry,
F32,
@@ -45,7 +46,7 @@ from orisys_app_shared.scene3d.contracts import (
SurfaceMapping,
ThreeDError,
U32,
VertexFields,
diagnostic,
error,
freeze,
@@ -72,6 +73,7 @@ __all__ = [
"CompileOptions",
"CompileReport",
"Diagnostic",
"DisplayField",
"DisplaySettings",
"ExpectedGeometry",
"F32",
@@ -95,7 +97,6 @@ __all__ = [
"SurfaceMapping",
"ThreeDError",
"U32",
"VertexFields",
"diagnostic",
"error",
"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``.
This module decides what a colour and a vertex position *mean*, so the rules from
§3.2 of the design document are its reason to exist:
The composer no longer *samples*. It used to gather the field onto every sensing
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
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.
* without a trustworthy displacement the surface is coloured in place, and the total
force is never turned into a per-vertex height.
* a reference frame measures nothing: every validity flag is false and the scalar is
absent, so the UI hides the colour bar rather than leaving the previous one on
screen.
* a reference frame measures nothing: the field arrives with every validity flag
false and no scalar.
"""
from __future__ import annotations
@@ -27,8 +35,8 @@ import numpy as np
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
DisplayField,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
Quality,
RenderFrame,
@@ -47,7 +55,10 @@ from orisys_app_shared.scene3d.overlays import (
build_normal_overlay,
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
#: Reported when a display gain is applied to a displacement that is only
@@ -74,8 +85,7 @@ class SceneComposer:
looking at.
"""
def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None:
self._sampler = sampler or SurfaceSampler()
def __init__(self, *, ramp: str = "") -> None:
self.last_stats = ComposeStats()
#: Rest positions and normals, keyed by ``asset_hash``: with no displacement to
#: draw, the picture is the asset's own geometry, and recomputing its normals
@@ -83,6 +93,9 @@ class SceneComposer:
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
#: Last UV grid of "arrow was on" samples for flow hysteresis.
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(
self,
@@ -96,23 +109,67 @@ class SceneComposer:
self._check(assets, frame)
for item in validate_surface_frame(frame):
# 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":
raise error(item.code, item.message, **dict(item.details))
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()
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
rest_positions, rest_normals = self._rest(assets)
diagnostics: list[Diagnostic] = []
positions = rest_positions
normals = rest_normals
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:
gain = float(settings.display_gain)
displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64)
positions = np.asarray(rest_positions, dtype=np.float64) + gain * displacement
# A tap that was invalid leaves ``NaN`` here, and a vertex with no measured
# 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":
diagnostics.append(
diagnostic(
@@ -123,7 +180,7 @@ class SceneComposer:
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(
diagnostic(
"field_unavailable",
@@ -133,11 +190,8 @@ class SceneComposer:
normals_started = time.perf_counter()
# In place, the normals are the asset's own: the positions are untouched, so
# recomputing them from the same triangles gives the same answer every frame.
normals = (
vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
if deformed
else rest_normals
)
if deformed:
normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
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
@@ -154,21 +208,18 @@ class SceneComposer:
geometry_id=assets.geometry_id,
sensing_part_id=part.part_id,
positions_sensor_m=(
freeze(positions, dtype=np.float32)
if deformed
else rest_positions
freeze(positions, dtype=np.float32) if deformed else rest_positions
),
normals_sensor=(
freeze(normals, dtype=np.float32) if deformed else rest_normals
),
# The measurement, untouched by the display gain.
scalar=fields.scalar,
valid=fields.valid,
scalar_descriptor=frame.scalar_descriptor,
field=field,
diagnostics=tuple(diagnostics),
# Carried through untouched: only the display side knows whether the frame
# it is about to show is still the current one.
computed_frame_id=frame.computed_frame_id,
deformed=deformed,
# What this frame cost to compose, carried *on the frame*: the shared
# ``last_stats`` object is rewritten by the next task, so a reader on the
# GUI thread would report another frame's cost.
@@ -176,7 +227,11 @@ class SceneComposer:
flow_arrows=flow_arrows,
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
def _build_overlays(
@@ -275,7 +330,7 @@ class SceneComposer:
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
"""The static reference: nothing measured, so nothing coloured."""
return build_rest_frame(assets, identity)
return build_rest_frame(assets, identity, ramp=self.ramp)
@staticmethod
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.
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()
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(
identity=identity,
asset_hash=assets.asset_hash,
@@ -317,7 +381,14 @@ def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFram
sensing_part_id=part.part_id,
positions_sensor_m=part.positions_sensor_m,
normals_sensor=part.normals_sensor,
scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32),
valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_),
scalar_descriptor=rest_descriptor(),
field=DisplayField(
domain_id=assets.field_domain_id,
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)
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
geometry_id: str
field_domain_id: str
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_weights: F32 # [N,4], non-negative, used weights sum to 1
@@ -627,12 +641,37 @@ class DisplaySettings:
@dataclass(frozen=True)
class VertexFields:
"""A UV field sampled onto the sensing vertices."""
class DisplayField:
"""The one field both views draw, on the domain's own grid.
scalar: F32 # [N]
valid: Mask # [N]
displacement_sensor_m: Optional[F32] = None # [N,3]
This object exists so that the two-dimensional canvas and the three-dimensional
surface cannot disagree about what was measured. Before it, the 3D pane had a
*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)
@@ -645,13 +684,17 @@ class RenderFrame:
sensing_part_id: str
positions_sensor_m: F32 # [N,3], display displacement already applied
normals_sensor: F32 # [N,3]
scalar: F32 # [N], before display gain
valid: Mask # [N]
scalar_descriptor: FieldDescriptor
#: The measurement, on the domain's grid. Not per vertex: see :class:`DisplayField`.
field: DisplayField
diagnostics: tuple[Diagnostic, ...] = ()
#: The acquisition frame the coloured values belong to, carried through from the
#: field frame so the *display* side can re-check it before presenting.
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
#: rather than read from the composer's shared counter, which the next task rewrites.
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, ...]:
"""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 (
("uvs", binding.uvs),
("sample_indices", binding.sample_indices),
("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
# export), not a differently shaped UV field.
for name, array in (
("uvs", binding.uvs),
("sample_indices", binding.sample_indices),
("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 "
"vertices: this binding belongs to a different mesh",
)
_shape("uvs", binding.uvs, (vertices, 2))
_shape("sample_indices", binding.sample_indices, (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:
raise error("field_domain_mismatch", "sample_indices must be int32")
if binding.sample_weights.dtype != np.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
indices = binding.sample_indices
weights = binding.sample_weights
@@ -1156,20 +1221,47 @@ def validate_display_settings(settings: DisplaySettings) -> tuple[Diagnostic, ..
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 (
("positions_sensor_m", frame.positions_sensor_m),
("normals_sensor", frame.normals_sensor),
("scalar", frame.scalar),
("valid", frame.valid),
):
_readonly_c_contiguous(name, array)
_shape("positions_sensor_m", frame.positions_sensor_m, (vertices, 3))
_shape("normals_sensor", frame.normals_sensor, (vertices, 3))
_shape("scalar", frame.scalar, (vertices,))
_shape("valid", frame.valid, (vertices,))
if frame.scalar.dtype != np.float32 or frame.valid.dtype != np.bool_:
raise error("field_domain_mismatch", "render scalar is float32 and valid is bool")
field = frame.field
for name, array in (("field.scalar", field.scalar), ("field.valid", field.valid)):
_readonly_c_contiguous(name, array)
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():
raise error("geometry_mismatch", "a render frame must name its asset and geometry")
for label, arrows in (
@@ -1315,15 +1407,15 @@ class BoxFrameAdapter(Protocol):
) -> Optional[SurfaceFieldFrame]: ...
class SurfaceSampler(Protocol):
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ...
class SceneComposer(Protocol):
def compose(
self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings
) -> 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:
@@ -1349,7 +1441,7 @@ class GLDispatch(Protocol):
class GLRenderer(Protocol):
def initialize(self, capabilities: GLCapabilities) -> None: ...
def upload_scene(self, assets: SceneAssets) -> None: ...
def update_surface(self, frame: RenderFrame) -> None: ...
def update_field(self, frame: RenderFrame) -> None: ...
def draw(
self,
camera: CameraMatrices,
@@ -30,6 +30,38 @@ from orisys_app_shared.scene3d.contracts import MeshPart, SurfaceBinding, error
#: else is worse than no digest at all.
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:
"""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
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(
vertices: np.ndarray,
faces: np.ndarray,
@@ -540,16 +562,27 @@ def build_binding(
part_id: str,
geometry_id_value: str,
) -> 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
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.
``uvs`` is the map the *shader* reads: one pixel coordinate per render vertex, in
the same order as the mesh's positions, already flipped and scaled, so the
fragment program only normalises by the domain size. Each row of
``sample_indices``/``sample_weights`` holds up to four ``y*W+x`` indices and their
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
count = len(render_uv)
pixels = uvs_to_pixels(render_uv, domain)
indices = np.full((count, 4), -1, dtype=np.int32)
weights = np.zeros((count, 4), dtype=np.float32)
for row in range(count):
@@ -584,6 +617,7 @@ def build_binding(
geometry_id=geometry_id_value,
field_domain_id=domain.domain_id,
shape_hw=domain.shape_hw,
uvs=pixels,
sample_indices=freeze(indices, dtype=np.int32),
sample_weights=freeze(weights, dtype=np.float32),
)
@@ -766,6 +800,7 @@ class LegacyFingerImporter:
hash_arrays: dict[str, Any] = {
"positions": sensing.positions_sensor_m,
"triangles": sensing.triangles,
"uv": binding.uvs,
"indices": binding.sample_indices,
"weights": binding.sample_weights,
}
@@ -99,10 +99,18 @@ MAX_COMPRESSION_RATIO = 200
RATIO_FLOOR_BYTES = 4 << 20
#: 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/positions.npy": ("f4", 2),
"sensing/normals.npy": ("f4", 2),
"sensing/triangles.npy": ("u4", 2),
"sensing/uv.npy": ("f4", 2),
"sensing/sample_indices.npy": ("i4", 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/normals.npy"] = np.ascontiguousarray(sensing.normals_sensor)
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_weights.npy"] = np.ascontiguousarray(assets.binding.sample_weights)
if assets.source_vertex_of_render is not None:
@@ -688,11 +697,37 @@ def scene_from_contents(
)
if not 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(
part_id="sensing_surface",
geometry_id=str(manifest.get("geometry_id") or ""),
field_domain_id=str(domain.get("id") or ""),
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_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``.
The sampling table is built once, at load time, from the field's own domain. It is
never rebuilt per frame, and a vertex is never repaired by searching for the nearest
triangle: if any tap that carries weight is invalid, the vertex is invalid.
**The per-vertex colour gather is gone.** This module used to hold
``SurfaceSampler.sample``: a vectorised bilinear gather of the field onto every
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
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
neutral colour.
* **validity is a separate array from the value.** A vertex whose scalar happens to
be 0.0 is a measurement of zero; a vertex that is invalid has no value at all, and
the value is left as ``NaN`` so nobody can mistake one for the other.
* **the domain is checked, not assumed.** A frame whose domain or shape does not
match the binding is refused with ``field_domain_mismatch`` rather than sampled
into a plausible-looking but wrong picture.
Two things remain, and they are not the colour path:
* :func:`remap_to_domain` — a gather, but of a *source* field onto the scene's domain,
done once in the acquisition adapter before any frame is published. It is how a
tracker field that arrives in the camera's grid reaches the UV domain.
* :func:`sample_vertex_displacement` — the display **deformation**, which moves
vertices and is therefore a vertex-stage quantity by definition. It runs only when
``DisplaySettings.deform_enabled`` is set, and no adapter in this build publishes a
displacement, so the application never executes it.
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 typing import Optional
import numpy as np
from orisys_app_shared.scene3d.contracts import (
SurfaceBinding,
SurfaceFieldFrame,
VertexFields,
error,
freeze,
)
@@ -37,97 +47,87 @@ from orisys_app_shared.scene3d.contracts import (
WEIGHT_EPS = 1e-7
class SurfaceSampler:
"""Vectorised bilinear sampling through a precomputed binding."""
def check_field_domain(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None:
"""Refuse a frame that is not on this binding's geometry, domain and shape.
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields:
self._check_domains(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")
# ``-1`` -> index 0 for the gather; the weight there is 0 by contract and the
# ``used`` mask keeps it out of both the sum and the validity test.
safe = np.where(used, indices, 0)
height, width = frame.scalar.shape
flat_scalar = np.asarray(frame.scalar, dtype=np.float64).reshape(-1)
flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1)
if safe.size and int(safe.max()) >= flat_scalar.size:
raise error(
"field_domain_mismatch",
"a binding index falls outside the frame's field",
index=int(safe.max()),
size=int(flat_scalar.size),
)
gathered_scalar = flat_scalar[safe]
gathered_valid = flat_valid[safe]
finite = np.isfinite(gathered_scalar)
# A tap counts only where it is used, in range, finite and valid.
contributed = used & gathered_valid & finite
valid = contributed | ~used
valid = valid.all(axis=1)
contribution = np.where(contributed, gathered_scalar, 0.0) * weights
scalar = contribution.sum(axis=1)
# 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,
Kept as a function rather than a method so the *texture* path can make exactly the
same check without importing a sampler: a frame from another domain would be
uploaded as a texture of the wrong size and quietly rescaled by the sampler, which
is the class of bug this check exists to prevent.
"""
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"
)
@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:
"""Convenience wrapper: one sampler, no state to keep."""
return SurfaceSampler().sample(frame, binding)
``None`` when the frame carries no displacement — the common case, and the only
case this build's adapters can produce. A tap that is unused or invalid makes the
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(
@@ -26,6 +26,7 @@ from orisys_app_shared.scene3d.contracts import (
freeze,
)
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
#: 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:
"""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
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)
weights = np.zeros((count, 4), dtype=np.float32)
for index in range(count):
@@ -129,6 +151,7 @@ def _binding() -> SurfaceBinding:
geometry_id=POC_GEOMETRY_ID,
field_domain_id=POC_DOMAIN_ID,
shape_hw=(GRID, GRID),
uvs=uvs_to_pixels(uvs, domain),
sample_indices=freeze(indices, dtype=np.int32),
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
)[:vertices]
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(
part_id="sensing_surface",
geometry_id="geom-abc",
field_domain_id="domain-xyz",
shape_hw=(3, 4),
uvs=c.freeze(uvs, dtype=np.float32),
sample_indices=c.freeze(indices, dtype=np.int32),
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,
field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=c.freeze(indices, dtype=np.int32),
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,
field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=c.freeze(indices, dtype=np.int32),
sample_weights=binding.sample_weights,
)
@@ -207,6 +214,7 @@ def test_weights_must_sum_to_one():
geometry_id=binding.geometry_id,
field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=binding.sample_indices,
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,
field_domain_id=binding.field_domain_id,
shape_hw=binding.shape_hw,
uvs=binding.uvs,
sample_indices=binding.sample_indices,
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.
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
is the whole reason the contracts layer is separate from the view.
usable here — the composer and the field domain checks run on a machine with no
GPU, which is the whole reason the contracts layer is separate from the view.
"""
from orisys_app_shared.scene3d import assets, composer, sampling, synthetic
scene = synthetic.poc_scene()
frame = _frame()
fields = sampling.sample_field(frame, _binding())
assert np.asarray(fields.scalar).shape == (4,)
# The colour path makes no gather at all; the only sampling left is the field
# 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(
scene, c.SurfaceFieldFrame(
identity=frame.identity,
@@ -511,7 +525,7 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
),
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
# cannot honestly do: asking for encryption without key material, or without the
# 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
impossible to notice on screen: an invalid tap must invalidate the *vertex* rather
than read as zero, the display gain must not touch the measurement, and a producer
must never be able to make the display queue grow.
impossible to notice on screen: the *colour* path must never resample the field onto
the vertices (that is what painted 30.15 % of the island grey), the display gain must
not touch the measurement, and a producer must never be able to make the display queue
grow.
"""
from __future__ import annotations
@@ -30,9 +31,8 @@ from orisys_app_shared.scene3d.contracts import (
rest_descriptor,
)
from orisys_app_shared.scene3d.sampling import (
SurfaceSampler,
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
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(
part_id="sensing_surface",
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=shape,
uvs=freeze(uvs, dtype=np.float32),
sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32),
sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32),
)
# --------------------------------------------------------------------- sampling
def test_a_constant_field_samples_to_the_same_constant():
# -------------------------------------------------------- the colour field path
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()
fields = sample_field(_frame(scene, np.full((3, 3), 7.5)), scene.binding)
assert np.allclose(np.asarray(fields.scalar), 7.5)
assert np.asarray(fields.valid).all()
frame = _frame(scene, np.arange(9, dtype=np.float32).reshape(3, 3))
composed = SceneComposer().compose(scene, frame, DisplaySettings())
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():
"""Every vertex samples its own cell, so the sampled value is the cell value."""
def test_a_hole_in_the_field_stays_a_hole_and_is_never_a_vertex_nan():
"""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()
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[1, 1] = False # index 4 is a positive-weight tap
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)
valid[1, 1] = False
field = np.zeros((3, 3), dtype=np.float32)
field[1, 1] = 3.0
valid = np.ones((3, 3), dtype=bool)
valid[0, 0] = False # unused, so it must not matter
fields = sample_field(_frame(scene, field, valid), binding)
assert bool(np.asarray(fields.valid)[0])
assert np.asarray(fields.scalar)[0] == pytest.approx(3.0)
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])
field[1, 1] = 99.0
composed = SceneComposer().compose(scene, _frame(scene, field, valid), DisplaySettings())
assert composed.field.valid.tolist() == valid.tolist()
assert int(composed.field.valid.sum()) == 8
# The scalar is carried through untouched, `NaN` or not: nothing rewrites it into
# 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_zero_measurement_is_valid_and_stays_zero():
"""Zero is a value. Only `valid` decides whether it means anything."""
scene = synthetic.poc_scene()
fields = sample_field(_frame(scene, np.zeros((3, 3), dtype=np.float32)), scene.binding)
assert np.asarray(fields.valid).all()
assert np.all(np.asarray(fields.scalar) == 0.0)
assert not np.isnan(np.asarray(fields.scalar)).any()
composed = SceneComposer().compose(
scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings()
)
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()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain")
with pytest.raises(ThreeDError) as excinfo:
sample_field(frame, scene.binding)
SceneComposer().compose(scene, frame, DisplaySettings())
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()
frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32))
with pytest.raises(ThreeDError) as excinfo:
sample_field(frame, scene.binding)
assert "shape does not match" in excinfo.value.diagnostic.message
SceneComposer().compose(scene, frame, DisplaySettings())
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
def test_sampling_is_vectorised_enough_for_a_large_mesh():
"""A full-resolution binding is ~10^5 vertices; sampling must not crawl."""
def test_composing_a_frame_costs_no_per_vertex_work():
"""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()
count = 20_000
rng = np.random.default_rng(7)
indices = rng.integers(0, 9, size=(count, 4)).astype(np.int32)
weights = rng.random((count, 4)).astype(np.float32)
weights /= weights.sum(axis=1, keepdims=True)
binding = _binding(indices=indices, weights=weights)
frame = _frame(scene, rng.random((3, 3)).astype(np.float32))
started = time.perf_counter()
fields = sample_field(frame, binding)
elapsed = (time.perf_counter() - started) * 1000.0
assert np.asarray(fields.scalar).shape == (count,)
assert elapsed < 150.0, f"sampling {count} vertices took {elapsed:.1f} ms"
composer = SceneComposer()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
composer.compose(scene, frame, DisplaySettings())
samples = []
for _ in range(20):
composer.compose(scene, frame, DisplaySettings())
samples.append(composer.last_stats.sample_ms)
assert max(samples) < 2.0, f"the colour path still does per-vertex work: {samples}"
# -------------------------------------------- the vertex displacement (geometry)
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
@@ -267,9 +289,9 @@ def test_the_gain_moves_positions_and_never_the_measurement():
scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0)
)
# 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(small.scalar), np.asarray(large.scalar))
assert np.array_equal(np.asarray(quiet.valid), np.asarray(large.valid))
assert np.array_equal(np.asarray(quiet.field.scalar), np.asarray(small.field.scalar))
assert np.array_equal(np.asarray(small.field.scalar), np.asarray(large.field.scalar))
assert np.array_equal(np.asarray(quiet.field.valid), np.asarray(large.field.valid))
# The positions are not.
rest = np.asarray(small.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():
scene = synthetic.poc_scene()
rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1))
assert not np.asarray(rest.valid).any()
assert np.isnan(np.asarray(rest.scalar)).all()
assert rest.scalar_descriptor.quantity == "unavailable"
assert rest.scalar_descriptor.quality == "display_only"
assert not np.asarray(rest.field.valid).any()
assert np.isnan(np.asarray(rest.field.scalar)).all()
assert rest.field.descriptor.quantity == "unavailable"
assert rest.field.descriptor.quality == "display_only"
assert np.allclose(
np.asarray(rest.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(
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)
assert not value.flags["WRITEABLE"], f"{name} is writable"
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():
@@ -443,6 +469,7 @@ def test_a_calibrated_displacement_still_recomputes_positions_and_normals():
composed = SceneComposer().compose(
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.normals_sensor is not part.normals_sensor
assert not np.allclose(