fix Studio reset, 3D base colour and arrow-trigger defects

The tracker reset was decided by which panel was on screen rather than by the
session, the automatic colour range moved the 3D surface's base colour on the first
press, and the 2D canvas and the 3D overlay applied different flow thresholds (622
arrows against 432 on a real recording). ui_check 600/0; apps/studio 380 items and
shared 390 items with no failures; real-GL 18 items, only the pre-existing
colour-scale failure; AutoCal 358 passed; both views now select identical arrows on
all 42 frames of a real video.
This commit is contained in:
ykcheungab
2026-10-01 14:40:33 +08:00
parent 33a3d8c9c2
commit e0156a14c6
14 changed files with 446 additions and 72 deletions
@@ -266,6 +266,85 @@ def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_a
widget.set_settings(DisplaySettings(housing_visible=True))
def _plate_mode_rgb(pixels: np.ndarray) -> np.ndarray:
"""The most common colour of the drawn surface: its *base* colour.
The resting area is the largest uniform region of a depth picture, so the modal
colour is the colour the ramp gives "no response" — the thing that must not move
when the operator presses.
"""
drawn = _drawn(pixels)
assert drawn.size, "nothing was drawn"
quantised = (drawn // 4) * 4
values, counts = np.unique(quantised, axis=0, return_counts=True)
return values[int(np.argmax(counts))].astype(float)
def test_a_press_does_not_move_the_surfaces_base_colour(gl_widget, qt_app):
"""The base colour follows the field, not the frame's current response.
Reported as "the base color of 3d depth view will change upon pressing". Measured
before the fix on a real recording, with the automatic colour scale: the resting
plate was drawn RGB [248, 0, 240] (the middle of the ramp, because a flat field was
centred on its own midpoint) and the first press moved it to [8, 24, 48] (the first
colour), where it stayed after the release. The automatic range is now anchored at
the field's own zero, so the base colour is the ramp's first colour throughout and
only the upper bound follows the press.
"""
widget, renderer, _ = gl_widget
from orisys_studio.scene3d.gl_backend import lut_rgba
assets = synthetic.poc_scene()
descriptor = FieldDescriptor("depth_response", "1", "approximate", False)
automatic = DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=descriptor,
lower=0.0,
upper=1.0,
lut_id="sequential",
mode="automatic",
),
)
widget.set_settings(automatic)
rest = np.zeros(assets.shape_hw, dtype=np.float32)
widget.present(_frame(assets, rest, frame_id=21))
rest_base = _plate_mode_rgb(_render(widget, qt_app))
rest_range = renderer._scalar_range
# A press on one cell of the 3x3 PoC plate: the rest of the plate is still "no
# response", so the modal colour is still the base colour.
pressed = rest.copy()
pressed[1, 1] = 1.0
widget.present(_frame(assets, pressed, frame_id=22))
pressed_base = _plate_mode_rgb(_render(widget, qt_app))
table = lut_rgba("sequential")[:, 0, :3].astype(float)
first_colour = table[0]
assert np.abs(rest_base - first_colour).max() <= 6.0, (
f"the resting plate is not drawn in the ramp's first colour: {rest_base} vs "
f"{first_colour}"
)
assert np.abs(pressed_base - rest_base).max() <= 6.0, (
"pressing changed the surface's base colour: "
f"{rest_base} at rest against {pressed_base} pressed"
)
# The press still has to show: the range's upper bound follows the response.
scale = renderer._last_scale
assert scale is not None and scale[0] == 0.0, (
f"the automatic range is not anchored at the field's zero: {scale}"
)
assert (
renderer._scalar_range is not None
and rest_range is not None
and renderer._scalar_range[1] > rest_range[1]
), (
"the press did not reach the colour scale: "
f"{rest_range} at rest against {renderer._scalar_range} pressed"
)
def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app):
widget, renderer, _ = gl_widget
assets = synthetic.poc_scene()
+56 -22
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import numpy as np
from orisys_app_shared.acquisition.arrows import _policy_from_style
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
DisplaySettings,
@@ -12,7 +11,10 @@ from orisys_app_shared.scene3d.contracts import (
freeze,
)
from orisys_app_shared.scene3d.synthetic import poc_identity, poc_scene
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
from orisys_app_shared.scene3d.visual_policy import (
policy_for_settings,
select_flow_samples,
)
def _depth_frame(assets, *, flow=None, contact_xy=None, fn=0.0):
@@ -81,10 +83,20 @@ def test_composer_hides_overlays_when_disabled() -> None:
def test_2d_3d_flow_sample_points_match() -> None:
"""Both figures show the same arrows for the same frame — hysteresis included.
The regression: the composer kept a "was this sample on last frame" grid and the 2D
canvas did not, so a sample between ``flow_off_px`` and ``flow_on_px`` was drawn by
one view and not the other. Measured on a real recording before the fix: 622 arrows
in the 3D overlay against 432 in the canvas, differing on 34 of 41 frames.
Frame A is above ``flow_on_px`` (both views draw it); frame B is inside the band —
above the old hysteresis floor, below the threshold the canvas applies. Both views
must agree on frame B, which is what fails if the composer re-acquires state the
canvas does not have.
"""
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float64)
flow[1, 1] = (0.7, 0.2)
mask = np.ones((h, w), dtype=bool)
settings = DisplaySettings(
flow_on_px=0.4,
@@ -92,23 +104,45 @@ def test_2d_3d_flow_sample_points_match() -> None:
flow_grid_spacing=1,
max_arrows=50,
)
policy_3d = _policy_from_style(
"finger",
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
policy = policy_for_settings(settings)
composer = SceneComposer()
def both_views(flow_value: float) -> tuple[int, int]:
flow = np.zeros((h, w, 2), dtype=np.float64)
flow[1, 1] = (flow_value, 0.0)
composed = composer.compose(
assets, _depth_frame(assets, flow=flow), settings
)
arrows_3d = (
0 if composed.flow_arrows is None else int(composed.flow_arrows.origins.shape[0])
)
# Exactly what the 2D canvas draws from: ``draw_arrow_display`` calls
# ``select_flow_samples`` with the policy the window passes it.
samples_2d = select_flow_samples(flow, mask, policy)
return arrows_3d, int(samples_2d.rows.size)
above, above_2d = both_views(0.30) # |2*flow| = 0.6 px, clearly on
assert above == 1 and above_2d == 1, (above, above_2d)
inside_band, inside_band_2d = both_views(0.175) # |2*flow| = 0.35 px: 0.3 < 0.35 < 0.4
assert inside_band_2d == 0, inside_band_2d
assert inside_band == inside_band_2d, (
"the 3D overlay drew an arrow the 2D canvas did not: the two views are not "
f"triggering at the same strength ({inside_band} against {inside_band_2d})"
)
policy_shared = VisualPolicy(
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
flow_edge_margin=0,
).normalised()
samples_2d = select_flow_samples(flow, mask, policy_shared)
samples_3d = select_flow_samples(flow, mask, policy_shared)
assert list(zip(samples_2d.rows, samples_2d.cols)) == list(
zip(samples_3d.rows, samples_3d.cols)
def test_both_views_build_their_policy_from_the_same_settings() -> None:
"""One mapping, one policy: the thresholds are display settings, not per-view ones."""
settings = DisplaySettings(
flow_on_px=0.7,
flow_off_px=0.2,
flow_grid_spacing=6,
max_arrows=123,
)
assert policy_3d.flow_on_px == settings.flow_on_px
policy = policy_for_settings(settings)
assert (policy.flow_on_px, policy.flow_off_px) == (0.7, 0.2)
assert policy.flow_grid_spacing == 6
assert policy.max_arrows == 123
# Normalised, so a hand-edited preference cannot invert the hysteresis band.
inverted = policy_for_settings(DisplaySettings(flow_on_px=0.1, flow_off_px=0.9))
assert inverted.flow_on_px >= inverted.flow_off_px
+40
View File
@@ -27,6 +27,7 @@ from orisys_studio.scene3d.gl_backend import (
LUT_SIZE,
STRIDE_FLOATS,
VALID_BYTES_PER_TEXEL,
_valid_scalar_range,
estimate_gpu_bytes,
is_software_renderer,
lut_rgba,
@@ -289,6 +290,45 @@ def test_the_lut_is_an_ordered_ramp_and_the_diverging_one_is_centred():
assert not np.array_equal(diverging, sequential)
def test_the_automatic_range_is_anchored_at_the_fields_zero():
"""The base colour is a property of the field, not of how hard the frame is pressed.
A flat field used to be *centred* on its own midpoint (the middle of the ramp), and
the first press moved the range to [P5, P95] — so the resting surface jumped from
the ramp's middle colour to its first one, and stayed there after the release
(measured on a real recording: RGB [248, 0, 240] at rest against [8, 24, 48] once
pressed). Anchoring an unsigned field at its own zero — the ramp's first colour —
is what makes the base colour constant.
"""
rest = np.zeros((16, 16), dtype=np.float32)
valid = np.ones((16, 16), dtype=bool)
pressed = rest.copy()
# A press big enough to move the robust upper bound (P95): a quarter of the field.
pressed[4:12, 4:12] = 1.0
pressed[8, 8] = 0.6
rest_range = _valid_scalar_range(rest, valid, signed=False)
pressed_range = _valid_scalar_range(pressed, valid, signed=False)
assert rest_range is not None and pressed_range is not None
assert rest_range[0] == 0.0 and pressed_range[0] == 0.0, (
"an unsigned field's lower bound must be its own zero, or the base colour "
"moves with the measurement"
)
assert pressed_range[1] > rest_range[1], (
"the upper bound must still follow the frame's response"
)
# ...and the colour the shader draws for a field value of 0 is therefore identical
# in both frames: the ramp's first colour.
for lower, upper in (rest_range, pressed_range):
assert upper > lower
assert (0.0 - lower) / (upper - lower) == 0.0
# A signed quantity keeps the diverging ramp's *middle* at zero instead.
signed = _valid_scalar_range(pressed, valid, signed=True)
assert signed is not None and signed[0] < 0.0 < signed[1]
assert abs(signed[0]) == pytest.approx(signed[1])
def test_software_renderers_are_recognised():
assert is_software_renderer("Mesa", "llvmpipe (LLVM 15.0.7, 256 bits)")
assert is_software_renderer("Microsoft", "Microsoft Basic Render Driver")
+83
View File
@@ -2142,6 +2142,89 @@ def test_the_live_view_keeps_the_tracker_reset(make_window):
window.close()
def test_the_tracker_reset_follows_the_session_across_a_view_switch(make_window):
"""Zeroing the tracker is a *device* action: which panel is on screen is not a fact
about the session, so switching live → calibration → live must not take the reset
away from a session that is still running.
The reported defect: with a session running, opening the 标定 view left the button
unavailable, and coming back to 实时 did not restore it — the session was unchanged
throughout. The state asserted at every step is the session's, so the test fails if
the view switch is what drives the control rather than the session.
"""
from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
try:
# A first frame is what makes the session a *running* one: the phase, and with it
# the device controls, key off it (see the rail's own rule).
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
assert window._session_phase == "running", window._session_phase
assert window.toolbar.btn_reset.isEnabled() and window.act_reset.isEnabled(), (
"a running session must offer the tracker reset before any view switch"
)
assert window.toolbar.btn_reset.text() == t("toolbar.reset")
window.brand_bar.btn_calibration.click()
QApplication.processEvents()
assert window.layout_ctl.view_preset == "calibration"
assert window.toolbar.btn_reset.isEnabled() and window.act_reset.isEnabled(), (
"the session still runs in the calibration view: the reset went unavailable"
)
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.layout_ctl.view_preset == "live"
assert window.toolbar.btn_reset.isEnabled() and window.act_reset.isEnabled(), (
"the return to the live view did not restore the session's reset"
)
finally:
window._stop_run(reason="user_stop")
stuck.release()
def test_the_two_figures_are_drawn_from_one_arrow_policy(make_window, monkeypatch):
"""The 2D canvas and the 3D surface must sample the same policy.
The pane owns the arrow thresholds (they are display settings and are persisted),
so the 2D renderer is handed a policy built from that same object rather than
inventing its own. A canvas that picks its own threshold is how the two figures
ended up drawing a different number of arrows for one frame (measured: 432 against
622 on a real recording, differing on 34 of 41 frames).
"""
import orisys_studio.app_window as app_window
from orisys_app_shared.acquisition.image_backend import ImageFrame
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
seen: dict = {}
def spy(frame, plan=None, **kwargs):
seen.update(kwargs)
return None # the panel is not the subject here
monkeypatch.setattr(app_window, "render_image", spy)
window = _show(make_window(sensor_type="finger"), (1440, 880))
expected = policy_for_settings(window.mesh3d_panel.display_settings())
assert window._arrow_policy() == expected
flow = np.zeros((12, 16, 2), dtype=np.float32)
window._show_image_frame(
ImageFrame(np.zeros((12, 16, 3), np.uint8), flow=flow, flow_valid=True)
)
assert seen.get("arrow_policy") == expected, (
"the 2D canvas was not drawn with the pane's arrow policy"
)
window.close()
def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, record_root):
"""A clip with no forces.csv beside it is still something to watch.