三维未激活采样显示灰点,二维增加深度叠箭头。
分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user