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
+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(