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
+99 -51
View File
@@ -27,6 +27,7 @@ import pytest
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplayField,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
@@ -44,6 +45,48 @@ pytestmark = pytest.mark.skipif(
BACKGROUND_RGB = np.array([15, 18, 23])
def _field(assets, values, valid=None, descriptor=None) -> DisplayField:
"""A display field on the scene's own domain, from a flat or gridded array.
The surface's colour and validity now come from the *field*, not from the vertices,
so a test that wants a particular picture builds one of these and hands it to the
widget. ``values`` is reshaped to the scene's ``[H,W]`` domain — which is what the
2D canvas would paint — and the fragment reads it through the mesh's UVs.
"""
shape = (int(assets.shape_hw[0]), int(assets.shape_hw[1]))
scalar = np.asarray(values, dtype=np.float32).reshape(shape)
mask = (
np.ones(shape, dtype=bool)
if valid is None
else np.asarray(valid, dtype=bool).reshape(shape)
)
return DisplayField(
domain_id=assets.field_domain_id,
shape_hw=shape,
scalar=freeze(np.array(scalar, dtype=np.float32), dtype=np.float32),
valid=freeze(mask, dtype=np.bool_),
descriptor=(
descriptor
or FieldDescriptor("flow_magnitude", "px", "approximate", False)
),
)
def _frame(assets, values, valid=None, *, frame_id=5, positions=None, normals=None,
descriptor=None, deformed=False) -> RenderFrame:
part = assets.sensing_part()
return RenderFrame(
identity=FrameIdentity("poc", 1, frame_id, 0.5, generation=1),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=part.part_id,
positions_sensor_m=part.positions_sensor_m if positions is None else positions,
normals_sensor=part.normals_sensor if normals is None else normals,
field=_field(assets, values, valid, descriptor),
deformed=deformed,
)
def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
"""Let Qt paint until ``predicate`` holds. Returns whether it did."""
import time
@@ -123,6 +166,13 @@ def _plate_is_grey(pixels: np.ndarray) -> float:
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean())
def _plate_mean_rgb(pixels: np.ndarray) -> np.ndarray:
"""The mean RGB of the drawn pixels: the plate's own colour, on a flat surface."""
drawn = _drawn(pixels)
assert drawn.size, "nothing was drawn"
return drawn.mean(axis=0)
def test_the_capabilities_come_from_a_real_context(gl_widget):
widget, _, _ = gl_widget
capabilities = widget.capabilities()
@@ -157,33 +207,30 @@ def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_app):
"""No measurement yet: the plate must not be coloured as if it had one.
Checked as a difference: the neutral path is grey (r≈g), the colour map's low
end is not. Counting "grey pixels" over the whole frame would just be counting
the background.
Asserted as a *colour identity*, not as a hue difference: the valid path must draw
the lookup table's own colour for the value it was given, and the neutral path must
draw the neutral colour. (The old form of this test compared mean ``|r-g|``, which
only worked because the 3D pane used a purple ramp whose low end is far from grey.
With one ramp serving both views the low end is the theme's own navy, where ``r≈g``,
so a hue test would have had to be loosened until it stopped meaning anything. A
colour-identity test is both stricter and independent of which ramp is in use.)
"""
from orisys_studio.scene3d.gl_backend import INVALID_COLOR, LUT_SIZE, lut_rgba
widget, _, _ = gl_widget
assets = synthetic.poc_scene()
count = int(assets.sensing_part().positions_sensor_m.shape[0])
widget.set_settings(DisplaySettings(housing_visible=False))
rest_grey = _plate_is_grey(_render(widget, qt_app))
rest_rgb = _plate_mean_rgb(_render(widget, qt_app))
neutral_8bit = np.array(INVALID_COLOR[:3]) * 255.0
frame = RenderFrame(
identity=FrameIdentity("poc", 1, 5, 0.5, generation=1),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=assets.sensing_part().part_id,
positions_sensor_m=assets.sensing_part().positions_sensor_m,
normals_sensor=assets.sensing_part().normals_sensor,
scalar=freeze(np.full(count, 0.05, dtype=np.float32), dtype=np.float32),
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
)
value = 0.05
frame = _frame(assets, np.full(assets.shape_hw, value, dtype=np.float32))
widget.set_settings(
DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=frame.scalar_descriptor,
descriptor=frame.field.descriptor,
lower=0.0,
upper=1.0,
lut_id="sequential",
@@ -191,12 +238,27 @@ def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_a
)
)
widget.present(frame)
measured_grey = _plate_is_grey(_render(widget, qt_app))
measured_rgb = _plate_mean_rgb(_render(widget, qt_app))
assert rest_grey < 6.0, f"the unmeasured surface is not neutral (r-g={rest_grey:.1f})"
assert measured_grey > rest_grey + 10.0, (
"the colour map made no difference: the invalid path is not being taken "
f"(rest r-g={rest_grey:.1f}, measured r-g={measured_grey:.1f})"
# Where the fragment shader samples the table: ``vec2(t, 0.5)`` on a 256x1 texture
# with linear filtering, i.e. ``t * 256 - 0.5`` clamped into the table.
table = lut_rgba("sequential")[:, 0, :3].astype(float)
position = float(np.clip(value * LUT_SIZE - 0.5, 0.0, LUT_SIZE - 1.0))
low = int(np.floor(position))
high = min(low + 1, LUT_SIZE - 1)
fraction = position - low
expected = (1.0 - fraction) * table[low] + fraction * table[high]
assert np.abs(rest_rgb - neutral_8bit).max() < 6.0, (
f"the unmeasured surface is not the neutral colour: {rest_rgb} vs {neutral_8bit}"
)
assert np.abs(measured_rgb - expected).max() < 8.0, (
"the colour map's own colour did not reach the plate: "
f"drawn {measured_rgb}, the table gives {expected} for {value} — the valid path "
"is not being taken, or the pane is not using the shared ramp"
)
assert np.abs(expected - neutral_8bit).max() > 10.0, (
"this value is too close to the neutral colour to make the point; pick another"
)
widget.set_settings(DisplaySettings(housing_visible=True))
@@ -207,21 +269,18 @@ def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app):
count = int(assets.sensing_part().positions_sensor_m.shape[0])
positions = np.asarray(assets.sensing_part().positions_sensor_m).copy()
positions[:, 2] = 0.004 # a visible bump
frame = RenderFrame(
identity=FrameIdentity("poc", 1, 6, 0.6, generation=1),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=assets.sensing_part().part_id,
positions_sensor_m=freeze(positions, dtype=np.float32),
normals_sensor=assets.sensing_part().normals_sensor,
scalar=freeze(np.linspace(0.0, 1.0, count).astype(np.float32), dtype=np.float32),
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
frame = _frame(
assets,
np.linspace(0.0, 1.0, count).reshape(assets.shape_hw),
frame_id=6,
positions=freeze(positions, dtype=np.float32),
deformed=True,
)
widget.set_settings(
DisplaySettings(
color_scale=ColorScale(
descriptor=frame.scalar_descriptor, lower=0.0, upper=1.0, lut_id="sequential"
descriptor=frame.field.descriptor, lower=0.0, upper=1.0,
lut_id="sequential",
)
)
)
@@ -242,21 +301,13 @@ def _buffer_names(renderer, part_id: str) -> tuple[int, int, int]:
def test_a_frame_from_another_geometry_is_refused_without_losing_the_view(gl_widget, qt_app):
"""A bad frame is refused; the scene stays usable and stops showing it."""
widget, _, failures = gl_widget
count = 9
before = widget.rejected_frames
frame = RenderFrame(
identity=FrameIdentity("poc", 1, 2, 0.2, generation=1),
asset_hash="other",
geometry_id="some-other-geometry",
sensing_part_id="sensing_surface",
positions_sensor_m=freeze(np.zeros((count, 3), dtype=np.float32), dtype=np.float32),
normals_sensor=freeze(
np.tile([0.0, 0.0, 1.0], (count, 1)).astype(np.float32), dtype=np.float32
),
scalar=freeze(np.zeros(count, dtype=np.float32), dtype=np.float32),
valid=freeze(np.zeros(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
frame = _frame(
synthetic.poc_scene(), np.zeros(synthetic.poc_scene().shape_hw, dtype=np.float32),
frame_id=2,
)
object.__setattr__(frame, "asset_hash", "other")
object.__setattr__(frame, "geometry_id", "some-other-geometry")
widget.present(frame)
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
assert widget.rejected_frames == before + 1
@@ -525,7 +576,6 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
widget = view.gl_widget
assets_frame = view.assets
assert assets_frame is not None
count = int(assets_frame.sensing_part().positions_sensor_m.shape[0])
def frame_with(values: np.ndarray) -> RenderFrame:
return RenderFrame(
@@ -535,9 +585,7 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
sensing_part_id=assets_frame.sensing_part().part_id,
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
normals_sensor=assets_frame.sensing_part().normals_sensor,
scalar=freeze(np.asarray(values, dtype=np.float32), dtype=np.float32),
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
field=_field(assets_frame, values),
)
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
@@ -553,7 +601,7 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
),
)
ramp = np.linspace(3.0, 7.0, count)
ramp = np.linspace(3.0, 7.0, int(assets_frame.shape_hw[0]) * int(assets_frame.shape_hw[1]))
widget.set_settings(fixed_wide)
widget.present(frame_with(ramp))