Compare commits

...

2 Commits

Author SHA1 Message Date
ykcheungab 0e8fb9e789 added archify to gitignore 2026-10-09 13:34:29 +08:00
ykcheungab d4b66c65a1 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.
2026-10-09 13:34:10 +08:00
8 changed files with 99 additions and 160 deletions
+1
View File
@@ -19,6 +19,7 @@ venv/
apps/studio/preference.json apps/studio/preference.json
# Tooling scratch (local pip/pytest caches) # Tooling scratch (local pip/pytest caches)
.archify/
.piptmp/ .piptmp/
.pytestlib/ .pytestlib/
.pytest_cache/ .pytest_cache/
@@ -294,11 +294,10 @@ class AnalysisSession:
the pass's own answer — the field is finite only at those cells, and 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 :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 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 question for the shared painter, which re-selects the recovered field. A box
can be asked again later. A box result on the live painter's grid is different: the result keeps the box grid's on/off floors (2 / 1.5 px) rather than the operator's
2D canvas calls ``orisys.util.draw_arrows``, which does not read the display finger thresholds, so live and replay zero at the same strength. The 3D overlay
threshold, so the on/off floors stay at that painter's values (2 / 1.5 px). The 3D uses this same policy.
overlay uses this same policy so the two figures zero at the same strength.
Without this, a display setting that moves the grid would be refused by 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 ``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 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. 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 At ``flow_arrow_scale == 1`` that divisor is ``DISPLACEMENT_PX_PER_FLOW`` times
the margin. the margin. Box and finger use the same inverse; only the policy numbers differ.
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.
""" """
rows = np.asarray(samples.rows, np.int64) rows = np.asarray(samples.rows, np.int64)
cols = np.asarray(samples.cols, 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 # ``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 # discarded cell is defined to be discarded: say so here rather than let
# numpy warn about an invalid cast on every frame. # numpy warn about an invalid cast on every frame.
if uses_sdk_box_grid(self.policy): scale = float(self.policy.flow_arrow_scale)
magnitude = np.asarray(samples.magnitude, np.float64) if scale <= 0.0:
length = np.sqrt(dx * dx + dy * dy) scale = 1.0
flow_mag = magnitude / DISPLACEMENT_PX_PER_FLOW divisor = (
safe = np.maximum(length, 1e-12) DISPLACEMENT_PX_PER_FLOW
moving = length > 0.0 * float(self.policy.flow_length_margin)
flow[rows, cols, 0] = np.where(moving, dx / safe * flow_mag, 0.0) / scale
flow[rows, cols, 1] = np.where(moving, dy / safe * flow_mag, 0.0) )
else: flow[rows, cols, 0] = dx / divisor
scale = float(self.policy.flow_arrow_scale) flow[rows, cols, 1] = dy / divisor
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 return flow
@@ -17,8 +17,8 @@ Where a failure would be invisible:
reference frame are read back from the dialog's own widgets; 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 * 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; 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 * a box result is recorded on the box sample grid, so the canvas is compared with
``draw_arrows`` on the recovered field once its gaps are filled with zero. ``draw_arrow_display`` on the recovered field.
The fixture clip is the project's recorded box footage under ``dist/``, copied into the test's 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 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( def test_the_box_arrow_canvas_is_painted_by_the_live_painter(
bare_clip, window, monkeypatch 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 The recovered field is NaN off the recorded cells. The shared selector only samples
is what the live painter reads as "no motion", and that picture is the panel. finite cells, so the panel matches ``draw_arrow_display`` on that sparse field.
""" """
open_bare_video(window, bare_clip, monkeypatch) open_bare_video(window, bare_clip, monkeypatch)
settle(window) settle(window)
@@ -451,7 +451,6 @@ def test_the_box_arrow_canvas_is_painted_by_the_live_painter(
"draw_style": window._draw_style(), "draw_style": window._draw_style(),
"colormap": flow_colormap(window._theme), "colormap": flow_colormap(window._theme),
"policy": policy, "policy": policy,
"shared_painter": False,
} }
set_mode(window, ARROW_PURE) set_mode(window, ARROW_PURE)
@@ -460,15 +459,14 @@ def test_the_box_arrow_canvas_is_painted_by_the_live_painter(
QApplication.processEvents() QApplication.processEvents()
shown = panel_bgr(window) shown = panel_bgr(window)
assert painted(shown, canvas) > 0, "the operator's arrow canvas is blank" 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( expected = draw_arrow_display(
first.base_bgr, first.base_bgr,
filled, first.flow,
valid_mask=first.valid_mask, valid_mask=first.valid_mask,
**common, **common,
) )
assert np.array_equal(shown, np.asarray(expected)), ( 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): 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 The operator's live view paints the chain's dense flow, and the replay paints a
paints the chain's dense flow, and the replay paints a field recovered from the field recovered from the stored samples. Both go through :func:`render_image` with
stored samples. Both go through :func:`render_image` with ``draw_style="box"``. ``draw_style="box"`` and the shared painter.
""" """
service, outcome = box_pass service, outcome = box_pass
assert outcome.complete, outcome.reason assert outcome.complete, outcome.reason
@@ -78,16 +78,16 @@ _ARROW_DRAW_KW = dict(
below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX), below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX),
) )
#: ``draw_arrows`` reads every point of this grid, including those under its threshold #: Box/Mini live and analysis walk every point of this grid, including those under
#: (they become the idle dots). The cap is only a guard: a stitch image does not have #: the threshold (they become the idle dots). The cap is only a guard: a stitch image
#: this many points, and a lower cap would drop cells the live painter still draws. #: does not have this many points, and a lower cap would drop cells the painter still draws.
SDK_BOX_MAX_ARROWS = 100_000 SDK_BOX_MAX_ARROWS = 100_000
def sdk_box_policy(): 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 A stored result has to record *this* grid or the replay cannot hand the same cells
back to the same painter. back to the same painter.
""" """
@@ -98,8 +98,7 @@ def sdk_box_policy():
flow_off_px=float(_ARROW_DRAW_KW["threshold"]) * 0.75, flow_off_px=float(_ARROW_DRAW_KW["threshold"]) * 0.75,
flow_grid_spacing=int(_ARROW_DRAW_KW["grid_spacing"]), flow_grid_spacing=int(_ARROW_DRAW_KW["grid_spacing"]),
flow_arrow_scale=float(_ARROW_DRAW_KW["arrow_scale"]), flow_arrow_scale=float(_ARROW_DRAW_KW["arrow_scale"]),
# ``draw_arrows`` still folds the border cells into its colour range, then # Edge 0 keeps border cells in the colour range the box pass has always used.
# refuses to draw them. Recording them (edge 0) is what keeps that range.
flow_edge_margin=0, flow_edge_margin=0,
flow_length_margin=0.8, flow_length_margin=0.8,
max_arrows=SDK_BOX_MAX_ARROWS, max_arrows=SDK_BOX_MAX_ARROWS,
@@ -107,7 +106,7 @@ def sdk_box_policy():
def uses_sdk_box_grid(policy) -> bool: 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: if policy is None:
return False return False
spacing = getattr(policy, "flow_grid_spacing", None) 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: def arrow_display_scale(draw_style: str = "box") -> float:
"""How much the drawn arrows amplify the measured displacement. """How much the drawn arrows amplify the measured displacement.
``orisys.util.draw_arrows`` divides the displacement by ``arrow_scale``, so The shared selector divides displacement by ``flow_arrow_scale``, so the
the effective display magnification is its reciprocal (box ``0.5`` -> ``2x``, effective display magnification is its reciprocal (box ``0.5`` -> ``2x``,
finger ``1.0`` -> ``1x``). The drawn length is additionally capped at finger ``1.0`` -> ``1x``). The drawn length is additionally capped at
``grid_spacing * 0.8`` pixels, so the returned factor describes the mapping ``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. 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 #: 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 #: from the centre it is given. One plus-shaped 5-pixel blob for every sensor type.
#: ``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.
#: Painting the offsets in one vectorised write, instead of one ``cv2.circle`` per dot, #: 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 #: 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. #: 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, magnitude_range: tuple[float, float] | None = None,
per_frame_colour: bool = False, per_frame_colour: bool = False,
idle_dots: bool | None = None, idle_dots: bool | None = None,
shared_painter: bool = False,
) -> np.ndarray: ) -> np.ndarray:
"""Draw flow arrows on a solid canvas or over the processed frame. """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` ``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``). 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 Passing it is what makes the two figures trigger at the same strength. Without it,
finger-style defaults below are only the fallback for a caller that has no pane finger style falls back to the finger defaults and box style to :func:`sdk_box_policy`.
(a tool, a test). Box style keeps the SDK path either way.
``magnitude_range`` fixes the colour scale, in the same unit as ``flow_on_px`` ``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 (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 back in to normalising every frame against its own maximum, which makes one
magnitude a different colour from one frame to the next. magnitude a different colour from one frame to the next.
``idle_dots`` shows or hides the below-threshold markers of the **shared finger ``idle_dots`` shows or hides the below-threshold markers; ``None`` means
canvas**; ``None`` means :data:`DRAW_IDLE_GRID_DOTS`, which is on. Box/Mini draw :data:`DRAW_IDLE_GRID_DOTS`, which is on.
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.
Returns the drawn image, which carries ``frame_diagnostic``: ``None`` when the frame Returns the drawn image, which carries ``frame_diagnostic``: ``None`` when the frame
was drawn, and otherwise a :class:`~orisys_app_shared.scene3d.contracts.Diagnostic` 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 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 could be drawn". A ``flow`` of ``None`` is a legitimate empty frame and leaves the
attribute ``None``. 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) base = _ensure_bgr(processed_frame)
if flow is None: if flow is None:
return _flow_result(base) 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) draw_kw = dict(_FINGER_ARROW_DRAW_KW if finger else _ARROW_DRAW_KW)
if colormap is not None:
draw_kw["colormap"] = colormap
try: try:
if background == "pure_color": if background == "pure_color":
color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr) 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: else:
canvas = base canvas = base
if finger: if policy is not None:
selected = ( selected = policy
policy elif finger:
if policy is not None selected = _policy_from_style(
else _policy_from_style( draw_style,
draw_style, flow_on_px=flow_on_px,
flow_on_px=flow_on_px, flow_off_px=flow_off_px,
flow_off_px=flow_off_px, flow_grid_spacing=flow_grid_spacing,
flow_grid_spacing=flow_grid_spacing, max_arrows=max_arrows,
max_arrows=max_arrows,
)
) )
return _flow_result( else:
_paint_shared_flow_arrows( selected = sdk_box_policy()
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
return _flow_result( 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: except Exception as exc:
# The frame is *not* idle: it failed to draw. The image alone cannot say so, so # 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, depth_map_to_bgr,
draw_arrow_display, draw_arrow_display,
flow_colormap, flow_colormap,
uses_sdk_box_grid,
) )
from orisys_app_shared.acquisition.display_orientation import ( from orisys_app_shared.acquisition.display_orientation import (
ORIENTATION_IDENTITY, ORIENTATION_IDENTITY,
@@ -396,26 +395,14 @@ def _draw_flow(
domain_mask: np.ndarray | None, domain_mask: np.ndarray | None,
arrow_policy, arrow_policy,
canvas_bgr: Sequence[int] | None = None, canvas_bgr: Sequence[int] | None = None,
sdk_result: bool | None = None,
) -> np.ndarray: ) -> np.ndarray:
"""Arrows on ``base``, using the same painter the pure-arrow views use.""" """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( return draw_arrow_display(
base, base,
flow, frame.flow,
background=background, background=background,
canvas_bgr=canvas_bgr, canvas_bgr=canvas_bgr,
draw_style=draw_style, draw_style=draw_style,
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
valid_mask=frame.valid_mask, valid_mask=frame.valid_mask,
domain_mask=domain_mask, domain_mask=domain_mask,
colormap=flow_colormap(theme), colormap=flow_colormap(theme),
@@ -534,9 +534,8 @@ class AnalysisService:
) )
#: The policy the samples are selected with. Recorded, never keyed (see #: The policy the samples are selected with. Recorded, never keyed (see
#: ``model.py``): the shipped defaults make a stored result independent of the #: ``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 #: operator's display preferences. A box chain records the box sample grid;
#: grid; the shared defaults are the finger painter's and would drop every cell #: the finger defaults would drop cells that grid still draws.
#: ``draw_arrows`` still draws.
if policy is None and chain.sensor_type == "box": if policy is None and chain.sensor_type == "box":
from orisys_app_shared.acquisition.arrows import sdk_box_policy 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(): 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 Box and finger both use the shared per-sample painter. With the same policy they
below-threshold sample with ``cv2.circle(radius=1, thickness=-1)`` in the same must put the same plus-shaped idle blob on the same quiet sample.
``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.
""" """
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.acquisition.arrows import FLOW_IDLE_HEX, hex_to_bgr
from orisys_app_shared.scene3d.visual_policy import VisualPolicy 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 # One candidate location on a 20 px grid with a 15 px edge: a 60x60 frame has
# a 15 px edge margin, so a 60x60 frame has exactly the (30, 30) sample. # exactly the (30, 30) sample inside the margin.
size = 60 size = 60
flow = np.zeros((size, size, 2), dtype=np.float64) 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) canvas = _flat_canvas(size, size)
policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=20, flow_edge_margin=15) 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( finger = draw_arrow_display(
canvas, flow, background="captured", draw_style="finger", policy=policy 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(): 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 The field is finite only at the stored candidate cells. The shared selector walks
stored result's candidate cells it finds no sample at all and paints nothing: measured those cells, so both sources paint the same picture under the same policy.
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.
""" """
from orisys_app_shared.scene3d.visual_policy import select_flow_samples from orisys_app_shared.scene3d.visual_policy import select_flow_samples
sparse, samples = _recovered_flow() sparse, samples = _recovered_flow()
base = np.zeros((60, 80, 3), dtype=np.uint8) base = np.zeros((60, 80, 3), dtype=np.uint8)
base[:, :] = (0x16, 0x13, 0x12) base[:, :] = (0x16, 0x13, 0x12)
policy = _policy()
result_frame = backend.ImageFrame( result_frame = backend.ImageFrame(
base, flow=sparse, source=backend.SOURCE_ANALYSIS, flow_valid=True 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 base, flow=sparse, source=backend.SOURCE_LIVE, flow_valid=True
) )
drawn = backend.render_image( 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( expected = draw_arrow_display(
base, 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 # 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. # as well; the default is ``cv2.COLORMAP_JET``, which is a different picture.
colormap=flow_colormap("dark"), colormap=flow_colormap("dark"),
policy=_policy(), policy=policy,
shared_painter=True,
) )
assert np.array_equal(np.asarray(drawn), np.asarray(expected)), ( assert np.array_equal(np.asarray(drawn), np.asarray(expected)), (
"a result's canvas is not the shared painter's output" "a result's canvas is not the shared painter's output"
) )
assert _painted(drawn) > 0, "the recovered field painted nothing" 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 live = backend.render_image(
# sensor family's draw style has always meant, and which paints nothing here. live_frame, mode=backend.ARROW_PURE, draw_style="box", arrow_policy=policy
sdk = 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 # One marker per recorded cell at most: the recovered field carries no other value, so
# nothing outside those cells can become a sample. # 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(): 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 """A box pass stores the box grid; the idempotent inverse redraws the dense field."""
back draws the same picture as the dense field."""
from orisys_app_shared.acquisition.arrows import sdk_box_policy from orisys_app_shared.acquisition.arrows import sdk_box_policy
from orisys_app_shared.scene3d.visual_policy import ( from orisys_app_shared.scene3d.visual_policy import (
DISPLACEMENT_PX_PER_FLOW, 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 assert samples.rows.size > 0
dx = np.asarray(samples.dx, np.float64) dx = np.asarray(samples.dx, np.float64)
dy = np.asarray(samples.dy, np.float64) dy = np.asarray(samples.dy, np.float64)
length = np.sqrt(dx * dx + dy * dy) scale = float(policy.flow_arrow_scale) or 1.0
flow_mag = np.asarray(samples.magnitude, np.float64) / DISPLACEMENT_PX_PER_FLOW divisor = (
safe = np.maximum(length, 1e-12) DISPLACEMENT_PX_PER_FLOW * float(policy.flow_length_margin) / scale
moving = length > 0.0 )
sparse = np.full(dense.shape, np.nan, dtype=np.float64) 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, 0] = dx / divisor
sparse[samples.rows, samples.cols, 1] = np.where(moving, dy / safe * flow_mag, 0.0) sparse[samples.rows, samples.cols, 1] = dy / divisor
base = np.zeros(dense.shape[:2] + (3,), np.uint8) base = np.zeros(dense.shape[:2] + (3,), np.uint8)
base[:] = (0x16, 0x13, 0x12) base[:] = (0x16, 0x13, 0x12)
cmap = flow_colormap("dark") cmap = flow_colormap("dark")
@@ -1056,6 +1048,7 @@ def test_a_box_result_on_the_sdk_grid_matches_the_live_painter():
background="pure_color", background="pure_color",
canvas_bgr=(0x16, 0x13, 0x12), canvas_bgr=(0x16, 0x13, 0x12),
colormap=cmap, colormap=cmap,
policy=policy,
) )
frame = backend.ImageFrame( frame = backend.ImageFrame(
base, base,