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:
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 890 KiB |
@@ -73,6 +73,14 @@
|
||||
"plot_deadband_raw": "Zero threshold (raw)",
|
||||
"plot_deadband_raw_tip": "Display deadband floor in raw mode (raw counts). 0 = auto (the built-in 50-count floor). Raise it by hand when the calibration data is suspect; display only",
|
||||
"reset_defaults": "Reset to defaults",
|
||||
"plot_orientation_group": "Finger 2D presentation (2D only; the 3D view keeps the design frame)",
|
||||
"plot_orientation": "Orientation",
|
||||
"plot_orientation_tip": "How the finger's 2D canvas (the unrolled domain) is presented. The 180° default is derived from the unroll map and the design frame: the canvas's +x is the frame's +X and its screen up is −Z, while the 3D default view puts −X right and +Z up, so both axes are reversed and a half turn is what makes the arrows agree left-to-right. Display only — the record and the stored arrays are untouched",
|
||||
"plot_orientation_note": "Affects the 2D finger canvas only: the stored flow, valid mask and recorded data keep the domain's own orientation. The 3D view is not affected (its frame is the design export's)",
|
||||
"orientation_identity": "As drawn (no rotation)",
|
||||
"orientation_rotate180": "Rotate 180° (default)",
|
||||
"orientation_mirror_h": "Mirror horizontally",
|
||||
"orientation_mirror_v": "Mirror vertically",
|
||||
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
|
||||
"filter_preset": "Preset",
|
||||
"filter_preset_tip": "A ready-made combination; editing any value below switches to Custom",
|
||||
|
||||
@@ -73,6 +73,14 @@
|
||||
"plot_deadband_raw": "归零阈值(原始)",
|
||||
"plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示",
|
||||
"reset_defaults": "恢复默认",
|
||||
"plot_orientation_group": "手指 2D 显示方向(只影响 2D 画面;3D 视图仍用设计坐标系)",
|
||||
"plot_orientation": "方向",
|
||||
"plot_orientation_tip": "手指 2D 画布(展开域)的显示方向。默认 180° 是按展开图与设计坐标系推导出来的:画布的 +x 是设计系的 +X、屏幕向上是 −Z,与 3D 默认视图的 −X / +Z 正好都相反,所以要把画面转半圈,箭头左右才与 3D 一致。只影响显示,不改变记录与存储的数组",
|
||||
"plot_orientation_note": "只影响 2D 手指画布的显示:存储的光流、有效掩膜与录制数据始终保持域本身的朝向。3D 视图不受影响(它的坐标系是设计导出坐标系)",
|
||||
"orientation_identity": "原始(不旋转)",
|
||||
"orientation_rotate180": "旋转 180°(默认)",
|
||||
"orientation_mirror_h": "水平镜像",
|
||||
"orientation_mirror_v": "垂直镜像",
|
||||
"plot_filters": "滤波(作用于采集,界面与记录同步)",
|
||||
"filter_preset": "预设",
|
||||
"filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"plot_deadband_raw": 0.0,
|
||||
"image_view_mode": "arrows_pure",
|
||||
"arrow_rotation_deg": 90,
|
||||
"finger_2d_orientation": "rotate180",
|
||||
"settings_expanded": false,
|
||||
"view_preset": "custom",
|
||||
"splitter_sizes": [340, 480, 400]
|
||||
|
||||
@@ -10,6 +10,11 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.acquisition.display_orientation import (
|
||||
DEFAULT_FINGER_ORIENTATION,
|
||||
effective_orientation,
|
||||
normalise_orientation,
|
||||
)
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
DEPTH,
|
||||
SOURCE_LIVE,
|
||||
@@ -841,6 +846,12 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
|
||||
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
|
||||
self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90))
|
||||
# The finger's 2D presentation: a *display* convention for the unrolled canvas
|
||||
# (see ``acquisition.display_orientation``), stored as a preference and applied
|
||||
# where the picture is drawn, never to the frame's own arrays.
|
||||
self._finger_orientation = normalise_orientation(
|
||||
self.prefs.get("finger_2d_orientation", DEFAULT_FINGER_ORIENTATION)
|
||||
)
|
||||
self.toolbar.set_settings_expanded(bool(self.prefs.get("settings_expanded", False)))
|
||||
self._sync_settings_action()
|
||||
self._apply_plot_prefs_to_panel()
|
||||
@@ -1521,6 +1532,30 @@ class StudioMainWindow(QMainWindow):
|
||||
def _draw_style(self) -> str:
|
||||
return self.display_ctl.draw_style()
|
||||
|
||||
def finger_orientation(self) -> str:
|
||||
"""How the finger's 2D canvas is presented (a display convention only)."""
|
||||
return normalise_orientation(
|
||||
getattr(self, "_finger_orientation", DEFAULT_FINGER_ORIENTATION)
|
||||
)
|
||||
|
||||
def set_finger_orientation(self, orientation: str) -> str:
|
||||
"""Adopt a 2D presentation for the finger and redraw the frame on screen.
|
||||
|
||||
Takes effect at once — the next render of the frame the panel is already
|
||||
showing uses it, and a running session repaints immediately — and is stored in
|
||||
the preferences, so it also survives a restart. The 3D pane is untouched by
|
||||
design: its frame is the design export's, and the 2D orientation exists to make
|
||||
the *canvas* agree with it.
|
||||
"""
|
||||
wanted = normalise_orientation(orientation)
|
||||
if wanted == self.finger_orientation():
|
||||
return wanted
|
||||
self._finger_orientation = wanted
|
||||
self.prefs["finger_2d_orientation"] = wanted
|
||||
self._persist_prefs()
|
||||
self._repaint_live_frame()
|
||||
return wanted
|
||||
|
||||
def _arrow_policy(self):
|
||||
"""The one flow-arrow policy the 2D canvas and the 3D surface both sample with.
|
||||
|
||||
@@ -1580,12 +1615,17 @@ class StudioMainWindow(QMainWindow):
|
||||
# dialog is the place that has to say "next session, not this one".
|
||||
filters_locked=bool(self._running or self.recorder.is_recording),
|
||||
sample_rate_hz=self.display_ctl.device_sample_rate(),
|
||||
finger_orientation=self._finger_orientation,
|
||||
)
|
||||
if dialog.exec() != PlotSettingsDialog.Accepted:
|
||||
return
|
||||
# One call for the whole picture: the dialog owns the schema, the controller
|
||||
# writes what it was given and re-reads the rest.
|
||||
self.display_ctl.apply_plot_settings(dialog.settings())
|
||||
# The 2D presentation is applied here rather than in the controller: it changes
|
||||
# the picture the *window* draws (``_show_image_frame``), and it must take
|
||||
# effect without a restart.
|
||||
self.set_finger_orientation(dialog.orientation())
|
||||
filters = dialog.filter_values()
|
||||
# The same keys ``_filter_settings()`` reads back, so what the dialog
|
||||
# shows next time is what the session will actually be given.
|
||||
@@ -2585,6 +2625,12 @@ class StudioMainWindow(QMainWindow):
|
||||
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
||||
domain_mask=boundary,
|
||||
arrow_policy=self._arrow_policy(),
|
||||
# The 2D presentation of the finger's unrolled canvas. Display only: the
|
||||
# frame's own flow/mask/image are handed over untouched, and the 3D pane is
|
||||
# not involved at all (its frame is the design export's).
|
||||
orientation=effective_orientation(
|
||||
self._finger_orientation, draw_style=draw_style
|
||||
),
|
||||
)
|
||||
if rendered is None:
|
||||
# Never substitute another picture here: a missing depth map that showed
|
||||
@@ -2619,6 +2665,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.prefs["units"] = self.toolbar.units()
|
||||
self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
|
||||
self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg()
|
||||
self.prefs["finger_2d_orientation"] = self.finger_orientation()
|
||||
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
|
||||
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
|
||||
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
|
||||
|
||||
@@ -11,6 +11,11 @@ reports as done is what the record will say (see ``force/filters.py``).
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.acquisition.display_orientation import (
|
||||
DEFAULT_FINGER_ORIENTATION,
|
||||
ORIENTATIONS,
|
||||
normalise_orientation,
|
||||
)
|
||||
from orisys_app_shared.force.filters import (
|
||||
FilterSettings,
|
||||
available_presets,
|
||||
@@ -69,9 +74,12 @@ class PlotSettingsDialog(QDialog):
|
||||
filters: FilterSettings | None = None,
|
||||
filters_locked: bool = False,
|
||||
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
|
||||
finger_orientation: str = DEFAULT_FINGER_ORIENTATION,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setModal(True)
|
||||
# The layout's own minimum (618 px with the 2D presentation group) keeps the
|
||||
# buttons on screen; this is the height it opens at.
|
||||
self.resize(460, 640)
|
||||
self._sample_rate_hz = float(sample_rate_hz)
|
||||
#: True while the widgets are being filled in from a preset, so the
|
||||
@@ -153,6 +161,22 @@ class PlotSettingsDialog(QDialog):
|
||||
self.deadband_raw_spin.setDecimals(0)
|
||||
self.deadband_raw_spin.setValue(float(deadband_raw))
|
||||
|
||||
# How the finger's 2D canvas is turned for display. A *presentation* choice, so
|
||||
# it lives with the other display ones (this dialog), not with the acquisition
|
||||
# filters below it, and it is applied at display time only — see
|
||||
# ``acquisition.display_orientation`` for why the shipped default is a half turn.
|
||||
self.orientation_label = QLabel()
|
||||
self.orientation_combo = QComboBox()
|
||||
for name in ORIENTATIONS:
|
||||
self.orientation_combo.addItem(t(f"dialog.orientation_{name}"), name)
|
||||
index = self.orientation_combo.findData(
|
||||
normalise_orientation(finger_orientation)
|
||||
)
|
||||
self.orientation_combo.setCurrentIndex(max(index, 0))
|
||||
self.orientation_note = QLabel()
|
||||
self.orientation_note.setObjectName("hintText")
|
||||
self.orientation_note.setWordWrap(True)
|
||||
|
||||
form.addRow(self.window_label, self.window_spin)
|
||||
form.addRow(self.fn_min_label, self.fn_min_spin)
|
||||
form.addRow(self.fn_max_label, self.fn_max_spin)
|
||||
@@ -164,6 +188,12 @@ class PlotSettingsDialog(QDialog):
|
||||
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
|
||||
root.addLayout(form)
|
||||
|
||||
self.orientation_group = QGroupBox()
|
||||
orientation_form = QFormLayout(self.orientation_group)
|
||||
orientation_form.addRow(self.orientation_label, self.orientation_combo)
|
||||
orientation_form.addRow(self.orientation_note)
|
||||
root.addWidget(self.orientation_group)
|
||||
|
||||
newton = str(units or "newton").lower().startswith("n")
|
||||
raw_widgets = (
|
||||
self.fn_raw_min_spin,
|
||||
@@ -418,6 +448,10 @@ class PlotSettingsDialog(QDialog):
|
||||
"""Everything the plot half of the dialog decides, in one mapping."""
|
||||
return {**self.ranges(), **self.deadbands()}
|
||||
|
||||
def orientation(self) -> str:
|
||||
"""The 2D presentation the dialog is showing (see ``display_orientation``)."""
|
||||
return normalise_orientation(self.orientation_combo.currentData())
|
||||
|
||||
def _reset_defaults(self) -> None:
|
||||
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
|
||||
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN)
|
||||
@@ -426,6 +460,10 @@ class PlotSettingsDialog(QDialog):
|
||||
self.fn_raw_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
|
||||
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
|
||||
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
|
||||
# "Reset" means the shipped picture as well: the finger's derived default.
|
||||
self.orientation_combo.setCurrentIndex(
|
||||
max(self.orientation_combo.findData(DEFAULT_FINGER_ORIENTATION), 0)
|
||||
)
|
||||
# "Auto" is the shipped answer for the deadband: the estimate from the
|
||||
# quiet startup window, with the layer's own floor under it.
|
||||
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
|
||||
@@ -461,6 +499,13 @@ class PlotSettingsDialog(QDialog):
|
||||
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
|
||||
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
|
||||
self.btn_reset.setText(t("dialog.reset_defaults"))
|
||||
self.orientation_group.setTitle(t("dialog.plot_orientation_group"))
|
||||
self.orientation_label.setText(t("dialog.plot_orientation"))
|
||||
self.orientation_combo.setToolTip(t("dialog.plot_orientation_tip"))
|
||||
self.orientation_note.setText(t("dialog.plot_orientation_note"))
|
||||
for index in range(self.orientation_combo.count()):
|
||||
name = str(self.orientation_combo.itemData(index) or "")
|
||||
self.orientation_combo.setItemText(index, t(f"dialog.orientation_{name}"))
|
||||
self.filters_group.setTitle(t("dialog.plot_filters"))
|
||||
self.filter_preset_label.setText(t("dialog.filter_preset"))
|
||||
self.preset_combo.setToolTip(t("dialog.filter_preset_tip"))
|
||||
|
||||
@@ -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