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