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
@@ -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"),
}
@@ -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,
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))
@@ -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
@@ -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)
@@ -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] = []