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

The unrolled canvas put the finger's +x where the 3D default view puts screen-left,
and the 3D flow arrows rasterised as 0.23 px hairlines because their stroke was 2 %
of their own sub-millimetre length. The 2D presentation is now the derived half turn
(persisted, set from the plot-settings dialog) and the 3D stroke is one canvas pixel
— the width the 2D canvas draws — verified numerically and with real-GL snapshots.
This commit is contained in:
ykcheungab
2026-10-04 10:03:46 +08:00
parent 9b1a3fb922
commit dda6c74fb7
14 changed files with 1383 additions and 15 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 890 KiB

+8
View File
@@ -73,6 +73,14 @@
"plot_deadband_raw": "Zero threshold (raw)", "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", "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", "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)", "plot_filters": "Filters (applied while acquiring; screen and record agree)",
"filter_preset": "Preset", "filter_preset": "Preset",
"filter_preset_tip": "A ready-made combination; editing any value below switches to Custom", "filter_preset_tip": "A ready-made combination; editing any value below switches to Custom",
+8
View File
@@ -73,6 +73,14 @@
"plot_deadband_raw": "归零阈值(原始)", "plot_deadband_raw": "归零阈值(原始)",
"plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示", "plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示",
"reset_defaults": "恢复默认", "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": "滤波(作用于采集,界面与记录同步)", "plot_filters": "滤波(作用于采集,界面与记录同步)",
"filter_preset": "预设", "filter_preset": "预设",
"filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”", "filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”",
@@ -32,6 +32,7 @@
"plot_deadband_raw": 0.0, "plot_deadband_raw": 0.0,
"image_view_mode": "arrows_pure", "image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90, "arrow_rotation_deg": 90,
"finger_2d_orientation": "rotate180",
"settings_expanded": false, "settings_expanded": false,
"view_preset": "custom", "view_preset": "custom",
"splitter_sizes": [340, 480, 400] "splitter_sizes": [340, 480, 400]
@@ -10,6 +10,11 @@ from typing import Any
import numpy as np import numpy as np
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ 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 ( from orisys_app_shared.acquisition.image_backend import (
DEPTH, DEPTH,
SOURCE_LIVE, SOURCE_LIVE,
@@ -841,6 +846,12 @@ class StudioMainWindow(QMainWindow):
self.toolbar.set_units(str(self.prefs.get("units") or "newton")) 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_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)) 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.toolbar.set_settings_expanded(bool(self.prefs.get("settings_expanded", False)))
self._sync_settings_action() self._sync_settings_action()
self._apply_plot_prefs_to_panel() self._apply_plot_prefs_to_panel()
@@ -1521,6 +1532,30 @@ class StudioMainWindow(QMainWindow):
def _draw_style(self) -> str: def _draw_style(self) -> str:
return self.display_ctl.draw_style() 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): def _arrow_policy(self):
"""The one flow-arrow policy the 2D canvas and the 3D surface both sample with. """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". # dialog is the place that has to say "next session, not this one".
filters_locked=bool(self._running or self.recorder.is_recording), filters_locked=bool(self._running or self.recorder.is_recording),
sample_rate_hz=self.display_ctl.device_sample_rate(), sample_rate_hz=self.display_ctl.device_sample_rate(),
finger_orientation=self._finger_orientation,
) )
if dialog.exec() != PlotSettingsDialog.Accepted: if dialog.exec() != PlotSettingsDialog.Accepted:
return return
# One call for the whole picture: the dialog owns the schema, the controller # One call for the whole picture: the dialog owns the schema, the controller
# writes what it was given and re-reads the rest. # writes what it was given and re-reads the rest.
self.display_ctl.apply_plot_settings(dialog.settings()) 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() filters = dialog.filter_values()
# The same keys ``_filter_settings()`` reads back, so what the dialog # The same keys ``_filter_settings()`` reads back, so what the dialog
# shows next time is what the session will actually be given. # 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")), canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
domain_mask=boundary, domain_mask=boundary,
arrow_policy=self._arrow_policy(), 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: if rendered is None:
# Never substitute another picture here: a missing depth map that showed # 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["units"] = self.toolbar.units()
self.prefs["image_view_mode"] = self.arrow_panel.view_mode() self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg() 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_window_sec", DEFAULT_PLOT_WINDOW_SEC)
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX) self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_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 __future__ import annotations
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ 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 ( from orisys_app_shared.force.filters import (
FilterSettings, FilterSettings,
available_presets, available_presets,
@@ -69,9 +74,12 @@ class PlotSettingsDialog(QDialog):
filters: FilterSettings | None = None, filters: FilterSettings | None = None,
filters_locked: bool = False, filters_locked: bool = False,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ, sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
finger_orientation: str = DEFAULT_FINGER_ORIENTATION,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setModal(True) 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.resize(460, 640)
self._sample_rate_hz = float(sample_rate_hz) self._sample_rate_hz = float(sample_rate_hz)
#: True while the widgets are being filled in from a preset, so the #: 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.setDecimals(0)
self.deadband_raw_spin.setValue(float(deadband_raw)) 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.window_label, self.window_spin)
form.addRow(self.fn_min_label, self.fn_min_spin) form.addRow(self.fn_min_label, self.fn_min_spin)
form.addRow(self.fn_max_label, self.fn_max_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) form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
root.addLayout(form) 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") newton = str(units or "newton").lower().startswith("n")
raw_widgets = ( raw_widgets = (
self.fn_raw_min_spin, self.fn_raw_min_spin,
@@ -418,6 +448,10 @@ class PlotSettingsDialog(QDialog):
"""Everything the plot half of the dialog decides, in one mapping.""" """Everything the plot half of the dialog decides, in one mapping."""
return {**self.ranges(), **self.deadbands()} 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: def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC) self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN) 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_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX) self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_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 # "Auto" is the shipped answer for the deadband: the estimate from the
# quiet startup window, with the layer's own floor under it. # quiet startup window, with the layer's own floor under it.
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N) 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_label.setText(t("dialog.plot_deadband_raw"))
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip")) self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
self.btn_reset.setText(t("dialog.reset_defaults")) 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.filters_group.setTitle(t("dialog.plot_filters"))
self.filter_preset_label.setText(t("dialog.filter_preset")) self.filter_preset_label.setText(t("dialog.filter_preset"))
self.preset_combo.setToolTip(t("dialog.filter_preset_tip")) 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()
@@ -7,11 +7,14 @@ __all__ = [
"FingerAcquisitionWorker", "FingerAcquisitionWorker",
"SdkAcquisitionWorker", "SdkAcquisitionWorker",
"SdkPacket", "SdkPacket",
"apply_orientation",
"depth_map_to_bgr", "depth_map_to_bgr",
"draw_arrow_display", "draw_arrow_display",
"effective_orientation",
"flow_colormap", "flow_colormap",
"hex_to_bgr", "hex_to_bgr",
"load_fixed_mode", "load_fixed_mode",
"normalise_orientation",
"orisys_importable", "orisys_importable",
"resolve_finger_export_dir", "resolve_finger_export_dir",
] ]
@@ -27,6 +30,9 @@ _LAZY_ATTRS = {
"draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"), "draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"),
"flow_colormap": ("orisys_app_shared.acquisition.arrows", "flow_colormap"), "flow_colormap": ("orisys_app_shared.acquisition.arrows", "flow_colormap"),
"hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"), "hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"),
"apply_orientation": ("orisys_app_shared.acquisition.display_orientation", "apply_orientation"),
"effective_orientation": ("orisys_app_shared.acquisition.display_orientation", "effective_orientation"),
"normalise_orientation": ("orisys_app_shared.acquisition.display_orientation", "normalise_orientation"),
} }
@@ -0,0 +1,209 @@
"""Which way up the 2D canvas is presented. Display only, never the stored arrays.
The 2D canvas is drawn in the finger's **unrolled UV pixel space** (columns are
``u``, rows are ``1 - v``), while the 3D pane is drawn in the **design export's
frame**. The two are related by the unroll map, and the relation is a derivation
rather than a preference, so it is measured once here and written down.
Measured on the shipped finger asset (``unroll_map_uv_s0.42.npz`` through
``finger_r01_design_to_sensor_rigid_m.npy``, averaged over the 32 571 island
pixels whose +column and +row neighbours are both inside the island, expressed in
the design frame's own axes ``(X, Y, Z)``):
* the canvas's **+x** (increasing column) is ``(0.99981, 0.01146, -0.01549)`` —
the frame's ``+X``. The 3D default view puts screen-right at ``-X``
(``test_scene3d_frame_views.py``), so the canvas's right is the 3D view's left;
* the canvas's **+y** (increasing row) is ``(-0.00791, -0.48232, 0.87596)``, so
the canvas's *screen up* (``-y``, because rows grow downwards) is 28.8 degrees
off the frame's ``-Z``: the 3D view's screen up is ``+Z``, so the canvas's up is
the 3D view's down.
Both axes are therefore reversed, and the transform that agrees with the 3D
picture is a **180 degree rotation of the displayed picture** — not a mirror,
which would fix one axis and leave the other one wrong. A mirror looks plausible
and is wrong, which is exactly why the two vectors above are recorded: with them,
:func:`agreement` makes the choice a measurement.
The transform is applied to the *rendered* image (see
:func:`~orisys_app_shared.acquisition.image_backend.render_image`), never to the
frame's own ``flow``/``valid_mask``/``image_bgr``: a recorded session stores its
flow in the domain's own orientation, and a display convention must not rewrite
what was measured.
"""
from __future__ import annotations
import numpy as np
ORIENTATION_IDENTITY = "identity"
"""Draw the canvas exactly as the domain grid is ordered (columns to the right)."""
ORIENTATION_ROTATE_180 = "rotate180"
"""Turn the displayed canvas half a turn."""
ORIENTATION_MIRROR_H = "mirror_h"
"""Flip the displayed canvas left-to-right (its columns)."""
ORIENTATION_MIRROR_V = "mirror_v"
"""Flip the displayed canvas top-to-bottom (its rows)."""
#: Every orientation the preference may hold, in the order a dialog lists them.
ORIENTATIONS: tuple[str, ...] = (
ORIENTATION_IDENTITY,
ORIENTATION_ROTATE_180,
ORIENTATION_MIRROR_H,
ORIENTATION_MIRROR_V,
)
#: What the finger's 2D canvas is drawn as unless the operator says otherwise.
#: Derived, not guessed: see the module docstring (both canvas axes are reversed
#: against the 3D default view, so the transform is a half turn).
DEFAULT_FINGER_ORIENTATION = ORIENTATION_ROTATE_180
#: The draw styles the preference applies to. ``draw_style()`` is ``"finger"`` or
#: ``"box"``: only the finger canvas is the unrolled domain the transform was
#: derived for. The box's canvas is its own unrolled domain (a different map and a
#: 90-degree mounting rotation the panel's own 旋转 control already handles), and a
#: finger-derived half turn there would be an unrelated convention applied blind.
ORIENTATION_DRAW_STYLES = ("finger",)
def normalise_orientation(value: object) -> str:
"""A known orientation name, or the shipped default for anything else.
A preference file is data: a value this build does not know must fall back to
the derived default rather than to an arbitrary picture.
"""
name = str(value or "").strip().lower()
return name if name in ORIENTATIONS else DEFAULT_FINGER_ORIENTATION
def effective_orientation(preference: object, *, draw_style: str) -> str:
"""The orientation to draw ``draw_style``'s canvas with.
The 3D pane is **not** affected by any of this: its camera frame is the design
export's frame, which is what the presets and the left/right sense of its own
arrows are stated in. This only decides how the 2D canvas is presented, so that
the two pictures agree when the operator looks at them side by side.
"""
if str(draw_style).strip().lower() not in ORIENTATION_DRAW_STYLES:
return ORIENTATION_IDENTITY
return normalise_orientation(preference)
def apply_orientation(image: np.ndarray, orientation: object) -> np.ndarray:
"""The displayed picture for ``orientation``, from the drawn one.
A pure display transform: the input array is never modified in place, and the
frame's own flow / mask / base image are never reshaped. ``identity`` returns
the array it was given (the caller's array is already a fresh canvas).
"""
name = normalise_orientation(orientation)
array = np.asarray(image)
if name == ORIENTATION_ROTATE_180:
# Two flips, not a reshape: the picture keeps its height and width, which is
# what the panel's aspect handling expects.
return np.ascontiguousarray(array[::-1, ::-1])
if name == ORIENTATION_MIRROR_H:
return np.ascontiguousarray(array[:, ::-1])
if name == ORIENTATION_MIRROR_V:
return np.ascontiguousarray(array[::-1])
return array
def canvas_axes_in_design_frame(
positions_sensor_m: np.ndarray,
valid: np.ndarray,
frame_axes_sensor: np.ndarray,
*,
column_step: int = 1,
row_step: int = 1,
) -> tuple[np.ndarray, np.ndarray]:
"""The canvas's ``+x`` and ``+y`` directions, in the design frame's axes.
``positions_sensor_m`` is the surface mapping's grid — indexed exactly as the
canvas is (``[row, column]``) — so the difference between neighbouring samples
*is* the world direction the canvas's column/row axis points along.
``frame_axes_sensor`` is a 3x3 whose columns are the design frame's X, Y and Z
in the sensor frame (the compile transform's rotation block). The result is
``(column_direction, row_direction)``, each a unit vector in ``(X, Y, Z)``.
Averaged over unit directions rather than over raw differences, so a long step
across a steep part of the surface cannot outvote the rest of the island.
"""
positions = np.asarray(positions_sensor_m, dtype=np.float64)
mask = np.asarray(valid, dtype=bool)
if positions.ndim != 3 or positions.shape[2] != 3:
raise ValueError(f"positions must be [H,W,3], got {positions.shape}")
if tuple(mask.shape) != tuple(positions.shape[:2]):
raise ValueError("the valid mask does not describe the position grid")
axes = np.asarray(frame_axes_sensor, dtype=np.float64).reshape(3, 3)
columns = _mean_direction(
_steps(positions, mask, axis=1, step=max(1, int(column_step)))
)
rows = _mean_direction(
_steps(positions, mask, axis=0, step=max(1, int(row_step)))
)
if columns is None or rows is None:
raise ValueError("the island has no neighbouring valid pixels to difference")
return axes.T @ columns, axes.T @ rows
def _steps(
positions: np.ndarray, mask: np.ndarray, *, axis: int, step: int
) -> np.ndarray:
"""Every chord across ``step`` pixels along ``axis``, where both ends are valid."""
if axis == 1:
left, right = mask[:, :-step], mask[:, step:]
delta = positions[:, step:, :] - positions[:, :-step, :]
inside = left & right
else:
left, right = mask[:-step, :], mask[step:, :]
delta = positions[step:, :, :] - positions[:-step, :, :]
inside = left & right
return delta[inside]
def _mean_direction(vectors: np.ndarray) -> np.ndarray | None:
"""The mean unit direction of ``vectors``, or ``None`` when there are none."""
if vectors.size == 0:
return None
lengths = np.linalg.norm(vectors, axis=1)
usable = np.isfinite(lengths) & (lengths > 1e-12)
if not np.any(usable):
return None
units = vectors[usable] / lengths[usable, None]
mean = units.mean(axis=0)
norm = float(np.linalg.norm(mean))
if norm <= 1e-12:
return None
return mean / norm
def agreement(
canvas_axes_design: tuple[np.ndarray, np.ndarray],
screen_right_design: np.ndarray,
screen_up_design: np.ndarray,
orientation: object,
) -> tuple[float, float]:
"""How well an orientation makes the canvas agree with a 3D view's screen.
``canvas_axes_design`` is :func:`canvas_axes_in_design_frame`'s output and the
two screen vectors are that view's own right/up axes, also in the design frame
(``-X`` and ``+Z`` for the default view). Returns ``(right_dot, up_dot)``: each
is in ``[-1, 1]``, and ``+1`` on both means "the canvas points the same way the
3D view does, left to right and up to down". A transform that fixes one axis
and reverses the other — a mirror — scores one positive and one negative.
"""
column_direction, row_direction = (
np.asarray(canvas_axes_design[0], dtype=np.float64),
np.asarray(canvas_axes_design[1], dtype=np.float64),
)
# The displayed +x/+y of the canvas, after the transform: the transform acts on
# the *displayed* columns and rows, and reversing an axis negates its direction.
name = normalise_orientation(orientation)
flip_x = name in (ORIENTATION_ROTATE_180, ORIENTATION_MIRROR_H)
flip_y = name in (ORIENTATION_ROTATE_180, ORIENTATION_MIRROR_V)
display_x = -column_direction if flip_x else column_direction
display_y = -row_direction if flip_y else row_direction
right = np.asarray(screen_right_design, dtype=np.float64)
up = np.asarray(screen_up_design, dtype=np.float64)
# Rows grow downwards on a canvas, so the canvas's screen up is -y.
return float(display_x @ right), float(-display_y @ up)
@@ -32,6 +32,11 @@ from orisys_app_shared.acquisition.arrows import (
draw_arrow_display, draw_arrow_display,
flow_colormap, flow_colormap,
) )
from orisys_app_shared.acquisition.display_orientation import (
ORIENTATION_IDENTITY,
apply_orientation,
normalise_orientation,
)
from orisys_app_shared.acquisition.models import ensure_bgr from orisys_app_shared.acquisition.models import ensure_bgr
#: The view modes, in the order a panel should offer them. ``recorded`` is not a #: The view modes, in the order a panel should offer them. ``recorded`` is not a
@@ -286,6 +291,7 @@ def render_image(
canvas_bgr: Sequence[int] | None = None, canvas_bgr: Sequence[int] | None = None,
domain_mask: np.ndarray | None = None, domain_mask: np.ndarray | None = None,
arrow_policy=None, arrow_policy=None,
orientation: str = ORIENTATION_IDENTITY,
) -> np.ndarray | None: ) -> np.ndarray | None:
"""Draw ``frame`` in ``mode``; ``None`` when there is nothing to draw. """Draw ``frame`` in ``mode``; ``None`` when there is nothing to draw.
@@ -304,37 +310,54 @@ def render_image(
over the pane's display settings): the same object the 3D surface samples its overlay over the pane's display settings): the same object the 3D surface samples its overlay
with, so both figures trigger at the same strength. ``None`` means the draw style's with, so both figures trigger at the same strength. ``None`` means the draw style's
own defaults. own defaults.
``orientation`` is a **display** convention for the domain-grid views: the picture is
turned or mirrored once it has been drawn, so the frame's own ``flow``, ``valid_mask``
and ``image_bgr`` are untouched — a recorded session's flow is stored in the domain's
own orientation and is never reshaped here. It applies to the views drawn from the
domain grid (arrows and depth); ``recorded`` is the camera's own picture, not the
domain's, and is left as it was captured. See
:mod:`orisys_app_shared.acquisition.display_orientation` for why the finger's default
is a half turn.
""" """
plan = plan or plan_image(frame) plan = plan or plan_image(frame)
wanted = plan.resolve(mode) wanted = plan.resolve(mode)
boundary = frame.valid_mask if domain_mask is None else domain_mask boundary = frame.valid_mask if domain_mask is None else domain_mask
turn = normalise_orientation(orientation) if wanted != RECORDED else ORIENTATION_IDENTITY
if wanted == DEPTH: if wanted == DEPTH:
# Never substitute another picture for a missing depth map: that made "no # Never substitute another picture for a missing depth map: that made "no
# depth" look like a depth reading. The panel says so instead. # depth" look like a depth reading. The panel says so instead.
if frame.depth_valid is False: if frame.depth_valid is False:
return None return None
return depth_map_to_bgr( depth_image = depth_map_to_bgr(
frame.depth, frame.depth,
flow_colormap(theme), flow_colormap(theme),
value_range=depth_range, value_range=depth_range,
invalid_mask=boundary, invalid_mask=boundary,
) )
if depth_image is None:
return None
return apply_orientation(depth_image, turn)
if frame.image_bgr is None: if frame.image_bgr is None:
return None return None
if wanted in (ARROW_PURE, ARROW_STITCHED): if wanted in (ARROW_PURE, ARROW_STITCHED):
if frame.flow is None or frame.flow_valid is False: if frame.flow is None or frame.flow_valid is False:
# The base picture with no arrows, and the plan's ``missing`` says why. # The base picture with no arrows, and the plan's ``missing`` says why.
return ensure_bgr(np.asarray(frame.image_bgr)) return apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn)
return draw_arrow_display( return apply_orientation(
frame.image_bgr, draw_arrow_display(
frame.flow, frame.image_bgr,
background="captured" if wanted == ARROW_STITCHED else "pure_color", frame.flow,
canvas_bgr=canvas_bgr, background="captured" if wanted == ARROW_STITCHED else "pure_color",
draw_style=draw_style, canvas_bgr=canvas_bgr,
valid_mask=frame.valid_mask, draw_style=draw_style,
domain_mask=domain_mask, valid_mask=frame.valid_mask,
colormap=flow_colormap(theme), domain_mask=domain_mask,
policy=arrow_policy, colormap=flow_colormap(theme),
policy=arrow_policy,
),
turn,
) )
# ``recorded``: the frame is the picture, with nothing drawn over it. # ``recorded``: the frame is the picture, with nothing drawn over it — and the
# camera's own orientation, which no domain convention applies to.
return ensure_bgr(np.asarray(frame.image_bgr)) return ensure_bgr(np.asarray(frame.image_bgr))
@@ -62,6 +62,11 @@ _DEFAULTS: dict[str, Any] = {
"plot_deadband_raw": 0.0, "plot_deadband_raw": 0.0,
"image_view_mode": "arrows_pure", "image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90, "arrow_rotation_deg": 90,
#: How the finger's 2D canvas is presented. A display convention (the unrolled
#: domain drawn against the design frame the 3D pane uses); ``rotate180`` is the
#: derived default — see ``acquisition/display_orientation.py``. Display only:
#: the stored flow and mask are never reshaped.
"finger_2d_orientation": "rotate180",
"show_fps": False, "show_fps": False,
"raw_auto_zero": True, "raw_auto_zero": True,
#: The GL profile the 3D view is started with. One per process: the surface #: The GL profile the 3D view is started with. One per process: the surface
@@ -230,6 +230,12 @@ class ArrowInstances:
kind: ArrowKind kind: ArrowKind
#: Optional per-arrow thickness scale relative to a template (contact arrows). #: Optional per-arrow thickness scale relative to a template (contact arrows).
thickness_scales: F32 | None = None # [M] thickness_scales: F32 | None = None # [M]
#: The stroke radius, in metres, the merged mesh is built with. ``0`` means
#: "derive one from the arrows' own lengths" (what the contact arrow wants); a
#: positive value is an absolute statement, which is what the flow overlay needs:
#: its arrows are a fraction of a millimetre long, so a radius proportional to
#: that length is a tenth of a pixel and the glyph rasterises as a dashed hairline.
shaft_radius_m: float = 0.0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -16,6 +16,7 @@ from orisys_app_shared.scene3d.contracts import (
Diagnostic, Diagnostic,
SurfaceMapping, SurfaceMapping,
diagnostic, diagnostic,
error,
freeze, freeze,
) )
from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy
@@ -114,6 +115,81 @@ def mesh_span_from_mapping(mapping: SurfaceMapping) -> float:
return float(max(np.max(extent), 1e-6)) return float(max(np.max(extent), 1e-6))
#: The 2D canvas draws every arrow as ``cv2.arrowedLine(..., thickness=1)`` on the
#: domain's own pixel grid, so "the size of a 2D arrow" is one canvas pixel of
#: stroke and at most ``flow_grid_spacing * flow_length_margin`` pixels of length
#: (8 px for the finger's 10 px grid). Those two numbers are the reference the 3D
#: overlay is sized against: :func:`metres_per_domain_pixel` is one canvas pixel in
#: metres, and the values below are how many of them the 3D stroke is.
FLOW_ARROW_SHAFT_PX = 1.0
"""Stroke width of a flow arrow, in 2D-canvas pixels — the 2D canvas's ``thickness=1``."""
#: The stroke's floor and ceiling, as a fraction of the drawn scene's span. Both are
#: fractions of the *model*, not of the screen: the 2D canvas has no zoom, so its
#: arrow is a fixed fraction of the finger at every size, and an arrow that stayed a
#: fixed number of screen pixels would stop matching that picture the moment the
#: operator zoomed in. The band only stops a degenerate mapping (a nearly
#: zero-extent island) or a pathological stroke from being drawn at all.
FLOW_ARROW_SHAFT_SPAN_FRACTION = (0.0002, 0.005)
def metres_per_domain_pixel(mapping: SurfaceMapping) -> float:
"""How many metres one pixel of the domain grid spans, on the surface.
The median chord between neighbouring *valid* grid samples, along both axes. This
is the unit the 2D canvas's arrow is drawn in (one pixel), so it is the unit the
3D overlay has to be sized in for the two pictures to look alike: an arrow four
canvas pixels long covers four of these, whichever view draws it.
The median, not the mean or the local value: the island's rim has shorter chords
where the surface turns away, and a stroke width that followed the rim would make
the arrows at the edge of the finger thinner than the ones in the middle for no
reason the operator could see.
"""
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
valid = np.asarray(mapping.valid, dtype=bool)
if positions.shape[:2] != tuple(valid.shape):
raise error(
"geometry_mismatch",
"the surface mapping's positions and validity disagree about the grid",
)
chords: list[np.ndarray] = []
if positions.shape[1] > 1:
across = np.linalg.norm(positions[:, 1:, :] - positions[:, :-1, :], axis=2)
inside = valid[:, 1:] & valid[:, :-1]
inside &= np.isfinite(across)
chords.append(across[inside])
if positions.shape[0] > 1:
down = np.linalg.norm(positions[1:, :, :] - positions[:-1, :, :], axis=2)
inside = valid[1:, :] & valid[:-1, :]
inside &= np.isfinite(down)
chords.append(down[inside])
usable = np.concatenate(chords) if chords else np.empty(0, dtype=np.float64)
positive = usable[usable > 1e-12]
if positive.size:
return float(np.median(positive))
# No neighbouring valid pixels to measure (a one-pixel island): fall back to the
# island's own extent over the grid, which is the same statement without the
# local detail.
shape = positions.shape[:2]
return mesh_span_from_mapping(mapping) / max(int(shape[0]), int(shape[1]), 1)
def flow_arrow_shaft_radius_m(
mapping: SurfaceMapping, *, stroke_px: float = FLOW_ARROW_SHAFT_PX
) -> float:
"""The flow arrow's stroke radius, in metres, from the domain's own pixel size.
``stroke_px`` canvas pixels wide, clamped into
:data:`FLOW_ARROW_SHAFT_SPAN_FRACTION` of the drawing's span so a degenerate
asset cannot produce an invisible or a blotting stroke.
"""
span = mesh_span_from_mapping(mapping)
radius = 0.5 * max(float(stroke_px), 0.0) * metres_per_domain_pixel(mapping)
low, high = FLOW_ARROW_SHAFT_SPAN_FRACTION
return float(np.clip(radius, span * low, span * high))
def build_flow_overlay( def build_flow_overlay(
samples: FlowSamples, samples: FlowSamples,
mapping: SurfaceMapping, mapping: SurfaceMapping,
@@ -187,6 +263,13 @@ def build_flow_overlay(
lengths=freeze(lengths, dtype=np.float32), lengths=freeze(lengths, dtype=np.float32),
colors=freeze(colors, dtype=np.float32), colors=freeze(colors, dtype=np.float32),
kind="flow", kind="flow",
# The stroke is the *canvas's* pixel, not a fraction of the arrow's own
# length: these arrows are a fraction of a millimetre long, so a
# proportional radius (2 % of 0.5 mm) is a tenth of a pixel and the arrow
# rasterised as a dashed hairline with no head. One domain pixel of stroke
# is what the 2D canvas draws (``cv2.arrowedLine(thickness=1)``), so the
# two views now show the same glyph at the same fraction of the finger.
shaft_radius_m=flow_arrow_shaft_radius_m(mapping),
), ),
(), (),
) )
@@ -435,7 +518,13 @@ def arrow_instances_to_mesh(
*, *,
default_radius_fraction: float = 0.02, default_radius_fraction: float = 0.02,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Merge arrow instances into one triangle mesh (positions, triangles, colors).""" """Merge arrow instances into one triangle mesh (positions, triangles, colors).
The stroke radius is the batch's own ``shaft_radius_m`` when it states one (the
flow overlay does: one 2D-canvas pixel, see :func:`flow_arrow_shaft_radius_m`),
and otherwise ``default_radius_fraction`` of the batch's mean length — which is
right for the contact arrow, whose length is a fraction of the mesh it sits on.
"""
empty_v = np.zeros((0, 3), dtype=np.float32) empty_v = np.zeros((0, 3), dtype=np.float32)
empty_t = np.zeros((0, 3), dtype=np.uint32) empty_t = np.zeros((0, 3), dtype=np.uint32)
empty_c = np.zeros((0, 4), dtype=np.float32) empty_c = np.zeros((0, 4), dtype=np.float32)
@@ -456,7 +545,15 @@ def arrow_instances_to_mesh(
) )
mean_length = float(np.mean(lengths)) if count else 1.0 mean_length = float(np.mean(lengths)) if count else 1.0
base_radius = max(mean_length * default_radius_fraction, 1e-5) # An explicit stroke radius wins over the proportional one: see
# ``flow_arrow_shaft_radius_m`` for why a flow arrow cannot use a fraction of its
# own length. The contact arrow keeps the proportional template.
shaft_radius_m = float(getattr(arrows, "shaft_radius_m", 0.0) or 0.0)
base_radius = (
shaft_radius_m
if shaft_radius_m > 0.0
else max(mean_length * default_radius_fraction, 1e-5)
)
parts_v: list[np.ndarray] = [] parts_v: list[np.ndarray] = []
parts_t: list[np.ndarray] = [] parts_t: list[np.ndarray] = []