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
@@ -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: