Merge branch 'main' of ssh://892887cvmk08.aicp.io:24928/orisys-tech/orisys-app

This commit is contained in:
2026-10-09 13:41:39 +08:00
8 changed files with 99 additions and 160 deletions
@@ -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
+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():
"""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,