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