autocal layout first design
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user