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:
ykcheungab
2026-09-13 14:55:53 +08:00
parent 9354d7be1a
commit 22cc59c402
26 changed files with 3387 additions and 290 deletions
+111 -28
View File
@@ -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()
+114 -10
View File
@@ -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: