补齐 Autocal 实时反馈与稳定窗口复核
显示实时力值、曲线及 UV 图层,并保存人工确认窗口和零载检查基准。定向测试、真实视频预览与已录会话复核闭环验证通过。
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user