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:
@@ -146,12 +146,22 @@
|
||||
"depth_config_missing": "Cannot recompute: the recording has no sensor config reference",
|
||||
"depth_config_file_missing": "Cannot recompute: the config file no longer exists at that path",
|
||||
"depth_no_video": "Cannot recompute: this recording has no video segment",
|
||||
"depth_not_recomputed": "Depth has not been recomputed for this cursor position\n(the previous frame's map is not reused)",
|
||||
"depth_recomputing": "Recomputing depth for the current cursor position…",
|
||||
"depth_frame_not_recorded": "This frame was not recorded: its data row was lost to a full queue and only the index row remains",
|
||||
"depth_frame_not_in_video": "This frame never reached the video, so its depth cannot be recomputed",
|
||||
"depth_state_across_segment": "Replay cannot carry the algorithm state across segments: this one restarts at its first frame",
|
||||
"depth_state_approximate": "No reset or pre-roll history for this segment: the algorithm state is an approximation",
|
||||
"depth_warnings": "Recomputation notes: {detail}",
|
||||
"warnings": "Replay notes: {detail}",
|
||||
"warn_config_changed": "the config differs from recording time, so the recomputed depth may differ",
|
||||
"warn_sdk_changed": "the SDK version differs from recording time, so the recomputation may differ",
|
||||
"warn_config_missing": "no config hash was recorded, so recomputation cannot be verified",
|
||||
"warn_depth_recomputed": "depth comes from offline recomputation, not from stored data",
|
||||
"warn_rows_skipped": "some unparsable CSV rows were skipped"
|
||||
"warn_rows_skipped": "some unparsable CSV rows were skipped",
|
||||
"warn_frames_dropped": "frames were lost to a full queue; only their index rows remain (shown as \"not recorded\")",
|
||||
"warn_state_across_segment": "the algorithm state cannot be carried across a segment",
|
||||
"warn_state_approximate": "the algorithm state is an approximation"
|
||||
},
|
||||
"record": {
|
||||
"label": "Record",
|
||||
@@ -180,7 +190,23 @@
|
||||
"delete_row": "Delete row",
|
||||
"clear_channel": "Clear channel",
|
||||
"clear_all": "Clear all",
|
||||
"export": "Export…", "sn": "SN",
|
||||
"export": "Export coefficients…",
|
||||
"export_tip": "Write the SDK coefficient file only; the running device is untouched",
|
||||
"apply": "Apply to device",
|
||||
"apply_tip": "Push the last export to the running device (confirmed again)",
|
||||
"apply_no_export": "No exported coefficients yet — export them first",
|
||||
"save_record": "Save record…",
|
||||
"save_record_tip": "Save every point of this session with its full three-axis samples (audit data), independently of the coefficient file",
|
||||
"save_record_empty": "No calibration points to save yet",
|
||||
"save_record_ok": "Saved the calibration record to {path}\n{points} point(s), {samples} three-axis samples",
|
||||
"save_record_failed": "Could not save the calibration record: {error}",
|
||||
"open_record": "Open record…",
|
||||
"open_record_tip": "Reopen a saved calibration session to inspect every point's samples and diagnostics",
|
||||
"open_record_filter": "Calibration records (*.json)",
|
||||
"open_record_ok": "Loaded {path} ({points} point(s))",
|
||||
"open_record_warnings": "Note: {detail}",
|
||||
"open_record_failed": "Could not open the calibration record: {error}",
|
||||
"sn": "SN",
|
||||
"sn_placeholder": "Serial number (optional)",
|
||||
"live_raw_label": "Live raw",
|
||||
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
|
||||
@@ -206,8 +232,10 @@
|
||||
"marked_validation": "Point marked as a validation point",
|
||||
"unmarked_validation": "Validation mark removed",
|
||||
"fit_insufficient": "A fit needs at least 2 usable points (currently {count})",
|
||||
"fit_summary": "Fit {channel}: N = {gain} × raw + {offset} ({count} points, max residual {residual} N)",
|
||||
"fit_summary": "Exported model {channel}: {method} ({count} points / {breakpoints} breakpoints, max residual {residual} N)",
|
||||
"fit_validation": "Independent validation on {count} point(s), max deviation {error} N",
|
||||
"method_piecewise_linear": "piecewise linear",
|
||||
"method_linear": "linear",
|
||||
"point_title": "Calibration point {channel} @ {load} N",
|
||||
"point_channel": "Loaded axis",
|
||||
"point_direction": "Direction",
|
||||
@@ -230,7 +258,9 @@
|
||||
"apply_title": "Apply calibration",
|
||||
"apply_confirm": "Apply the calibration just exported to the current device?\n\nSensor type: {sensor_type}\nConfig: {config}\n\nChoosing No keeps the exported file and leaves the running device untouched.",
|
||||
"apply_skipped": "Exported; not applied to the device",
|
||||
"apply_done": "Calibration applied to {sensor_type}",
|
||||
"apply_pending": "Requested calibration for {sensor_type}; waiting for the device to confirm…",
|
||||
"applied": "Device confirmed the calibration is in effect (version {version})",
|
||||
"export_only": "Exported coefficients to {path} (not applied to the device)",
|
||||
"apply_no_target": "No device to apply to (run an acquisition first)"
|
||||
},
|
||||
"status": {
|
||||
|
||||
@@ -146,12 +146,22 @@
|
||||
"depth_config_missing": "无法重算:录制未记录传感器配置",
|
||||
"depth_config_file_missing": "无法重算:配置文件的原始路径已不存在",
|
||||
"depth_no_video": "无法重算:该录制没有视频分段",
|
||||
"depth_not_recomputed": "当前游标位置尚未重算深度\n(不会沿用其他帧的结果)",
|
||||
"depth_recomputing": "正在为当前游标位置重算深度…",
|
||||
"depth_frame_not_recorded": "该帧未录制:数据行在队列溢出时丢失,只有索引记录",
|
||||
"depth_frame_not_in_video": "该帧未写入视频,无法重算深度",
|
||||
"depth_state_across_segment": "重算无法跨分段延续算法状态:本段从段首重放",
|
||||
"depth_state_approximate": "本段缺少归零或预卷历史,算法参考状态为近似重建",
|
||||
"depth_warnings": "重算提示: {detail}",
|
||||
"warnings": "回放提示: {detail}",
|
||||
"warn_config_changed": "配置文件与录制时不一致,重算出的深度可能与当时不同",
|
||||
"warn_sdk_changed": "SDK 版本与录制时不一致,重算结果可能不同",
|
||||
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
|
||||
"warn_depth_recomputed": "深度由离线重算得到,不是当时保存的数据",
|
||||
"warn_rows_skipped": "有无法解析的 CSV 行被跳过"
|
||||
"warn_rows_skipped": "有无法解析的 CSV 行被跳过",
|
||||
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
|
||||
"warn_state_across_segment": "重算不能跨分段延续算法状态",
|
||||
"warn_state_approximate": "算法参考状态为近似重建"
|
||||
},
|
||||
"record": {
|
||||
"label": "记录",
|
||||
@@ -180,7 +190,22 @@
|
||||
"delete_row": "删除行",
|
||||
"clear_channel": "清空通道",
|
||||
"clear_all": "全部清空",
|
||||
"export": "导出…",
|
||||
"export": "导出系数…",
|
||||
"export_tip": "只写出 SDK 标定系数文件,不改变运行中的设备",
|
||||
"apply": "应用到设备",
|
||||
"apply_tip": "把最近导出的系数下发到运行中的设备(需再次确认)",
|
||||
"apply_no_export": "还没有导出的系数文件,请先「导出系数」",
|
||||
"save_record": "保存标定记录…",
|
||||
"save_record_tip": "保存本次标定会话的全部点与三轴完整采样(审计数据),与系数文件相互独立",
|
||||
"save_record_empty": "还没有标定点,无法保存标定记录",
|
||||
"save_record_ok": "已保存标定记录 {path}\n{points} 个点,共 {samples} 条三轴采样",
|
||||
"save_record_failed": "保存标定记录失败: {error}",
|
||||
"open_record": "打开标定记录…",
|
||||
"open_record_tip": "重新打开已保存的标定会话,检查每个点的完整采样与诊断状态",
|
||||
"open_record_filter": "标定记录 (*.json)",
|
||||
"open_record_ok": "已载入 {path}({points} 个点)",
|
||||
"open_record_warnings": "注意: {detail}",
|
||||
"open_record_failed": "打开标定记录失败: {error}",
|
||||
"sn": "序列号",
|
||||
"sn_placeholder": "序列号(可选)",
|
||||
"live_raw_label": "实时原始值",
|
||||
@@ -207,8 +232,10 @@
|
||||
"marked_validation": "该点已标记为验证点",
|
||||
"unmarked_validation": "该点已取消验证点标记",
|
||||
"fit_insufficient": "拟合需要至少 2 个有效点(当前 {count} 个)",
|
||||
"fit_summary": "拟合 {channel}: N = {gain} × raw + {offset}({count} 点,最大残差 {residual} N)",
|
||||
"fit_summary": "导出模型 {channel}: {method}({count} 点 / {breakpoints} 个折点,最大残差 {residual} N)",
|
||||
"fit_validation": "独立验证 {count} 点,最大偏差 {error} N",
|
||||
"method_piecewise_linear": "分段线性",
|
||||
"method_linear": "线性",
|
||||
"point_title": "标定点 {channel} @ {load} N",
|
||||
"point_channel": "加载轴",
|
||||
"point_direction": "方向",
|
||||
@@ -231,7 +258,9 @@
|
||||
"apply_title": "应用标定",
|
||||
"apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。",
|
||||
"apply_skipped": "已导出,未应用到设备",
|
||||
"apply_done": "标定已应用到 {sensor_type}",
|
||||
"apply_pending": "已请求把标定应用到 {sensor_type},等待设备确认…",
|
||||
"applied": "设备已确认标定生效(版本 {version})",
|
||||
"export_only": "已导出系数 {path}(未应用到设备)",
|
||||
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
|
||||
},
|
||||
"status": {
|
||||
|
||||
@@ -166,6 +166,83 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
||||
|
||||
---
|
||||
|
||||
## 7. 第一、二阶段评审 P1/P2 的整改(数据语义、终态、标定身份、深度重算)
|
||||
|
||||
评审脚本 `D:/orisys_ws/_review_phase12/reproduce.py` 的十条复现条件已全部翻转,改后
|
||||
输出见同目录 `reproduce_after.log`。以下按发现逐条说明改法。
|
||||
|
||||
### 7.1 记录语义(P1-1)
|
||||
|
||||
唯一转换入口 `shared/.../record/sample.py::to_record_sample`。规则:
|
||||
|
||||
1. 计算链失败(`valid=False` 或 `stale_forces=True`)的帧,六个力值**全部**记为不可用。
|
||||
SDK 的 `info.FNORMAL` 在计算失败时仍返回上一帧的数值,记录它等于伪造测量值。
|
||||
2. 只有**该轴确实加载了标定曲线**才写 N 值(`calibrated_axes` 按轴判断,而不是
|
||||
`force_calibrated` 这个传感器级标志)。未标定的轴留空。
|
||||
3. `mono_t` 是排序基准,墙钟只进元数据。`SdkPacket.mono_t/wall_t` 默认 `None` 而不是
|
||||
`0.0`:默认 0 会让所有帧的时间相同。
|
||||
4. 每行 `calibration_id` 取 **packet 自带版本**(采集线程在采集瞬间盖的哈希),
|
||||
会话级版本只作回退;一份 CSV 中途换标定也说得清。
|
||||
|
||||
### 7.2 记录终态与队列损失(P1-5、P1-6)
|
||||
|
||||
- 生命周期显式化为 `starting/recording/stopping/stopped/failed`;**写线程拥有终态与最终
|
||||
报告**,文件打开、写入、flush、close 全在异常边界内。连续写失败会立刻停止接收
|
||||
(`rejected_after_failure` 计数),`is_recording` 随之变假 —— 不会出现 failed=True 却
|
||||
仍在录制。`starting` 也算“录制中”,否则开始录制后立刻点停止会失效。
|
||||
- `stop(wait=True, timeout=…)` 超时返回 `completed=False` 的报告,**不写 `ended_at`**、
|
||||
不丢弃线程引用;未结束的 recorder 不允许再次 `start()`。
|
||||
- 队列按**字节预算**(`max_queue_bytes`,默认 512 MiB)而不是仅按条数:1920×1080 BGR
|
||||
排 2048 条副本约 11.9 GiB。预算用尽时**丢视频保数据行**;连条目队列都满时把数据行放进
|
||||
独立的 `_row_backlog`(默认 8192 行),因此视频溢出不再连带丢掉 CSV。
|
||||
- 真的丢掉的数据行会在 `frames.csv` 留下 `dropped=1` 的索引行,回放据此显示“该帧未录制”,
|
||||
而不是拿上一帧冒充。统计区分 `dropped_data`、`dropped_video`、`dropped_gaps`
|
||||
(已标记的缺口)与 `unrecorded_gaps`(连标记都放不下的)。
|
||||
|
||||
### 7.3 标定身份与生效确认(P1-7)
|
||||
|
||||
- 标定身份是**内容哈希**(`calibration_digest`,sha256),不是路径:同一路径可以重新导出
|
||||
不同系数。`Sensor` 打开时自带 bundle 标定,因此采集线程在**第一帧之前**就把实际加载的
|
||||
标定哈希报出来(`on_calibration_applied(path, version, frame_id)`),事件与每行版本都
|
||||
来自这次确认,而不是“已下发”的意图。
|
||||
- 事件在**发生时**打时间戳(`log_event(..., mono_t=…, frame_id=…)`),不再在写线程出队时
|
||||
重新计时。
|
||||
- 面板拆成三个独立动作:**保存标定记录 / 导出系数 / 应用到设备**。导出只写文件,
|
||||
应用需要再次确认并等设备确认后才记 `calibration_applied`。
|
||||
|
||||
### 7.4 标定诊断与实验明细(P1-2、P1-8)
|
||||
|
||||
- `CalibrationRecorder.fit` 用**将要导出的那个模型**(`build_export_model` + SDK 的
|
||||
`Calibration.convert`)计算残差与验证误差。分段线性模型会精确穿过它自己的标定点,
|
||||
所以训练残差恒为 0 —— 真正有意义的诊断是**留出验证点**的偏差。界面按“导出模型:分段线性
|
||||
(N 点 / M 个折点,最大残差)…”显示。
|
||||
- 验证点**不进入**构建(`CalPanel._rebuild_store` 跳过 `is_validation`),重复参考载荷按
|
||||
n 加权合并(`merge_samples`),不再“最后一个覆盖前面的”。
|
||||
- `CalibrationPoint.samples` 保存该点**全部**三轴采样(帧号、相对时间、三轴 raw、三轴 N、
|
||||
valid),连同排除/验证状态、方向、备注与实际时长一起写入独立的标定会话文件
|
||||
(`calibration_session_*.json`,`force/calibration_session.py`),并可用「打开标定记录」
|
||||
重新载入检查。界面上的降采样 trace 只是预览,不是审计数据。
|
||||
|
||||
### 7.5 深度重算与游标同步(P1-3、P1-4)
|
||||
|
||||
- **不再重开回放位置**:`DepthRecomputer` 记录当前已读到的段内帧号,请求第 2 帧再请求第 3
|
||||
帧只多读 1 帧(旧实现每次都从头读,2 和 3 会落到 5);往回退才重开视频。
|
||||
- 每帧都执行**实时同一条链路**:盒子 `compute_deformation + compute_contact`,手指再加上
|
||||
`FingerRuntime` 的展开与有效区域(`_BoxReplay` / `_FingerReplay`)。
|
||||
- `config_name` 用**完整解析后的配置路径**(`load_fixed_mode`),不再缩减成父目录名或
|
||||
stem —— 那可能加载同名但不同的配置,而哈希校验还在比对原路径。
|
||||
- **算法参考状态**:SDK 的跟踪器/滤波是有状态的,离线复现需要历史。两条可用依据:
|
||||
1. 操作归零(`zeroing` 事件,带帧号)会清空跟踪器,因此从该帧重放是**精确**的;
|
||||
2. 录制开始前采集线程保留 `PREROLL_FRAMES`(30 帧,且不超过 192 MiB)的预处理帧,
|
||||
录制开始时写入 `preroll_0001.mp4`,`session.json` 记 `preroll_frames` /
|
||||
`preroll_segment`,回放先把它们喂给 SDK 再进入首帧。
|
||||
都不满足时(跨分段、无归零、无预卷)仍然重算,但结果带
|
||||
`state_approximate` / `state_across_segment` 提示,**不假装与当时一致**。
|
||||
- 回放游标与深度结果按**请求序号 + 帧号**绑定:等待期间移动游标会作废旧请求,结果回来
|
||||
只渲染属于当前帧的那一张;否则面板显示「当前游标位置尚未重算深度」,绝不复用旧图。
|
||||
|
||||
---
|
||||
|
||||
## 7. 同步回放与深度重算
|
||||
|
||||
**决定:不保存深度。** 回放时按录像与配置**离线重算**。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
|
||||
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -309,6 +309,13 @@ def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40
|
||||
|
||||
|
||||
def test_calibration_diagnostics_drive_the_fit(make_window):
|
||||
"""The diagnostics must describe the model that would be exported.
|
||||
|
||||
The export builds a piecewise-linear curve, which passes through every point
|
||||
it is built from — so the training residual is zero by construction and an
|
||||
off-trend point can only show up as a *validation* error. A private
|
||||
least-squares residual would have described a model nobody applies.
|
||||
"""
|
||||
window = _show(make_window(), (1400, 880))
|
||||
window.cal_panel.set_running(True)
|
||||
_add_calibration_point(window, load=1.0, raw=100.2)
|
||||
@@ -319,23 +326,36 @@ def test_calibration_diagnostics_drive_the_fit(make_window):
|
||||
recorder = window.cal_recorder
|
||||
fit = recorder.fit("fn")
|
||||
assert fit.usable and fit.n_points == 4
|
||||
worst = fit.max_residual_n
|
||||
assert worst > 0.05, "the off-trend point produced no residual"
|
||||
assert fit.method == "piecewise_linear", fit.method
|
||||
assert fit.breakpoints == 4, fit.breakpoints
|
||||
assert fit.max_residual_n <= 1e-9, "the exported model misses its own points"
|
||||
assert window.cal_panel.fit_label.text()
|
||||
|
||||
# Hold the off-trend point (load 2.0 at raw 400) out: the exported model is
|
||||
# then built without it and the misfit is measurable.
|
||||
window.cal_panel.table.setCurrentCell(1, 0)
|
||||
window.cal_panel._on_toggle_validation()
|
||||
held_out = recorder.fit("fn")
|
||||
assert held_out.validation_points == 1
|
||||
assert held_out.validation_error_n > 0.05, held_out.validation_error_n
|
||||
assert recorder.points[1].is_validation
|
||||
assert np.isnan(recorder.points[1].residual_n), "validation point entered the fit"
|
||||
|
||||
window.cal_panel._on_toggle_validation()
|
||||
window.cal_panel._on_toggle_excluded()
|
||||
assert recorder.fit("fn").max_residual_n < worst, "exclusion did not tighten the fit"
|
||||
assert recorder.points[1].excluded
|
||||
# An excluded point leaves the export store but stays on record.
|
||||
assert len(window.cal_panel._store.channel("fn")) == 3
|
||||
|
||||
window.cal_panel.table.setCurrentCell(2, 0)
|
||||
window.cal_panel._on_toggle_validation()
|
||||
held_out = recorder.fit("fn")
|
||||
assert held_out.validation_points == 1
|
||||
assert recorder.points[2].is_validation
|
||||
assert np.isnan(recorder.points[2].residual_n), "validation point entered the fit"
|
||||
# The exported model no longer passes through the excluded point.
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
build_export_model,
|
||||
predict_channel,
|
||||
)
|
||||
|
||||
store = window.cal_panel._store
|
||||
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
|
||||
assert abs(predict_channel(model, "fn", 400.0) - 2.0) > 0.05
|
||||
|
||||
|
||||
def test_calibration_point_dialog_shows_three_axes(make_window):
|
||||
|
||||
@@ -608,9 +608,12 @@ def main() -> int:
|
||||
"the fit summary is empty",
|
||||
)
|
||||
|
||||
# Three more points, one of them off-trend, so the fit has a residual
|
||||
# that excluding the outlier must reduce and enough left over to hold
|
||||
# one out as a validation point.
|
||||
# Four points, one of them off-trend (raw 400 where the others imply
|
||||
# ~200). The exported model is piecewise-linear, so it passes through
|
||||
# every point it is built from: the training residual is zero by
|
||||
# construction and the off-trend point can only show up as a
|
||||
# *validation* error. The summary must describe that model, not a
|
||||
# private least-squares line.
|
||||
for load, raw in ((2.0, 400.0), (3.0, 300.6), (5.0, 500.9)):
|
||||
window.cal_recorder.start(
|
||||
channel="fn", reference_load_n=load, duration_s=1.0
|
||||
@@ -621,22 +624,24 @@ def main() -> int:
|
||||
app.processEvents()
|
||||
check(window.cal_panel.table.rowCount() == 4, "calibration rows missing")
|
||||
|
||||
worst = window.cal_recorder.fit("fn").max_residual_n
|
||||
check(worst > 0.05, f"the off-trend point produced no residual: {worst}")
|
||||
window.cal_panel.table.setCurrentCell(1, 0)
|
||||
window.cal_panel._on_toggle_excluded()
|
||||
app.processEvents()
|
||||
fit = window.cal_recorder.fit("fn")
|
||||
check(
|
||||
window.cal_recorder.fit("fn").max_residual_n < worst,
|
||||
"excluding an outlier did not tighten the fit",
|
||||
fit.method == "piecewise_linear",
|
||||
f"the summary is not about the exported model: {fit.method!r}",
|
||||
)
|
||||
check(
|
||||
window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"),
|
||||
"the excluded row does not show its state",
|
||||
fit.breakpoints == 4,
|
||||
f"the exported model has {fit.breakpoints} breakpoints, not 4",
|
||||
)
|
||||
check(
|
||||
fit.max_residual_n <= 1e-9,
|
||||
"the exported model does not reproduce its own calibration points: "
|
||||
f"max residual {fit.max_residual_n}",
|
||||
)
|
||||
|
||||
# A validation point is held out of the fit and reported separately.
|
||||
window.cal_panel.table.setCurrentCell(2, 0)
|
||||
# Hold the off-trend point out: the exported model is then built
|
||||
# without it and the misfit becomes visible as a validation error.
|
||||
window.cal_panel.table.setCurrentCell(1, 0)
|
||||
window.cal_panel._on_toggle_validation()
|
||||
app.processEvents()
|
||||
check(
|
||||
@@ -644,6 +649,35 @@ def main() -> int:
|
||||
in window.cal_panel.fit_label.text(),
|
||||
"no validation summary after marking a validation point",
|
||||
)
|
||||
validation = window.cal_recorder.fit("fn").validation_error_n
|
||||
check(
|
||||
validation > 0.05,
|
||||
f"the off-trend point produced no validation error: {validation}",
|
||||
)
|
||||
window.cal_panel._on_toggle_validation()
|
||||
|
||||
# Excluding a point removes it from the build, so the exported model
|
||||
# no longer passes through it.
|
||||
window.cal_panel._on_toggle_excluded()
|
||||
app.processEvents()
|
||||
check(
|
||||
window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"),
|
||||
"the excluded row does not show its state",
|
||||
)
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
build_export_model,
|
||||
predict_channel,
|
||||
)
|
||||
|
||||
store = window.cal_panel._store
|
||||
model = build_export_model(
|
||||
{key: store.channel(key) for key in ("fn", "fx", "fy")}
|
||||
)
|
||||
predicted = predict_channel(model, "fn", 400.0)
|
||||
check(
|
||||
abs(predicted - 2.0) > 0.05,
|
||||
f"excluding a point left the exported model unchanged: {predicted}",
|
||||
)
|
||||
window.cal_recorder.reset_session()
|
||||
window.cal_panel._refresh_table()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user