diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png index b017f12..ec65765 100644 Binary files a/apps/studio/config/compound_eye.png and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 5692792..4e49876 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -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", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 13a9914..a545981 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -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": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”", diff --git a/apps/studio/config/preferences.default.json b/apps/studio/config/preferences.default.json index 3fc4043..a14fd58 100644 --- a/apps/studio/config/preferences.default.json +++ b/apps/studio/config/preferences.default.json @@ -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] diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index d126936..2683447 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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) diff --git a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py index 11b7d92..327ac0e 100644 --- a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py +++ b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py @@ -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")) diff --git a/apps/studio/tests/test_arrow_display_size.py b/apps/studio/tests/test_arrow_display_size.py new file mode 100644 index 0000000..4ac0b1e --- /dev/null +++ b/apps/studio/tests/test_arrow_display_size.py @@ -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) diff --git a/apps/studio/tests/test_finger_2d_orientation.py b/apps/studio/tests/test_finger_2d_orientation.py new file mode 100644 index 0000000..3cb4d07 --- /dev/null +++ b/apps/studio/tests/test_finger_2d_orientation.py @@ -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() diff --git a/shared/src/orisys_app_shared/acquisition/__init__.py b/shared/src/orisys_app_shared/acquisition/__init__.py index 219e55b..a9e3ad3 100644 --- a/shared/src/orisys_app_shared/acquisition/__init__.py +++ b/shared/src/orisys_app_shared/acquisition/__init__.py @@ -7,11 +7,14 @@ __all__ = [ "FingerAcquisitionWorker", "SdkAcquisitionWorker", "SdkPacket", + "apply_orientation", "depth_map_to_bgr", "draw_arrow_display", + "effective_orientation", "flow_colormap", "hex_to_bgr", "load_fixed_mode", + "normalise_orientation", "orisys_importable", "resolve_finger_export_dir", ] @@ -27,6 +30,9 @@ _LAZY_ATTRS = { "draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"), "flow_colormap": ("orisys_app_shared.acquisition.arrows", "flow_colormap"), "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"), } diff --git a/shared/src/orisys_app_shared/acquisition/display_orientation.py b/shared/src/orisys_app_shared/acquisition/display_orientation.py new file mode 100644 index 0000000..80e7494 --- /dev/null +++ b/shared/src/orisys_app_shared/acquisition/display_orientation.py @@ -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) diff --git a/shared/src/orisys_app_shared/acquisition/image_backend.py b/shared/src/orisys_app_shared/acquisition/image_backend.py index a5f954b..51766b5 100644 --- a/shared/src/orisys_app_shared/acquisition/image_backend.py +++ b/shared/src/orisys_app_shared/acquisition/image_backend.py @@ -32,6 +32,11 @@ from orisys_app_shared.acquisition.arrows import ( draw_arrow_display, 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 #: 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, domain_mask: np.ndarray | None = None, arrow_policy=None, + orientation: str = ORIENTATION_IDENTITY, ) -> np.ndarray | None: """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 with, so both figures trigger at the same strength. ``None`` means the draw style's 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) wanted = plan.resolve(mode) 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: # Never substitute another picture for a missing depth map: that made "no # depth" look like a depth reading. The panel says so instead. if frame.depth_valid is False: return None - return depth_map_to_bgr( + depth_image = depth_map_to_bgr( frame.depth, flow_colormap(theme), value_range=depth_range, invalid_mask=boundary, ) + if depth_image is None: + return None + return apply_orientation(depth_image, turn) if frame.image_bgr is None: return None if wanted in (ARROW_PURE, ARROW_STITCHED): if frame.flow is None or frame.flow_valid is False: # The base picture with no arrows, and the plan's ``missing`` says why. - return ensure_bgr(np.asarray(frame.image_bgr)) - return draw_arrow_display( - frame.image_bgr, - frame.flow, - background="captured" if wanted == ARROW_STITCHED else "pure_color", - canvas_bgr=canvas_bgr, - draw_style=draw_style, - valid_mask=frame.valid_mask, - domain_mask=domain_mask, - colormap=flow_colormap(theme), - policy=arrow_policy, + return apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn) + return apply_orientation( + draw_arrow_display( + frame.image_bgr, + frame.flow, + background="captured" if wanted == ARROW_STITCHED else "pure_color", + canvas_bgr=canvas_bgr, + draw_style=draw_style, + valid_mask=frame.valid_mask, + domain_mask=domain_mask, + 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)) diff --git a/shared/src/orisys_app_shared/prefs/store.py b/shared/src/orisys_app_shared/prefs/store.py index e5fa3b1..9a38c15 100644 --- a/shared/src/orisys_app_shared/prefs/store.py +++ b/shared/src/orisys_app_shared/prefs/store.py @@ -62,6 +62,11 @@ _DEFAULTS: dict[str, Any] = { "plot_deadband_raw": 0.0, "image_view_mode": "arrows_pure", "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, "raw_auto_zero": True, #: The GL profile the 3D view is started with. One per process: the surface diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 4a759ad..4034912 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -230,6 +230,12 @@ class ArrowInstances: kind: ArrowKind #: Optional per-arrow thickness scale relative to a template (contact arrows). 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) diff --git a/shared/src/orisys_app_shared/scene3d/overlays.py b/shared/src/orisys_app_shared/scene3d/overlays.py index d33bdb9..521ce0c 100644 --- a/shared/src/orisys_app_shared/scene3d/overlays.py +++ b/shared/src/orisys_app_shared/scene3d/overlays.py @@ -16,6 +16,7 @@ from orisys_app_shared.scene3d.contracts import ( Diagnostic, SurfaceMapping, diagnostic, + error, freeze, ) 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)) +#: 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( samples: FlowSamples, mapping: SurfaceMapping, @@ -187,6 +263,13 @@ def build_flow_overlay( lengths=freeze(lengths, dtype=np.float32), colors=freeze(colors, dtype=np.float32), 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, ) -> 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_t = np.zeros((0, 3), dtype=np.uint32) 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 - 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_t: list[np.ndarray] = []