三维未激活采样显示灰点,二维增加深度叠箭头。

分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
This commit is contained in:
ykcheungab
2026-10-08 17:04:30 +08:00
parent 3d2fb0c4eb
commit 091d0fda35
18 changed files with 507 additions and 202 deletions
@@ -47,9 +47,10 @@ from orisys_app_shared.acquisition.models import ensure_bgr
ARROW_PURE = "arrows_pure"
ARROW_STITCHED = "arrows_stitched"
DEPTH = "depth"
DEPTH_ARROWS = "depth_arrows"
RECORDED = "recorded"
DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS)
ALL_MODES = (*DERIVED_MODES, RECORDED)
#: Where a frame came from. Only used for wording and for the recording; the modes
@@ -294,6 +295,8 @@ class ImagePlan:
return "flow"
if wanted == DEPTH and not self.has_depth():
return "depth"
if wanted == DEPTH_ARROWS and not self.has_depth() and not self.has_flow():
return "depth"
return None
def has_flow(self) -> bool:
@@ -374,6 +377,52 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
)
def _orient_drawn(drawn: np.ndarray, turn: str) -> np.ndarray:
"""Apply a display orientation without dropping a draw-failure mark."""
diagnostic = getattr(drawn, "frame_diagnostic", None)
oriented = apply_orientation(drawn, turn)
if diagnostic is None:
return oriented
return carry_frame_diagnostic(oriented, diagnostic)
def _draw_flow(
frame: ImageFrame,
base: np.ndarray,
*,
background: str,
theme: str,
draw_style: str,
domain_mask: np.ndarray | None,
arrow_policy,
canvas_bgr: Sequence[int] | None = None,
sdk_result: bool | None = None,
) -> np.ndarray:
"""Arrows on ``base``, using the same painter the pure-arrow views use."""
if sdk_result is None:
box_style = not str(draw_style).lower().startswith("f")
sdk_result = (
box_style
and str(frame.source or "") in RESULT_SOURCES
and uses_sdk_box_grid(arrow_policy)
)
flow = frame.flow
if sdk_result and flow is not None:
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
return draw_arrow_display(
base,
flow,
background=background,
canvas_bgr=canvas_bgr,
draw_style=draw_style,
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
valid_mask=frame.valid_mask,
domain_mask=domain_mask,
colormap=flow_colormap(theme),
policy=arrow_policy,
)
def render_image(
frame: ImageFrame,
plan: ImagePlan | None = None,
@@ -418,59 +467,55 @@ def render_image(
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
depth_image = None
if wanted in (DEPTH, DEPTH_ARROWS) and frame.depth_valid is not False:
depth_image = depth_map_to_bgr(
frame.depth,
flow_colormap(theme),
value_range=depth_range,
invalid_mask=boundary,
)
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 depth_image is None:
return None
return apply_orientation(depth_image, turn)
if wanted == DEPTH_ARROWS:
if depth_image is None:
wanted = ARROW_PURE
elif frame.flow is None or frame.flow_valid is False:
return apply_orientation(depth_image, turn)
else:
drawn = _draw_flow(
frame,
depth_image,
background="captured",
theme=theme,
draw_style=draw_style,
domain_mask=domain_mask,
arrow_policy=arrow_policy,
)
return _orient_drawn(drawn, 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 apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn)
# A recovered field is finite only at the cells the pass stored. The shared
# painter draws those cells; the SDK painter walks its own grid and finds
# nothing wherever that grid and the stored cells disagree. A box result whose
# grid *is* the SDK grid is the exception: fill the gaps with zero (the cells
# the painter does not read) and call the same ``draw_arrows`` live uses.
box_style = not str(draw_style).lower().startswith("f")
sdk_result = (
box_style
and str(frame.source or "") in RESULT_SOURCES
and uses_sdk_box_grid(arrow_policy)
)
flow = frame.flow
if sdk_result:
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
drawn = draw_arrow_display(
# A recovered field is finite only at the cells the pass stored. ``_draw_flow``
# fills a box result's gaps with zero and keeps the shared painter otherwise.
drawn = _draw_flow(
frame,
frame.image_bgr,
flow,
background="captured" if wanted == ARROW_STITCHED else "pure_color",
canvas_bgr=canvas_bgr,
theme=theme,
draw_style=draw_style,
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
valid_mask=frame.valid_mask,
canvas_bgr=canvas_bgr,
domain_mask=domain_mask,
colormap=flow_colormap(theme),
policy=arrow_policy,
arrow_policy=arrow_policy,
)
# Orientation may rebuild the array; keep the draw failure on the result so the
# panel can say so instead of showing a silent black canvas.
diagnostic = getattr(drawn, "frame_diagnostic", None)
oriented = apply_orientation(drawn, turn)
if diagnostic is None:
return oriented
return carry_frame_diagnostic(oriented, diagnostic)
return _orient_drawn(drawn, turn)
# ``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))
@@ -282,11 +282,10 @@ class SceneComposer:
frame.flow_valid_mask,
policy,
)
# The selector returns every candidate location plus an ``active`` flag;
# this figure draws the active ones, which is what it drew before the
# split (the selector used to filter and this figure drew the lot).
# Inactive candidates are grey points; active ones are coloured arrows.
# Same samples the 2D canvas paints, including the quiet ones.
flow_arrows, extra = build_flow_overlay(
samples.active_only(),
samples,
mapping,
style=policy,
normal_offset_m=mesh_span_from_mapping(mapping) * 0.002,
+159 -11
View File
@@ -19,6 +19,14 @@ from orisys_app_shared.scene3d.contracts import (
error,
freeze,
)
from orisys_app_shared.acquisition.arrows import (
FLOW_IDLE_HEX,
FLOW_MAGNITUDE_RANGE,
hex_to_bgr,
magnitude_to_ramp_index,
ramp_for_theme,
ramp_rgba_table,
)
from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy
ForceStrengthMode = Literal["log", "power", "linear"]
@@ -43,8 +51,11 @@ class ContactArrowStyle:
DEFAULT_CONTACT_ARROW_STYLE = ContactArrowStyle()
FLOW_TAIL_RGBA = (38 / 255.0, 235 / 255.0, 89 / 255.0, 0.85)
FLOW_HEAD_RGBA = (13 / 255.0, 115 / 255.0, 31 / 255.0, 0.95)
def _idle_rgba() -> np.ndarray:
"""The 2D idle-dot tint, as linear RGBA. Same hex, so a quiet sample matches."""
blue, green, red = hex_to_bgr(FLOW_IDLE_HEX)
return np.asarray((red, green, blue, 230), dtype=np.float32) / 255.0
def _empty_arrows(kind: str) -> ArrowInstances:
@@ -190,6 +201,75 @@ def flow_arrow_shaft_radius_m(
return float(np.clip(radius, span * low, span * high))
def _merge_arrows(first: ArrowInstances, second: ArrowInstances) -> ArrowInstances:
"""One flow batch: coloured arrows, then the grey idle points."""
if int(first.origins.shape[0]) == 0:
return second
if int(second.origins.shape[0]) == 0:
return first
return ArrowInstances(
origins=freeze(np.vstack([first.origins, second.origins]), dtype=np.float32),
directions=freeze(
np.vstack([first.directions, second.directions]), dtype=np.float32
),
lengths=freeze(np.concatenate([first.lengths, second.lengths]), dtype=np.float32),
colors=freeze(np.vstack([first.colors, second.colors]), dtype=np.float32),
kind="flow",
shaft_radius_m=float(first.shaft_radius_m or second.shaft_radius_m),
)
def _idle_flow_points(
samples: FlowSamples,
mapping: SurfaceMapping,
*,
normal_offset_m: float,
) -> ArrowInstances:
"""A fixed grey point at every candidate the threshold has not turned on.
A negative length is the mesh builder's mark for that point (see
:func:`arrow_instances_to_mesh`). The colour is the 2D idle dot, not the ramp.
"""
active = np.asarray(samples.active, dtype=bool)
if active.shape != samples.rows.shape or not np.any(~active):
return _empty_arrows("flow")
rows = np.asarray(samples.rows, dtype=np.int32)[~active]
cols = np.asarray(samples.cols, dtype=np.int32)[~active]
h, w = mapping.shape_hw
inside = (rows >= 0) & (rows < h) & (cols >= 0) & (cols < w)
if not np.any(inside):
return _empty_arrows("flow")
rows = rows[inside]
cols = cols[inside]
valid = np.asarray(mapping.valid, dtype=bool)
keep = valid[rows, cols]
if not np.any(keep):
return _empty_arrows("flow")
rows = rows[keep]
cols = cols[keep]
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
normals = np.asarray(mapping.normals_sensor, dtype=np.float64)
origins = positions[rows, cols].copy()
directions = normals[rows, cols].copy()
norms = np.linalg.norm(directions, axis=1)
bad = norms < 1e-8
directions[bad] = (0.0, 0.0, 1.0)
norms = np.where(bad, 1.0, norms)
directions = directions / norms[:, None]
if normal_offset_m:
origins = origins + normal_offset_m * directions
radius = flow_arrow_shaft_radius_m(mapping)
count = int(rows.size)
return ArrowInstances(
origins=freeze(origins, dtype=np.float32),
directions=freeze(directions, dtype=np.float32),
lengths=freeze(np.full(count, -radius, dtype=np.float32), dtype=np.float32),
colors=freeze(np.tile(_idle_rgba(), (count, 1)), dtype=np.float32),
kind="flow",
shaft_radius_m=radius,
)
def build_flow_overlay(
samples: FlowSamples,
mapping: SurfaceMapping,
@@ -197,7 +277,31 @@ def build_flow_overlay(
style: VisualPolicy | None = None,
normal_offset_m: float = 0.0,
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
"""Map shared UV flow samples onto the sensing surface."""
"""Map shared UV flow samples onto the sensing surface.
Inactive candidates are grey points. Active ones are arrows coloured by the
same magnitude ramp the 2D canvas uses.
"""
points = _idle_flow_points(
samples, mapping, normal_offset_m=normal_offset_m
)
arrows, diags = _active_flow_overlay(
samples.active_only(),
mapping,
style=style,
normal_offset_m=normal_offset_m,
)
return _merge_arrows(arrows, points), diags
def _active_flow_overlay(
samples: FlowSamples,
mapping: SurfaceMapping,
*,
style: VisualPolicy | None = None,
normal_offset_m: float = 0.0,
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
"""Arrows for samples that cleared the flow threshold."""
del style # lengths come from UV tip deltas projected into scene metres
if samples.rows.size == 0:
return _empty_arrows("flow"), ()
@@ -216,6 +320,7 @@ def build_flow_overlay(
rows = np.asarray(samples.rows, dtype=np.int32)
cols = np.asarray(samples.cols, dtype=np.int32)
magnitude = np.asarray(samples.magnitude, dtype=np.float64)
end_x = np.clip(np.round(cols + samples.dx).astype(np.int32), 0, w - 1)
end_y = np.clip(np.round(rows + samples.dy).astype(np.int32), 0, h - 1)
@@ -232,6 +337,7 @@ def build_flow_overlay(
rows = rows[inside]
cols = cols[inside]
magnitude = magnitude[inside]
end_x = end_x[inside]
end_y = end_y[inside]
origins = positions[rows, cols].copy()
@@ -248,13 +354,15 @@ def build_flow_overlay(
origins = origins[keep]
vectors = vectors[keep]
lengths = lengths[keep]
magnitude = magnitude[keep]
directions = vectors / lengths[:, None]
colors = np.tile(np.asarray(FLOW_TAIL_RGBA, dtype=np.float32), (len(origins), 1))
# Stronger arrows tip toward the head colour.
t = np.clip(lengths / max(float(np.percentile(lengths, 95)), 1e-9), 0.0, 1.0)
head = np.asarray(FLOW_HEAD_RGBA, dtype=np.float32)
colors = colors * (1.0 - t)[:, None] + head * t[:, None]
# Same ramp index the 2D canvas asks for, so an active arrow is the same colour
# in both views. Quiet samples never reach here: they are the grey points.
index = magnitude_to_ramp_index(magnitude, value_range=FLOW_MAGNITUDE_RANGE)
table = ramp_rgba_table(ramp_for_theme("dark")).reshape(-1, 4).astype(np.float32)
colors = table[index] / 255.0
colors[:, 3] = 0.9
return (
ArrowInstances(
@@ -499,6 +607,35 @@ def _cylinder_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
)
def _point_template() -> tuple[np.ndarray, np.ndarray]:
"""Unit octahedron. A negative arrow length asks the mesh builder for one of these."""
vertices = np.asarray(
[
[1.0, 0.0, 0.0],
[-1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, -1.0, 0.0],
[0.0, 0.0, 1.0],
[0.0, 0.0, -1.0],
],
dtype=np.float64,
)
triangles = np.asarray(
[
[0, 2, 4],
[2, 1, 4],
[1, 3, 4],
[3, 0, 4],
[2, 0, 5],
[1, 2, 5],
[3, 1, 5],
[0, 3, 5],
],
dtype=np.int32,
)
return vertices, triangles
def _cone_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
"""Unit-radius cone with apex at z=1 and base at z=0."""
angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False)
@@ -534,6 +671,7 @@ def arrow_instances_to_mesh(
cyl_v, cyl_t = _cylinder_template()
cone_v, cone_t = _cone_template()
point_v, point_t = _point_template()
origins = np.asarray(arrows.origins, dtype=np.float64)
directions = np.asarray(arrows.directions, dtype=np.float64)
lengths = np.asarray(arrows.lengths, dtype=np.float64)
@@ -544,7 +682,8 @@ def arrow_instances_to_mesh(
else np.ones(count, dtype=np.float64)
)
mean_length = float(np.mean(lengths)) if count else 1.0
positive = lengths[lengths > 0.0]
mean_length = float(np.mean(positive)) if positive.size else 1.0
# 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.
@@ -561,11 +700,20 @@ def arrow_instances_to_mesh(
offset = 0
for i in range(count):
length = float(lengths[i])
origin = origins[i]
radius = base_radius * float(max(thickness[i], 0.05))
if length < 0.0:
# Idle sample: a fixed grey point, not an arrow. ``length`` is unused;
# the radius is the same stroke the active arrows use.
verts = point_v * radius + origin
parts_v.append(verts)
parts_t.append(point_t + offset)
parts_c.append(np.tile(colors[i], (len(verts), 1)))
offset += len(verts)
continue
if length <= 1e-9:
continue
direction = directions[i]
origin = origins[i]
radius = base_radius * float(max(thickness[i], 0.05))
rotation = _rotation_matrix_z_to(direction)
# Tail: 75% cylinder, head: 25% cone.
tail_len = length * 0.75