From 34a1d9b1f189bcc9f7405cbd9a3481a27a5d8051 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 11 Oct 2026 18:08:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Autocal=20=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E5=8F=8D=E9=A6=88=E4=B8=8E=E7=A8=B3=E5=AE=9A=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=A4=8D=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 显示实时力值、曲线及 UV 图层,并保存人工确认窗口和零载检查基准。定向测试、真实视频预览与已录会话复核闭环验证通过。 --- apps/autocal/config/locales/en_US.json | 33 +++- apps/autocal/config/locales/zh_CN.json | 33 +++- .../docs/calibration-data-format.zh.md | 9 ++ .../src/orisys_autocal/session_review.py | 149 +++++++++++++++--- .../orisys_autocal/ui/calibration_panel.py | 32 +++- .../orisys_autocal/ui/finger_source_panel.py | 77 ++++++++- .../src/orisys_autocal/ui/live_force_view.py | 112 +++++++++++++ .../ui/session_review_dialog.py | 131 +++++++++++++-- .../tests/test_calibration_feedback.py | 85 ++++++++++ apps/autocal/tests/test_session_review.py | 88 +++++++++++ shared/src/orisys_app_shared/force/history.py | 8 +- shared/tests/test_force_history.py | 13 ++ 12 files changed, 731 insertions(+), 39 deletions(-) create mode 100644 apps/autocal/src/orisys_autocal/ui/live_force_view.py create mode 100644 apps/autocal/tests/test_calibration_feedback.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index 74aa175..2c321c0 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -471,10 +471,41 @@ "yes": "Yes", "no": "No" }, + "feedback": { + "waiting": "Waiting for force samples", + "uncalibrated": "N unavailable", + "stopped": "Input stopped; showing the last frame, values are no longer live.", + "stale": "No new frame yet or no update for over 1 s; check the input.", + "invalid": "Current force sample is invalid: {reason}", + "raw_note": "Live raw force signals (last 60 s); raw is not N. Enter the manual reference force separately.", + "overflow": "Preview omitted {count} frames; check acquisition drop status", + "raw_axis": "Raw force signal (raw)", + "time_axis": "Acquisition time (s)", + "background": "Real background", + "background_tip": "Checked: real UV image. Unchecked: solid background. Depth colours can overlay either background.", + "arrows": "Displacement arrows", + "depth": "Depth", + "depth_tip": "False colour of algorithm depth/deformation, not calibrated millimetres.", + "depth_scale": "Deformation colour range 0–1 (algorithm units); values outside saturate", + "depth_missing": "No valid depth/deformation for this frame", + "flow_missing": "No valid displacement for this frame" + }, "review": { + "load_selection": "Load saved review…", + "confirm_stable": "Reviewed as stable", + "apply_window": "Apply window and recalculate", + "start_offset": "Start", + "end_offset": "End", + "verified": "Stability confirmed by operator", + "confirmed_fit": "Fit uses operator-confirmed windows; independent validation is still required.", + "candidate_fit": "Fit uses candidate windows; insufficient confirmed data.", + "valid_samples": "Valid raw samples", + "hold_time_axis": "Time since hold start (s)", + "trace_title": "Selected hold signal; vertical lines mark the analysis window", + "selection_unsaved": "Window applied; save the analysis to preserve this review.", "open": "Review calibration data…", "title": "Exploratory calibration review", - "notice": "Inspection only: manual hold windows have not been verified stable. Replay does not establish original experiment timing. Fits are exploratory and never written to sensor configuration.", + "notice": "Inspect each raw hold signal, adjust the times relative to hold start, confirm stability and apply. Operator review does not prove reference accuracy or synchronisation. Replay is for debugging; fits are exploratory.", "save": "Save new review", "no_sites": "No sites in this session", "candidate": "Candidate window", diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 3b93185..4dfe567 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -471,10 +471,41 @@ "yes": "是", "no": "否" }, + "feedback": { + "waiting": "等待力信号", + "uncalibrated": "无 N 值", + "stopped": "输入已停止;显示最后一帧,数值不再更新。", + "stale": "尚无新帧或超过 1 秒未更新,请检查输入。", + "invalid": "当前力信号无效:{reason}", + "raw_note": "实时原始力信号(最近 60 秒);raw 不能直接当作 N。人工参考力由操作者另行填写。", + "overflow": "预览漏显 {count} 帧;请检查采集记录的丢帧状态", + "raw_axis": "原始力信号 (raw)", + "time_axis": "采集时间 (s)", + "background": "真实背景", + "background_tip": "勾选显示真实 UV 图像;取消显示纯色背景。深度颜色可叠加在两种背景上。", + "arrows": "位移箭头", + "depth": "深度", + "depth_tip": "显示算法深度/形变信号的伪彩色,不表示已标定的毫米深度。", + "depth_scale": "形变伪彩色范围 0–1(算法单位);超范围颜色饱和", + "depth_missing": "本帧无有效深度/形变信号", + "flow_missing": "本帧无有效位移信号" + }, "review": { + "load_selection": "载入已保存复核…", + "confirm_stable": "已检查此段稳定", + "apply_window": "应用窗口并重算", + "start_offset": "起点", + "end_offset": "终点", + "verified": "人工已确认稳定", + "confirmed_fit": "拟合使用人工确认窗口;仍需独立验证。", + "candidate_fit": "拟合使用候选窗口;人工确认数据尚不足。", + "valid_samples": "有效原始样本", + "hold_time_axis": "距保持开始 (s)", + "trace_title": "选中保持段原始信号;竖线为分析窗口边界", + "selection_unsaved": "窗口已应用;请另存分析结果以保留复核。", "open": "查看标定数据…", "title": "标定数据初步分析", - "notice": "仅供检查:人工参考保持窗口尚未复核稳定性。视频重放不能证明原实验的时间同步;以下拟合不是正式标定系数,不会写入传感器配置。", + "notice": "逐段查看原始信号,调整起止时间并确认稳定后应用。起止时间相对本段保持开始;人工复核不证明参考力准确或同步。视频重放仅供调试;拟合不是正式标定系数。", "save": "另存分析结果", "no_sites": "会话没有测点", "candidate": "候选窗口", diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index b505a09..8221a7c 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -80,6 +80,7 @@ finger_F001_<时间>_<标识>/ | `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 | | `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 | | `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 | +| `derived/review_<时间>_<标识>/baseline_.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable | | `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 | | 参数/字段 | 定义 | @@ -108,4 +109,12 @@ finger_F001_<时间>_<标识>/ 点击“查看标定数据…”选择会话根目录;在独立窗口切换测点,检查每档有效样本、均值、标准差和质量说明。初步拟合仅使用初始零载及加载分支的窗口均值,各窗口等权;卸载点保留用于对照,不混入加载拟合。缺样本、档位或信号变化时不生成系数。UV 图仅显示已测点,不外推整面;拟合 RMSE 不是独立验证误差。 +采集页持续显示 FN/FX/FY 原始信号及最近 60 秒曲线;已有标定的轴另显示 N,未标定的轴不将 raw 转称为 N。无效帧在曲线中断开;停止或超过 1 秒没有新帧时明确提示。此处没有实时 FT 参考力,人工填写的参考力仍是独立声明。 + +实时 UV 图有“真实背景”“位移箭头”“深度”三个独立开关;取消真实背景显示纯色画布,深度颜色可叠加在两种背景上。深度使用固定 0–1 算法单位色阶,超范围饱和,不代表毫米;缺失或过期字段不绘制,显示原因。显示切换不改变数据和录像,也不旋转 UV 坐标。 + +复核时选中表格中的保持段,检查下方原始样本图;起点、终点以该段保持开始为 0 秒,只能在原保持段内调整。勾选“已检查此段稳定”,点击“应用窗口并重算”,最后“另存分析结果”。至少有两个不同载荷的已确认加载/零载窗口时,显示已确认窗口拟合;不足时明确显示候选拟合。人工确认只表示看过稳定性,不证明参考力准确或物理同步。 + +分析结果保存 selections、候选与已确认拟合、原始样本轨迹、输入 CSV/manifest 的 SHA256、配置快照及分析包版本;每个已确认初始零载段保存基准图像及帧身份、图像哈希。点击“载入已保存复核…”选 review.json 恢复窗口并重算;输入身份或哈希改变时拒绝载入及保存。新增结果另存为独立目录,不修改 reference.csv 的采集声明。 + 正式分析拟合 `F_ref = gain_n_per_raw × raw + offset_n`,保存有效档位、范围、误差及加载/卸载差异,并保留独立 trial 验证。基准图像与算法历史尚未完整快照,因此当前录像不承诺从全新进程精确复算同样信号。FT、实际平台反馈、视频容量与驱动延迟仍须联调;自动加载、剪切、空间外推及实时部署留到后续。实施顺序见[实施计划](implementation-plan.zh.md)。 diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py index 94d940d..a8fb1e3 100644 --- a/apps/autocal/src/orisys_autocal/session_review.py +++ b/apps/autocal/src/orisys_autocal/session_review.py @@ -5,6 +5,8 @@ from __future__ import annotations import csv import json import math +import hashlib +from importlib.metadata import PackageNotFoundError, version from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 @@ -19,7 +21,24 @@ def _rows(path: Path) -> list[dict]: return list(csv.DictReader(handle)) -def review_session(directory: Path) -> dict: +def _fit(windows: list[dict]) -> dict | None: + raw = np.array([w["raw_mean"] for w in windows], dtype=float) + loads = np.array([w["reference_n"] for w in windows], dtype=float) + if (len(set(loads)) < 2 or raw.size < 2 + or np.allclose(raw, raw[0], rtol=1e-9, atol=1e-12)): + return None + gain, offset = np.polyfit(raw, loads, 1) + if not math.isfinite(gain) or not math.isfinite(offset) or abs(gain) <= 1e-12: + return None + return { + "gain_n_per_raw": float(gain), "offset_n": float(offset), + "sensitivity_raw_per_n": float(1 / gain), + "rmse_n": float(np.sqrt(np.mean((gain * raw + offset - loads) ** 2))), + "windows": len(windows), "status": "exploratory", + } + + +def review_session(directory: Path, *, selections: dict | None = None) -> dict: """Join frame identity to host timestamps and inspect only complete windows. Manual reference values are declarations. Fits remain exploratory regardless @@ -31,6 +50,10 @@ def review_session(directory: Path) -> dict: raise ValueError("select a calibration session root, not an individual trial") windows = [] issues = [] + selections = selections or {} + used = set() + inputs = {"session.json": hashlib.sha256((root / "session.json").read_bytes()).hexdigest()} + analysis_inputs = {} for trial in manifest["trials"]: trial_id = trial["trial_id"] if trial["status"] != "complete": @@ -40,6 +63,13 @@ def review_session(directory: Path) -> dict: if not path.is_relative_to(root): raise ValueError("trial directory is outside the calibration session") metadata = json.loads((path / "session.json").read_text(encoding="utf-8")) + analysis_inputs[trial_id] = { + "config_snapshot": metadata.get("config_snapshot"), + "config_sha256": metadata.get("config_sha256"), + "uv_definition": metadata.get("uv_definition"), + } + for name in ("session.json", "frames.csv", "forces.csv", "reference.csv"): + inputs[str((path / name).relative_to(root))] = hashlib.sha256((path / name).read_bytes()).hexdigest() if (metadata.get("trial_status") != "complete" or metadata.get("trial_id") != trial_id or metadata.get("site_id") != trial["site_id"] @@ -63,7 +93,8 @@ def review_session(directory: Path) -> dict: stamp = frames.get(int(row["frame_id"])) raw = float(row["FN_raw"]) if stamp is not None and math.isfinite(raw): - samples.append((stamp, raw)) + samples.append((stamp, int(row["frame_id"]), raw)) + samples.sort() for reference in _rows(path / "reference.csv"): if reference["hold_complete"] != "1": continue @@ -72,19 +103,37 @@ def review_session(directory: Path) -> dict: load = float(reference["F_ref_normal_N"]) if end <= start or not math.isfinite(load) or not 0 <= load <= 30: raise ValueError(f"invalid reference window in {trial_id}") - raw = np.array([v for stamp, v in samples if start <= stamp < end], dtype=float) + key = f"{trial_id}:{int(reference['step_index'])}" + choice = selections.get(key) + selected_start, selected_end = start, end + verified = False + if choice is not None: + used.add(key) + selected_start = int(choice["t_start_host_mono_ns"]) + selected_end = int(choice["t_end_host_mono_ns"]) + verified = choice["stability_verified"] is True + if not start <= selected_start < selected_end <= end: + raise ValueError(f"selection outside recorded hold: {key}") + trace = [(stamp, frame, value) for stamp, frame, value in samples if start <= stamp < end] + raw = np.array([v for stamp, _, v in trace if selected_start <= stamp < selected_end], dtype=float) + if verified and raw.size < 2: + raise ValueError(f"not enough valid samples to confirm: {key}") windows.append({ "trial_id": trial_id, "site_id": trial["site_id"], "step_index": int(reference["step_index"]), "branch": reference["branch"], "reference_n": load, - "t_start_host_mono_ns": start, "t_end_host_mono_ns": end, - "duration_s": (end - start) / 1e9, "samples": int(raw.size), + "hold_start_host_mono_ns": start, "hold_end_host_mono_ns": end, + "t_start_host_mono_ns": selected_start, "t_end_host_mono_ns": selected_end, + "duration_s": (selected_end - selected_start) / 1e9, "samples": int(raw.size), + "trace": trace, "raw_mean": float(raw.mean()) if raw.size else None, "raw_std": float(raw.std()) if raw.size else None, - "stability_verified": reference["stability_verified"] == "1", + "stability_verified": verified, "reference_alignment_possible": bool(metadata["reference_alignment_possible"]), "status": "candidate" if raw.size >= 2 else "insufficient_samples", }) + if set(selections) != used: + raise ValueError("selection refers to an unavailable hold window") sites = [] for site in manifest["sites"]: selected = [w for w in windows if w["site_id"] == site["site_id"]] @@ -92,33 +141,91 @@ def review_session(directory: Path) -> dict: # merges repeated loads and would hide the hysteresis under inspection. training = [w for w in selected if w["branch"] in {"zero", "load"} and w["status"] == "candidate"] - raw = np.array([w["raw_mean"] for w in training], dtype=float) - loads = np.array([w["reference_n"] for w in training], dtype=float) - fit = None - if (len(set(loads)) >= 2 and raw.size >= 2 - and not np.allclose(raw, raw[0], rtol=1e-9, atol=1e-12)): - gain, offset = np.polyfit(raw, loads, 1) - if math.isfinite(gain) and math.isfinite(offset) and abs(gain) > 1e-12: - fit = { - "gain_n_per_raw": float(gain), "offset_n": float(offset), - "sensitivity_raw_per_n": float(1 / gain), - "rmse_n": float(np.sqrt(np.mean((gain * raw + offset - loads) ** 2))), - "windows": len(training), "status": "exploratory", - } - sites.append({**site, "windows": selected, "fit": fit}) + fit = _fit(training) + confirmed = [w for w in training if w["stability_verified"]] + confirmed_fit = _fit(confirmed) + sites.append({**site, "windows": selected, "fit": fit, "confirmed_fit": confirmed_fit}) return { "format_version": "autocal-review-1.0", "session_id": manifest["session_id"], "source_directory": str(root), "session_status": manifest["status"], "status": "exploratory", "sites": sites, "issues": issues, + "selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs, } +def load_review(directory: Path, review_path: Path) -> dict: + """Recompute saved selections only when the acquired inputs still match.""" + saved = json.loads(review_path.read_text(encoding="utf-8")) + result = review_session(directory, selections=saved["selections"]) + if saved["session_id"] != result["session_id"] or saved["input_sha256"] != result["input_sha256"]: + raise ValueError("saved review does not match the recorded inputs") + return result + + def save_review(result: dict) -> Path: """Save a new derived artifact; never replace acquired measurements.""" root = Path(result["source_directory"]) + fresh = review_session(root, selections=result["selections"]) + if fresh["input_sha256"] != result["input_sha256"]: + raise ValueError("recorded inputs changed since this review was opened") stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") directory = root / "derived" / f"review_{stamp}_{uuid4().hex[:8]}" directory.mkdir(parents=True, exist_ok=False) path = directory / "review.json" - write_json(path, result) + saved = dict(fresh) + versions = {} + for package in ("orisys", "orisys-autocal", "orisys-app-shared", "numpy"): + try: + versions[package] = version(package) + except PackageNotFoundError: + versions[package] = "unavailable" + saved["analysis_versions"] = versions + saved["baselines"] = _save_baselines(fresh, directory) + write_json(path, saved) return path + + +def _save_baselines(result: dict, destination: Path) -> list[dict]: + """Snapshot a reviewed zero-load image for inspection, not tracker state.""" + import cv2 + + root = Path(result["source_directory"]) + manifest = json.loads((root / "session.json").read_text(encoding="utf-8")) + trials = {t["trial_id"]: t for t in manifest["trials"]} + baselines = [] + for site in result["sites"]: + # Each repeat has its own initial zero and its own image baseline. + for window in site["windows"]: + if window["branch"] != "zero" or not window["stability_verified"]: + continue + selected = [r for r in window["trace"] if window["t_start_host_mono_ns"] <= r[0] < window["t_end_host_mono_ns"]] + frame_id = selected[0][1] + trial_id = window["trial_id"] + trial_path = (root / trials[trial_id]["directory"]).resolve() + row = next(r for r in _rows(trial_path / "frames.csv") if int(r["frame_id"]) == frame_id) + item = {"site_id": site["site_id"], "trial_id": trial_id, + "frame_id": frame_id, "role": "reviewed_zero_load_inspection", + "tracker_reference": False, "status": "unavailable"} + if row.get("in_video") == "1" and row.get("segment") and row.get("segment_frame"): + media = (trial_path / f"raw_{int(row['segment']):04d}.mp4") + capture = cv2.VideoCapture(str(media)) + try: + # Recorder indices are one-based; OpenCV seeks from zero. + position = int(row["segment_frame"]) - 1 + capture.set(cv2.CAP_PROP_POS_FRAMES, position) + ok, image = capture.read() + ok = ok and round(capture.get(cv2.CAP_PROP_POS_FRAMES)) == position + 1 + finally: + capture.release() + if ok: + name = f"baseline_{trial_id}.png" + ok, encoded = cv2.imencode(".png", image) + if not ok: + raise OSError("cannot encode baseline image") + (destination / name).write_bytes(encoded.tobytes()) + item.update(status="saved", image=name, + source_video=str(media.relative_to(root)), + segment_frame=int(row["segment_frame"]), + image_sha256=hashlib.sha256(encoded.tobytes()).hexdigest()) + baselines.append(item) + return baselines diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py index 8d28ee8..0e22a48 100644 --- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -19,6 +19,8 @@ from PySide6.QtWidgets import ( QLineEdit, QPushButton, QSpinBox, + QScrollArea, + QSizePolicy, QTabWidget, QVBoxLayout, QWidget, @@ -27,6 +29,7 @@ from PySide6.QtWidgets import ( from ..sensor.uv_flat import site_at, uv_origin from ..trial import ManualTrial, load_path from .sensor_panel import FlatSurfaceView +from .live_force_view import LiveForceView class CalibrationPanel(QWidget): @@ -148,7 +151,32 @@ class CalibrationPanel(QWidget): self.result_label = QLabel(controls) self.result_label.setWordWrap(True) form.addRow(self.result_label) - body.addWidget(controls, 2) + for label in (self.path_label, self.target_label, self.result_label): + label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + for widget in (self.target_label, self.reference_label, self.reference, + actions, self.progress_label, self.result_label): + form.removeWidget(widget) + right = QWidget(self) + right.setMinimumWidth(560) + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + control_scroll = QScrollArea(right) + control_scroll.setWidgetResizable(True) + control_scroll.setMinimumHeight(150) + control_scroll.setWidget(controls) + right_layout.addWidget(control_scroll, 3) + right_layout.addWidget(self.target_label) + reference_row = QHBoxLayout() + reference_row.addWidget(self.reference_label) + reference_row.addWidget(self.reference, 1) + right_layout.addLayout(reference_row) + right_layout.addWidget(actions) + right_layout.addWidget(self.progress_label) + right_layout.addWidget(self.result_label) + self.force_view = LiveForceView(source_panel.worker, right) + self.force_view.setMaximumHeight(280) + right_layout.addWidget(self.force_view, 2) + body.addWidget(right, 2) outer.addLayout(body, 1) self.output_browse.clicked.connect(self._choose_output) @@ -339,6 +367,7 @@ class CalibrationPanel(QWidget): def shutdown(self) -> None: self._timer.stop() + self.force_view.shutdown() try: if self.trial.status == "recording": self.trial.finish(aborted=True, reason="application closed") @@ -423,6 +452,7 @@ class CalibrationPanel(QWidget): ) def retranslate(self) -> None: + self.force_view.retranslate() self.model_label.setText(t("calibration.sensor_model")) self.sn_label.setText(t("calibration.sensor_sn")) self.output_label.setText(t("calibration.output")) diff --git a/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py b/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py index c4cf997..fc8d52c 100644 --- a/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/finger_source_panel.py @@ -6,12 +6,15 @@ from datetime import datetime from pathlib import Path import numpy as np +import cv2 from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker +from orisys_app_shared.acquisition.arrows import depth_map_to_bgr, draw_arrow_display, flow_colormap from orisys_app_shared.i18n import t from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QImage, QPixmap from PySide6.QtWidgets import ( QFileDialog, + QCheckBox, QComboBox, QGridLayout, QHBoxLayout, @@ -46,6 +49,7 @@ class FingerSourcePanel(QWidget): self.worker = FingerAcquisitionWorker(on_error=self.errorReported.emit) self._last_frame_id: tuple[int, int] | None = None self._last_image: QImage | None = None + self._last_packet = None self._active = False self._error = "" self._status_key = "source.idle" @@ -105,6 +109,22 @@ class FingerSourcePanel(QWidget): self.preview_hint = QLabel(self) self.preview_hint.setWordWrap(True) layout.addWidget(self.preview_hint) + display_row = QHBoxLayout() + self.background_check = QCheckBox(self) + self.background_check.setObjectName("calibrationRealBackground") + self.background_check.setChecked(True) + self.arrows_check = QCheckBox(self) + self.arrows_check.setObjectName("calibrationArrows") + self.depth_check = QCheckBox(self) + self.depth_check.setObjectName("calibrationDepth") + for check in (self.background_check, self.arrows_check, self.depth_check): + display_row.addWidget(check) + check.toggled.connect(self._redraw) + display_row.addStretch() + layout.addLayout(display_row) + self.display_note = QLabel(self) + self.display_note.setWordWrap(True) + layout.addWidget(self.display_note) self.preview = QLabel(self) self.preview.setObjectName("fingerVideoPreview") @@ -138,6 +158,11 @@ class FingerSourcePanel(QWidget): self.start_button.setText(t("source.start")) self.stop_button.setText(t("source.stop")) self.preview_hint.setText(t("source.preview_only")) + self.background_check.setText(t("feedback.background")) + self.background_check.setToolTip(t("feedback.background_tip")) + self.arrows_check.setText(t("feedback.arrows")) + self.depth_check.setText(t("feedback.depth")) + self.depth_check.setToolTip(t("feedback.depth_tip")) self._set_status(self._status_key) if self._last_image is None: self.preview.setText(t("source.no_frame")) @@ -177,6 +202,7 @@ class FingerSourcePanel(QWidget): self._error = "" self._last_frame_id = None self._last_image = None + self._last_packet = None self.preview.clear() self.preview.setText(t("source.no_frame")) self.frame_label.clear() @@ -229,7 +255,8 @@ class FingerSourcePanel(QWidget): identity = (packet.session_id, packet.frame_id) if identity != self._last_frame_id: self._last_frame_id = identity - self._show_frame(packet.processed_frame) + self._last_packet = packet + self._redraw() acquired = datetime.fromtimestamp(packet.timestamp).strftime( "%H:%M:%S.%f" )[:-3] @@ -262,6 +289,54 @@ class FingerSourcePanel(QWidget): }.get(reason, "source.stopped") self._set_status(key) + def _redraw(self) -> None: + packet = self._last_packet + if packet is None: + return + frame = np.array(packet.processed_frame, copy=True) + mask = getattr(packet, "valid_mask", None) + if mask is not None and np.shape(mask) != frame.shape[:2]: + mask = None + if not self.background_check.isChecked(): + frame[:] = (235, 230, 224) + if mask is not None: + frame[~np.asarray(mask, dtype=bool)] = (25, 25, 25) + notes = [] + if self.depth_check.isChecked(): + depth = getattr(packet, "depth", None) + fresh = (getattr(packet, "depth_valid", None) is True + and getattr(packet, "depth_frame_id", None) == packet.frame_id) + if fresh and depth is not None and np.shape(depth) == frame.shape[:2]: + # Fixed scale in SDK divergence units, never millimetres. + colored = depth_map_to_bgr(depth, flow_colormap("light"), + value_range=(0, 1), invalid_mask=mask) + if colored is not None: + valid = np.isfinite(depth) + if mask is not None: + valid &= np.asarray(mask, dtype=bool) + blended = cv2.addWeighted(frame, .4, colored, .6, 0) + frame[valid] = blended[valid] + notes.append(t("feedback.depth_scale")) + else: + notes.append(t("feedback.depth_missing")) + else: + notes.append(t("feedback.depth_missing")) + if self.arrows_check.isChecked(): + flow = getattr(packet, "flow", None) + fresh = (getattr(packet, "flow_valid", None) is True + and getattr(packet, "flow_frame_id", None) == packet.frame_id) + if fresh and flow is not None: + frame = draw_arrow_display(frame, flow, background="captured", + draw_style="finger", valid_mask=mask, + colormap=flow_colormap("light")) + diagnostic = getattr(frame, "frame_diagnostic", None) + if diagnostic is not None: + notes.append(str(diagnostic)) + else: + notes.append(t("feedback.flow_missing")) + self.display_note.setText(" · ".join(notes)) + self._show_frame(frame) + def _show_frame(self, frame: np.ndarray) -> None: if frame.ndim != 3 or frame.shape[2] != 3: return diff --git a/apps/autocal/src/orisys_autocal/ui/live_force_view.py b/apps/autocal/src/orisys_autocal/ui/live_force_view.py new file mode 100644 index 0000000..482e2cc --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/live_force_view.py @@ -0,0 +1,112 @@ +"""Operator force feedback from the same samples written by the recorder.""" + +from __future__ import annotations + +import math +import threading +import time +from collections import deque + +import pyqtgraph as pg +from orisys_app_shared.force.history import MetricHistory +from orisys_app_shared.i18n import t +from orisys_app_shared.record.sample import to_record_sample +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + + +class LiveForceView(QWidget): + """Collect on the worker thread; update widgets only on the GUI thread.""" + + def __init__(self, worker, parent=None): + super().__init__(parent) + self.worker = worker + self._lock = threading.Lock() + self._pending = deque(maxlen=5400) + self._overflow = 0 + self._session = None + self._last_arrival = None + self._sample = None + self.history = MetricHistory(channels=3, window_sec=60, max_fps=90) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.readings = QLabel(self) + self.readings.setObjectName("calibrationForceValues") + self.readings.setWordWrap(True) + self.readings.setStyleSheet("font-size: 16px; font-weight: 600;") + self.state = QLabel(self) + self.state.setWordWrap(True) + layout.addWidget(self.readings) + layout.addWidget(self.state) + self.plot = pg.PlotWidget(background="w") + self.plot.setMinimumHeight(130) + self.plot.showGrid(x=True, y=True, alpha=.15) + self.plot.addLegend() + self.curves = [self.plot.plot(name=name, pen=pg.mkPen(color, width=2), + connect="finite") + for name, color in (("FN", "#167ac6"), ("FX", "#dc8413"), + ("FY", "#40854b"))] + layout.addWidget(self.plot, 1) + worker.add_sink(self.on_packet) + self._timer = QTimer(self) + self._timer.setInterval(100) + self._timer.timeout.connect(self.refresh) + self._timer.start() + self.retranslate() + self.refresh() + + def on_packet(self, packet): + sample = to_record_sample(packet) + with self._lock: + if len(self._pending) == self._pending.maxlen: + self._overflow += 1 + self._pending.append((packet.session_id, sample, time.monotonic())) + + def refresh(self): + with self._lock: + pending = list(self._pending) + self._pending.clear() + overflow = self._overflow + for session, sample, arrival in pending: + if session != self._session: + self.history.reset() + self._session = session + values = sample.raw_triplet() if sample.valid else (math.nan,) * 3 + self.history.push(sample.mono_t, *values) + self._sample, self._last_arrival = sample, arrival + state = self.history.state() if self.history.sample_count else None + if state is not None: + for index, curve in enumerate(self.curves): + curve.setData(state.times, state.values[:, index]) + self.plot.setXRange(state.x_range[0], max(10, float(state.times[-1])), padding=0) + sample = self._sample + if sample is None: + self.readings.setText(t("feedback.waiting")) + else: + values = [] + for axis in ("fn", "fx", "fy"): + raw = sample.raw_for(axis) if sample.valid else math.nan + newton = sample.newton_for(axis) if sample.valid else math.nan + raw_text = f"{raw:.6g}" if math.isfinite(raw) else "—" + n_text = f"{newton:.3f} N" if math.isfinite(newton) else t("feedback.uncalibrated") + values.append(f"{axis.upper()}: {raw_text} raw · {n_text}") + self.readings.setText(" ".join(values)) + if not self.worker.is_running: + message = t("feedback.stopped") + elif self._last_arrival is None or time.monotonic() - self._last_arrival > 1: + message = t("feedback.stale") + elif not sample.valid: + message = t("feedback.invalid").format(reason=sample.invalid_reason) + else: + message = t("feedback.raw_note") + if overflow: + message += " · " + t("feedback.overflow").format(count=overflow) + self.state.setText(message) + + def retranslate(self): + self.plot.setLabel("left", t("feedback.raw_axis")) + self.plot.setLabel("bottom", t("feedback.time_axis")) + + def shutdown(self): + self._timer.stop() + self.worker.remove_sink(self.on_packet) diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py index ded15ff..94e00f7 100644 --- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py @@ -6,14 +6,14 @@ from pathlib import Path from orisys_app_shared.i18n import t from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QPointF from PySide6.QtGui import QColor, QPainter from PySide6.QtWidgets import ( QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, - QTableWidget, QTableWidgetItem, QVBoxLayout, + QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, ) -from ..session_review import review_session, save_review +from ..session_review import review_session, save_review, load_review class SessionReviewDialog(QDialog): @@ -24,6 +24,7 @@ class SessionReviewDialog(QDialog): self.setWindowTitle(t("review.title")) self.resize(1080, 760) self.review = review_session(directory) + self.directory = directory layout = QVBoxLayout(self) self.notice = QLabel(t("review.notice"), self) self.notice.setWordWrap(True) @@ -35,8 +36,10 @@ class SessionReviewDialog(QDialog): u, v = site["uv_xy_px"] self.sites.addItem(f"{site['site_id']} · U {u}, V {v} px") self.save_button = QPushButton(t("review.save"), self) + self.load_button = QPushButton(t("review.load_selection"), self) row.addWidget(self.sites, 1) row.addWidget(self.save_button) + row.addWidget(self.load_button) layout.addLayout(row) self.summary = QLabel(self) self.summary.setWordWrap(True) @@ -59,11 +62,35 @@ class SessionReviewDialog(QDialog): self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.table.horizontalHeader().setStretchLastSection(True) layout.addWidget(self.table, 1) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + editor = QHBoxLayout() + self.start_s = QDoubleSpinBox(self) + self.end_s = QDoubleSpinBox(self) + for spin in (self.start_s, self.end_s): + spin.setDecimals(3) + spin.setSuffix(" s") + self.verified = QCheckBox(t("review.confirm_stable"), self) + self.apply_button = QPushButton(t("review.apply_window"), self) + editor.addWidget(QLabel(t("review.start_offset"), self)) + editor.addWidget(self.start_s) + editor.addWidget(QLabel(t("review.end_offset"), self)) + editor.addWidget(self.end_s) + editor.addWidget(self.verified) + editor.addWidget(self.apply_button) + layout.addLayout(editor) + self.trace_chart = QChart() + self.trace_view = QChartView(self.trace_chart, self) + self.trace_view.setMinimumHeight(130) + layout.addWidget(self.trace_view, 1) self.message = QLabel(self) self.message.setWordWrap(True) layout.addWidget(self.message) self.sites.currentIndexChanged.connect(self._show_site) self.save_button.clicked.connect(self._save) + self.load_button.clicked.connect(self._load) + self.table.itemSelectionChanged.connect(self._show_window) + self.apply_button.clicked.connect(self._apply_window) self._show_site() def _show_site(self) -> None: @@ -81,17 +108,17 @@ class SessionReviewDialog(QDialog): quality = t("review." + window["status"]) if not window["reference_alignment_possible"]: quality += " · " + t("review.replay") - if not window["stability_verified"]: - quality += " · " + t("review.unverified") + quality += " · " + t("review.verified" if window["stability_verified"] else "review.unverified") values = [window["trial_id"], t("calibration." + window["branch"]), f"{window['reference_n']:g}", str(window["samples"]), self._number(window["raw_mean"]), self._number(window["raw_std"]), f"{window['duration_s']:g}", quality] for column, value in enumerate(values): self.table.setItem(row, column, QTableWidgetItem(value)) - fit = site["fit"] + fit = site["confirmed_fit"] or site["fit"] self.summary.setText( - t("review.fit").format(**fit) if fit else t("review.no_fit") + ((t("review.confirmed_fit") if site["confirmed_fit"] else t("review.candidate_fit")) + + "\n" + t("review.fit").format(**fit)) if fit else t("review.no_fit") ) for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")): series = QScatterSeries() @@ -119,19 +146,101 @@ class SessionReviewDialog(QDialog): self.chart.setTitle(t("review.chart_title")) self._show_uv(index) self.message.setText(t("review.issues").format(count=len(self.review["issues"]))) + if windows: + self.table.selectRow(0) + self._show_window() + + def _current_window(self): + site, row = self.sites.currentIndex(), self.table.currentRow() + if site < 0 or row < 0 or row >= len(self.review["sites"][site]["windows"]): + return None + return self.review["sites"][site]["windows"][row] + + def _show_window(self): + window = self._current_window() + self.trace_chart.removeAllSeries() + for axis in self.trace_chart.axes(): + self.trace_chart.removeAxis(axis) + self.apply_button.setEnabled(window is not None) + if window is None: + return + origin = window["hold_start_host_mono_ns"] + duration = (window["hold_end_host_mono_ns"] - origin) / 1e9 + self.start_s.setRange(0, duration) + self.end_s.setRange(0, duration) + self.start_s.setValue((window["t_start_host_mono_ns"] - origin) / 1e9) + self.end_s.setValue((window["t_end_host_mono_ns"] - origin) / 1e9) + self.verified.setChecked(window["stability_verified"]) + trace = QScatterSeries() + trace.setMarkerSize(3) + trace.setName(t("review.valid_samples")) + trace.replace([QPointF((stamp - origin) / 1e9, raw) for stamp, _, raw in window["trace"]]) + self.trace_chart.addSeries(trace) + if window["trace"]: + raw_values = [r[2] for r in window["trace"]] + low, high = min(raw_values), max(raw_values) + margin = max((high - low) * .1, .1) + for stamp in (window["t_start_host_mono_ns"], window["t_end_host_mono_ns"]): + line = QLineSeries() + line.append((stamp - origin) / 1e9, low - margin) + line.append((stamp - origin) / 1e9, high + margin) + self.trace_chart.addSeries(line) + self.trace_chart.createDefaultAxes() + for axis in self.trace_chart.axes(Qt.Orientation.Horizontal): + axis.setRange(0, duration) + axis.setTitleText(t("review.hold_time_axis")) + for axis in self.trace_chart.axes(Qt.Orientation.Vertical): + axis.setTitleText(t("review.raw_axis")) + self.trace_chart.legend().hide() + self.trace_chart.setTitle(t("review.trace_title")) + + def _apply_window(self): + window = self._current_window() + if window is None: + return + selections = dict(self.review["selections"]) + origin = window["hold_start_host_mono_ns"] + selections[f"{window['trial_id']}:{window['step_index']}"] = { + "t_start_host_mono_ns": origin + round(self.start_s.value() * 1e9), + "t_end_host_mono_ns": origin + round(self.end_s.value() * 1e9), + "stability_verified": self.verified.isChecked(), + } + row = self.table.currentRow() + try: + updated = review_session(self.directory, selections=selections) + if updated["input_sha256"] != self.review["input_sha256"]: + raise ValueError("recorded inputs changed since this review was opened") + self.review = updated + self._show_site() + self.table.selectRow(row) + self._show_window() + self.message.setText(t("review.selection_unsaved")) + except (OSError, ValueError) as exc: + self.message.setText(str(exc)) + + def _load(self): + path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"), + str(self.directory / "derived"), "JSON (*.json)") + if path: + try: + self.review = load_review(self.directory, Path(path)) + self._show_site() + except (OSError, ValueError, KeyError, TypeError) as exc: + self.message.setText(str(exc)) def _show_uv(self, selected: int) -> None: self.uv_chart.removeAllSeries() for axis in self.uv_chart.axes(): self.uv_chart.removeAxis(axis) - values = [s["fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["fit"]] + fits = [s["confirmed_fit"] or s["fit"] for s in self.review["sites"]] + values = [fit["sensitivity_raw_per_n"] for fit in fits if fit] low, high = (min(values), max(values)) if values else (0, 0) for index, site in enumerate(self.review["sites"]): series = QScatterSeries() series.setName(site["site_id"]) color = QColor("#929aa7") - if site["fit"]: - value = site["fit"]["sensitivity_raw_per_n"] + if fits[index]: + value = fits[index]["sensitivity_raw_per_n"] ratio = (value - low) / (high - low) if high > low else .5 color = QColor.fromHsvF((1 - ratio) * .65, .8, .85) series.setColor(color) @@ -164,5 +273,5 @@ class SessionReviewDialog(QDialog): try: path = save_review(self.review) self.message.setText(t("review.saved").format(path=path)) - except OSError as exc: + except (OSError, ValueError) as exc: self.message.setText(t("calibration.error").format(detail=exc)) diff --git a/apps/autocal/tests/test_calibration_feedback.py b/apps/autocal/tests/test_calibration_feedback.py new file mode 100644 index 0000000..79d6ae6 --- /dev/null +++ b/apps/autocal/tests/test_calibration_feedback.py @@ -0,0 +1,85 @@ +"""Live operator feedback must preserve sample validity and UV orientation.""" + +from dataclasses import replace + +import numpy as np +from orisys_app_shared.acquisition.models import SdkPacket + + +class Worker: + is_running = True + + def __init__(self): + self.sinks = [] + + def add_sink(self, sink): + self.sinks.append(sink) + + def remove_sink(self, sink): + self.sinks.remove(sink) + + +def packet(**values): + image = np.full((64, 80, 3), (10, 20, 30), dtype=np.uint8) + base = SdkPacket(raw_frame=image.copy(), processed_frame=image, + timestamp=100, mono_t=1, fps=30, process_ms=1, + session_id=1, frame_id=1, normal_force_raw=12, + shear_x_raw=2, shear_y_raw=-3) + return replace(base, **values) + + +def test_force_feedback_consumes_every_sample_and_breaks_invalid_gaps(qt_app): + from orisys_autocal.ui.live_force_view import LiveForceView + worker = Worker() + view = LiveForceView(worker) + for item in (packet(), packet(frame_id=2, mono_t=1.1, stale_forces=True), + packet(frame_id=3, mono_t=1.2, normal_force_raw=20)): + worker.sinks[0](item) + view.refresh() + assert view.history.sample_count == 3 + x, y = view.curves[0].getData() + np.testing.assert_allclose(x, [0, .1, .2]) + assert y[0] == 12 and np.isnan(y[1]) and y[2] == 20 + assert "20 raw" in view.readings.text() + assert "20.000 N" not in view.readings.text() + view.on_packet(packet(session_id=2, normal_force=4, force_calibrated=True, + calibrated_axes=("fn",))) + view.refresh() + assert view.history.sample_count == 1 + assert "4.000 N" in view.readings.text() + assert "2.000 N" not in view.readings.text() + worker.is_running = False + view.refresh() + assert view.state.text() + view.shutdown() + assert not worker.sinks + + +def test_display_toggles_redraw_same_frame_without_mutating_measurements(qt_app, monkeypatch): + from orisys_autocal.ui import finger_source_panel + panel = finger_source_panel.FingerSourcePanel() + item = packet(flow=np.full((64, 80, 2), 2., dtype=float), + depth=np.full((64, 80), .5), valid_mask=np.ones((64, 80), bool), + flow_valid=True, flow_frame_id=1, depth_valid=True, depth_frame_id=1) + original = item.processed_frame.copy() + panel._last_packet = item + frames = [] + monkeypatch.setattr(panel, "_show_frame", lambda frame: frames.append(frame.copy())) + panel._redraw() + np.testing.assert_array_equal(frames[-1], original) + panel.background_check.setChecked(False) + assert not np.array_equal(frames[-1], original) + solid = frames[-1] + panel.depth_check.setChecked(True) + assert not np.array_equal(frames[-1], solid) + depth = frames[-1] + panel.arrows_check.setChecked(True) + assert not np.array_equal(frames[-1], depth) + assert frames[-1].shape == original.shape + np.testing.assert_array_equal(item.processed_frame, original) + assert np.all(item.flow == 2) + panel._last_packet = replace(item, flow_valid=False, depth_frame_id=0) + panel._redraw() + assert panel.display_note.text() + np.testing.assert_array_equal(frames[-1], solid) + panel.shutdown() diff --git a/apps/autocal/tests/test_session_review.py b/apps/autocal/tests/test_session_review.py index 4d06cc4..1496455 100644 --- a/apps/autocal/tests/test_session_review.py +++ b/apps/autocal/tests/test_session_review.py @@ -122,3 +122,91 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session qt_app.processEvents() assert dialog.grab().save(str(tmp_path / "review.png")) dialog.close() + + +def test_reviewed_windows_roundtrip_and_reject_changed_inputs(session): + from orisys_autocal.session_review import load_review + + selections = {f"trial-001:{index}": { + "t_start_host_mono_ns": (index * 20 + 1) * 1_000_000_000, + "t_end_host_mono_ns": (index * 20 + 3) * 1_000_000_000, + "stability_verified": True, + } for index in (0, 1, 2)} + before = (session / "trials/trial-001/reference.csv").read_bytes() + result = review_session(session, selections=selections) + first = result["sites"][0] + assert first["confirmed_fit"]["gain_n_per_raw"] == pytest.approx(.5) + assert all(w["duration_s"] == 2 for w in first["windows"][:3]) + saved = save_review(result) + assert load_review(session, saved)["sites"] == result["sites"] + document = json.loads(saved.read_text(encoding="utf-8")) + assert document["baselines"][0]["status"] == "unavailable" + assert document["baselines"][0]["tracker_reference"] is False + assert document["analysis_versions"]["numpy"] + assert (session / "trials/trial-001/reference.csv").read_bytes() == before + with (session / "trials/trial-001/forces.csv").open("a", encoding="utf-8") as handle: + handle.write("\n") + with pytest.raises(ValueError, match="match"): + load_review(session, saved) + with pytest.raises(ValueError, match="changed"): + save_review(result) + + +def test_invalid_review_selection_does_not_become_verified(session): + for start, end in ((0, 2), (1, 1), (1, 12), (3, 4)): + with pytest.raises(ValueError): + review_session(session, selections={"trial-001:0": { + "t_start_host_mono_ns": start * 1_000_000_000, + "t_end_host_mono_ns": end * 1_000_000_000, + "stability_verified": True, + }}) + + +def test_operator_confirms_windows_in_dialog_and_reloads_them(qt_app, session, monkeypatch): + from orisys_autocal.ui import session_review_dialog + + dialog = session_review_dialog.SessionReviewDialog(session) + for row in (0, 1): + dialog.table.selectRow(row) + dialog.end_s.setValue(2) + dialog.verified.setChecked(True) + dialog.apply_button.click() + assert dialog.review["sites"][0]["windows"][row]["stability_verified"] + assert dialog.review["sites"][0]["confirmed_fit"] is not None + dialog.save_button.click() + saved = next(session.glob("derived/*/review.json")) + fresh = session_review_dialog.SessionReviewDialog(session) + monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName", + lambda *args: (str(saved), "")) + fresh.load_button.click() + assert fresh.review["sites"] == dialog.review["sites"] + dialog.close() + fresh.close() + + +def test_baseline_image_matches_first_reviewed_zero_frame(session): + import cv2 + import numpy as np + + path = session / "trials/trial-001" + frames = list(csv.DictReader((path / "frames.csv").open(encoding="utf-8"))) + writer = cv2.VideoWriter(str(path / "raw_0001.mp4"), cv2.VideoWriter_fourcc(*"mp4v"), + 30, (32, 24)) + assert writer.isOpened() + try: + for index, row in enumerate(frames): + writer.write(np.full((24, 32, 3), 20 if index == 0 else 220, np.uint8)) + row.update(segment=1, segment_frame=index + 1, in_video=1) + finally: + writer.release() + _csv(path / "frames.csv", frames) + result = review_session(session, selections={"trial-001:0": { + "t_start_host_mono_ns": 1_000_000_000, + "t_end_host_mono_ns": 3_000_000_000, "stability_verified": True, + }}) + saved = save_review(result) + document = json.loads(saved.read_text(encoding="utf-8")) + baseline = document["baselines"][0] + assert baseline["status"] == "saved" and baseline["segment_frame"] == 1 + image = cv2.imread(str(saved.parent / baseline["image"])) + assert 10 < image.mean() < 30 diff --git a/shared/src/orisys_app_shared/force/history.py b/shared/src/orisys_app_shared/force/history.py index a69f3fa..b7cb56f 100644 --- a/shared/src/orisys_app_shared/force/history.py +++ b/shared/src/orisys_app_shared/force/history.py @@ -210,9 +210,11 @@ class MetricHistory: y_ranges = [] for i in range(self.channels): col = values[:, i] - ymin = float(np.nanmin(col)) - ymax = float(np.nanmax(col)) - if not np.isfinite(ymin) or not np.isfinite(ymax): + finite = col[np.isfinite(col)] + if finite.size: + ymin = float(finite.min()) + ymax = float(finite.max()) + else: ymin, ymax = 0.0, 1.0 y_ranges.append(self._smooth_y_range(i, ymin, ymax)) return PlotState( diff --git a/shared/tests/test_force_history.py b/shared/tests/test_force_history.py index 2d83f7f..9d90c0e 100644 --- a/shared/tests/test_force_history.py +++ b/shared/tests/test_force_history.py @@ -3,9 +3,22 @@ from __future__ import annotations import pytest +import numpy as np +import warnings from orisys_app_shared.force.history import MetricHistory +def test_unavailable_channels_keep_gaps_without_empty_slice_warnings(): + history = MetricHistory() + history.push(1, np.nan, np.nan, np.nan) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + state = history.state() + assert np.isnan(state.values).all() + assert all(np.isfinite(bounds).all() and bounds[0] < bounds[1] + for bounds in state.y_ranges) + + def _push_at_rate(hist: MetricHistory, *, fps: float, duration_sec: float, t0: float = 1000.0) -> None: dt = 1.0 / fps n = int(round(duration_sec * fps)) + 1