补齐 Autocal 实时反馈与稳定窗口复核

显示实时力值、曲线及 UV 图层,并保存人工确认窗口和零载检查基准。定向测试、真实视频预览与已录会话复核闭环验证通过。
This commit is contained in:
ykcheungab
2026-10-11 18:08:09 +08:00
parent 04adf17f60
commit 34a1d9b1f1
12 changed files with 731 additions and 39 deletions
+32 -1
View File
@@ -471,10 +471,41 @@
"yes": "Yes", "yes": "Yes",
"no": "No" "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": { "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…", "open": "Review calibration data…",
"title": "Exploratory calibration review", "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", "save": "Save new review",
"no_sites": "No sites in this session", "no_sites": "No sites in this session",
"candidate": "Candidate window", "candidate": "Candidate window",
+32 -1
View File
@@ -471,10 +471,41 @@
"yes": "是", "yes": "是",
"no": "否" "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": { "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": "查看标定数据…", "open": "查看标定数据…",
"title": "标定数据初步分析", "title": "标定数据初步分析",
"notice": "仅供检查:人工参考保持窗口尚未复核稳定性。视频重放不能证明原实验的时间同步;以下拟合不是正式标定系数,不会写入传感器配置。", "notice": "逐段查看原始信号,调整起止时间并确认稳定后应用。起止时间相对本段保持开始;人工复核不证明参考力准确或同步。视频重放仅供调试;拟合不是正式标定系数。",
"save": "另存分析结果", "save": "另存分析结果",
"no_sites": "会话没有测点", "no_sites": "会话没有测点",
"candidate": "候选窗口", "candidate": "候选窗口",
@@ -80,6 +80,7 @@ finger_F001_<时间>_<标识>/
| `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 | | `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 |
| `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 | | `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 |
| `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 | | `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 |
| `derived/review_<时间>_<标识>/baseline_<trial>.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable |
| `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 | | `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 |
| 参数/字段 | 定义 | | 参数/字段 | 定义 |
@@ -108,4 +109,12 @@ finger_F001_<时间>_<标识>/
点击“查看标定数据…”选择会话根目录;在独立窗口切换测点,检查每档有效样本、均值、标准差和质量说明。初步拟合仅使用初始零载及加载分支的窗口均值,各窗口等权;卸载点保留用于对照,不混入加载拟合。缺样本、档位或信号变化时不生成系数。UV 图仅显示已测点,不外推整面;拟合 RMSE 不是独立验证误差。 点击“查看标定数据…”选择会话根目录;在独立窗口切换测点,检查每档有效样本、均值、标准差和质量说明。初步拟合仅使用初始零载及加载分支的窗口均值,各窗口等权;卸载点保留用于对照,不混入加载拟合。缺样本、档位或信号变化时不生成系数。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)。 正式分析拟合 `F_ref = gain_n_per_raw × raw + offset_n`,保存有效档位、范围、误差及加载/卸载差异,并保留独立 trial 验证。基准图像与算法历史尚未完整快照,因此当前录像不承诺从全新进程精确复算同样信号。FT、实际平台反馈、视频容量与驱动延迟仍须联调;自动加载、剪切、空间外推及实时部署留到后续。实施顺序见[实施计划](implementation-plan.zh.md)。
+128 -21
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import csv import csv
import json import json
import math import math
import hashlib
from importlib.metadata import PackageNotFoundError, version
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -19,7 +21,24 @@ def _rows(path: Path) -> list[dict]:
return list(csv.DictReader(handle)) 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. """Join frame identity to host timestamps and inspect only complete windows.
Manual reference values are declarations. Fits remain exploratory regardless 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") raise ValueError("select a calibration session root, not an individual trial")
windows = [] windows = []
issues = [] issues = []
selections = selections or {}
used = set()
inputs = {"session.json": hashlib.sha256((root / "session.json").read_bytes()).hexdigest()}
analysis_inputs = {}
for trial in manifest["trials"]: for trial in manifest["trials"]:
trial_id = trial["trial_id"] trial_id = trial["trial_id"]
if trial["status"] != "complete": if trial["status"] != "complete":
@@ -40,6 +63,13 @@ def review_session(directory: Path) -> dict:
if not path.is_relative_to(root): if not path.is_relative_to(root):
raise ValueError("trial directory is outside the calibration session") raise ValueError("trial directory is outside the calibration session")
metadata = json.loads((path / "session.json").read_text(encoding="utf-8")) 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" if (metadata.get("trial_status") != "complete"
or metadata.get("trial_id") != trial_id or metadata.get("trial_id") != trial_id
or metadata.get("site_id") != trial["site_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"])) stamp = frames.get(int(row["frame_id"]))
raw = float(row["FN_raw"]) raw = float(row["FN_raw"])
if stamp is not None and math.isfinite(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"): for reference in _rows(path / "reference.csv"):
if reference["hold_complete"] != "1": if reference["hold_complete"] != "1":
continue continue
@@ -72,19 +103,37 @@ def review_session(directory: Path) -> dict:
load = float(reference["F_ref_normal_N"]) load = float(reference["F_ref_normal_N"])
if end <= start or not math.isfinite(load) or not 0 <= load <= 30: if end <= start or not math.isfinite(load) or not 0 <= load <= 30:
raise ValueError(f"invalid reference window in {trial_id}") 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({ windows.append({
"trial_id": trial_id, "site_id": trial["site_id"], "trial_id": trial_id, "site_id": trial["site_id"],
"step_index": int(reference["step_index"]), "step_index": int(reference["step_index"]),
"branch": reference["branch"], "reference_n": load, "branch": reference["branch"], "reference_n": load,
"t_start_host_mono_ns": start, "t_end_host_mono_ns": end, "hold_start_host_mono_ns": start, "hold_end_host_mono_ns": end,
"duration_s": (end - start) / 1e9, "samples": int(raw.size), "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_mean": float(raw.mean()) if raw.size else None,
"raw_std": float(raw.std()) 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"]), "reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
"status": "candidate" if raw.size >= 2 else "insufficient_samples", "status": "candidate" if raw.size >= 2 else "insufficient_samples",
}) })
if set(selections) != used:
raise ValueError("selection refers to an unavailable hold window")
sites = [] sites = []
for site in manifest["sites"]: for site in manifest["sites"]:
selected = [w for w in windows if w["site_id"] == site["site_id"]] 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. # merges repeated loads and would hide the hysteresis under inspection.
training = [w for w in selected if w["branch"] in {"zero", "load"} training = [w for w in selected if w["branch"] in {"zero", "load"}
and w["status"] == "candidate"] and w["status"] == "candidate"]
raw = np.array([w["raw_mean"] for w in training], dtype=float) fit = _fit(training)
loads = np.array([w["reference_n"] for w in training], dtype=float) confirmed = [w for w in training if w["stability_verified"]]
fit = None confirmed_fit = _fit(confirmed)
if (len(set(loads)) >= 2 and raw.size >= 2 sites.append({**site, "windows": selected, "fit": fit, "confirmed_fit": confirmed_fit})
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})
return { return {
"format_version": "autocal-review-1.0", "session_id": manifest["session_id"], "format_version": "autocal-review-1.0", "session_id": manifest["session_id"],
"source_directory": str(root), "session_status": manifest["status"], "source_directory": str(root), "session_status": manifest["status"],
"status": "exploratory", "sites": sites, "issues": issues, "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: def save_review(result: dict) -> Path:
"""Save a new derived artifact; never replace acquired measurements.""" """Save a new derived artifact; never replace acquired measurements."""
root = Path(result["source_directory"]) 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") stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
directory = root / "derived" / f"review_{stamp}_{uuid4().hex[:8]}" directory = root / "derived" / f"review_{stamp}_{uuid4().hex[:8]}"
directory.mkdir(parents=True, exist_ok=False) directory.mkdir(parents=True, exist_ok=False)
path = directory / "review.json" 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 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
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QScrollArea,
QSizePolicy,
QTabWidget, QTabWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -27,6 +29,7 @@ from PySide6.QtWidgets import (
from ..sensor.uv_flat import site_at, uv_origin from ..sensor.uv_flat import site_at, uv_origin
from ..trial import ManualTrial, load_path from ..trial import ManualTrial, load_path
from .sensor_panel import FlatSurfaceView from .sensor_panel import FlatSurfaceView
from .live_force_view import LiveForceView
class CalibrationPanel(QWidget): class CalibrationPanel(QWidget):
@@ -148,7 +151,32 @@ class CalibrationPanel(QWidget):
self.result_label = QLabel(controls) self.result_label = QLabel(controls)
self.result_label.setWordWrap(True) self.result_label.setWordWrap(True)
form.addRow(self.result_label) 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) outer.addLayout(body, 1)
self.output_browse.clicked.connect(self._choose_output) self.output_browse.clicked.connect(self._choose_output)
@@ -339,6 +367,7 @@ class CalibrationPanel(QWidget):
def shutdown(self) -> None: def shutdown(self) -> None:
self._timer.stop() self._timer.stop()
self.force_view.shutdown()
try: try:
if self.trial.status == "recording": if self.trial.status == "recording":
self.trial.finish(aborted=True, reason="application closed") self.trial.finish(aborted=True, reason="application closed")
@@ -423,6 +452,7 @@ class CalibrationPanel(QWidget):
) )
def retranslate(self) -> None: def retranslate(self) -> None:
self.force_view.retranslate()
self.model_label.setText(t("calibration.sensor_model")) self.model_label.setText(t("calibration.sensor_model"))
self.sn_label.setText(t("calibration.sensor_sn")) self.sn_label.setText(t("calibration.sensor_sn"))
self.output_label.setText(t("calibration.output")) self.output_label.setText(t("calibration.output"))
@@ -6,12 +6,15 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
import cv2
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker 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 orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QImage, QPixmap from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFileDialog, QFileDialog,
QCheckBox,
QComboBox, QComboBox,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
@@ -46,6 +49,7 @@ class FingerSourcePanel(QWidget):
self.worker = FingerAcquisitionWorker(on_error=self.errorReported.emit) self.worker = FingerAcquisitionWorker(on_error=self.errorReported.emit)
self._last_frame_id: tuple[int, int] | None = None self._last_frame_id: tuple[int, int] | None = None
self._last_image: QImage | None = None self._last_image: QImage | None = None
self._last_packet = None
self._active = False self._active = False
self._error = "" self._error = ""
self._status_key = "source.idle" self._status_key = "source.idle"
@@ -105,6 +109,22 @@ class FingerSourcePanel(QWidget):
self.preview_hint = QLabel(self) self.preview_hint = QLabel(self)
self.preview_hint.setWordWrap(True) self.preview_hint.setWordWrap(True)
layout.addWidget(self.preview_hint) 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 = QLabel(self)
self.preview.setObjectName("fingerVideoPreview") self.preview.setObjectName("fingerVideoPreview")
@@ -138,6 +158,11 @@ class FingerSourcePanel(QWidget):
self.start_button.setText(t("source.start")) self.start_button.setText(t("source.start"))
self.stop_button.setText(t("source.stop")) self.stop_button.setText(t("source.stop"))
self.preview_hint.setText(t("source.preview_only")) 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) self._set_status(self._status_key)
if self._last_image is None: if self._last_image is None:
self.preview.setText(t("source.no_frame")) self.preview.setText(t("source.no_frame"))
@@ -177,6 +202,7 @@ class FingerSourcePanel(QWidget):
self._error = "" self._error = ""
self._last_frame_id = None self._last_frame_id = None
self._last_image = None self._last_image = None
self._last_packet = None
self.preview.clear() self.preview.clear()
self.preview.setText(t("source.no_frame")) self.preview.setText(t("source.no_frame"))
self.frame_label.clear() self.frame_label.clear()
@@ -229,7 +255,8 @@ class FingerSourcePanel(QWidget):
identity = (packet.session_id, packet.frame_id) identity = (packet.session_id, packet.frame_id)
if identity != self._last_frame_id: if identity != self._last_frame_id:
self._last_frame_id = identity self._last_frame_id = identity
self._show_frame(packet.processed_frame) self._last_packet = packet
self._redraw()
acquired = datetime.fromtimestamp(packet.timestamp).strftime( acquired = datetime.fromtimestamp(packet.timestamp).strftime(
"%H:%M:%S.%f" "%H:%M:%S.%f"
)[:-3] )[:-3]
@@ -262,6 +289,54 @@ class FingerSourcePanel(QWidget):
}.get(reason, "source.stopped") }.get(reason, "source.stopped")
self._set_status(key) 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: def _show_frame(self, frame: np.ndarray) -> None:
if frame.ndim != 3 or frame.shape[2] != 3: if frame.ndim != 3 or frame.shape[2] != 3:
return return
@@ -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)
@@ -6,14 +6,14 @@ from pathlib import Path
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries 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.QtGui import QColor, QPainter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, 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): class SessionReviewDialog(QDialog):
@@ -24,6 +24,7 @@ class SessionReviewDialog(QDialog):
self.setWindowTitle(t("review.title")) self.setWindowTitle(t("review.title"))
self.resize(1080, 760) self.resize(1080, 760)
self.review = review_session(directory) self.review = review_session(directory)
self.directory = directory
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
self.notice = QLabel(t("review.notice"), self) self.notice = QLabel(t("review.notice"), self)
self.notice.setWordWrap(True) self.notice.setWordWrap(True)
@@ -35,8 +36,10 @@ class SessionReviewDialog(QDialog):
u, v = site["uv_xy_px"] u, v = site["uv_xy_px"]
self.sites.addItem(f"{site['site_id']} · U {u}, V {v} px") self.sites.addItem(f"{site['site_id']} · U {u}, V {v} px")
self.save_button = QPushButton(t("review.save"), self) 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.sites, 1)
row.addWidget(self.save_button) row.addWidget(self.save_button)
row.addWidget(self.load_button)
layout.addLayout(row) layout.addLayout(row)
self.summary = QLabel(self) self.summary = QLabel(self)
self.summary.setWordWrap(True) self.summary.setWordWrap(True)
@@ -59,11 +62,35 @@ class SessionReviewDialog(QDialog):
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True) self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table, 1) 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 = QLabel(self)
self.message.setWordWrap(True) self.message.setWordWrap(True)
layout.addWidget(self.message) layout.addWidget(self.message)
self.sites.currentIndexChanged.connect(self._show_site) self.sites.currentIndexChanged.connect(self._show_site)
self.save_button.clicked.connect(self._save) 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() self._show_site()
def _show_site(self) -> None: def _show_site(self) -> None:
@@ -81,17 +108,17 @@ class SessionReviewDialog(QDialog):
quality = t("review." + window["status"]) quality = t("review." + window["status"])
if not window["reference_alignment_possible"]: if not window["reference_alignment_possible"]:
quality += " · " + t("review.replay") quality += " · " + t("review.replay")
if not window["stability_verified"]: quality += " · " + t("review.verified" if window["stability_verified"] else "review.unverified")
quality += " · " + t("review.unverified")
values = [window["trial_id"], t("calibration." + window["branch"]), values = [window["trial_id"], t("calibration." + window["branch"]),
f"{window['reference_n']:g}", str(window["samples"]), f"{window['reference_n']:g}", str(window["samples"]),
self._number(window["raw_mean"]), self._number(window["raw_std"]), self._number(window["raw_mean"]), self._number(window["raw_std"]),
f"{window['duration_s']:g}", quality] f"{window['duration_s']:g}", quality]
for column, value in enumerate(values): for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value)) self.table.setItem(row, column, QTableWidgetItem(value))
fit = site["fit"] fit = site["confirmed_fit"] or site["fit"]
self.summary.setText( 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")): for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
series = QScatterSeries() series = QScatterSeries()
@@ -119,19 +146,101 @@ class SessionReviewDialog(QDialog):
self.chart.setTitle(t("review.chart_title")) self.chart.setTitle(t("review.chart_title"))
self._show_uv(index) self._show_uv(index)
self.message.setText(t("review.issues").format(count=len(self.review["issues"]))) 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: def _show_uv(self, selected: int) -> None:
self.uv_chart.removeAllSeries() self.uv_chart.removeAllSeries()
for axis in self.uv_chart.axes(): for axis in self.uv_chart.axes():
self.uv_chart.removeAxis(axis) 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) low, high = (min(values), max(values)) if values else (0, 0)
for index, site in enumerate(self.review["sites"]): for index, site in enumerate(self.review["sites"]):
series = QScatterSeries() series = QScatterSeries()
series.setName(site["site_id"]) series.setName(site["site_id"])
color = QColor("#929aa7") color = QColor("#929aa7")
if site["fit"]: if fits[index]:
value = site["fit"]["sensitivity_raw_per_n"] value = fits[index]["sensitivity_raw_per_n"]
ratio = (value - low) / (high - low) if high > low else .5 ratio = (value - low) / (high - low) if high > low else .5
color = QColor.fromHsvF((1 - ratio) * .65, .8, .85) color = QColor.fromHsvF((1 - ratio) * .65, .8, .85)
series.setColor(color) series.setColor(color)
@@ -164,5 +273,5 @@ class SessionReviewDialog(QDialog):
try: try:
path = save_review(self.review) path = save_review(self.review)
self.message.setText(t("review.saved").format(path=path)) 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)) self.message.setText(t("calibration.error").format(detail=exc))
@@ -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()
+88
View File
@@ -122,3 +122,91 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
qt_app.processEvents() qt_app.processEvents()
assert dialog.grab().save(str(tmp_path / "review.png")) assert dialog.grab().save(str(tmp_path / "review.png"))
dialog.close() 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
@@ -210,9 +210,11 @@ class MetricHistory:
y_ranges = [] y_ranges = []
for i in range(self.channels): for i in range(self.channels):
col = values[:, i] col = values[:, i]
ymin = float(np.nanmin(col)) finite = col[np.isfinite(col)]
ymax = float(np.nanmax(col)) if finite.size:
if not np.isfinite(ymin) or not np.isfinite(ymax): ymin = float(finite.min())
ymax = float(finite.max())
else:
ymin, ymax = 0.0, 1.0 ymin, ymax = 0.0, 1.0
y_ranges.append(self._smooth_y_range(i, ymin, ymax)) y_ranges.append(self._smooth_y_range(i, ymin, ymax))
return PlotState( return PlotState(
+13
View File
@@ -3,9 +3,22 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
import numpy as np
import warnings
from orisys_app_shared.force.history import MetricHistory 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: def _push_at_rate(hist: MetricHistory, *, fps: float, duration_sec: float, t0: float = 1000.0) -> None:
dt = 1.0 / fps dt = 1.0 / fps
n = int(round(duration_sec * fps)) + 1 n = int(round(duration_sec * fps)) + 1