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
@@ -294,11 +294,10 @@ class AnalysisSession:
the pass's own answer — the field is finite only at those cells, and
:meth:`serve_policy` refuses a display setting that moves the grid — so the grid
travels from the result's provenance. *Which* candidates are active is a display
question for the shared finger painter, and ``magnitude`` is stored precisely so it
can be asked again later. A box result on the live painter's grid is different: the
2D canvas calls ``orisys.util.draw_arrows``, which does not read the display
threshold, so the on/off floors stay at that painter's values (2 / 1.5 px). The 3D
overlay uses this same policy so the two figures zero at the same strength.
question for the shared painter, which re-selects the recovered field. A box
result keeps the box grid's on/off floors (2 / 1.5 px) rather than the operator's
finger thresholds, so live and replay zero at the same strength. The 3D overlay
uses this same policy.
Without this, a display setting that moves the grid would be refused by
``serve_policy`` and then applied to the drawing anyway, re-selecting the recovered
@@ -541,12 +540,7 @@ class AnalysisSession:
The shared painter re-selects this field, so the inverse divides by that
product and the cap stays idempotent: the stored offsets come back exactly.
At ``flow_arrow_scale == 1`` that divisor is ``DISPLACEMENT_PX_PER_FLOW`` times
the margin.
The box painter does not re-select. It colours by the displacement *before*
the cap, which ``magnitude`` still holds (``|DISPLACEMENT_PX_PER_FLOW * flow|``).
Direction comes from ``dx``/``dy``. Float32 magnitude is not exact enough to
reproduce a capped offset, which is why only the box grid uses it.
the margin. Box and finger use the same inverse; only the policy numbers differ.
"""
rows = np.asarray(samples.rows, np.int64)
cols = np.asarray(samples.cols, np.int64)
@@ -563,25 +557,16 @@ class AnalysisSession:
# ``NaN`` is the point of the mask, and the selector's tip arithmetic on a
# discarded cell is defined to be discarded: say so here rather than let
# numpy warn about an invalid cast on every frame.
if uses_sdk_box_grid(self.policy):
magnitude = np.asarray(samples.magnitude, np.float64)
length = np.sqrt(dx * dx + dy * dy)
flow_mag = magnitude / DISPLACEMENT_PX_PER_FLOW
safe = np.maximum(length, 1e-12)
moving = length > 0.0
flow[rows, cols, 0] = np.where(moving, dx / safe * flow_mag, 0.0)
flow[rows, cols, 1] = np.where(moving, dy / safe * flow_mag, 0.0)
else:
scale = float(self.policy.flow_arrow_scale)
if scale <= 0.0:
scale = 1.0
divisor = (
DISPLACEMENT_PX_PER_FLOW
* float(self.policy.flow_length_margin)
/ scale
)
flow[rows, cols, 0] = dx / divisor
flow[rows, cols, 1] = dy / divisor
scale = float(self.policy.flow_arrow_scale)
if scale <= 0.0:
scale = 1.0
divisor = (
DISPLACEMENT_PX_PER_FLOW
* float(self.policy.flow_length_margin)
/ scale
)
flow[rows, cols, 0] = dx / divisor
flow[rows, cols, 1] = dy / divisor
return flow
@@ -17,8 +17,8 @@ Where a failure would be invisible:
reference frame are read back from the dialog's own widgets;
* a recomputed force trace and a recorded one are different claims, so the charts' source
kind and the provenance's ``sources``/``synthetic`` are asserted, not just the pixels;
* a box result is recorded on the live painter's grid, so the canvas is compared with
``draw_arrows`` on the recovered field once its gaps are filled with zero.
* a box result is recorded on the box sample grid, so the canvas is compared with
``draw_arrow_display`` on the recovered field.
The fixture clip is the project's recorded box footage under ``dist/``, copied into the test's
own directory. If it is absent the test **fails** (``_require_clip``) rather than skipping: a
@@ -433,10 +433,10 @@ def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions(
def test_the_box_arrow_canvas_is_painted_by_the_live_painter(
bare_clip, window, monkeypatch
):
"""A box pass stores the live painter's grid, so the window draws ``draw_arrows``.
"""A box pass stores the box sample grid; the panel redraws with that policy.
The recovered field is still NaN off the recorded cells. Filling those gaps with zero
is what the live painter reads as "no motion", and that picture is the panel.
The recovered field is NaN off the recorded cells. The shared selector only samples
finite cells, so the panel matches ``draw_arrow_display`` on that sparse field.
"""
open_bare_video(window, bare_clip, monkeypatch)
settle(window)
@@ -451,7 +451,6 @@ def test_the_box_arrow_canvas_is_painted_by_the_live_painter(
"draw_style": window._draw_style(),
"colormap": flow_colormap(window._theme),
"policy": policy,
"shared_painter": False,
}
set_mode(window, ARROW_PURE)
@@ -460,15 +459,14 @@ def test_the_box_arrow_canvas_is_painted_by_the_live_painter(
QApplication.processEvents()
shown = panel_bgr(window)
assert painted(shown, canvas) > 0, "the operator's arrow canvas is blank"
filled = np.nan_to_num(np.asarray(first.flow, dtype=np.float64), nan=0.0)
expected = draw_arrow_display(
first.base_bgr,
filled,
first.flow,
valid_mask=first.valid_mask,
**common,
)
assert np.array_equal(shown, np.asarray(expected)), (
"the panel's canvas is not the live painter's output for this frame"
"the panel's canvas is not the shared painter's output for this frame"
)
+4 -4
View File
@@ -738,11 +738,11 @@ def test_the_recovered_field_reselects_to_the_stored_samples(session):
def test_box_live_and_replay_arrow_canvases_do_not_diverge(box_pass, box_session, monkeypatch):
"""Live ``draw_arrows`` on the dense field equals the replay canvas, frame for frame.
"""Live dense field equals the replay canvas, frame for frame.
This is the discrepancy the box arrow work exists to close: the operator's live view
paints the chain's dense flow, and the replay paints a field recovered from the
stored samples. Both go through :func:`render_image` with ``draw_style="box"``.
The operator's live view paints the chain's dense flow, and the replay paints a
field recovered from the stored samples. Both go through :func:`render_image` with
``draw_style="box"`` and the shared painter.
"""
service, outcome = box_pass
assert outcome.complete, outcome.reason
@@ -78,16 +78,16 @@ _ARROW_DRAW_KW = dict(
below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX),
)
#: ``draw_arrows`` reads every point of this grid, including those under its threshold
#: (they become the idle dots). The cap is only a guard: a stitch image does not have
#: this many points, and a lower cap would drop cells the live painter still draws.
#: Box/Mini live and analysis walk every point of this grid, including those under
#: the threshold (they become the idle dots). The cap is only a guard: a stitch image
#: does not have this many points, and a lower cap would drop cells the painter still draws.
SDK_BOX_MAX_ARROWS = 100_000
def sdk_box_policy():
"""The sample grid :func:`orisys.util.draw_arrows` walks for a box frame.
"""The sample grid a box frame is selected and drawn with.
Live box arrows are not the shared selector's default (spacing 10, 400 strongest).
Box arrows are not the finger selector's default (spacing 10, 400 strongest).
A stored result has to record *this* grid or the replay cannot hand the same cells
back to the same painter.
"""
@@ -98,8 +98,7 @@ def sdk_box_policy():
flow_off_px=float(_ARROW_DRAW_KW["threshold"]) * 0.75,
flow_grid_spacing=int(_ARROW_DRAW_KW["grid_spacing"]),
flow_arrow_scale=float(_ARROW_DRAW_KW["arrow_scale"]),
# ``draw_arrows`` still folds the border cells into its colour range, then
# refuses to draw them. Recording them (edge 0) is what keeps that range.
# Edge 0 keeps border cells in the colour range the box pass has always used.
flow_edge_margin=0,
flow_length_margin=0.8,
max_arrows=SDK_BOX_MAX_ARROWS,
@@ -107,7 +106,7 @@ def sdk_box_policy():
def uses_sdk_box_grid(policy) -> bool:
"""True when ``policy`` is the box painter's grid, not the shared selector's."""
"""True when ``policy`` is the box sample grid, not the finger selector's."""
if policy is None:
return False
spacing = getattr(policy, "flow_grid_spacing", None)
@@ -223,8 +222,8 @@ def flow_colormap(theme: str = "dark") -> np.ndarray:
def arrow_display_scale(draw_style: str = "box") -> float:
"""How much the drawn arrows amplify the measured displacement.
``orisys.util.draw_arrows`` divides the displacement by ``arrow_scale``, so
the effective display magnification is its reciprocal (box ``0.5`` -> ``2x``,
The shared selector divides displacement by ``flow_arrow_scale``, so the
effective display magnification is its reciprocal (box ``0.5`` -> ``2x``,
finger ``1.0`` -> ``1x``). The drawn length is additionally capped at
``grid_spacing * 0.8`` pixels, so the returned factor describes the mapping
only while an arrow has not hit that cap — the UI says so in its tooltip.
@@ -345,9 +344,7 @@ def _policy_from_style(
#: The pixels ``cv2.circle(..., radius=1, thickness=-1)`` fills, as ``(dy, dx)`` offsets
#: from the centre it is given. One marker shape for every sensor type: the SDK's
#: ``orisys.util.draw_arrows`` paints a below-threshold sample with exactly that call, so
#: a dot on the finger canvas has to be the same plus-shaped 5-pixel blob a box dot is.
#: from the centre it is given. One plus-shaped 5-pixel blob for every sensor type.
#: Painting the offsets in one vectorised write, instead of one ``cv2.circle`` per dot,
#: is what keeps a full finger grid cheap: on a 276-candidate island the pass costs 47 us
#: against 223 us for the call loop, and the whole draw gains only ~35 us.
@@ -482,7 +479,6 @@ def draw_arrow_display(
magnitude_range: tuple[float, float] | None = None,
per_frame_colour: bool = False,
idle_dots: bool | None = None,
shared_painter: bool = False,
) -> np.ndarray:
"""Draw flow arrows on a solid canvas or over the processed frame.
@@ -498,9 +494,8 @@ def draw_arrow_display(
``policy`` is the **shared** :class:`~orisys_app_shared.scene3d.visual_policy.VisualPolicy`
the 3D surface also draws its overlay from (``visual_policy.policy_for_settings``).
Passing it is what makes the two figures trigger at the same strength; the
finger-style defaults below are only the fallback for a caller that has no pane
(a tool, a test). Box style keeps the SDK path either way.
Passing it is what makes the two figures trigger at the same strength. Without it,
finger style falls back to the finger defaults and box style to :func:`sdk_box_policy`.
``magnitude_range`` fixes the colour scale, in the same unit as ``flow_on_px``
(uv pixels of displacement); the default is :data:`FLOW_MAGNITUDE_RANGE` and a
@@ -508,11 +503,8 @@ def draw_arrow_display(
back in to normalising every frame against its own maximum, which makes one
magnitude a different colour from one frame to the next.
``idle_dots`` shows or hides the below-threshold markers of the **shared finger
canvas**; ``None`` means :data:`DRAW_IDLE_GRID_DOTS`, which is on. Box/Mini draw
through the SDK's own ``orisys.util.draw_arrows``, which paints its own below-threshold
dots (same tint, same radius-1 marker) and has no such switch: this one governs the
canvas that draws here, and Box keeps the appearance it has.
``idle_dots`` shows or hides the below-threshold markers; ``None`` means
:data:`DRAW_IDLE_GRID_DOTS`, which is on.
Returns the drawn image, which carries ``frame_diagnostic``: ``None`` when the frame
was drawn, and otherwise a :class:`~orisys_app_shared.scene3d.contracts.Diagnostic`
@@ -521,22 +513,12 @@ def draw_arrow_display(
that shows it without reading the attribute reports "nothing measured" for "nothing
could be drawn". A ``flow`` of ``None`` is a legitimate empty frame and leaves the
attribute ``None``.
``shared_painter`` forces this module's per-sample painter for a field that came from
somewhere other than a live chain — a *recovered* field, finite only at the cells a
stored analysis result recorded. The SDK painter walks the dense grid, which on such a
field is almost entirely ``NaN``; measured on one stored box frame it paints **0 px**
where the shared painter paints **1,486 px** (the frame's own candidate cells). It is a
statement about the input, not about the sensor: the box *style* still decides the
magnification and the idle-dot tint, and a live box frame keeps the SDK path.
"""
base = _ensure_bgr(processed_frame)
if flow is None:
return _flow_result(base)
finger = str(draw_style).lower().startswith("f") or bool(shared_painter)
finger = str(draw_style).lower().startswith("f")
draw_kw = dict(_FINGER_ARROW_DRAW_KW if finger else _ARROW_DRAW_KW)
if colormap is not None:
draw_kw["colormap"] = colormap
try:
if background == "pure_color":
color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
@@ -568,39 +550,33 @@ def draw_arrow_display(
else:
canvas = base
if finger:
selected = (
policy
if policy is not None
else _policy_from_style(
draw_style,
flow_on_px=flow_on_px,
flow_off_px=flow_off_px,
flow_grid_spacing=flow_grid_spacing,
max_arrows=max_arrows,
)
if policy is not None:
selected = policy
elif finger:
selected = _policy_from_style(
draw_style,
flow_on_px=flow_on_px,
flow_off_px=flow_off_px,
flow_grid_spacing=flow_grid_spacing,
max_arrows=max_arrows,
)
return _flow_result(
_paint_shared_flow_arrows(
canvas,
np.asarray(flow),
valid_mask=valid_mask,
policy=selected,
colormap=colormap,
below_threshold_color=tuple(draw_kw["below_threshold_color"]),
magnitude_range=magnitude_range,
per_frame_colour=per_frame_colour,
# Read here rather than as a parameter default: the module constant is
# the display decision, and a caller that flips it (a build, a test)
# must be obeyed without every call site having to pass it.
idle_dots=DRAW_IDLE_GRID_DOTS if idle_dots is None else bool(idle_dots),
)
)
import orisys
else:
selected = sdk_box_policy()
return _flow_result(
orisys.util.draw_arrows(canvas, flow, valid_mask=valid_mask, **draw_kw)
_paint_shared_flow_arrows(
canvas,
np.asarray(flow),
valid_mask=valid_mask,
policy=selected,
colormap=colormap,
below_threshold_color=tuple(draw_kw["below_threshold_color"]),
magnitude_range=magnitude_range,
per_frame_colour=per_frame_colour,
# Read here rather than as a parameter default: the module constant is
# the display decision, and a caller that flips it (a build, a test)
# must be obeyed without every call site having to pass it.
idle_dots=DRAW_IDLE_GRID_DOTS if idle_dots is None else bool(idle_dots),
)
)
except Exception as exc:
# The frame is *not* idle: it failed to draw. The image alone cannot say so, so
@@ -33,7 +33,6 @@ from orisys_app_shared.acquisition.arrows import (
depth_map_to_bgr,
draw_arrow_display,
flow_colormap,
uses_sdk_box_grid,
)
from orisys_app_shared.acquisition.display_orientation import (
ORIENTATION_IDENTITY,
@@ -396,26 +395,14 @@ def _draw_flow(
domain_mask: np.ndarray | None,
arrow_policy,
canvas_bgr: Sequence[int] | None = None,
sdk_result: bool | None = None,
) -> np.ndarray:
"""Arrows on ``base``, using the same painter the pure-arrow views use."""
if sdk_result is None:
box_style = not str(draw_style).lower().startswith("f")
sdk_result = (
box_style
and str(frame.source or "") in RESULT_SOURCES
and uses_sdk_box_grid(arrow_policy)
)
flow = frame.flow
if sdk_result and flow is not None:
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
return draw_arrow_display(
base,
flow,
frame.flow,
background=background,
canvas_bgr=canvas_bgr,
draw_style=draw_style,
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
valid_mask=frame.valid_mask,
domain_mask=domain_mask,
colormap=flow_colormap(theme),
@@ -534,9 +534,8 @@ class AnalysisService:
)
#: The policy the samples are selected with. Recorded, never keyed (see
#: ``model.py``): the shipped defaults make a stored result independent of the
#: operator's display preferences. A box chain records the live painter's own
#: grid; the shared defaults are the finger painter's and would drop every cell
#: ``draw_arrows`` still draws.
#: operator's display preferences. A box chain records the box sample grid;
#: the finger defaults would drop cells that grid still draws.
if policy is None and chain.sensor_type == "box":
from orisys_app_shared.acquisition.arrows import sdk_box_policy
+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,