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