fix the finger 2D canvas orientation and the 3D arrow size

The unrolled canvas put the finger's +x where the 3D default view puts screen-left,
and the 3D flow arrows rasterised as 0.23 px hairlines because their stroke was 2 %
of their own sub-millimetre length. The 2D presentation is now the derived half turn
(persisted, set from the plot-settings dialog) and the 3D stroke is one canvas pixel
— the width the 2D canvas draws — verified numerically and with real-GL snapshots.
This commit is contained in:
ykcheungab
2026-10-04 10:03:46 +08:00
parent 9b1a3fb922
commit dda6c74fb7
14 changed files with 1383 additions and 15 deletions
@@ -0,0 +1,357 @@
"""The 3D flow arrows are drawn at the 2D canvas's own size.
What "too small" turned out to be is measured here rather than assumed. On the
shipped finger asset, at the pane's real viewport (607x672, the shipped default
layout) and its default framed camera, the 3D arrow's *length* is already the 2D
arrow's length carried onto the surface: the median arrow projects to about 9.5
screen pixels against about 5.5 canvas pixels in the canvas, and 23.7 against 8 at
the policy's cap — because both views draw the same pixel delta through the same
surface metric, and the pane magnifies the finger by about the same factor the 2D
panel does. The stroke was the defect: ``2 %`` of the arrow's own mean length is
0.114 canvas pixels (0.23 px on screen) where the 2D canvas draws
``cv2.arrowedLine(thickness=1)``, so the 3D glyph rasterised as a dashed hairline
with no head.
The assertions below therefore pin both halves of the reference:
* length — the world length is the domain grid's own pixel metric times the flow's
pixel delta, and what that projects to on screen is the same picture the canvas
draws (``projected == pixels x metres-per-pixel x screen-pixels-per-metre``);
* stroke — one canvas pixel wide, i.e. the 2D canvas's own ``thickness=1``.
The choice behind that is **world-constant** (a fixed fraction of the sensing
surface), not screen-constant, and the zoom test states why: the 2D canvas has no
zoom, so its arrow is a fixed fraction of the finger at every size the operator
gives the panel, and an arrow that held a fixed number of *screen* pixels would
stop matching that picture the moment the 3D pane was zoomed. The relation that is
kept across zoom is therefore the canvas-pixel one, and the raw screen length is
allowed to change with the magnification.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.scene3d.overlays import (
FLOW_ARROW_SHAFT_PX,
FLOW_ARROW_SHAFT_SPAN_FRACTION,
arrow_instances_to_mesh,
build_flow_overlay,
build_normal_overlay,
flow_arrow_shaft_radius_m,
mesh_span_from_mapping,
metres_per_domain_pixel,
)
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
from orisys_app_shared.scene3d.visual_policy import (
policy_for_settings,
select_flow_samples,
)
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.prefs import settings_from_prefs
REPO = Path(__file__).resolve().parents[3]
PACKAGE = REPO / "apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene"
#: The 3D pane's viewport at the shipped default layout (``DEFAULT_SPLITTER_SIZES``
#: with the window's default size), measured from the running window.
VIEWPORT = (607, 672)
#: How far the surface's own metric may differ from its median across the island
#: (measured p5..p95 is 0.81..1.12 of it) plus the rounding of a flow tip to whole
#: pixels, and the difference between a straight chord and the local tangent.
METRIC_TOLERANCE = 0.30
#: The projection itself, given the world length: float32 matrices, a perspective
#: divide and the chord-versus-tangent difference. Measured within 7 % at the median.
PROJECTION_TOLERANCE = 0.12
#: Arrows shorter than this are dominated by the tip's rounding to whole pixels (a
#: 0.4-px flow delta becomes 0 or 1 px), so the projection equality is asserted on
#: the arrows an operator can actually read.
MIN_COMPARABLE_PX = 2.0
def _scene():
if not PACKAGE.is_file():
pytest.skip(f"the shipped finger package is missing: {PACKAGE}")
return scene_from_contents(read_package(PACKAGE), None)
def _camera(assets, viewport=VIEWPORT) -> OrbitCamera:
presets = {preset.name: preset for preset in assets.camera_presets}
camera = OrbitCamera()
camera.set_preset(presets["default"])
camera.fit(assets.bounds_sensor_m, viewport[0] / viewport[1])
return camera
def _flow(height: int, width: int) -> np.ndarray:
"""The flow ``tools/ui_snapshot.py --scene flow3d`` drives: a pressed fingertip.
Its magnitudes reach the policy's cap at the centre and fall away to the rim, so
the comparison covers both a saturated arrow and a weak one.
"""
grid_y, grid_x = np.mgrid[0:height, 0:width].astype(np.float32)
centre_y, centre_x = height * 0.52, width / 2.0
dy, dx = grid_y - centre_y, grid_x - centre_x
radius = np.sqrt(dx * dx + dy * dy) + 1e-3
profile = np.exp(-((radius / (width * 0.30)) ** 2)).astype(np.float32)
return np.stack([dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1)
def _arrows(assets):
"""``(mapping, policy, samples, instances, endpoints)`` for the shipped asset."""
mapping = assets.surface_mapping
assert mapping is not None
policy = policy_for_settings(settings_from_prefs({}))
flow = _flow(*mapping.shape_hw)
samples = select_flow_samples(flow, np.asarray(mapping.valid, dtype=bool), policy)
assert samples.rows.size > 100, "the fixture no longer produces a full field"
arrows, _ = build_flow_overlay(samples, mapping, style=policy)
height, width = mapping.shape_hw
end_x = np.clip(np.round(samples.cols + samples.dx).astype(np.int32), 0, width - 1)
end_y = np.clip(np.round(samples.rows + samples.dy).astype(np.int32), 0, height - 1)
return mapping, policy, samples, arrows, (end_y, end_x)
def _screen_scales(camera, directions, origins, viewport=VIEWPORT) -> np.ndarray:
"""Screen pixels per metre, along each arrow's own direction.
The camera's own matrices, so this is the real projection and not a formula the
test believes: ``focal_px / depth`` is the frontal scale, and the direction's
camera-space length is the surface's foreshortening for that arrow.
"""
view = np.asarray(camera.matrices(viewport).view, dtype=np.float64).reshape(4, 4)
focal_px = 0.5 * float(viewport[1]) / np.tan(0.5 * camera.vertical_fov_rad)
homogeneous = np.concatenate(
[np.asarray(origins, dtype=np.float64), np.ones((len(origins), 1))], axis=1
)
depth = np.abs((view @ homogeneous.T)[2])
steps = np.zeros((len(directions), 4), dtype=np.float64)
steps[:, :3] = np.asarray(directions, dtype=np.float64)
camera_step = (view @ steps.T).T[:, :3]
return np.linalg.norm(camera_step[:, :2], axis=1) * focal_px / np.maximum(depth, 1e-9)
def _projected_pixels(camera, origins, ends, viewport=VIEWPORT) -> np.ndarray:
"""Pixel distance between each arrow's origin and tip, through the real camera."""
matrices = camera.matrices(viewport)
view = np.asarray(matrices.view, dtype=np.float64).reshape(4, 4)
projection = np.asarray(matrices.projection, dtype=np.float64).reshape(4, 4)
points = np.vstack([np.asarray(origins, dtype=np.float64), np.asarray(ends, dtype=np.float64)])
homogeneous = np.concatenate([points, np.ones((len(points), 1))], axis=1)
clip = (projection @ (view @ homogeneous.T)).T
ndc = clip[:, :3] / clip[:, 3:4]
pixels = (ndc[:, :2] + 1.0) * 0.5 * np.array(viewport, dtype=np.float64)
return np.linalg.norm(pixels[len(origins) :] - pixels[: len(origins)], axis=1)
def _pixel_delta(samples, endpoints) -> np.ndarray:
end_y, end_x = endpoints
return np.hypot(end_x - samples.cols, end_y - samples.rows)
def test_one_canvas_pixel_is_one_pixel_of_the_domain_grid() -> None:
"""The unit both views are sized in, against an independent estimate of it."""
assets = _scene()
mapping = assets.surface_mapping
metric = metres_per_domain_pixel(mapping)
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
valid = np.asarray(mapping.valid, dtype=bool)
points = positions[valid]
# Independent: the island's own extent in metres over its extent in pixels. The
# local metric varies with the surface, so this is the same statement only to
# within the island's own shape factor — which is what the band allows.
extent_m = float(np.ptp(points, axis=0).max())
rows, cols = np.nonzero(valid)
extent_px = float(max(np.ptp(rows), np.ptp(cols)))
cruder = extent_m / extent_px
assert 0.6 * cruder < metric < 1.6 * cruder, (metric, cruder)
# A millimetre of finger is about eleven of these pixels on the shipped asset.
assert metric == pytest.approx(9.27e-5, rel=0.1)
def test_the_3d_length_is_the_2d_length_carried_onto_the_surface() -> None:
"""The world length is the flow's pixel delta at the domain's own metric."""
assets = _scene()
mapping, policy, samples, arrows, endpoints = _arrows(assets)
metric = metres_per_domain_pixel(mapping)
delta = _pixel_delta(samples, endpoints)
lengths = np.asarray(arrows.lengths, dtype=np.float64)
usable = delta > 0
ratio = lengths[usable] / (metric * delta[usable])
assert np.median(ratio) == pytest.approx(1.0, abs=0.10), np.median(ratio)
assert np.percentile(ratio, 5) > 1.0 - METRIC_TOLERANCE
assert np.percentile(ratio, 95) < 1.0 + METRIC_TOLERANCE
# The 2D canvas draws that same delta, from the shared policy: the flow's pixel
# displacement times ``flow_length_margin``, and anything past the sample grid's
# own spacing is drawn at ``spacing x margin`` (8 px for the finger's 10 px grid).
drawn = np.hypot(samples.dx, samples.dy)
cap = policy.flow_grid_spacing * policy.flow_length_margin
assert drawn.max() == pytest.approx(cap, abs=1e-6)
assert np.allclose(
drawn,
np.where(
samples.magnitude > cap,
cap,
policy.flow_length_margin * samples.magnitude,
),
)
assert usable.all(), "the fixture must not produce zero-length arrows"
def test_the_projected_length_equals_the_2d_arrow_at_a_stated_camera_distance() -> None:
"""The claim, numerically: same length on screen, at a stated camera and viewport.
The 3D arrow's projected length is compared with the 2D arrow's own length
converted through the pane's magnification for that arrow — the metre of surface
one canvas pixel buys (measured from the mapping) times the screen pixels that
metre projects to (measured from the camera). That is what "the same on-screen
size" means once a perspective pane and a canvas bitmap are compared.
"""
assets = _scene()
mapping, policy, samples, arrows, endpoints = _arrows(assets)
metric = metres_per_domain_pixel(mapping)
camera = _camera(assets)
assert camera.distance_m == pytest.approx(0.03293, rel=0.01), camera.distance_m
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
origins = positions[samples.rows, samples.cols]
ends = positions[endpoints[0], endpoints[1]]
projected = _projected_pixels(camera, origins, ends)
scale = _screen_scales(camera, arrows.directions, origins)
delta = _pixel_delta(samples, endpoints)
comparable = delta >= MIN_COMPARABLE_PX
assert comparable.sum() > 200, int(comparable.sum())
ratio = projected[comparable] / (delta[comparable] * metric * scale[comparable])
assert np.median(ratio) == pytest.approx(1.0, abs=PROJECTION_TOLERANCE), np.median(ratio)
assert np.percentile(ratio, 10) > 1.0 - METRIC_TOLERANCE
assert np.percentile(ratio, 90) < 1.0 + METRIC_TOLERANCE
# Stated in the two views' own pixels, so the magnitudes are on the record: at the
# policy's cap the canvas draws 8 px and the pane draws about the same *relative*
# size on a finger that it magnifies by the same factor.
at_cap = np.isclose(delta, policy.flow_grid_spacing * policy.flow_length_margin)
assert at_cap.any()
canvas_cap = float(np.median(np.hypot(samples.dx, samples.dy)[at_cap]))
assert canvas_cap == pytest.approx(8.0, abs=1e-6)
assert np.median(projected[at_cap]) > 5.0, "the 3D arrows are already longer, not shorter"
def test_the_arrows_are_the_same_size_after_a_zoom_step() -> None:
"""The chosen rule: world-constant, so the canvas-pixel relation survives zoom.
The raw screen length is *not* held — it grows with the magnification, exactly as
the finger does — and that is the deliberate choice: the 2D canvas cannot zoom, so
its arrow is a fixed fraction of the finger, and this is the same fraction.
"""
assets = _scene()
mapping, policy, samples, arrows, endpoints = _arrows(assets)
metric = metres_per_domain_pixel(mapping)
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
origins = positions[samples.rows, samples.cols]
ends = positions[endpoints[0], endpoints[1]]
delta = _pixel_delta(samples, endpoints)
comparable = delta >= MIN_COMPARABLE_PX
camera = _camera(assets)
before_px = _projected_pixels(camera, origins, ends)
before_scale = _screen_scales(camera, arrows.directions, origins)
camera.zoom(5.0)
after_px = _projected_pixels(camera, origins, ends)
after_scale = _screen_scales(camera, arrows.directions, origins)
assert camera.distance_m < 0.02, camera.distance_m
# The magnification changed, so the screen length did...
assert np.median(after_px) > 1.2 * np.median(before_px)
assert np.median(after_scale) > 1.5 * np.median(before_scale)
# ...while the length in the *canvas's* own pixels is the same statement.
for px, scale in ((before_px, before_scale), (after_px, after_scale)):
ratio = px[comparable] / (delta[comparable] * metric * scale[comparable])
assert np.median(ratio) == pytest.approx(1.0, abs=PROJECTION_TOLERANCE), np.median(ratio)
def test_the_stroke_is_one_pixel_wide_and_reaches_the_mesh() -> None:
"""The defect and its fix: 0.114 canvas pixels before, 1.0 after.
Measured on the merged triangle mesh, not on the instance field: the radius has to
arrive in the geometry the GL layer uploads.
"""
assets = _scene()
mapping, policy, samples, arrows, _endpoints = _arrows(assets)
metric = metres_per_domain_pixel(mapping)
radius = float(arrows.shaft_radius_m)
assert radius > 0.0, "the flow overlay states no stroke width"
assert FLOW_ARROW_SHAFT_PX == 1.0
# One canvas pixel wide, i.e. the 2D canvas's own ``thickness=1``.
assert 2.0 * radius / metric == pytest.approx(1.0, abs=0.05)
assert flow_arrow_shaft_radius_m(mapping) == pytest.approx(radius, rel=1e-9)
# The old rule, kept here as the measurement it was: 2 % of the arrows' mean length.
mean_length = float(np.asarray(arrows.lengths, dtype=np.float64).mean())
before = max(mean_length * 0.02, 1e-5)
assert 2.0 * before / metric < 0.25, 2.0 * before / metric
assert 2.0 * radius / metric > 3.0 * (2.0 * before / metric), (
"the fix is not a visible change in stroke width"
)
positions, triangles, _colors = arrow_instances_to_mesh(arrows)
measured = _ring_radius(arrows, positions)
assert measured == pytest.approx(radius, rel=0.05), (measured, radius)
assert triangles.shape[1] == 3 and len(positions) > 0
def _ring_radius(arrows, positions: np.ndarray) -> float:
"""The shaft radius the mesh was actually built with, from its first arrow."""
origin = np.asarray(arrows.origins[0], dtype=np.float64)
direction = np.asarray(arrows.directions[0], dtype=np.float64)
offsets = np.asarray(positions, dtype=np.float64) - origin
along = offsets @ direction
# The cylinder's base ring is the only geometry in the plane through the origin;
# its eight vertices are the ones with the smallest |along|.
ring = np.argsort(np.abs(along))[:8]
radial = offsets[ring] - np.outer(along[ring], direction)
return float(np.median(np.linalg.norm(radial, axis=1)))
def test_the_stroke_has_a_floor_and_a_ceiling() -> None:
"""Neither an invisible hair nor a blot: the band is a fraction of the model."""
assets = _scene()
mapping = assets.surface_mapping
span = mesh_span_from_mapping(mapping)
low, high = FLOW_ARROW_SHAFT_SPAN_FRACTION
assert flow_arrow_shaft_radius_m(mapping, stroke_px=1e9) == pytest.approx(span * high)
assert flow_arrow_shaft_radius_m(mapping, stroke_px=0.0) == pytest.approx(span * low)
# The floor is only reached below a twentieth of a pixel of stroke; the ceiling is
# a width no arrow should ever reach.
assert flow_arrow_shaft_radius_m(mapping, stroke_px=0.05) == pytest.approx(span * low)
assert flow_arrow_shaft_radius_m(mapping, stroke_px=50.0) == pytest.approx(span * high)
# The shipped stroke is inside the band, so it is the measured one that is drawn.
camera_radius = flow_arrow_shaft_radius_m(mapping)
assert span * low < camera_radius < span * high
def test_the_contact_arrow_keeps_its_proportional_stroke() -> None:
"""The contact arrow is a fraction of the mesh it sits on: its rule is untouched."""
assets = _scene()
mapping = assets.surface_mapping
valid = np.asarray(mapping.valid, dtype=bool)
rows, cols = np.nonzero(valid)
index = len(rows) // 2
arrows, _ = build_normal_overlay(
contact_uv_xy=(float(cols[index]), float(rows[index])),
contact_found=True,
contact_valid=True,
fn=5000.0,
mapping=mapping,
)
assert arrows.origins.shape[0] == 1
assert float(arrows.shaft_radius_m) == 0.0, (
"the contact arrow must keep the proportional template"
)
merged, _triangles, _colors = arrow_instances_to_mesh(arrows)
measured = _ring_radius(arrows, merged)
mean_length = float(np.asarray(arrows.lengths, dtype=np.float64).mean())
thickness = float(np.asarray(arrows.thickness_scales, dtype=np.float64).mean())
assert measured == pytest.approx(0.02 * mean_length * max(thickness, 0.05), rel=0.1)
@@ -0,0 +1,556 @@
"""The 2D finger canvas is presented the way the 3D view draws the same part.
The two views are drawn in different spaces: the canvas in the finger's unrolled UV
pixel grid, the 3D pane in the design export's frame (``test_scene3d_frame_views.py``).
The relation between them is measured here rather than assumed, and the presentation
that makes them agree is asserted axis by axis:
* the canvas's **+x** is the design frame's **+X**, and the 3D default view puts
screen-right at **-X**;
* the canvas's screen **up** (``-y``, rows grow downwards) is close to the frame's
**-Z**, and the 3D default view puts screen-up at **+Z**.
Both axes are therefore reversed: the transform is a half turn, not a mirror. The
mirror is the guess that looks right from the left-right sense alone, so it is
asserted to be wrong here.
The last block drives the *real* 2D rendering path and the *real* 3D overlay builder
from one flow field and compares the direction each one draws, so "the arrows agree"
is a measurement of the two pictures rather than of the constants in this file.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.acquisition.display_orientation import (
DEFAULT_FINGER_ORIENTATION,
ORIENTATION_IDENTITY,
ORIENTATION_MIRROR_H,
ORIENTATION_MIRROR_V,
ORIENTATION_ROTATE_180,
agreement,
apply_orientation,
canvas_axes_in_design_frame,
effective_orientation,
normalise_orientation,
)
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
DEPTH,
RECORDED,
ImageFrame,
render_image,
)
from orisys_app_shared.scene3d.overlays import build_flow_overlay
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
from orisys_app_shared.scene3d.visual_policy import (
policy_for_settings,
select_flow_samples,
)
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.prefs import settings_from_prefs
REPO = Path(__file__).resolve().parents[3]
PACKAGE = REPO / "apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene"
RIGID = REPO / "tools/scene_assets/housing/finger_r01_design_to_sensor_rigid_m.npy"
#: One canvas pixel of sweep for a direction that is nearly frontal on the pad.
#: A 1e-2 tolerance on a dot product is 0.6 degrees.
EXACT = 1e-2
VIEWPORT = (607, 672)
#: ``(right, up)`` of the 3D default view, in the design frame's own axes. Stated in
#: the frame's basis they are exactly these two vectors: the test asserts below that
#: the camera really hands those axes out, so the constants cannot drift.
SCREEN_RIGHT_DESIGN = np.array([-1.0, 0.0, 0.0])
SCREEN_UP_DESIGN = np.array([0.0, 0.0, 1.0])
def _scene():
if not PACKAGE.is_file():
pytest.skip(f"the shipped finger package is missing: {PACKAGE}")
return scene_from_contents(read_package(PACKAGE), None)
def _transform() -> np.ndarray:
if not RIGID.is_file():
pytest.skip(f"the fitted design frame is missing: {RIGID}")
return np.load(RIGID, allow_pickle=False)
def _frame_axes() -> np.ndarray:
return _transform()[:3, :3]
def _camera(assets, viewport=VIEWPORT) -> OrbitCamera:
presets = {preset.name: preset for preset in assets.camera_presets}
camera = OrbitCamera()
camera.set_preset(presets["default"])
camera.fit(assets.bounds_sensor_m, viewport[0] / viewport[1])
return camera
def _screen_axes(camera, viewport=VIEWPORT) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
view = np.asarray(camera.matrices(viewport).view, dtype=np.float64)
return view[0, :3], view[1, :3], view[2, :3]
def _canvas_directions(assets):
"""The canvas's +x/+y, in the design frame's axes, from the asset's own mapping."""
mapping = assets.surface_mapping
assert mapping is not None, "the shipped package has no surface mapping"
return canvas_axes_in_design_frame(
np.asarray(mapping.positions_sensor_m, dtype=np.float64),
np.asarray(mapping.valid, dtype=bool),
_frame_axes(),
)
# --------------------------------------------------------------------- derivation
def test_the_camera_hands_out_the_frames_axes_for_the_default_view() -> None:
"""The screen basis this file compares against is the camera's, not a claim."""
assets = _scene()
camera = _camera(assets)
right, up, backward = _screen_axes(camera)
frame_x, frame_y, frame_z = (_frame_axes()[:, i] for i in range(3))
assert float(backward @ frame_y) == pytest.approx(1.0, abs=1e-6)
assert float(up @ frame_z) == pytest.approx(1.0, abs=1e-6)
assert float(right @ frame_x) == pytest.approx(-1.0, abs=1e-6)
# In the design frame's own coordinates those are -X right and +Z up.
for axis, expected in ((right, SCREEN_RIGHT_DESIGN), (up, SCREEN_UP_DESIGN)):
design = _frame_axes().T @ axis
assert np.allclose(design, expected, atol=EXACT), design
def test_the_canvas_axes_are_both_reversed_against_the_3d_view() -> None:
"""The measurement: canvas +x is +X, canvas screen-up is -Z.
These are the two vectors the transform is derived from. Their signs, not their
magnitudes, are what decides between a half turn and a mirror.
"""
assets = _scene()
column, row = _canvas_directions(assets)
# The canvas's +x is the frame's +X (the small Y/Z terms are the pad's own tilt).
assert float(column @ np.array([1.0, 0.0, 0.0])) == pytest.approx(1.0, abs=0.05)
# The canvas's screen up is -y, and it is the frame's -Z.
canvas_up = -row
assert float(canvas_up @ SCREEN_UP_DESIGN) < -0.8
assert float(column @ SCREEN_RIGHT_DESIGN) < -0.99
# ...so the 3D view's own axes are what the canvas's screen axes must be negated to.
right_dot, up_dot = agreement(
(column, row), SCREEN_RIGHT_DESIGN, SCREEN_UP_DESIGN, ORIENTATION_IDENTITY
)
assert right_dot == pytest.approx(-1.0, abs=0.05)
assert up_dot < -0.8
def test_only_a_half_turn_agrees_with_the_3d_view_on_both_axes() -> None:
"""The four candidates, scored: a mirror fixes one axis and breaks the other."""
assets = _scene()
axes = _canvas_directions(assets)
scores = {
name: agreement(axes, SCREEN_RIGHT_DESIGN, SCREEN_UP_DESIGN, name)
for name in (
ORIENTATION_IDENTITY,
ORIENTATION_ROTATE_180,
ORIENTATION_MIRROR_H,
ORIENTATION_MIRROR_V,
)
}
for name, (right_dot, up_dot) in scores.items():
if name == ORIENTATION_ROTATE_180:
assert right_dot > 0.99 and up_dot > 0.8, (name, right_dot, up_dot)
continue
assert not (right_dot > 0 and up_dot > 0), (
f"{name} must not agree on both axes: {right_dot:.3f}, {up_dot:.3f}"
)
# Specifically: both mirrors keep the axis they do not touch pointing the wrong way.
assert scores[ORIENTATION_MIRROR_H][0] > 0.99 and scores[ORIENTATION_MIRROR_H][1] < -0.8
assert scores[ORIENTATION_MIRROR_V][1] > 0.8 and scores[ORIENTATION_MIRROR_V][0] < -0.99
assert scores[ORIENTATION_IDENTITY][0] < -0.99 and scores[ORIENTATION_IDENTITY][1] < -0.8
def test_the_shipped_default_is_the_derived_half_turn() -> None:
"""The default the app applies is the one the measurement selects."""
assert DEFAULT_FINGER_ORIENTATION == ORIENTATION_ROTATE_180
assert normalise_orientation(DEFAULT_FINGER_ORIENTATION) == ORIENTATION_ROTATE_180
# A preference file this build does not understand falls back to that default,
# never to an arbitrary picture.
assert normalise_orientation("sideways") == ORIENTATION_ROTATE_180
assert normalise_orientation(None) == ORIENTATION_ROTATE_180
assert normalise_orientation("MIRROR_H") == ORIENTATION_MIRROR_H
def test_the_orientation_applies_to_the_finger_canvas_only() -> None:
"""The box canvas is a different unrolled domain; the 3D pane is not involved."""
assert (
effective_orientation(ORIENTATION_ROTATE_180, draw_style="finger")
== ORIENTATION_ROTATE_180
)
assert (
effective_orientation(ORIENTATION_ROTATE_180, draw_style="box")
== ORIENTATION_IDENTITY
)
# ------------------------------------------------------------------ the pictures
def _policy():
"""The pane's own policy for a default settings block: the one both views use."""
return policy_for_settings(settings_from_prefs({}))
def _patch_flow(mask: np.ndarray, dx: float, dy: float):
"""A flow field that is nonzero around exactly one sample of the shared grid.
``select_flow_samples`` samples at ``spacing // 2 + k * spacing``, so a five-pixel
patch centred on one of those points selects that point and no other: the two
figures then draw one arrow each, and the test compares those two directions.
"""
spacing = _policy().flow_grid_spacing
rows = cols = None
for candidate in np.arange(spacing // 2, mask.shape[0], spacing):
for column in np.arange(spacing // 2, mask.shape[1], spacing):
window = mask[
candidate - 2 * spacing : candidate + 2 * spacing + 1,
column - 2 * spacing : column + 2 * spacing + 1,
]
if window.size and window.all():
rows, cols = int(candidate), int(column)
break
if rows is not None:
break
if rows is None:
pytest.skip("no sample point has a fully valid neighbourhood on this island")
flow = np.zeros((*mask.shape, 2), dtype=np.float32)
flow[rows - 2 : rows + 3, cols - 2 : cols + 3, 0] = dx
flow[rows - 2 : rows + 3, cols - 2 : cols + 3, 1] = dy
return flow, (rows, cols)
def _drawn_displacement(image: np.ndarray, origin: tuple[int, int]) -> tuple[float, float]:
"""The drawn arrow's displacement, in display pixels, from its own origin.
The origin is where the canvas would have put the sample; every non-background
pixel belongs to the one arrow drawn, and their centroid sits along it. The sign
is the whole assertion, but the magnitude is returned so a failure is readable.
"""
pixels = np.argwhere(np.any(image != 0, axis=2))
assert pixels.size, "the canvas drew no arrow at all"
centre = pixels.mean(axis=0)
return float(centre[1] - origin[1]), float(centre[0] - origin[0])
def _canvas_and_arrow(assets, orientation: str, direction: tuple[float, float]):
"""Render the canvas through the real 2D path and build the real 3D arrow."""
mapping = assets.surface_mapping
mask = np.asarray(mapping.valid, dtype=bool)
flow, (row, col) = _patch_flow(mask, *direction)
policy = _policy()
samples = select_flow_samples(flow, mask, policy)
assert samples.rows.size == 1, f"expected one sample, got {samples.rows.size}"
frame = ImageFrame(
np.zeros((*mask.shape, 3), dtype=np.uint8),
flow=flow,
valid_mask=mask,
source="live",
flow_valid=True,
)
# ``island_bgr``/``exterior_bgr`` are black here, so every non-black pixel is arrow.
canvas = render_image(
frame,
mode=ARROW_PURE,
draw_style="finger",
canvas_bgr=(0, 0, 0),
arrow_policy=policy,
orientation=orientation,
)
assert canvas is not None
height, width = mask.shape
name = normalise_orientation(orientation)
origin_row = height - 1 - row if name in (ORIENTATION_ROTATE_180, ORIENTATION_MIRROR_V) else row
origin_col = width - 1 - col if name in (ORIENTATION_ROTATE_180, ORIENTATION_MIRROR_H) else col
arrows, _ = build_flow_overlay(samples, mapping, style=policy)
return canvas, (origin_row, origin_col), arrows
@pytest.mark.parametrize(
"direction",
((2.0, 0.0), (0.0, 2.0), (-2.0, 0.0), (0.0, -2.0)),
)
def test_a_known_flow_points_the_same_way_in_both_views(direction) -> None:
"""One flow field, two figures: the left-right and up-down senses agree.
The 3D arrow's own direction is projected through the camera the pane uses, so
"the same way" is asserted in screen terms on both sides: right/left for +x, and
up/down for +y. Run with ``identity`` this is the assertion that fails before the
change — the canvas would draw it mirrored, which is the defect being fixed.
"""
assets = _scene()
camera = _camera(assets)
right, up, _backward = _screen_axes(camera)
canvas, origin, arrows = _canvas_and_arrow(
assets, DEFAULT_FINGER_ORIENTATION, direction
)
display_dx, display_dy = _drawn_displacement(canvas, origin)
assert arrows.origins.shape[0] == 1
world = np.asarray(arrows.directions[0], dtype=np.float64)
screen_right = float(world @ right)
screen_up = float(world @ up)
# 2D: +x is right, +y is down. 3D: ``right`` is screen-right, ``up`` is screen-up,
# so the 3D arrow's own displacement on the screen is ``(right, -up)``.
two_d = np.array([display_dx, display_dy], dtype=np.float64)
three_d = np.array([screen_right, -screen_up], dtype=np.float64)
assert np.linalg.norm(two_d) > 0.2, "the canvas drew no usable arrow"
assert np.linalg.norm(three_d) > 0.2, "the 3D arrow has no on-screen direction"
# The whole direction, to within about 8 degrees (a tolerance that still leaves
# room for the pad's own tilt, and one a mirror cannot pass).
cosine = float(two_d @ three_d) / (np.linalg.norm(two_d) * np.linalg.norm(three_d))
assert cosine > 0.99, (
f"the two views disagree for flow {direction}: 2D {two_d} px, "
f"3D {three_d} screen px (cos {cosine:.5f})"
)
# Per axis, where *both* views carry the arrow on that axis: a horizontal arrow
# has no vertical component to compare, and the pad's own tilt puts a small one on
# the 3D side, so an axis is only demanded of when both displace along it.
for label, ours, theirs in (
("left/right", two_d[0], three_d[0]),
("up/down", two_d[1], three_d[1]),
):
if abs(ours) > 0.1 * np.linalg.norm(two_d) and abs(theirs) > 0.1 * np.linalg.norm(
three_d
):
assert np.sign(ours) == np.sign(theirs), (
f"{label} disagree for flow {direction}: 2D {ours:+.3f}, 3D {theirs:+.3f}"
)
def test_the_default_is_what_makes_them_agree() -> None:
"""Without the transform the same comparison disagrees — the before-the-fix state."""
assets = _scene()
camera = _camera(assets)
right, _up, _backward = _screen_axes(camera)
direction = (2.0, 0.0)
identity_canvas, identity_origin, arrows = _canvas_and_arrow(
assets, ORIENTATION_IDENTITY, direction
)
fixed_canvas, fixed_origin, _ = _canvas_and_arrow(
assets, DEFAULT_FINGER_ORIENTATION, direction
)
world = np.asarray(arrows.directions[0], dtype=np.float64)
identity_dx, _ = _drawn_displacement(identity_canvas, identity_origin)
fixed_dx, _ = _drawn_displacement(fixed_canvas, fixed_origin)
assert np.sign(identity_dx) != np.sign(float(world @ right)), (
"identity already agreed: this test no longer demonstrates the defect"
)
assert np.sign(fixed_dx) == np.sign(float(world @ right))
# A 180-degree turn mirrors the arrow about its origin: the two displacements are
# opposite, so the fix is a real change of picture, not a rounding difference.
assert identity_dx * fixed_dx < 0.0
def test_the_stored_arrays_and_the_recorded_picture_are_never_turned() -> None:
"""The transform is applied to the drawing, at display time, and to nothing else."""
assets = _scene()
mapping = assets.surface_mapping
mask = np.asarray(mapping.valid, dtype=bool)
flow, _ = _patch_flow(mask, 2.0, 0.0)
flow_before = flow.copy()
mask_before = mask.copy()
policy = _policy()
frame = ImageFrame(
np.zeros((*mask.shape, 3), dtype=np.uint8),
flow=flow,
valid_mask=mask,
source="live",
flow_valid=True,
)
turned = render_image(
frame,
mode=ARROW_PURE,
draw_style="finger",
canvas_bgr=(0, 0, 0),
arrow_policy=policy,
orientation=ORIENTATION_ROTATE_180,
)
straight = render_image(
frame,
mode=ARROW_PURE,
draw_style="finger",
canvas_bgr=(0, 0, 0),
arrow_policy=policy,
orientation=ORIENTATION_IDENTITY,
)
assert turned is not None and straight is not None
assert np.array_equal(flow, flow_before), "the stored flow was reshaped"
assert np.array_equal(mask, mask_before), "the stored mask was reshaped"
assert np.array_equal(turned, straight[::-1, ::-1]), (
"the display transform is not a half turn of the drawn picture"
)
# A recording's own picture is the camera's, not the domain's: it is not turned.
recorded = np.arange(12 * 16 * 3, dtype=np.uint8).reshape(12, 16, 3)
assert np.array_equal(
render_image(
ImageFrame(recorded, source="replay"),
mode=RECORDED,
orientation=ORIENTATION_ROTATE_180,
),
recorded,
)
# The depth view is drawn on the domain grid, so it is.
depth = np.linspace(0.0, 1.0, 12 * 16, dtype=np.float32).reshape(12, 16)
depth_frame = ImageFrame(None, depth=depth, valid_mask=np.ones((12, 16), bool))
plain = render_image(
depth_frame, mode=DEPTH, orientation=ORIENTATION_IDENTITY
)
flipped = render_image(
depth_frame, mode=DEPTH, orientation=ORIENTATION_ROTATE_180
)
assert plain is not None and flipped is not None
assert np.array_equal(flipped, plain[::-1, ::-1])
def test_apply_orientation_is_a_pure_display_transform() -> None:
source = np.arange(3 * 4 * 3, dtype=np.uint8).reshape(3, 4, 3)
original = source.copy()
assert apply_orientation(source, ORIENTATION_IDENTITY) is source
assert np.array_equal(apply_orientation(source, ORIENTATION_MIRROR_H), source[:, ::-1])
assert np.array_equal(apply_orientation(source, ORIENTATION_MIRROR_V), source[::-1])
assert np.array_equal(
apply_orientation(source, ORIENTATION_ROTATE_180), source[::-1, ::-1]
)
assert np.array_equal(source, original), "the input array was modified"
# A turn keeps the picture's shape: the panel's aspect handling depends on it.
for name in (ORIENTATION_IDENTITY, ORIENTATION_ROTATE_180, ORIENTATION_MIRROR_H,
ORIENTATION_MIRROR_V):
assert apply_orientation(source, name).shape == source.shape
# ------------------------------------------------------------------ the preference
def _finger_frame():
from orisys_app_shared.acquisition.models import ensure_bgr
assets = _scene()
mapping = assets.surface_mapping
mask = np.asarray(mapping.valid, dtype=bool)
flow, _ = _patch_flow(mask, 2.0, 0.0)
image = np.zeros((*mask.shape, 3), dtype=np.uint8)
image[..., 1] = 90
return ImageFrame(
ensure_bgr(image), flow=flow, valid_mask=mask, source="live", flow_valid=True
), mask.shape
def _panel_pixels(window) -> np.ndarray:
"""The 2D panel's picture as an array: the end of the real display path."""
image = window.arrow_panel._source_image
assert image is not None and not image.isNull(), "the panel shows no picture"
width, height = image.width(), image.height()
buffer = bytes(image.constBits())
rows = np.frombuffer(buffer, dtype=np.uint8)
rows = rows.reshape(height, image.bytesPerLine())
return rows[:, : width * 3].reshape(height, width, 3).copy()
def test_the_orientation_preference_survives_a_restart(make_window, qt_app) -> None:
"""Chosen in the dialog, applied at once, and still there after a new window."""
from orisys_app_shared.prefs import load_preferences
frame, shape = _finger_frame()
window = make_window(sensor_type="finger")
assert window.finger_orientation() == ORIENTATION_ROTATE_180, (
"the shipped default is not the derived half turn"
)
# The panel's own 90-degree mounting rotation is off for a finger: the canvas it
# shows is the canvas this preference decides.
window._show_image_frame(frame)
qt_app.processEvents()
default_picture = _panel_pixels(window)
# The 3D pane is not part of this preference: its display settings do not move.
pane_settings = window.mesh3d_panel.display_settings()
# The same frame drawn with no transform at all, to compare against.
window._finger_orientation = ORIENTATION_IDENTITY
window._show_image_frame(frame)
qt_app.processEvents()
straight = _panel_pixels(window)
assert np.array_equal(default_picture, straight[::-1, ::-1]), (
"the default picture is not the half turn of the untransformed one"
)
# Applied without a restart: the very next frame the panel draws uses it.
window.set_finger_orientation(ORIENTATION_MIRROR_H)
assert window.finger_orientation() == ORIENTATION_MIRROR_H
window._show_image_frame(frame)
qt_app.processEvents()
mirrored = _panel_pixels(window)
assert window.mesh3d_panel.display_settings() == pane_settings, (
"the 2D presentation changed the 3D pane's display settings"
)
assert mirrored.shape == default_picture.shape == (*shape, 3)
assert np.array_equal(mirrored, straight[:, ::-1]), (
"the mirrored picture is not the untransformed picture, mirrored"
)
assert not np.array_equal(mirrored, default_picture)
assert not np.array_equal(mirrored, straight)
# Persisted: a new window built from the stored preferences shows the same picture.
assert load_preferences()["finger_2d_orientation"] == ORIENTATION_MIRROR_H
stored = dict(load_preferences())
stored["sensor_type"] = "finger"
restarted = make_window(**stored)
assert restarted.finger_orientation() == ORIENTATION_MIRROR_H
restarted._show_image_frame(frame)
qt_app.processEvents()
assert np.array_equal(_panel_pixels(restarted), mirrored)
window.close()
restarted.close()
def test_the_dialog_carries_the_orientation_to_the_window(make_window, monkeypatch) -> None:
"""The control is in the dialog the app already opens, and reaches the prefs."""
from orisys_app_shared.prefs import load_preferences
from orisys_studio import app_window as app_window_module
seen: dict[str, object] = {}
class _Dialog:
Accepted = 1
def __init__(self, *args, **kwargs) -> None:
seen.update(kwargs)
def exec(self) -> int:
return 1
def settings(self) -> dict:
return {}
def filter_values(self):
from orisys_app_shared.force.filters import FilterSettings
return FilterSettings()
def orientation(self) -> str:
return ORIENTATION_MIRROR_V
monkeypatch.setattr(app_window_module, "PlotSettingsDialog", _Dialog)
window = make_window(sensor_type="finger")
# No device: the dialog path would otherwise create the acquisition workers.
window._ensure_workers = lambda: None
window._on_plot_settings()
assert seen.get("finger_orientation") == ORIENTATION_ROTATE_180, (
"the dialog was not told what the window is showing"
)
assert window.finger_orientation() == ORIENTATION_MIRROR_V
assert load_preferences()["finger_2d_orientation"] == ORIENTATION_MIRROR_V
window.close()