subject: store the whole session and drive calibration from the acquisition stream

This commit is contained in:
ykcheungab
2026-09-13 12:34:47 +08:00
parent f16d2b3d84
commit db228b6f57
6 changed files with 958 additions and 32 deletions
+208 -31
View File
@@ -29,6 +29,8 @@ from PyQt5.QtWidgets import (
)
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
@@ -115,6 +117,19 @@ class StudioMainWindow(QMainWindow):
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self._run_source_label = ""
# ---- record channel -------------------------------------------------
# Fed from the acquisition thread via the worker's sink list, so it does
# not depend on the UI refresh rate or on which panels are visible.
self.session_store = SessionStore()
self.cal_recorder = CalibrationRecorder(
on_finished=self._on_calibration_point,
on_cancelled=self._on_calibration_cancelled,
)
self._record_error_count = 0
self._dropped_reported = 0
self._active_calibration_id = ""
self._depth_range_value: tuple[float, float] | None = None
self._last_depth_frame = None
icon = QIcon(str(logo_path()))
if not icon.isNull():
@@ -169,6 +184,7 @@ class StudioMainWindow(QMainWindow):
self._apply_panel_visibility()
self._restore_splitter_sizes()
self._update_run_enabled()
self.cal_panel.bind_recorder(self.cal_recorder)
self.cal_panel.set_meta_context(
sensor_type=self.sensor_picker.sensor_type(),
config_ref=self._resolve_config_ref(),
@@ -204,6 +220,40 @@ class StudioMainWindow(QMainWindow):
on_error=self._on_worker_error,
on_status=self._on_worker_status,
)
# Both workers publish into the same record channel.
for worker in (self._box_worker, self._finger_worker):
worker.add_sink(self._on_packet_recorded)
# ------------------------------------------------------- record channel
def _on_packet_recorded(self, packet) -> None:
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
The plots are allowed to drop frames; this path is not. It is also the
reason calibration sampling no longer depends on the calibration panel
being visible.
"""
sample = SessionSample(
frame_id=int(getattr(packet, "frame_id", 0)),
t_rel_s=float(packet.timestamp),
fn_raw=float(packet.normal_force_raw),
fx_raw=float(packet.shear_x_raw),
fy_raw=float(packet.shear_y_raw),
fn_n=float(packet.normal_force),
fx_n=float(packet.shear_x),
fy_n=float(packet.shear_y),
valid=bool(getattr(packet, "valid", True)),
calibration_id=self._calibration_id(),
error=str(getattr(packet, "invalid_reason", "") or ""),
)
self.session_store.append(sample)
self.cal_recorder.feed(sample)
if not sample.valid:
self._record_error_count += 1
def _calibration_id(self) -> str:
"""Cached per session — this runs for every acquired sample."""
return self._active_calibration_id
def _wire_toolbar(self) -> None:
self.toolbar.search_clicked.connect(self._on_search_cameras)
@@ -511,6 +561,12 @@ class StudioMainWindow(QMainWindow):
def _on_units_changed(self, units: str) -> None:
self.prefs["units"] = units
self.force_plots.set_fixed_newton(str(units) == "newton")
# Rebuild the visible curves from the whole-session store, which keeps
# both triplets, instead of appending new-unit values onto old ones.
self.force_plots.set_units(
"N" if str(units) == "newton" else "raw",
store=self.session_store,
)
self._persist_prefs()
def _on_image_view_mode_changed(self, mode: str) -> None:
@@ -567,11 +623,45 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
def _on_calibration_exported(self, path: str) -> None:
"""Apply newly written force_calibration.json to the running Sensor."""
"""Apply a freshly exported calibration — only on explicit confirmation.
The export no longer overwrites the device bundle, and it no longer
silently pushes the coefficients into both workers. The user is shown
the exact target device first, and only the matching worker is updated.
"""
sensor_type = self.sensor_picker.sensor_type()
config_ref = self._resolve_config_ref() or ""
answer = QMessageBox.question(
self,
t("cal.apply_title"),
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self.statusBar().showMessage(t("cal.apply_skipped"))
return
worker = self._worker_for(sensor_type)
if worker is None or not hasattr(worker, "set_pending_calibration"):
self.statusBar().showMessage(t("cal.apply_no_target"))
return
worker.set_pending_calibration(path)
self.statusBar().showMessage(
t("cal.apply_done", sensor_type=sensor_type)
)
def _worker_for(self, sensor_type: str):
self._ensure_workers()
for worker in (self._box_worker, self._finger_worker):
if worker is not None and hasattr(worker, "set_pending_calibration"):
worker.set_pending_calibration(path)
if sensor_type == "finger":
return self._finger_worker
return self._box_worker
def _on_calibration_point(self, point) -> None:
"""Called on the UI thread when a calibration point finishes."""
self.cal_panel.on_record_finished(point)
def _on_calibration_cancelled(self) -> None:
self.cal_panel.on_record_cancelled()
def _on_finger_3d_toggled(self, visible: bool) -> None:
if self.sensor_picker.sensor_type() != "finger":
@@ -806,10 +896,22 @@ class StudioMainWindow(QMainWindow):
self._last_session_error = ""
self._run_source_label = str(source)
self._active_sensor_type = sensor_type
# Fresh record channel for the new session.
self._active_calibration_id = str(config_ref)
self._record_error_count = 0
self._dropped_reported = 0
self._depth_range_value = None
self._last_depth_frame = None
self.session_store.reset(calibration_id=str(config_ref))
self.cal_recorder.reset_session()
self._ensure_workers()
self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker
)
# Register the record channel on the worker we are about to start, so
# it is wired even if the worker was installed by something other than
# ``_ensure_workers`` (``add_sink`` is idempotent).
self._active_worker.add_sink(self._on_packet_recorded)
try:
self._active_worker.start(source, config_ref=config_ref)
except Exception as exc:
@@ -948,32 +1050,43 @@ class StudioMainWindow(QMainWindow):
if is_new:
self._last_plot_packet_key = packet_key
display = select_display_forces(
units_pref=self.toolbar.units(),
force_calibrated=packet.force_calibrated,
fn=packet.normal_force,
fx=packet.shear_x,
fy=packet.shear_y,
fn_raw=packet.normal_force_raw,
fx_raw=packet.shear_x_raw,
fy_raw=packet.shear_y_raw,
)
if display.using_raw_fallback:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.push(
packet.timestamp,
display.fn,
display.fx,
display.fy,
unit=display.unit,
)
if self._show_cal:
self.cal_panel.push_raw(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
packet.timestamp,
# Invalid readouts must not be drawn as if they were data, and they
# must not be written into the plot buffer: NaN would poison the
# auto-range and a zero would be indistinguishable from a real one.
if packet.valid:
display = select_display_forces(
units_pref=self.toolbar.units(),
force_calibrated=packet.force_calibrated,
fn=packet.normal_force,
fx=packet.shear_x,
fy=packet.shear_y,
fn_raw=packet.normal_force_raw,
fx_raw=packet.shear_x_raw,
fy_raw=packet.shear_y_raw,
)
if display.using_raw_fallback:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.push(
packet.timestamp,
display.fn,
display.fx,
display.fy,
unit=display.unit,
)
else:
self._report_invalid_sample(packet.invalid_reason)
# The calibration panel is a *view*: sampling is driven by the
# record channel, so hiding the panel can no longer stall it.
self.cal_panel.set_live(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
)
# Finalise calibration sampling on the UI thread once its deadline passes.
self.cal_recorder.tick()
self.cal_panel.poll_recorder()
self._report_dropped_samples()
mode = self.arrow_panel.view_mode()
if is_new or mode != self._last_rendered_view_mode:
@@ -981,6 +1094,52 @@ class StudioMainWindow(QMainWindow):
if self._show_fps and is_new:
self.arrow_panel.set_fps(packet.fps)
def _report_invalid_sample(self, reason: str) -> None:
"""Surface failed readouts instead of letting them pass as data."""
if self._session_phase == "running":
self._set_phase("error")
self.statusBar().showMessage(
t("status.sample_invalid", count=self._record_error_count, reason=reason)
)
def _report_dropped_samples(self) -> None:
"""Never let the memory bound discard data silently."""
dropped = self.session_store.dropped
if dropped > self._dropped_reported:
self._dropped_reported = dropped
self.statusBar().showMessage(t("status.samples_dropped", count=dropped))
# ------------------------------------------------------------ depth scale
def _depth_range(self) -> tuple[float, float] | None:
"""The colour scale, latched to the first frame that carries depth.
Re-fitting the range on every frame (the old behaviour) made colours
drift, so two frames could look identical while holding different
depths. Latching keeps frames comparable for the whole session; the
numeric range is shown in the panel header.
"""
if self._depth_range_value is not None:
return self._depth_range_value
depth = self._last_depth_frame
if depth is None:
return None
arr = np.asarray(depth, dtype=np.float64)
finite = np.isfinite(arr)
if arr.size == 0 or not np.any(finite):
return None
lo = float(np.nanmin(arr[finite]))
hi = float(np.nanmax(arr[finite]))
if hi <= lo:
hi = lo + 1.0
self._depth_range_value = (lo, hi)
return self._depth_range_value
def _depth_scale_text(self) -> str:
span = self._depth_range()
if span is None:
return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
def _render_run_image(self, packet) -> None:
if packet is None:
return
@@ -996,8 +1155,20 @@ class StudioMainWindow(QMainWindow):
draw_style = "finger" if self._active_sensor_type == "finger" else "box"
colormap = flow_colormap(self._theme)
if mode == "depth":
depth_bgr = depth_map_to_bgr(packet.depth)
frame = depth_bgr if depth_bgr is not None else packet.processed_frame
self._last_depth_frame = packet.depth
depth_bgr = depth_map_to_bgr(
packet.depth,
value_range=self._depth_range(),
invalid_mask=packet.valid_mask,
)
if depth_bgr is None:
# Never substitute the processed frame here: that made a missing
# depth map look like a depth reading. Say so instead.
self.arrow_panel.clear_preview(t("panels.depth_unavailable"))
self.arrow_panel.set_depth_scale(None)
return
self.arrow_panel.set_depth_scale(self._depth_scale_text())
frame = depth_bgr
elif mode == "arrows_stitched":
frame = draw_arrow_display(
packet.processed_frame,
@@ -1056,6 +1227,12 @@ class StudioMainWindow(QMainWindow):
def closeEvent(self, event) -> None: # noqa: N802
self._stop_preview(announce=False)
# Detach the record channel first: a worker that is still winding down
# must not keep a reference to this window (and through it to widgets
# that Qt is about to destroy).
for worker in (self._box_worker, self._finger_worker):
if worker is not None and hasattr(worker, "remove_sink"):
worker.remove_sink(self._on_packet_recorded)
# Blocking: on the way out we must not leave a device handle behind.
self._stop_run(reason="user_stop", wait=True)
self._persist_prefs()