Unify 2D arrows on the shared painter.

Drop orisys.util.draw_arrows; box live and replay both use
_paint_shared_flow_arrows with sdk_box_policy when no policy is passed.
Recovered box flow uses the same inverse as finger. Verified with
shared/tests/test_image_backend.py arrow/idle/recovered cases.
This commit is contained in:
ykcheungab
2026-10-09 13:34:10 +08:00
parent 091d0fda35
commit d4b66c65a1
7 changed files with 98 additions and 160 deletions
+29 -36
View File
@@ -582,28 +582,25 @@ def test_the_idle_dot_switch_hides_the_dots_and_leaves_the_arrows_alone():
def test_the_idle_marker_is_the_marker_a_box_sample_gets():
"""One marker shape and one tint across sensor types.
"""One marker shape and one tint across sensor styles.
Box and Mini draw through the SDK's ``orisys.util.draw_arrows``, which paints a
below-threshold sample with ``cv2.circle(radius=1, thickness=-1)`` in the same
``below_threshold_color`` this module passes it. The finger canvas has to put the same
blob on the same kind of location, or a quiet sample would look like two different
things depending on the sensor; this compares the two paths' pixels for one
below-threshold sample.
Box and finger both use the shared per-sample painter. With the same policy they
must put the same plus-shaped idle blob on the same quiet sample.
"""
pytest.importorskip("orisys", reason="the SDK path needs the SDK")
from orisys_app_shared.acquisition.arrows import FLOW_IDLE_HEX, hex_to_bgr
from orisys_app_shared.scene3d.visual_policy import VisualPolicy
# One candidate location, shared by both paths: the box draw's own grid is 20 px with
# a 15 px edge margin, so a 60x60 frame has exactly the (30, 30) sample.
# One candidate location on a 20 px grid with a 15 px edge: a 60x60 frame has
# exactly the (30, 30) sample inside the margin.
size = 60
flow = np.zeros((size, size, 2), dtype=np.float64)
flow[30, 30] = (0.1, 0.0) # 0.2 px: under both paths' thresholds
flow[30, 30] = (0.1, 0.0) # 0.2 px: under the threshold
canvas = _flat_canvas(size, size)
policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=20, flow_edge_margin=15)
box = draw_arrow_display(canvas, flow, background="captured", draw_style="box")
box = draw_arrow_display(
canvas, flow, background="captured", draw_style="box", policy=policy
)
finger = draw_arrow_display(
canvas, flow, background="captured", draw_style="finger", policy=policy
)
@@ -970,19 +967,17 @@ def _painted(image, canvas=(0x16, 0x13, 0x12)) -> int:
def test_a_recovered_field_is_drawn_by_the_shared_painter_whatever_the_style_is():
"""Where a frame came from decides the painter, not which sensor's style is selected.
"""Live and analysis sources use the same painter on a sparse recovered field.
The SDK painter walks the dense sampling grid, so on a field that is finite only at a
stored result's candidate cells it finds no sample at all and paints nothing: measured
on a real box recording, **0 px** against the shared painter's **1,486 px** on the same
frame. So the routing is by ``ImageFrame.source`` — the frame came from a result — and
the box *style* still owns the magnification and the idle-dot tint.
The field is finite only at the stored candidate cells. The shared selector walks
those cells, so both sources paint the same picture under the same policy.
"""
from orisys_app_shared.scene3d.visual_policy import select_flow_samples
sparse, samples = _recovered_flow()
base = np.zeros((60, 80, 3), dtype=np.uint8)
base[:, :] = (0x16, 0x13, 0x12)
policy = _policy()
result_frame = backend.ImageFrame(
base, flow=sparse, source=backend.SOURCE_ANALYSIS, flow_valid=True
@@ -991,7 +986,7 @@ def test_a_recovered_field_is_drawn_by_the_shared_painter_whatever_the_style_is(
base, flow=sparse, source=backend.SOURCE_LIVE, flow_valid=True
)
drawn = backend.render_image(
result_frame, mode=backend.ARROW_PURE, draw_style="box", arrow_policy=_policy()
result_frame, mode=backend.ARROW_PURE, draw_style="box", arrow_policy=policy
)
expected = draw_arrow_display(
base,
@@ -1001,30 +996,27 @@ def test_a_recovered_field_is_drawn_by_the_shared_painter_whatever_the_style_is(
# The backend hands the theme's ramp to the painter, so the reference call has to
# as well; the default is ``cv2.COLORMAP_JET``, which is a different picture.
colormap=flow_colormap("dark"),
policy=_policy(),
shared_painter=True,
policy=policy,
)
assert np.array_equal(np.asarray(drawn), np.asarray(expected)), (
"a result's canvas is not the shared painter's output"
)
assert _painted(drawn) > 0, "the recovered field painted nothing"
# The same array handed over as a *live* field keeps the SDK path — which is what the
# sensor family's draw style has always meant, and which paints nothing here.
sdk = backend.render_image(
live_frame, mode=backend.ARROW_PURE, draw_style="box", arrow_policy=_policy()
live = backend.render_image(
live_frame, mode=backend.ARROW_PURE, draw_style="box", arrow_policy=policy
)
assert np.array_equal(np.asarray(drawn), np.asarray(live)), (
"live and analysis disagree on the same sparse field"
)
assert _painted(sdk) == 0, "the SDK painter drew a recovered field"
assert not np.array_equal(np.asarray(drawn), np.asarray(sdk))
# One marker per recorded cell at most: the recovered field carries no other value, so
# nothing outside those cells can become a sample.
assert select_flow_samples(sparse, None, _policy()).rows.size == samples.rows.size
assert select_flow_samples(sparse, None, policy).rows.size == samples.rows.size
def test_a_box_result_on_the_sdk_grid_matches_the_live_painter():
"""A box pass stores the live painter's grid, so filling the gaps and calling it
back draws the same picture as the dense field."""
"""A box pass stores the box grid; the idempotent inverse redraws the dense field."""
from orisys_app_shared.acquisition.arrows import sdk_box_policy
from orisys_app_shared.scene3d.visual_policy import (
DISPLACEMENT_PX_PER_FLOW,
@@ -1039,13 +1031,13 @@ def test_a_box_result_on_the_sdk_grid_matches_the_live_painter():
assert samples.rows.size > 0
dx = np.asarray(samples.dx, np.float64)
dy = np.asarray(samples.dy, np.float64)
length = np.sqrt(dx * dx + dy * dy)
flow_mag = np.asarray(samples.magnitude, np.float64) / DISPLACEMENT_PX_PER_FLOW
safe = np.maximum(length, 1e-12)
moving = length > 0.0
scale = float(policy.flow_arrow_scale) or 1.0
divisor = (
DISPLACEMENT_PX_PER_FLOW * float(policy.flow_length_margin) / scale
)
sparse = np.full(dense.shape, np.nan, dtype=np.float64)
sparse[samples.rows, samples.cols, 0] = np.where(moving, dx / safe * flow_mag, 0.0)
sparse[samples.rows, samples.cols, 1] = np.where(moving, dy / safe * flow_mag, 0.0)
sparse[samples.rows, samples.cols, 0] = dx / divisor
sparse[samples.rows, samples.cols, 1] = dy / divisor
base = np.zeros(dense.shape[:2] + (3,), np.uint8)
base[:] = (0x16, 0x13, 0x12)
cmap = flow_colormap("dark")
@@ -1056,6 +1048,7 @@ def test_a_box_result_on_the_sdk_grid_matches_the_live_painter():
background="pure_color",
canvas_bgr=(0x16, 0x13, 0x12),
colormap=cmap,
policy=policy,
)
frame = backend.ImageFrame(
base,