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