Merge branch 'main' of ssh://892887cvmk08.aicp.io:24928/orisys-tech/orisys-app
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user