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:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user