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:
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user