Fix the phase-1/2 review findings in recording, calibration and depth replay
What - record: one packet->row conversion (record/sample.py). A failed compute chain records no force value at all, a newton value is written only for an axis that really has a calibration curve, and mono_t (not the wall clock) orders history with a per-row calibration hash. - record lifecycle: explicit starting/recording/stopping/stopped/failed states owned by the writer thread; stop() reports completed=False on timeout and does not write ended_at; consecutive write failures stop acceptance immediately. - record capacity: byte budget for the video queue (512 MiB default); a full queue sheds the *frame* and parks the tiny data row, so a video overflow no longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted as dropped_gaps / unrecorded_gaps. - calibration identity: sha256 content hash instead of a path, minted from the calibration the sensor actually loaded (reported before the first frame) and confirmed by the acquisition thread; events are timestamped when they happen. - calibration diagnostics: the fit summary, residual and validation error now come from the model that export/apply would use (piecewise_linear); validation points are held out of the build; repeated reference loads are merged with an n-weighted mean and pooled std. - calibration record: every point keeps its full three-axis sample series (frame id, time, raw, newtons, valid) plus diagnostics, written to an independent calibration_session_*.json that can be reopened for inspection; save record / export coefficients / apply to device are three separate actions. - depth replay: the cursor's frame is bound to the depth result by request sequence and frame id, so a moved cursor never shows another frame's map; the SDK is stepped from its current position instead of re-reading from the start; the live chain (box deformation+contact, finger unroll+tactile) is replayed with the exact resolved config path; algorithm state is rebuilt from a reset marker or from the recorded pre-roll, and says so when it cannot be. Why The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw counts into the newton columns, recording a stale value after a failed compute, losing CSV rows when the video queue filled, reporting success while a writer was still running, diagnosing a model nobody applies, applying a calibration before the device confirmed it, dropping the samples behind a calibration point, showing a stale depth map under a moved cursor and recomputing depth of the wrong frame. Verification - D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped (log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points. - apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice). - shared suites: 91 tests, 0 failures (new: test_record_semantics, test_record_lifecycle, test_calibration_model, test_calibration_record, test_depth_replay). - apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the shim cannot drive), unchanged from before this change. - compileall clean; locale parity zh/en + t() key coverage: 0 failures. Not in this change - P2#10 disk-based browsing of a long recording and range CSV export (next round, per your choice). - Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL three-dimensional view. - Hardware verification: no camera attached, so the SDK's real processing chain and the pre-roll's effect on a live tracker were exercised with fakes only.
This commit is contained in:
@@ -37,6 +37,7 @@ from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||
from orisys_app_shared.record.sample import to_record_sample
|
||||
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
|
||||
@@ -142,6 +143,10 @@ class StudioMainWindow(QMainWindow):
|
||||
self._record_error_count = 0
|
||||
self._dropped_reported = 0
|
||||
self._active_calibration_id = ""
|
||||
#: Last coefficients file written by the calibration panel (not applied).
|
||||
self._last_calibration_export = ""
|
||||
#: Confirmed calibration changes, handed over from the acquisition thread.
|
||||
self._pending_calibration_events: list[tuple[str, str, int]] = []
|
||||
self._depth_range_value: tuple[float, float] | None = None
|
||||
self._last_depth_frame = None
|
||||
|
||||
@@ -192,6 +197,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.statusBar().addPermanentWidget(self.status_chip)
|
||||
self.cal_panel.status_message.connect(self.statusBar().showMessage)
|
||||
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
|
||||
self.cal_panel.apply_requested.connect(self._on_calibration_apply)
|
||||
self._set_phase("idle")
|
||||
self._build_menus()
|
||||
self._wire_toolbar()
|
||||
@@ -230,11 +236,13 @@ class StudioMainWindow(QMainWindow):
|
||||
self._box_worker = SdkAcquisitionWorker(
|
||||
on_error=self._on_worker_error,
|
||||
on_status=self._on_worker_status,
|
||||
on_calibration_applied=self._on_calibration_applied,
|
||||
)
|
||||
if self._finger_worker is None:
|
||||
self._finger_worker = FingerAcquisitionWorker(
|
||||
on_error=self._on_worker_error,
|
||||
on_status=self._on_worker_status,
|
||||
on_calibration_applied=self._on_calibration_applied,
|
||||
)
|
||||
# Both workers publish into the same record channel.
|
||||
for worker in (self._box_worker, self._finger_worker):
|
||||
@@ -278,6 +286,11 @@ class StudioMainWindow(QMainWindow):
|
||||
session_id=int(self.session_store.session_id),
|
||||
metadata=self._build_session_metadata(config),
|
||||
calibration_id=self._active_calibration_id,
|
||||
calibration_version=self._active_calibration_id,
|
||||
# The frames acquired just before this moment. The depth chain is
|
||||
# stateful, so without them the first frames of the recording
|
||||
# could never be recomputed faithfully.
|
||||
preroll=self._take_preroll(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.toolbar.set_record_checked(False)
|
||||
@@ -313,6 +326,23 @@ class StudioMainWindow(QMainWindow):
|
||||
)
|
||||
)
|
||||
|
||||
def _take_preroll(self) -> list:
|
||||
"""Frame history from the active worker, if it keeps one.
|
||||
|
||||
The buffered frames are consumed (and the buffer restarted) even when no
|
||||
recording follows, so a stale history can never be attached to a later
|
||||
recording.
|
||||
"""
|
||||
worker = self._active_worker
|
||||
take = getattr(worker, "take_preroll", None)
|
||||
if not callable(take):
|
||||
return []
|
||||
try:
|
||||
return list(take())
|
||||
except Exception: # noqa: BLE001 - the history is optional, not the record
|
||||
logger.exception("Could not take the pre-roll buffer")
|
||||
return []
|
||||
|
||||
def _build_session_metadata(self, config: RecordConfig) -> dict:
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
|
||||
@@ -345,6 +375,10 @@ class StudioMainWindow(QMainWindow):
|
||||
"calibration": {
|
||||
"config_ref": config_ref,
|
||||
"force_calibrated": bool(getattr(info, "force_calibrated", False)),
|
||||
# Content hash of the coefficients the device confirmed. It is
|
||||
# filled in by the acquisition thread's confirmation, so it can
|
||||
# be empty here; the CSV's per-row version is authoritative.
|
||||
"version": self._active_calibration_id,
|
||||
},
|
||||
# Depth is deliberately NOT stored (decided with the team): it is a
|
||||
# function of the frame and the sensor configuration, so replay
|
||||
@@ -401,20 +435,15 @@ class StudioMainWindow(QMainWindow):
|
||||
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.
|
||||
|
||||
The packet is converted exactly once, by the same function the CSV writer
|
||||
uses, so the store and the file can never disagree about whether a value
|
||||
is a measurement.
|
||||
"""
|
||||
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 ""),
|
||||
record = to_record_sample(
|
||||
packet, calibration_version=self._active_calibration_id
|
||||
)
|
||||
sample = SessionSample.from_record(record)
|
||||
self.session_store.append(sample)
|
||||
self.cal_recorder.feed(sample)
|
||||
# Video + CSV + metadata share this one record path.
|
||||
@@ -422,14 +451,9 @@ class StudioMainWindow(QMainWindow):
|
||||
fps = float(getattr(packet, "fps", 0.0) or 0.0)
|
||||
if fps > 0.0:
|
||||
self._record_fps = fps
|
||||
if not sample.valid:
|
||||
if not record.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)
|
||||
self.toolbar.preview_toggled.connect(self._on_preview_toggled)
|
||||
@@ -886,12 +910,25 @@ class StudioMainWindow(QMainWindow):
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_calibration_exported(self, path: str) -> None:
|
||||
"""Apply a freshly exported calibration — only on explicit confirmation.
|
||||
"""Note the coefficients that were written. Nothing is applied here.
|
||||
|
||||
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.
|
||||
Exporting and applying are separate actions: writing a file must never
|
||||
change what a running device is using.
|
||||
"""
|
||||
self._last_calibration_export = str(path)
|
||||
self.recorder.log_event("calibration_exported", str(path))
|
||||
self.statusBar().showMessage(t("cal.export_only", path=Path(path).name))
|
||||
|
||||
def _on_calibration_apply(self, path: str) -> None:
|
||||
"""Push freshly exported coefficients into the device — on confirmation.
|
||||
|
||||
The user is shown the exact target device first, and only the matching
|
||||
worker is updated.
|
||||
"""
|
||||
path = str(path or self._last_calibration_export)
|
||||
if not path:
|
||||
self.statusBar().showMessage(t("cal.apply_no_export"))
|
||||
return
|
||||
sensor_type = self.sensor_picker.sensor_type()
|
||||
config_ref = self._resolve_config_ref() or ""
|
||||
answer = QMessageBox.question(
|
||||
@@ -908,12 +945,38 @@ class StudioMainWindow(QMainWindow):
|
||||
if worker is None or not hasattr(worker, "set_pending_calibration"):
|
||||
self.statusBar().showMessage(t("cal.apply_no_target"))
|
||||
return
|
||||
# Only a request. The acquisition thread confirms once the coefficients
|
||||
# are actually loaded; the event and the recorded version come from there.
|
||||
worker.set_pending_calibration(path)
|
||||
self.recorder.log_event("calibration_applied", f"{sensor_type}: {path}")
|
||||
self.statusBar().showMessage(
|
||||
t("cal.apply_done", sensor_type=sensor_type)
|
||||
t("cal.apply_pending", sensor_type=sensor_type)
|
||||
)
|
||||
|
||||
def _on_calibration_applied(self, path: str, version: str, frame_id: int) -> None:
|
||||
"""Called from the acquisition thread once new coefficients are loaded."""
|
||||
# The acquisition thread must not touch Qt, so only plain state moves here.
|
||||
self._pending_calibration_events.append(
|
||||
(str(path), str(version), int(frame_id))
|
||||
)
|
||||
|
||||
def _drain_calibration_events(self) -> None:
|
||||
"""Publish confirmed calibration changes on the UI thread."""
|
||||
while self._pending_calibration_events:
|
||||
path, version, frame_id = self._pending_calibration_events.pop(0)
|
||||
# The version is the calibration's content hash: a path can be
|
||||
# re-exported with different coefficients, a hash cannot.
|
||||
self._active_calibration_id = version or path
|
||||
self.session_store.set_calibration_id(self._active_calibration_id)
|
||||
self.recorder.set_calibration_version(self._active_calibration_id)
|
||||
self.recorder.log_event(
|
||||
"calibration_applied",
|
||||
f"{path} ({self._active_calibration_id[:12] or 'unhashed'})",
|
||||
frame_id=frame_id,
|
||||
)
|
||||
self.statusBar().showMessage(
|
||||
t("cal.applied", version=self._active_calibration_id[:12])
|
||||
)
|
||||
|
||||
def _worker_for(self, sensor_type: str):
|
||||
self._ensure_workers()
|
||||
if sensor_type == "finger":
|
||||
@@ -1160,8 +1223,12 @@ 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)
|
||||
# Fresh record channel for the new session. The calibration identity is
|
||||
# unknown until the device confirms which coefficients it loaded, so it
|
||||
# starts empty and is filled from that confirmation — a path is not a
|
||||
# version, and the same path can hold different numbers.
|
||||
self._active_calibration_id = ""
|
||||
self._pending_calibration_events.clear()
|
||||
self._record_error_count = 0
|
||||
self._dropped_reported = 0
|
||||
self._depth_range_value = None
|
||||
@@ -1171,7 +1238,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._stop_recording(wait=True)
|
||||
self._session_counter += 1
|
||||
self.session_store.reset(
|
||||
session_id=self._session_counter, calibration_id=str(config_ref)
|
||||
session_id=self._session_counter, calibration_id=""
|
||||
)
|
||||
self._ensure_workers()
|
||||
self._active_worker = (
|
||||
@@ -1254,11 +1321,26 @@ class StudioMainWindow(QMainWindow):
|
||||
self._active_worker.reset()
|
||||
self.force_plots.reset()
|
||||
self._last_plot_packet_key = None
|
||||
self.recorder.log_event("zeroing", "tracker reset")
|
||||
# Recorded with the frame it happened at: a reset clears the SDK's
|
||||
# tracker, so an offline depth replay can start here instead of
|
||||
# guessing at the algorithm's earlier state.
|
||||
self.recorder.log_event(
|
||||
"zeroing",
|
||||
"tracker reset",
|
||||
frame_id=self._current_frame_id(),
|
||||
)
|
||||
self.statusBar().showMessage(t("status.reset_ok"))
|
||||
except Exception as exc:
|
||||
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||
|
||||
def _current_frame_id(self) -> int:
|
||||
"""The most recent frame id in the session store (-1 when unknown)."""
|
||||
try:
|
||||
frame_ids = self.session_store.data().frame_ids
|
||||
except Exception: # noqa: BLE001 - an empty store is not an error here
|
||||
return -1
|
||||
return int(frame_ids[-1]) if len(frame_ids) else -1
|
||||
|
||||
def _on_worker_error(self, message: str) -> None:
|
||||
self._last_session_error = message
|
||||
logger.error("Acquisition error: %s", message)
|
||||
@@ -1360,6 +1442,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.cal_recorder.tick()
|
||||
self.cal_panel.poll_recorder()
|
||||
self._drain_record_alarms()
|
||||
self._drain_calibration_events()
|
||||
self._report_dropped_samples()
|
||||
|
||||
mode = self.arrow_panel.view_mode()
|
||||
|
||||
@@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -32,6 +33,11 @@ from orisys_app_shared.force.calibration_io import (
|
||||
export_force_calibration_v2,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.force.calibration_session import (
|
||||
read_calibration_session,
|
||||
session_filename,
|
||||
write_calibration_session,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
@@ -56,8 +62,10 @@ def _mono_font() -> str:
|
||||
|
||||
class CalPanel(QFrame):
|
||||
status_message = pyqtSignal(str)
|
||||
#: Coefficients were written; the window asks the user before applying them.
|
||||
#: Coefficients were written to disk. It does **not** mean they were applied.
|
||||
calibration_exported = pyqtSignal(str)
|
||||
#: The operator asked to push the last export into the running device.
|
||||
apply_requested = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
@@ -69,6 +77,8 @@ class CalPanel(QFrame):
|
||||
#: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the
|
||||
#: lifecycle no longer depends on this panel being visible.
|
||||
self._recorder = None
|
||||
#: Last successfully written coefficients file; applying is explicit.
|
||||
self._last_export_path = ""
|
||||
self._live_fn = 0.0
|
||||
self._live_fx = 0.0
|
||||
self._live_fy = 0.0
|
||||
@@ -190,14 +200,19 @@ class CalPanel(QFrame):
|
||||
self.btn_clear_all = QPushButton()
|
||||
self.btn_toggle_excluded = QPushButton()
|
||||
self.btn_toggle_validation = QPushButton()
|
||||
self.btn_open_record = QPushButton()
|
||||
self.btn_save_record = QPushButton()
|
||||
self.btn_export = QPushButton()
|
||||
self.btn_export.setObjectName("primaryButton")
|
||||
self.btn_apply = QPushButton()
|
||||
self.btn_apply.setObjectName("primaryButton")
|
||||
for button in (
|
||||
self.btn_delete,
|
||||
self.btn_clear_channel,
|
||||
self.btn_clear_all,
|
||||
self.btn_toggle_excluded,
|
||||
self.btn_toggle_validation,
|
||||
self.btn_open_record,
|
||||
self.btn_save_record,
|
||||
):
|
||||
# The action row must survive a narrow three-panel layout.
|
||||
button.setProperty("compact", "true")
|
||||
@@ -207,14 +222,20 @@ class CalPanel(QFrame):
|
||||
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
|
||||
self.btn_clear_all.clicked.connect(self._on_clear_all)
|
||||
self.btn_open_record.clicked.connect(self._on_open_record)
|
||||
self.btn_save_record.clicked.connect(self._on_save_record)
|
||||
self.btn_export.clicked.connect(self._on_export)
|
||||
self.btn_apply.clicked.connect(self._on_apply)
|
||||
actions.addWidget(self.btn_toggle_excluded)
|
||||
actions.addWidget(self.btn_toggle_validation)
|
||||
actions.addWidget(self.btn_delete)
|
||||
actions.addWidget(self.btn_clear_channel)
|
||||
actions.addWidget(self.btn_clear_all)
|
||||
actions.addStretch(1)
|
||||
actions.addWidget(self.btn_open_record)
|
||||
actions.addWidget(self.btn_save_record)
|
||||
actions.addWidget(self.btn_export)
|
||||
actions.addWidget(self.btn_apply)
|
||||
root.addLayout(actions)
|
||||
|
||||
self._refresh_enabled()
|
||||
@@ -284,8 +305,14 @@ class CalPanel(QFrame):
|
||||
self.btn_delete.setText(t("cal.delete_row"))
|
||||
self.btn_clear_channel.setText(t("cal.clear_channel"))
|
||||
self.btn_clear_all.setText(t("cal.clear_all"))
|
||||
self.btn_open_record.setText(t("cal.open_record"))
|
||||
self.btn_save_record.setText(t("cal.save_record"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self.btn_apply.setText(t("cal.apply"))
|
||||
self.btn_open_record.setToolTip(t("cal.open_record_tip"))
|
||||
self.btn_save_record.setToolTip(t("cal.save_record_tip"))
|
||||
self.btn_export.setToolTip(t("cal.export_tip"))
|
||||
self.btn_apply.setToolTip(t("cal.apply_tip"))
|
||||
self.btn_toggle_excluded.setText(t("cal.state_exclude"))
|
||||
self.btn_toggle_validation.setText(t("cal.mark_validation"))
|
||||
self.btn_toggle_excluded.setToolTip(t("cal.state_exclude_tip"))
|
||||
@@ -352,6 +379,11 @@ class CalPanel(QFrame):
|
||||
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
|
||||
radio.setEnabled(not self._recording)
|
||||
self.btn_export.setEnabled(not self._recording)
|
||||
# Saving the audit record is independent of the coefficients, and applying
|
||||
# is only meaningful once coefficients exist.
|
||||
self.btn_save_record.setEnabled(not self._recording and self._has_points())
|
||||
self.btn_open_record.setEnabled(not self._recording)
|
||||
self.btn_apply.setEnabled(bool(self._last_export_path) and not self._recording)
|
||||
if self._recording:
|
||||
self.progress.setVisible(True)
|
||||
record_hint = t("cal.record_disabled") if not self._running else ""
|
||||
@@ -491,12 +523,14 @@ class CalPanel(QFrame):
|
||||
if not fit.usable:
|
||||
self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points))
|
||||
return
|
||||
# The method and the breakpoint count describe the model that export and
|
||||
# the device use; the least-squares pair is only an equivalent summary.
|
||||
text = t(
|
||||
"cal.fit_summary",
|
||||
channel=self._channel.upper(),
|
||||
gain=f"{fit.gain:g}",
|
||||
offset=f"{fit.offset:g}",
|
||||
method=t(f"cal.method_{fit.method}"),
|
||||
count=fit.n_points,
|
||||
breakpoints=fit.breakpoints,
|
||||
residual=f"{fit.max_residual_n:.4f}",
|
||||
)
|
||||
if fit.validation_points:
|
||||
@@ -547,23 +581,29 @@ class CalPanel(QFrame):
|
||||
)
|
||||
|
||||
def _rebuild_store(self) -> None:
|
||||
"""Rebuild the export store from the points that are still in the fit."""
|
||||
"""Rebuild the export store from the points that are still in the fit.
|
||||
|
||||
Validation points are held out: they exist to check the model, so letting
|
||||
them into the build would both corrupt it and make the "independent"
|
||||
error meaningless. Repeated reference loads are merged rather than
|
||||
overwritten, so every sample that was actually taken reaches the model.
|
||||
"""
|
||||
self._store.clear()
|
||||
if self._recorder is None:
|
||||
return
|
||||
for point in self._recorder.all_points():
|
||||
if point.excluded:
|
||||
if point.excluded or point.is_validation:
|
||||
continue
|
||||
stats = point.loaded_stats()
|
||||
if not np.isfinite(stats.mean):
|
||||
continue
|
||||
self._store.upsert(
|
||||
self._store.aggregate(
|
||||
point.channel,
|
||||
CalSample(
|
||||
weight_n=float(point.reference_load_n),
|
||||
raw_mean=float(stats.mean),
|
||||
n=int(stats.n),
|
||||
std=float(stats.std),
|
||||
std=float(stats.std) if np.isfinite(stats.std) else 0.0,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -613,10 +653,74 @@ class CalPanel(QFrame):
|
||||
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
|
||||
return
|
||||
|
||||
self._last_export_path = str(primary)
|
||||
self._refresh_enabled()
|
||||
msg = t("cal.export_ok", path=str(primary))
|
||||
if warnings:
|
||||
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
|
||||
self.status_message.emit(msg)
|
||||
# Ask the window to confirm the target device before applying.
|
||||
# The window records which coefficients were exported. Applying them is a
|
||||
# separate button, so exporting cannot silently change a running device.
|
||||
self.calibration_exported.emit(str(primary))
|
||||
QMessageBox.information(self, t("panels.cal"), msg)
|
||||
|
||||
def _on_apply(self) -> None:
|
||||
if not self._last_export_path:
|
||||
self.status_message.emit(t("cal.apply_no_export"))
|
||||
return
|
||||
self.apply_requested.emit(self._last_export_path)
|
||||
|
||||
def _on_save_record(self) -> None:
|
||||
"""Write the session's audit record: every point and every sample."""
|
||||
if self._recorder is None:
|
||||
return
|
||||
points = self._recorder.all_points()
|
||||
if not points:
|
||||
self.status_message.emit(t("cal.save_record_empty"))
|
||||
return
|
||||
meta = meta_from_sensor_json(self._config_ref)
|
||||
meta["sensor_type"] = self._sensor_type
|
||||
meta["sn"] = self.sn_edit.text().strip()
|
||||
path = calibration_export_dir() / session_filename(self._sensor_type)
|
||||
try:
|
||||
written = write_calibration_session(path, points, meta=meta)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the operator
|
||||
QMessageBox.warning(
|
||||
self, t("panels.cal"), t("cal.save_record_failed", error=str(exc))
|
||||
)
|
||||
return
|
||||
samples = sum(len(point.samples) for point in points)
|
||||
msg = t("cal.save_record_ok", path=str(written), points=len(points), samples=samples)
|
||||
self.status_message.emit(msg)
|
||||
QMessageBox.information(self, t("panels.cal"), msg)
|
||||
|
||||
def _on_open_record(self) -> None:
|
||||
"""Reopen a saved session so it can be inspected again."""
|
||||
if self._recorder is None:
|
||||
return
|
||||
path, _filter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
t("cal.open_record"),
|
||||
str(calibration_export_dir()),
|
||||
t("cal.open_record_filter"),
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
record = read_calibration_session(path)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the operator
|
||||
QMessageBox.warning(
|
||||
self, t("panels.cal"), t("cal.open_record_failed", error=str(exc))
|
||||
)
|
||||
return
|
||||
self._recorder.load_points(record.points, replace=True)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
self._refresh_enabled()
|
||||
msg = t("cal.open_record_ok", path=record.path.name, points=len(record.points))
|
||||
if record.warnings:
|
||||
msg += "\n" + t("cal.open_record_warnings", detail="; ".join(record.warnings))
|
||||
self.status_message.emit(msg)
|
||||
|
||||
def _has_points(self) -> bool:
|
||||
return bool(self._recorder is not None and self._recorder.all_points())
|
||||
|
||||
@@ -463,18 +463,21 @@ class ForcePlotsPanel(QFrame):
|
||||
if self._store is None:
|
||||
return
|
||||
t0, t1 = self._window
|
||||
slice_ = self._store.visible(t0, t1, max_points=self._max_draw_points)
|
||||
slice_ = self._store.visible(
|
||||
t0, t1, max_points=self._max_draw_points, unit=self._unit
|
||||
)
|
||||
self._syncing = True
|
||||
try:
|
||||
if len(slice_) == 0:
|
||||
for curve in self.curves:
|
||||
curve.setData([], [])
|
||||
else:
|
||||
values = slice_.value_for_unit(self._unit)
|
||||
# One shared rule for every view: valid *and* finite. A stale
|
||||
# number left by a failed compute step must not reappear here
|
||||
# after another view already excluded it.
|
||||
times, values, _mask = slice_.plottable(self._unit)
|
||||
for index, curve in enumerate(self.curves):
|
||||
column = values[:, index]
|
||||
finite = np.isfinite(column)
|
||||
curve.setData(slice_.times[finite], column[finite])
|
||||
curve.setData(times, values[:, index])
|
||||
for plot in self.plots:
|
||||
plot.setXRange(t0, t1, padding=0.0)
|
||||
self._apply_axis_mode()
|
||||
@@ -584,7 +587,9 @@ class ForcePlotsPanel(QFrame):
|
||||
return
|
||||
# The overview is a full-store read, so only rebuild it when it can have
|
||||
# changed (window/unit/session) rather than on every pan.
|
||||
slice_ = self._store.visible(first, last, max_points=self._overview_points())
|
||||
slice_ = self._store.visible(
|
||||
first, last, max_points=self._overview_points(), unit=self._unit
|
||||
)
|
||||
values = slice_.value_for_unit(self._unit)
|
||||
column = values[:, 0]
|
||||
finite = np.isfinite(column)
|
||||
|
||||
@@ -29,7 +29,12 @@ from PyQt5.QtWidgets import (
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from orisys_app_shared.record import DepthRecomputer, RecordingReader, list_recordings
|
||||
from orisys_app_shared.record import (
|
||||
DepthRecomputer,
|
||||
DepthResult,
|
||||
RecordingReader,
|
||||
list_recordings,
|
||||
)
|
||||
from orisys_app_shared.record.playback import FORCE_COLUMNS
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
@@ -63,6 +68,13 @@ class PlaybackDialog(QDialog):
|
||||
self._depth_thread: threading.Thread | None = None
|
||||
self._depth_result = None
|
||||
self._depth_error: BaseException | None = None
|
||||
#: Which cursor position each depth answer belongs to. Recomputation is
|
||||
#: asynchronous, so an answer can arrive after the cursor has moved; the
|
||||
#: request it answers is remembered and compared, never assumed.
|
||||
self._depth_request_seq = 0
|
||||
self._depth_pending_seq: int | None = None
|
||||
self._depth_pending_position: float | None = None
|
||||
self._depth_result_seq: int | None = None
|
||||
self._depth_poll = QTimer(self)
|
||||
self._depth_poll.setInterval(120)
|
||||
self._depth_poll.timeout.connect(self._poll_depth)
|
||||
@@ -172,6 +184,8 @@ class PlaybackDialog(QDialog):
|
||||
self._render_curves()
|
||||
self._refresh_warnings()
|
||||
self._depth_ok = False
|
||||
self._depth_result = None
|
||||
self._depth_result_seq = None
|
||||
self.set_position(0.0)
|
||||
|
||||
def _refresh_warnings(self) -> None:
|
||||
@@ -197,11 +211,11 @@ class PlaybackDialog(QDialog):
|
||||
for curve in self.curves:
|
||||
curve.setData([], [])
|
||||
return
|
||||
values = info.value_for_unit(self._unit)
|
||||
# Same rule as the live charts: invalid or non-finite points are not
|
||||
# drawn, whichever view is asking.
|
||||
times, drawn, _mask = info.plottable(self._unit)
|
||||
for index, curve in enumerate(self.curves):
|
||||
column = values[:, index]
|
||||
finite = np.isfinite(column)
|
||||
curve.setData(info.times[finite], column[finite])
|
||||
curve.setData(times, drawn[:, index])
|
||||
self.plot_widget.ci.layout.setRowStretchFactor(index, 1)
|
||||
self.plot_widget.getItem(index, 0).setXRange(
|
||||
float(info.times[0]), float(info.times[-1]), padding=0.02
|
||||
@@ -238,7 +252,9 @@ class PlaybackDialog(QDialog):
|
||||
for line in self.cursor_lines:
|
||||
line.setValue(self._position)
|
||||
self._show_video_frame()
|
||||
if self._depth_ok:
|
||||
if self._depth_ok or self._depth_result is not None:
|
||||
# The held depth belongs to the frame it was computed for. Moving the
|
||||
# cursor must not keep showing it as if it were synchronised.
|
||||
self._show_depth_frame()
|
||||
self.cursor_moved.emit(self._position)
|
||||
|
||||
@@ -287,16 +303,26 @@ class PlaybackDialog(QDialog):
|
||||
return
|
||||
self._depth_ok = False
|
||||
self._depth_result = None
|
||||
self._depth_result_seq = None
|
||||
self._depth_error = None
|
||||
self.depth_label.setText(t("playback.depth_running"))
|
||||
position = self._position
|
||||
# Bind the answer to this request: a result that arrives after the cursor
|
||||
# moved is discarded rather than pasted onto the new position.
|
||||
self._depth_request_seq += 1
|
||||
seq = self._depth_request_seq
|
||||
self._depth_pending_seq = seq
|
||||
self._depth_pending_position = position
|
||||
|
||||
def work() -> None:
|
||||
# Runs off the UI thread and touches no Qt object.
|
||||
try:
|
||||
self._depth_result = self._recomputer.depth_at(position)
|
||||
result = self._recomputer.depth_at(position)
|
||||
except BaseException as exc: # noqa: BLE001 - reported via the poll
|
||||
self._depth_error = exc
|
||||
return
|
||||
self._depth_result = result
|
||||
self._depth_result_seq = seq
|
||||
|
||||
self._depth_thread = threading.Thread(
|
||||
target=work, name="depth-replay", daemon=True
|
||||
@@ -310,19 +336,62 @@ class PlaybackDialog(QDialog):
|
||||
if thread is None or thread.is_alive():
|
||||
return
|
||||
self._depth_poll.stop()
|
||||
pending = self._depth_pending_seq
|
||||
self._depth_pending_seq = None
|
||||
self._depth_pending_position = None
|
||||
if self._depth_error is not None:
|
||||
self._depth_ok = False
|
||||
self.depth_label.setPixmap(QPixmap())
|
||||
self.depth_label.setText(
|
||||
t("playback.depth_failed", detail=str(self._depth_error))
|
||||
)
|
||||
self._depth_error = None
|
||||
return
|
||||
result = self._depth_result
|
||||
if result is None:
|
||||
if result is None or self._depth_result_seq != pending:
|
||||
# A newer request (or a moved cursor) owns the pane now.
|
||||
self._show_depth_frame()
|
||||
return
|
||||
if not self._depth_matches_cursor(result):
|
||||
# The cursor moved while the SDK was reading: do not draw an answer
|
||||
# for another frame, and say the pane needs recomputing.
|
||||
self._show_depth_frame()
|
||||
return
|
||||
self._render_depth_result(result)
|
||||
|
||||
def _current_frame(self):
|
||||
"""The recorded frame under the cursor, or None without a frame index."""
|
||||
frame_at = getattr(self._reader, "frame_at", None)
|
||||
if not callable(frame_at):
|
||||
return None
|
||||
try:
|
||||
return frame_at(self._position)
|
||||
except Exception: # noqa: BLE001 - a stub reader has no frame index
|
||||
return None
|
||||
|
||||
def _depth_matches_cursor(self, result) -> bool:
|
||||
"""True when ``result`` was computed for the frame under the cursor."""
|
||||
frame = self._current_frame()
|
||||
frame_id = getattr(result, "frame_id", None)
|
||||
if frame is not None and frame_id is not None and int(frame_id) >= 0:
|
||||
return int(frame_id) == int(frame.frame_id)
|
||||
t_rel = getattr(result, "t_rel_s", None)
|
||||
if t_rel is None:
|
||||
# Nothing in the result says which frame it belongs to, so it cannot
|
||||
# be shown to be stale either.
|
||||
return True
|
||||
if frame is not None:
|
||||
return abs(float(t_rel) - float(frame.t_rel_s)) < 1e-6
|
||||
return abs(float(t_rel) - self._position) < 1e-9
|
||||
|
||||
def _render_depth_result(self, result) -> None:
|
||||
code = str(getattr(result, "error", "") or "")
|
||||
if code in ("not_recomputed", "recomputing", "stale"):
|
||||
# A placeholder, not a failure: the pane describes the cursor.
|
||||
self._depth_ok = False
|
||||
self.depth_label.setPixmap(QPixmap())
|
||||
self.depth_label.setText(t(f"playback.depth_{code}"))
|
||||
return
|
||||
if not result.ok:
|
||||
self._depth_ok = False
|
||||
self.depth_label.setPixmap(QPixmap())
|
||||
@@ -337,6 +406,7 @@ class PlaybackDialog(QDialog):
|
||||
self.depth_label.setText(t("playback.depth_empty"))
|
||||
return
|
||||
self._depth_ok = True
|
||||
self.depth_label.setText(self._depth_warning_text(result))
|
||||
rgb = bgr[:, :, ::-1].copy()
|
||||
height, width, channels = rgb.shape
|
||||
qimage = QImage(
|
||||
@@ -346,12 +416,40 @@ class PlaybackDialog(QDialog):
|
||||
self.depth_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
|
||||
)
|
||||
self.depth_label.setPixmap(pixmap)
|
||||
self.depth_label.setText("")
|
||||
|
||||
@staticmethod
|
||||
def _depth_warning_text(result) -> str:
|
||||
"""Say when a replay could not reproduce the live algorithm state."""
|
||||
codes = tuple(getattr(result, "warnings", ()) or ())
|
||||
if not codes:
|
||||
return ""
|
||||
return t(
|
||||
"playback.depth_warnings",
|
||||
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
|
||||
)
|
||||
|
||||
def _show_depth_frame(self) -> None:
|
||||
"""Re-render the last result at the new position (no SDK work)."""
|
||||
if self._depth_result is not None:
|
||||
self._render_depth_result(self._depth_result)
|
||||
"""Draw the depth for the cursor's frame, or say it must be recomputed.
|
||||
|
||||
Re-drawing the held map here is what used to show a stale depth as though
|
||||
it were in sync with the cursor.
|
||||
"""
|
||||
result = self._depth_result
|
||||
if result is not None and self._depth_matches_cursor(result):
|
||||
self._render_depth_result(result)
|
||||
return
|
||||
if (
|
||||
self._depth_pending_seq is not None
|
||||
and self._depth_pending_position == self._position
|
||||
and self._depth_busy()
|
||||
):
|
||||
self._render_depth_result(
|
||||
DepthResult(error="recomputing", t_rel_s=self._position)
|
||||
)
|
||||
return
|
||||
self._render_depth_result(
|
||||
DepthResult(error="not_recomputed", t_rel_s=self._position)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------- labels
|
||||
def retranslate(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user