Return flow sample locations with an active flag and a fixed colour ramp

The selector returned only the arrows it would draw, so a view could not draw
anything at a quiet sample and the below-threshold branch was unreachable. It
now returns every candidate location plus an `active` flag, both callers filter
on it, and the magnitude colour ramp is fixed so one magnitude is one colour
across frames. Verified by pixel parity against HEAD for the 2D canvas and the
3D overlay, and by the shared and studio suites.
This commit is contained in:
ykcheungab
2026-10-04 10:35:56 +08:00
parent dda6c74fb7
commit ca28d788c2
7 changed files with 426 additions and 45 deletions
+109
View File
@@ -148,6 +148,115 @@ def test_the_2d_canvas_draws_from_the_policy_it_is_given():
assert len(np.unique(direct.reshape(-1, 3), axis=0)) == 1
def test_the_2d_canvas_colours_one_magnitude_the_same_in_every_frame():
"""The colour range is fixed, so two frames are comparable by colour.
The canvas used to normalise each frame against its own maximum: the same magnitude
was one colour in a frame with a fast sample and another in a frame without one, so
two frames could look alike while showing different motion. ``magnitude_range``
defaults to :data:`arrows.FLOW_MAGNITUDE_RANGE`, and the old re-normalisation is
available only when a caller asks for it.
"""
from orisys_app_shared.acquisition.arrows import magnitude_to_ramp_index
# One probe magnitude in two frames whose maxima differ by a factor of four.
weak = magnitude_to_ramp_index(np.array([0.5, 1.0]))
strong = magnitude_to_ramp_index(np.array([0.5, 4.0]))
assert weak[0] == strong[0], (
"the same 0.5 px magnitude took two different ramp positions because the frame's "
f"maximum changed: {weak[0]} against {strong[0]}"
)
# The range is the documented default, not one frame's own span.
assert magnitude_to_ramp_index(np.array([0.0, 2.0])).tolist() == [0, 255]
# The per-frame mode is still available, and is what made the colours drift.
assert magnitude_to_ramp_index(np.array([0.5, 4.0]), per_frame=True)[0] != weak[0]
# A caller may pin the range it wants instead: 1.0 of a 0..4.0 range is a quarter up.
assert magnitude_to_ramp_index(np.array([1.0]), value_range=(0.0, 4.0))[0] == 63
def test_the_2d_canvas_draws_the_same_magnitude_in_the_same_colour():
"""The painted canvas obeys the fixed range through :func:`draw_arrow_display`.
The helper's contract is one thing; this is the pixel an operator looks at. Two
frames carry the same magnitude at the same sample and different maxima, and the
sample's pixel is the same colour in both.
"""
from orisys_app_shared.acquisition.arrows import ARROW_BG_BGR, flow_colormap
from orisys_app_shared.scene3d.visual_policy import VisualPolicy
h, w = 12, 12
policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=1, flow_edge_margin=0)
def canvas(probe: float, strongest: float) -> np.ndarray:
flow = np.zeros((h, w, 2), dtype=np.float64)
flow[8, 8] = (probe, 0.0)
flow[2, 8] = (strongest, 0.0)
return draw_arrow_display(
np.zeros((h, w, 3), dtype=np.uint8),
flow,
draw_style="finger",
policy=policy,
colormap=flow_colormap("dark"),
)
def sample_colour(drawn: np.ndarray, row: int, col: int) -> tuple[int, ...]:
"""The colour this sample was drawn in.
The canvas is one flat colour and this sample is drawn at the centre of the
patch, so any pixel that is not the canvas is the drawn sample.
"""
margin = 2
patch = drawn[row - margin : row + margin + 1, col - margin : col + margin + 1]
background = np.array(ARROW_BG_BGR, dtype=np.uint8)
drawn_pixels = patch[np.any(patch != background, axis=-1)]
assert drawn_pixels.size, f"nothing was drawn at {(row, col)}"
return tuple(int(v) for v in drawn_pixels[0])
quiet = canvas(0.25, 0.5) # probe |2*flow| = 0.5 px, frame maximum 1.0 px
loud = canvas(0.25, 4.0) # same probe, frame maximum 8.0 px
assert sample_colour(quiet, 8, 8) == sample_colour(loud, 8, 8), (
"one magnitude took two colours because another sample moved faster"
)
def test_a_failed_draw_is_not_an_idle_frame():
"""The base image comes back with a code on it, because the pixels cannot say so.
A render failure and a frame with nothing to draw produced the same picture, so
"the arrows are missing" was indistinguishable from "the device measured nothing".
The code travels on the returned image; the caller surfaces it (this backend's own
panel wiring is not part of this change).
"""
from orisys_app_shared.acquisition.arrows import ARROW_RENDER_FAILED
base = np.zeros((12, 12, 3), dtype=np.uint8)
base[:, :] = (0x20, 0x30, 0x40)
# ``select_flow_samples`` rejects a field that is not [H,W,2], which is a draw
# failure rather than an empty measurement.
failed = draw_arrow_display(base, np.zeros((12, 12), dtype=np.float32), draw_style="finger")
assert np.array_equal(failed, base), "a failed draw must still hand back the image"
assert failed.frame_diagnostic is not None
assert failed.frame_diagnostic.code == ARROW_RENDER_FAILED
assert failed.frame_diagnostic.severity == "warning"
# The exception's own message is not carried: diagnostics travel to the UI and logs.
assert "Traceback" not in failed.frame_diagnostic.message
# A frame with no flow at all is a legitimate empty frame, not a failure.
idle = draw_arrow_display(base, None, draw_style="finger")
assert idle.frame_diagnostic is None
assert np.array_equal(idle, base)
# A drawn frame says nothing, and stays an ordinary array for every other caller.
drawn = draw_arrow_display(
base,
np.full((12, 12, 2), 0.5, dtype=np.float32),
draw_style="finger",
)
assert drawn.frame_diagnostic is None
assert isinstance(drawn, np.ndarray)
assert drawn.shape == base.shape and drawn.dtype == np.uint8
def test_the_backend_renders_what_the_primitives_rendered_before():
"""Live went through these two calls; the backend must not change a pixel."""
image = _image()
+37 -2
View File
@@ -44,6 +44,13 @@ def test_select_flow_samples_uses_shared_grid_and_mask() -> None:
def test_select_flow_samples_hysteresis_keeps_weaker_motion() -> None:
"""The band between ``flow_off_px`` and ``flow_on_px`` decides through ``active``.
The location is a candidate either way — the band is a statement about activity, not
about where the policy samples — so what changes with ``previously_on`` is the flag.
This is the single place the two views' thresholds are applied: a caller that draws
``active_only()`` draws what it drew when the selector filtered for it.
"""
h, w = 30, 30
flow = np.zeros((h, w, 2), dtype=np.float64)
# |2*flow| = 0.7: below on (0.8) but above off (0.5)
@@ -58,8 +65,36 @@ def test_select_flow_samples_hysteresis_keeps_weaker_motion() -> None:
previously[15, 15] = True
off = select_flow_samples(flow, None, policy, previously_on=None)
on = select_flow_samples(flow, None, policy, previously_on=previously)
assert off.rows.size == 0
assert on.rows.size == 1
# Same candidate locations: the grid does not depend on the verdict.
assert off.rows.tolist() == on.rows.tolist()
assert off.cols.tolist() == on.cols.tolist()
assert (15, 15) in set(zip(on.rows.tolist(), on.cols.tolist()))
# Only the verdict does.
assert on.active_count == 1
assert off.active_count == 0
assert on.active_only().rows.tolist() == [15]
assert on.active_only().cols.tolist() == [15]
assert off.active_only().rows.size == 0
def test_the_location_cap_is_a_location_budget() -> None:
"""``max_arrows`` limits candidate locations, not arrows.
The cap has to be spent before the activity verdict, otherwise the set of locations
would depend on the threshold and a caller drawing inactive candidates too would draw
a different set from the one a caller drawing arrows does. The strongest candidates
are kept, and every inactive candidate is one no arrow was ever drawn for.
"""
h, w = 40, 40
flow = np.zeros((h, w, 2), dtype=np.float64)
flow[15, 15] = (5.0, 0.0) # far above the threshold
policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=1, flow_edge_margin=0, max_arrows=4)
samples = select_flow_samples(flow, np.ones((h, w), dtype=bool), policy)
assert samples.rows.size == 4
assert samples.active_count == 1
assert (15, 15) in set(zip(samples.rows.tolist(), samples.cols.tolist())), (
"the cap dropped the only moving sample"
)
def test_paint_domain_background_colours() -> None: