autocal layout first design

This commit is contained in:
ykcheungab
2026-09-20 21:15:39 +08:00
parent 07d7c3bad6
commit e276b5de0f
92 changed files with 18071 additions and 64 deletions
+12
View File
@@ -33,6 +33,10 @@
"scene3d_settings": "3D display settings…",
"show_fps": "Show FPS",
"show_diagnostics": "Diagnostics",
"raw_auto_zero": "Finger raw auto-zero at start",
"raw_auto_zero_tip": "Value cards only: estimate an idle bias and deadband after start. Curves, statistics and CSV keep the measured values.",
"raw_reestimate": "Re-estimate raw baseline…",
"raw_reestimate_tip": "Drop the current display baseline and re-estimate from the next quiet samples (value cards only).",
"settings": "Settings area",
"plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°",
@@ -217,6 +221,8 @@
"selection_empty": "No valid samples in the selection",
"value_unavailable": "n/a",
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
"zero_zone": "zero zone",
"zero_zone_tip": "Display deadband after auto baseline; curves, statistics and CSV keep the measured values",
"unit_toggle": "Switch unit",
"unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units",
"browse_only_tip": "Drag or scroll on the chart to browse history"
@@ -412,6 +418,12 @@
"field_depth": "Depth response",
"field_flow": "Optical flow magnitude",
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
"flow_arrows": "Surface flow arrows",
"flow_arrows_tip": "Draw optical-flow arrows on the sensing surface using the same sample points as the 2D view.",
"flow_arrows_unavailable": "This scene package has no UV→scene surface mapping, so flow arrows cannot be drawn.",
"contact_arrow": "Contact normal arrow",
"contact_arrow_tip": "Draw a yellow local-normal arrow at the contact centre (length from the normal response, capped — not a calibrated force vector).",
"contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.",
"quantity_flow_magnitude": "Optical flow magnitude",
"quantity_depth_response": "Depth response",
"mode_surface": "surface colouring",
+12
View File
@@ -33,6 +33,10 @@
"scene3d_settings": "三维显示设置…",
"show_fps": "显示 FPS",
"show_diagnostics": "诊断信息",
"raw_auto_zero": "Finger raw 启动归零",
"raw_auto_zero_tip": "仅影响数字卡展示:启动后自动估计无载偏置与死区。曲线、统计与 CSV 仍为测量值。",
"raw_reestimate": "重新估计 raw 基线…",
"raw_reestimate_tip": "丢弃当前展示基线,从接下来的无载样本重新估计(仅数字卡)。",
"settings": "设置区",
"plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
@@ -217,6 +221,8 @@
"selection_empty": "选区无有效样本",
"value_unavailable": "不可用",
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
"zero_zone": "零区",
"zero_zone_tip": "展示归零后的死区内读数;曲线、统计与 CSV 仍为原始测量值",
"unit_toggle": "切换单位",
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
@@ -412,6 +418,12 @@
"field_depth": "深度响应",
"field_flow": "光流幅值",
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
"flow_arrows": "表面光流箭头",
"flow_arrows_tip": "在传感表面上按与二维相同的采样点绘制光流箭头。",
"flow_arrows_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制光流箭头。",
"contact_arrow": "接触法向箭头",
"contact_arrow_tip": "在接触中心沿局部法向绘制黄色示意箭头(长度由法向响应限幅,不是标定合力)。",
"contact_arrow_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制接触法向箭头。",
"quantity_flow_magnitude": "光流幅值",
"quantity_depth_response": "深度响应",
"mode_surface": "曲面着色",
@@ -9,6 +9,7 @@
"show_cal_panel": false,
"show_finger_3d": true,
"show_fps": false,
"raw_auto_zero": true,
"gl_profile": "desktop33",
"scene3d_package": "",
"scene3d_display": {
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"schema_version": 2,
"unit": "N",
"channels": {
"normal": {
@@ -14,8 +14,22 @@
[62000.0, 2.377]
]
},
"shear": {
"shear_x": {
"method": "piecewise_linear",
"raw_key": "fshearx",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[36000.0, 1.19],
[68000.0, 2.06]
]
},
"shear_y": {
"method": "piecewise_linear",
"raw_key": "fsheary",
"input": "components",
"signed": true,
"extrapolate": true,
@@ -925,6 +925,16 @@ class StudioMainWindow(QMainWindow):
)
self.view_menu.addAction(self.act_show_diagnostics)
self.act_raw_auto_zero = QAction(self)
self.act_raw_auto_zero.setCheckable(True)
self.act_raw_auto_zero.setChecked(bool(self.prefs.get("raw_auto_zero", True)))
self.act_raw_auto_zero.toggled.connect(self.display_ctl.on_raw_auto_zero_toggled)
self.view_menu.addAction(self.act_raw_auto_zero)
self.act_raw_reestimate = QAction(self)
self.act_raw_reestimate.triggered.connect(self.display_ctl.request_raw_reestimate)
self.view_menu.addAction(self.act_raw_reestimate)
self.act_settings = QAction(self)
self.act_settings.setCheckable(True)
self.act_settings.setChecked(self.toolbar.settings_expanded())
@@ -1168,6 +1178,10 @@ class StudioMainWindow(QMainWindow):
self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_show_diagnostics.setText(t("menu.show_diagnostics"))
self.act_raw_auto_zero.setText(t("menu.raw_auto_zero"))
self.act_raw_auto_zero.setToolTip(t("menu.raw_auto_zero_tip"))
self.act_raw_reestimate.setText(t("menu.raw_reestimate"))
self.act_raw_reestimate.setToolTip(t("menu.raw_reestimate_tip"))
self.act_settings.setText(t("menu.settings"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
@@ -1886,6 +1900,16 @@ class StudioMainWindow(QMainWindow):
self._stop_preview(announce=False)
self.force_plots.reset()
units = "N" if self.toolbar.units() == "newton" else "raw"
self.force_plots.configure_presentation(
sensor_type=sensor_type,
unit=units,
enabled=(
sensor_type == "finger"
and units == "raw"
and bool(self.prefs.get("raw_auto_zero", True))
),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
# Fresh record channel for the new session. The calibration identity is
@@ -2126,6 +2150,30 @@ class StudioMainWindow(QMainWindow):
self.session.last_sample_valid = True
self._last_display_fallback = bool(display.using_raw_fallback)
self._last_display_available = tuple(bool(flag) for flag in display.available)
flow = getattr(packet, "flow", None)
flow_p95 = None
if flow is not None:
try:
import numpy as np
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim == 3 and arr.shape[-1] == 2:
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
if m.shape == mag.shape:
vals = mag[m & np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
flow_p95 = float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
flow_p95 = None
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self.force_plots.push(
# Session-relative time, the same base the store and
# ``forces.csv`` use. The wall clock must never reach the
@@ -2136,6 +2184,9 @@ class StudioMainWindow(QMainWindow):
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
else:
self.session.last_sample_valid = False
@@ -2226,6 +2277,11 @@ class StudioMainWindow(QMainWindow):
def _render_run_image(self, packet) -> None:
if packet is None or self._replay_owns_image():
return
flow_valid = getattr(packet, "flow_valid", None)
depth_valid = getattr(packet, "depth_valid", None)
stale = bool(getattr(packet, "stale_forces", False)) or (
flow_valid is False and depth_valid is False
)
self._show_image_frame(
ImageFrame(
getattr(packet, "processed_frame", None),
@@ -2235,6 +2291,11 @@ class StudioMainWindow(QMainWindow):
source=SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE,
frame_id=int(getattr(packet, "frame_id", 0) or 0),
t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.0),
stale=stale,
flow_valid=flow_valid,
flow_frame_id=getattr(packet, "flow_frame_id", None),
depth_valid=depth_valid,
depth_frame_id=getattr(packet, "depth_frame_id", None),
)
)
@@ -84,7 +84,10 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: Finger-raw value cards: startup auto baseline / deadband (display only).
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
def bind_actions(self, *, rotate_arrows: Any) -> None:
"""Take the rotate entry once the menus exist (it follows the sensor)."""
@@ -261,3 +264,43 @@ class DisplayPrefsController(QObject):
setter = getattr(panel, "set_diagnostics_visible", None)
if callable(setter):
setter(self._show_diagnostics)
# ------------------------------------------------------- raw display zeroing
@property
def raw_auto_zero(self) -> bool:
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether Finger raw value cards auto-baseline at session start."""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
self.apply_presentation_prefs()
def on_raw_auto_zero_toggled(self, enabled: bool) -> None:
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the current baseline policy onto the charts (Finger raw only)."""
configure = getattr(self._charts, "configure_presentation", None)
if not callable(configure):
return
sensor = (
self._picker.sensor_type()
if hasattr(self._picker, "sensor_type")
else "box"
)
unit = "N" if self._toolbar.units() == "newton" else "raw"
configure(
sensor_type=sensor,
unit=unit,
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
)
def request_raw_reestimate(self) -> None:
"""Operator entry: restart the idle baseline window on the value cards."""
request = getattr(self._charts, "request_presentation_reestimate", None)
if callable(request):
request()
else:
self.apply_presentation_prefs()
@@ -392,6 +392,26 @@ class FingerFrameAdapter:
refused.append(mask_problem)
return None
self.last_diagnostic = None
contact = getattr(packet, "contact", None)
contact_xy = None
contact_found = False
contact_valid = False
if contact is not None:
contact_xy = getattr(contact, "centroid_uv_xy", None)
contact_found = bool(getattr(contact, "found", False))
contact_valid = bool(getattr(contact, "valid", False))
flow = getattr(packet, "flow", None)
flow_uv = None
flow_mask = None
if flow is not None and _field_validity(packet, "flow", identity.frame_id) is None:
arr = np.asarray(flow, dtype=np.float32)
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
flow_uv = freeze(arr, dtype=np.float32)
device_for_flow, _ = _device_mask(packet, self.shape_hw)
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
if not np.isfinite(fn):
fn = float(getattr(packet, "normal_force", 0.0) or 0.0)
return SurfaceFieldFrame(
identity=identity,
geometry_id=self.geometry_id,
@@ -400,6 +420,12 @@ class FingerFrameAdapter:
scalar_descriptor=descriptor,
valid=_intersect(mask, device),
computed_frame_id=_computed_frame_id(packet, name, identity.frame_id),
flow_uv=flow_uv,
flow_valid_mask=flow_mask,
contact_uv_xy=contact_xy if isinstance(contact_xy, tuple) else None,
contact_found=contact_found,
contact_valid=contact_valid,
contact_fn=fn if np.isfinite(fn) else 0.0,
)
@@ -462,17 +462,24 @@ class ThreeDCoordinator:
self._set_state("no_data")
def set_settings(self, settings: DisplaySettings) -> None:
"""Apply display settings; a gain change means recomposing the last field."""
"""Apply display settings; a gain/overlay change recomposes the last field."""
previous = self._settings
self._settings = settings
self._widget.set_settings(settings)
gain_changed = (
needs_recompose = (
previous.display_gain != settings.display_gain
or previous.deform_enabled != settings.deform_enabled
or previous.field != settings.field
or previous.flow_arrows_enabled != settings.flow_arrows_enabled
or previous.contact_arrow_enabled != settings.contact_arrow_enabled
or previous.flow_on_px != settings.flow_on_px
or previous.flow_off_px != settings.flow_off_px
or previous.flow_grid_spacing != settings.flow_grid_spacing
or previous.max_arrows != settings.max_arrows
)
if gain_changed and self._assets is not None:
# The scalar is untouched by the gain, but the *positions* are not, so
# the last field frame has to be composed again.
if needs_recompose and self._assets is not None:
# Gain moves positions; overlay toggles rebuild arrow instances on the
# held field frame so the operator sees the change without waiting.
self._settings_dirty = True
def show_rest(self) -> None:
@@ -190,6 +190,31 @@ def shader_header(profile: Profile) -> str:
raise ValueError(f"unknown GL profile: {profile!r}") from None
_ARROW_VERTEX_BODY = """
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec4 a_color;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec4 v_color;
void main() {
v_color = a_color;
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
}
"""
_ARROW_FRAGMENT_BODY = """
in vec4 v_color;
out vec4 frag_color;
void main() {
frag_color = v_color;
}
"""
def shader_source(profile: Profile, program: str) -> tuple[str, str]:
"""``(vertex, fragment)`` source for a profile and a program name.
@@ -199,19 +224,23 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
header = shader_header(profile)
if program == "scalar":
body = _SCALAR_FRAGMENT_BODY
vertex = _VERTEX_BODY
elif program == "diffuse":
body = _DIFFUSE_FRAGMENT_BODY
vertex = _VERTEX_BODY
elif program == "arrow":
return header + _ARROW_VERTEX_BODY, header + _ARROW_FRAGMENT_BODY
else:
raise ValueError(f"unknown shader program: {program!r}")
return header + _VERTEX_BODY, header + body
return header + vertex, header + body
def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
"""The range of the *valid* samples, or ``None`` when there are none.
"""Robust P5/P95 range of *valid* samples, or ``None`` when there are none.
Only valid and finite samples count. Including the invalid ones would make the
colour range follow whatever garbage sits outside the UV island, and including
NaN would produce a range of NaN and a picture with no colours at all.
Using min/max made empty-field noise paint the whole LUT. A percentile span
with a minimum width keeps weak signals visible without amplifying floor
noise into a full contact.
"""
import numpy as np
@@ -222,9 +251,39 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
usable = values[mask & np.isfinite(values)]
if usable.size == 0:
return None
return float(usable.min()), float(usable.max())
if usable.size < 8:
lower = float(usable.min())
upper = float(usable.max())
else:
lower = float(np.percentile(usable, 5.0))
upper = float(np.percentile(usable, 95.0))
# Minimum span: avoid collapsing nearly-constant fields into one colour.
span = upper - lower
min_span = max(abs(lower) * 0.05, abs(upper) * 0.05, 1e-3)
if span < min_span:
mid = 0.5 * (lower + upper)
lower = mid - 0.5 * min_span
upper = mid + 0.5 * min_span
return lower, upper
def _blend_scale_range(
previous: Optional[tuple[float, float]],
current: Optional[tuple[float, float]],
*,
alpha: float = 0.15,
) -> Optional[tuple[float, float]]:
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
if current is None:
return previous
if previous is None:
return current
a = float(np.clip(alpha, 0.0, 1.0))
return (
(1.0 - a) * float(previous[0]) + a * float(current[0]),
(1.0 - a) * float(previous[1]) + a * float(current[1]),
)
def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]:
"""The colour range to draw with: the operator's, or the data's own.
@@ -514,6 +573,10 @@ class GLRenderer:
self._initialized = False
self._paint_calls = 0
self._last_upload_ms = 0.0
#: Overlay VAOs keyed by kind ("flow" / "contact").
self._overlay_vaos: dict[str, tuple[int, int, int, int]] = {}
self._arrow_program: int = 0
self._arrow_uniforms: dict[str, int] = {}
# ------------------------------------------------------------------ lifetime
def initialize(self, capabilities: GLCapabilities) -> None:
@@ -529,6 +592,7 @@ class GLRenderer:
try:
self._programs["scalar"] = _compile_program(GL, capabilities.profile, "scalar")
self._programs["diffuse"] = _compile_program(GL, capabilities.profile, "diffuse")
self._programs["arrow"] = _compile_program(GL, capabilities.profile, "arrow")
except Exception as exc: # noqa: BLE001
self.release()
raise error(
@@ -604,7 +668,74 @@ class GLRenderer:
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
self._scalar_range = _valid_scalar_range(frame.scalar, frame.valid)
measured = _valid_scalar_range(frame.scalar, frame.valid)
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
self._upload_overlays(frame)
def _upload_overlays(self, frame: RenderFrame) -> None:
"""Upload merged flow / contact arrow meshes for this frame."""
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
for kind, instances in (
("flow", getattr(frame, "flow_arrows", None)),
("contact", getattr(frame, "contact_arrow", None)),
):
if instances is None or int(instances.origins.shape[0]) == 0:
self._clear_overlay(kind)
continue
positions, triangles, colors = arrow_instances_to_mesh(instances)
if positions.size == 0:
self._clear_overlay(kind)
continue
self._set_overlay(kind, positions, triangles, colors)
def _set_overlay(
self,
kind: str,
positions: np.ndarray,
triangles: np.ndarray,
colors: np.ndarray,
) -> None:
from OpenGL import GL
self._clear_overlay(kind)
count = int(positions.shape[0])
interleaved = np.empty((count, 7), dtype=np.float32)
interleaved[:, 0:3] = positions
interleaved[:, 3:7] = colors
vao = int(GL.glGenVertexArrays(1))
vbo = int(GL.glGenBuffers(1))
ebo = int(GL.glGenBuffers(1))
GL.glBindVertexArray(vao)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo)
GL.glBufferData(
GL.GL_ARRAY_BUFFER, interleaved.nbytes, interleaved, GL.GL_DYNAMIC_DRAW
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER, triangles.nbytes, triangles, GL.GL_DYNAMIC_DRAW
)
stride = 7 * 4
GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
GL.glEnableVertexAttribArray(1)
GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
GL.glBindVertexArray(0)
self._overlay_vaos[kind] = (vao, vbo, ebo, int(triangles.size))
def _clear_overlay(self, kind: str) -> None:
from OpenGL import GL
entry = self._overlay_vaos.pop(kind, None)
if entry is None:
return
vao, vbo, ebo, _ = entry
try:
GL.glDeleteVertexArrays(1, [int(vao)])
GL.glDeleteBuffers(1, [int(vbo)])
GL.glDeleteBuffers(1, [int(ebo)])
except Exception: # noqa: BLE001
pass
def draw(
self,
@@ -659,6 +790,27 @@ class GLRenderer:
GL.GL_TRIANGLES, part.triangles * 3, GL.GL_UNSIGNED_INT, None
)
GL.glBindVertexArray(0)
# Overlays after the surface so arrows stay visible.
arrow_program = self._programs.get("arrow")
if arrow_program and self._overlay_vaos:
locations = self._uniforms.get("arrow", {})
GL.glUseProgram(arrow_program)
_set_mat4(GL, locations.get("u_view"), view)
_set_mat4(GL, locations.get("u_projection"), projection)
GL.glDisable(GL.GL_DEPTH_TEST)
for kind in ("flow", "contact"):
entry = self._overlay_vaos.get(kind)
if entry is None:
continue
vao, _vbo, _ebo, index_count = entry
if index_count <= 0:
continue
GL.glBindVertexArray(vao)
GL.glDrawElements(
GL.GL_TRIANGLES, int(index_count), GL.GL_UNSIGNED_INT, None
)
GL.glBindVertexArray(0)
GL.glEnable(GL.GL_DEPTH_TEST)
GL.glUseProgram(0)
self._paint_calls += 1
return RenderStats(
@@ -687,6 +839,8 @@ class GLRenderer:
from OpenGL import GL
self.release_parts()
for kind in list(self._overlay_vaos):
self._clear_overlay(kind)
for program in self._programs.values():
try:
GL.glDeleteProgram(int(program))
@@ -222,6 +222,16 @@ class ThreeDPanel(QFrame):
self.field_note.setVisible(False)
form.addRow(self.field_note)
self.flow_arrows_check = QCheckBox()
self.flow_arrows_check.setChecked(True)
self.flow_arrows_check.toggled.connect(self._emit_settings)
form.addRow(self.flow_arrows_check)
self.contact_arrow_check = QCheckBox()
self.contact_arrow_check.setChecked(True)
self.contact_arrow_check.toggled.connect(self._emit_settings)
form.addRow(self.contact_arrow_check)
self.scale_label = QLabel()
self.scale_combo = QComboBox()
self.scale_combo.addItem("fixed", "fixed")
@@ -403,6 +413,18 @@ class ThreeDPanel(QFrame):
self.deform_check.setChecked(False)
finally:
self._syncing = False
flow_ok = bool(capabilities and capabilities.flow_arrows_available)
contact_ok = bool(capabilities and capabilities.contact_arrow_available)
self.flow_arrows_check.setEnabled(flow_ok and enabled)
self.contact_arrow_check.setEnabled(contact_ok and enabled)
self.flow_arrows_check.setToolTip(
t("scene3d.flow_arrows_unavailable") if not flow_ok else t("scene3d.flow_arrows_tip")
)
self.contact_arrow_check.setToolTip(
t("scene3d.contact_arrow_unavailable")
if not contact_ok
else t("scene3d.contact_arrow_tip")
)
def capabilities(self) -> Optional[SceneCapabilities]:
return self._capabilities
@@ -604,6 +626,8 @@ class ThreeDPanel(QFrame):
self.housing_check,
self.deform_check,
self.gain_spin,
self.flow_arrows_check,
self.contact_arrow_check,
self.scale_combo,
self.fps_combo,
self.reset_button,
@@ -635,6 +659,8 @@ class ThreeDPanel(QFrame):
index = self.field_combo.findData(str(settings.field))
if index >= 0:
self.field_combo.setCurrentIndex(index)
self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled))
self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled))
scale = settings.color_scale
if scale is not None:
index = self.scale_combo.findData(str(scale.mode))
@@ -678,6 +704,8 @@ class ThreeDPanel(QFrame):
housing_visible=self.housing_check.isChecked(),
max_fps=int(self.fps_combo.currentData()),
field=self.field(),
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
)
# ------------------------------------------------------------------ signals
@@ -730,6 +758,8 @@ class ThreeDPanel(QFrame):
self.field_combo.setItemText(0, t("scene3d.field_depth"))
self.field_combo.setItemText(1, t("scene3d.field_flow"))
self._render_field_note()
self.flow_arrows_check.setText(t("scene3d.flow_arrows"))
self.contact_arrow_check.setText(t("scene3d.contact_arrow"))
self.scale_label.setText(t("scene3d.scale"))
self.range_label.setText(t("scene3d.scale_range"))
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
@@ -80,6 +80,12 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
"housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps),
"field": str(settings.field),
"flow_arrows_enabled": bool(settings.flow_arrows_enabled),
"contact_arrow_enabled": bool(settings.contact_arrow_enabled),
"flow_on_px": float(settings.flow_on_px),
"flow_off_px": float(settings.flow_off_px),
"flow_grid_spacing": int(settings.flow_grid_spacing),
"max_arrows": int(settings.max_arrows),
}
if scale is not None:
block["color_scale"] = {
@@ -138,6 +144,16 @@ def settings_from_prefs(
lut_id=str(saved.get("lut_id") or "sequential"),
mode=mode, # type: ignore[arg-type]
)
flow_on = max(0.0, _number(raw.get("flow_on_px"), 0.4))
flow_off = max(0.0, _number(raw.get("flow_off_px"), 0.3))
if flow_on < flow_off:
flow_on, flow_off = flow_off, flow_on
spacing = int(_number(raw.get("flow_grid_spacing"), 10))
if spacing < 1:
spacing = 10
max_arrows = int(_number(raw.get("max_arrows"), 400))
if max_arrows < 1:
max_arrows = 400
return DisplaySettings(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
@@ -145,4 +161,10 @@ def settings_from_prefs(
housing_visible=_flag(raw.get("housing_visible"), True),
max_fps=max_fps,
field=field,
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
contact_arrow_enabled=_flag(raw.get("contact_arrow_enabled"), True),
flow_on_px=flow_on,
flow_off_px=flow_off,
flow_grid_spacing=spacing,
max_arrows=max_arrows,
)
@@ -415,8 +415,10 @@ class Scene3DView(QWidget):
"""
adapter = self._adapter
descriptor = self._last_descriptor
if adapter is None and descriptor is None:
if adapter is None and descriptor is None and self._assets is None:
return None
mapping = getattr(self._assets, "surface_mapping", None) if self._assets else None
overlays_ok = mapping is not None
return SceneCapabilities(
quantity=str(
getattr(adapter, "quantity", "") or getattr(descriptor, "quantity", "")
@@ -427,6 +429,8 @@ class Scene3DView(QWidget):
),
displacement_quality=None,
source_kind=str(self._source_kind),
flow_arrows_available=overlays_ok,
contact_arrow_available=overlays_ok,
)
def _publish_capabilities(self) -> None:
@@ -18,6 +18,7 @@ from PyQt5.QtWidgets import (
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import ForcePresentation
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
@@ -70,8 +71,12 @@ def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
def format_force(value: float, unit: str) -> str:
def format_force(
value: float, unit: str, *, presentation: ForcePresentation | None = None
) -> str:
"""Render one force reading for the value cards."""
if presentation is not None:
return presentation.format_force(value, unit=unit)
if unit == "N":
return f"{value:.3f}"
return f"{value:.1f}"
@@ -137,6 +142,11 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for Finger raw value cards.
self.presentation = ForcePresentation(enabled=False)
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
# -- browsing state -------------------------------------------------
#: Where the curves read from: the in-memory session store here, a
#: recording on disk in the replay. One interface either way.
@@ -484,7 +494,9 @@ class ForcePlotsPanel(QFrame):
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._display_values = (0.0, 0.0, 0.0)
self._available = (True, True, True)
self.presentation.reset_session(unit=self._unit)
self._unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
@@ -500,6 +512,49 @@ class ForcePlotsPanel(QFrame):
self._apply_axis_mode()
self._refresh_mode_label()
def configure_presentation(
self,
*,
sensor_type: str,
unit: str,
enabled: bool | None = None,
) -> None:
"""Enable Finger-raw auto baseline for value cards only."""
self._sensor_type = str(sensor_type or "box")
use = (
bool(enabled)
if enabled is not None
else (self._sensor_type == "finger" and unit != "N")
)
self.presentation.enabled = use
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display cards only)."""
self.presentation.request_reestimate()
self._display_values = self._last_values
self._render_value_cards()
def observe_for_baseline(
self,
values: tuple[float, float, float],
*,
t_rel_s: float,
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
) -> None:
"""Feed the startup estimator; curves keep the raw measurement."""
if not self.presentation.enabled:
return
self.presentation.observe(
values,
t_rel_s=t_rel_s,
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
)
def push(
self,
t_rel_s: float,
@@ -509,6 +564,9 @@ class ForcePlotsPanel(QFrame):
*,
unit: str = "raw",
available: tuple[bool, bool, bool] | None = None,
flow_p95: float | None = None,
contact_found: bool = False,
forces_valid: bool = True,
) -> None:
"""Append one live sample.
@@ -532,6 +590,18 @@ class ForcePlotsPanel(QFrame):
self._last_values = (float(fn), float(fx), float(fy))
if available is not None:
self._available = tuple(bool(flag) for flag in available)
self.observe_for_baseline(
self._last_values,
t_rel_s=float(t_rel_s),
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
)
self._display_values = displayed.values
self._display_suppressed = displayed.suppressed
if unit != self._unit:
# A unit change mid-stream is a *switch*, never a reinterpretation:
# the buffered values were measured in the old unit and must not be
@@ -1182,6 +1252,8 @@ class ForcePlotsPanel(QFrame):
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled and self.presentation.ready:
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
# presented as a newton value.
@@ -1192,14 +1264,30 @@ class ForcePlotsPanel(QFrame):
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
)
continue
value_label.setText(fmt.format(value=value))
value_label.setToolTip(
t("chart.selection_peak_tip") if selecting is not None else ""
value_label.setText(
format_force(value, self._unit, presentation=self.presentation)
if selecting is None
else fmt.format(value=value)
)
tip = t("chart.selection_peak_tip") if selecting is not None else ""
zero_zone = (
selecting is None
and self.presentation.enabled
and self.presentation.ready
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
)
if zero_zone:
tip = t("chart.zero_zone_tip")
value_label.setToolTip(tip)
unit_label.setText(unit_display(self._unit))
detail_label.setText(
self._selection_detail(index) if selecting is not None else ""
)
if selecting is not None:
detail_label.setText(self._selection_detail(index))
elif zero_zone:
detail_label.setText(t("chart.zero_zone"))
else:
detail_label.setText("")
self.unit_badge.setText(unit_display(self._unit))
def _selection_detail(self, index: int) -> str:
+115
View File
@@ -0,0 +1,115 @@
"""Composer overlays and 2D/3D sample-point parity."""
from __future__ import annotations
import numpy as np
from orisys_app_shared.acquisition.arrows import _policy_from_style
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
DisplaySettings,
FieldDescriptor,
SurfaceFieldFrame,
freeze,
)
from orisys_app_shared.scene3d.synthetic import poc_identity, poc_scene
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
def _depth_frame(assets, *, flow=None, contact_xy=None, fn=0.0):
h, w = assets.shape_hw
scalar = freeze(np.zeros((h, w), dtype=np.float32), dtype=np.float32)
valid = freeze(np.ones((h, w), dtype=bool), dtype=np.bool_)
flow_uv = None if flow is None else freeze(np.asarray(flow, dtype=np.float32), dtype=np.float32)
flow_mask = None if flow is None else freeze(np.asarray(valid, dtype=bool).copy(), dtype=np.bool_)
found = contact_xy is not None
return SurfaceFieldFrame(
identity=poc_identity(),
geometry_id=assets.geometry_id,
field_domain_id=assets.field_domain_id,
scalar=scalar,
valid=valid,
scalar_descriptor=FieldDescriptor(
"depth_response", "1", "approximate", False
),
computed_frame_id=0,
flow_uv=flow_uv,
flow_valid_mask=flow_mask,
contact_uv_xy=None if contact_xy is None else tuple(contact_xy),
contact_found=found,
contact_valid=found,
contact_fn=float(fn),
)
def test_composer_builds_overlays_when_mapping_present() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0)
composed = SceneComposer().compose(
assets,
frame,
DisplaySettings(
flow_arrows_enabled=True,
contact_arrow_enabled=True,
flow_grid_spacing=1,
flow_on_px=0.4,
flow_off_px=0.3,
max_arrows=50,
),
)
assert composed.flow_arrows is not None
assert composed.flow_arrows.origins.shape[0] >= 1
assert composed.contact_arrow is not None
assert composed.contact_arrow.origins.shape[0] == 1
def test_composer_hides_overlays_when_disabled() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0)
composed = SceneComposer().compose(
assets,
frame,
DisplaySettings(flow_arrows_enabled=False, contact_arrow_enabled=False),
)
assert composed.flow_arrows is None
assert composed.contact_arrow is None
def test_2d_3d_flow_sample_points_match() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float64)
flow[1, 1] = (0.7, 0.2)
mask = np.ones((h, w), dtype=bool)
settings = DisplaySettings(
flow_on_px=0.4,
flow_off_px=0.3,
flow_grid_spacing=1,
max_arrows=50,
)
policy_3d = _policy_from_style(
"finger",
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
)
policy_shared = VisualPolicy(
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
flow_edge_margin=0,
).normalised()
samples_2d = select_flow_samples(flow, mask, policy_shared)
samples_3d = select_flow_samples(flow, mask, policy_shared)
assert list(zip(samples_2d.rows, samples_2d.cols)) == list(
zip(samples_3d.rows, samples_3d.cols)
)
assert policy_3d.flow_on_px == settings.flow_on_px
+12
View File
@@ -433,6 +433,12 @@ def test_the_display_settings_round_trip():
deform_enabled=True,
housing_visible=False,
max_fps=60,
flow_arrows_enabled=False,
contact_arrow_enabled=True,
flow_on_px=0.55,
flow_off_px=0.35,
flow_grid_spacing=12,
max_arrows=250,
color_scale=ColorScale(
descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
lower=0.25,
@@ -446,6 +452,12 @@ def test_the_display_settings_round_trip():
assert restored.deform_enabled is True
assert restored.housing_visible is False
assert restored.max_fps == 60
assert restored.flow_arrows_enabled is False
assert restored.contact_arrow_enabled is True
assert restored.flow_on_px == 0.55
assert restored.flow_off_px == 0.35
assert restored.flow_grid_spacing == 12
assert restored.max_arrows == 250
assert restored.color_scale is not None
assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5)
assert restored.color_scale.mode == "fixed"