From d06501d7d5196ef398d8f2e3bd9697a8480de78f Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 11 Oct 2026 20:15:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(autocal):=20=E5=AE=8C=E5=96=84=E6=A0=87?= =?UTF-8?q?=E5=AE=9A=E8=AE=B0=E5=BD=95=E6=A3=80=E6=9F=A5=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=B8=8EUV=E8=BD=AE=E5=BB=93=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐传感器身份、稳定段检查引导和未完成试验诊断,并保存采集轮廓快照。通过60项相关测试、已有记录读取及代码和文档检查。 --- apps/autocal/config/locales/en_US.json | 75 +++-- apps/autocal/config/locales/zh_CN.json | 75 +++-- .../docs/calibration-data-format.zh.md | 22 +- .../src/orisys_autocal/calibration_session.py | 1 + .../src/orisys_autocal/record_diagnostics.py | 56 ++++ .../src/orisys_autocal/sensor/uv_outline.py | 45 +++ .../src/orisys_autocal/session_review.py | 6 + apps/autocal/src/orisys_autocal/trial.py | 5 + .../orisys_autocal/ui/calibration_panel.py | 3 + .../src/orisys_autocal/ui/chart_helpers.py | 30 ++ .../ui/record_overview_dialog.py | 310 +++++++++++++++--- .../ui/session_review_dialog.py | 62 ++-- apps/autocal/tests/test_binding_purity.py | 7 + apps/autocal/tests/test_manual_trial.py | 24 ++ apps/autocal/tests/test_record_diagnostics.py | 84 +++++ apps/autocal/tests/test_record_overview.py | 52 ++- apps/autocal/tests/test_session_review.py | 3 + 17 files changed, 719 insertions(+), 141 deletions(-) create mode 100644 apps/autocal/src/orisys_autocal/record_diagnostics.py create mode 100644 apps/autocal/src/orisys_autocal/sensor/uv_outline.py create mode 100644 apps/autocal/src/orisys_autocal/ui/chart_helpers.py create mode 100644 apps/autocal/tests/test_record_diagnostics.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index cb4827e..dc2db74 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -488,6 +488,8 @@ "flow_missing": "No valid displacement for this frame" }, "overview": { + "capture_in_progress": "In progress", + "saved": "Analysis saved; hover for its location.", "open_session": "Open calibration session…", "empty": "Choose the session folder containing session.json to view collected sites, normal response and sensitivity. You can also open current data from Calibration capture.", "title": "Calibration record overview", @@ -496,36 +498,64 @@ "sensitivity": "Normal sensitivity", "import_plan": "Import planned-point CSV…", "clear_plan": "Clear plan comparison", - "load": "Load saved review…", - "save": "Save new overview and review", - "summary": "Acquired {collected}; planned {planned}; missing {missing}; incomplete attempts {incomplete}; pending review {pending}; reviewed {reviewed}; outside plan {extra}.", - "no_plan": "Without a plan, missing sites cannot be determined. Plan order is separate from acquisition site_id.", + "load": "Load saved analysis…", + "save": "Save analysis results", + "summary": "Collected {collected} / planned {planned}; missing {missing}; with incomplete attempts {incomplete}; unchecked {pending}; checked {reviewed}; extra {extra}.", + "no_plan": "Import a plan to identify missing sites.", "uncollected": "Not acquired", "incomplete": "Has incomplete attempts", - "pending": "Pending review", - "reviewed": "Reviewed", - "confirmed": "Confirmed", - "candidate": "Candidate", - "coverage_map": "Red: incomplete attempts; orange: pending; green: reviewed; grey: not acquired", + "pending": "Awaiting check", + "reviewed": "Stable windows checked", + "confirmed": "Checked", + "candidate": "Unchecked", + "coverage_map": "Red: incomplete attempts; amber: unchecked; green: checked; grey: uncollected", "sensitivity_map": "Confirmed sensitivity: blue→red {low:.4g}→{high:.4g} raw/N
Grey: no confirmed fit", - "details": "Inspect signal / review windows…", + "details": "Check hold windows…", "trial_filter": "Displayed trial", "all_trials": "All trials", "site_fit": "Site loading fit (all eligible trials)", "response_title": "Normal response: reference force → raw signal", - "selected_fit": "Site exploratory sensitivity {sensitivity} raw/N ({basis}); fit range and trial count are in the table. Physical signal/reference validation is still required.", - "no_windows": "No complete hold windows for this site/trial. Incomplete trials are excluded from fits; raw files are retained.", + "selected_fit": "Sensitivity {sensitivity} raw/N · {basis}. Fit uses all eligible loading windows at this site; range and trial count are in the site tooltip.", + "no_windows": "No eligible complete windows; recorded signal and stop diagnostics remain available.", "col_order": "Plan order", "col_site": "Acquired site", "col_uv": "U / V (px)", "col_xyz": "Sensor XYZ (mm)", - "col_status": "Status", + "col_status": "Analysis readiness", "col_trials": "Complete / all trials", "col_windows": "Confirmed / complete windows", "col_levels": "Confirmed loading levels (N)", "col_sensitivity": "Sensitivity (raw/N) / basis", "col_range": "Fit range (N)", - "col_fit_trials": "Fit trials" + "col_fit_trials": "Fit trials", + "tools": "Plan / saved analysis", + "identity": "Model: {model} SN: {sn} Acquired: {date} Source: {source} Capture: {status}", + "source_video": "Video replay (debug only)", + "source_camera": "Camera", + "timeline": "Time signal / diagnostics", + "response": "Loading / unloading response", + "time_axis": "Time from each trial’s first valid sample (s)", + "reference_axis": "Declared reference (N)", + "timeline_title": "Blue: FN_raw; orange: declared hold reference (not live FT)", + "no_trace": "No readable valid signal; inspect diagnostics below", + "outline_snapshot": "Grey: sensor boundary; blue: colour patch boundary (recorded snapshot)", + "outline_surface_only": "Sensor mapping verified; no recorded colour patch outline is available.", + "outline_unavailable": "Recorded UV outline unavailable; showing sites only.", + "outline_mismatch": "UV outline does not match the recorded mapping; overlay disabled.", + "analysis_reviewed": "Stable windows checked", + "analysis_pending": "Check stable windows", + "analysis_no_analysis": "No eligible windows", + "site_details": "XYZ (mm): {xyz}\nComplete / all attempts: {attempts}\nFit range (N): {range}; contributing trials: {trials}", + "inspect_incomplete": "Inspect incomplete attempt", + "diagnostic": "{trial} · {status} | Valid samples {samples}; invalid / unjoined {invalid}; recording drops {dropped}; largest frame gap {gap} s\nStop reason: {reason}", + "read_errors": "Missing or unreadable: {detail}", + "choose_trial": "Select one trial for integrity, drop and stop diagnostics. All-trial traces use separate time origins.", + "unsaved": "Window or plan changes have not been saved.", + "save_hint": "Confirming stability does not certify calibration accuracy.", + "guide_missing": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: this site has no attempt; choose a collected site or acquire it.", + "guide_incomplete": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect the stop reason and recorded signal. Incomplete attempts are excluded from fitting.", + "guide_check": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: select a trial and Check hold windows; adjust the time interval if needed.", + "guide_save": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect remaining holds or save current analysis." }, "quality": { "open": "Repeatability and validation…", @@ -562,9 +592,9 @@ "col_range_status": "Range/status" }, "review": { - "load_selection": "Load saved review…", - "confirm_stable": "Reviewed as stable", - "apply_window": "Apply window and recalculate", + "load_selection": "Load saved analysis…", + "confirm_stable": "This interval was checked for stability", + "apply_window": "Confirm interval and recompute", "start_offset": "Start", "end_offset": "End", "verified": "Stability confirmed by operator", @@ -575,9 +605,9 @@ "trace_title": "Selected hold signal; vertical lines mark the analysis window", "selection_unsaved": "Window applied; save the analysis to preserve this review.", "open": "Review calibration data…", - "title": "Exploratory calibration review", - "notice": "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", + "title": "Hold window inspection", + "notice": "Select a hold → inspect signal, standard deviation and drift → adjust the interval → confirm stability and recompute → save. Operator confirmation is not accuracy acceptance.", + "save": "Save analysis results", "no_sites": "No sites in this session", "candidate": "Candidate window", "insufficient_samples": "Insufficient valid samples", @@ -590,7 +620,7 @@ "force_axis": "Manual reference force (N)", "chart_title": "Hold-window means: loading vs unloading", "uv_title": "Site sensitivity: blue→red {low:.4g}→{high:.4g} raw/N; grey has no fit", - "issues": "Skipped {count} incomplete trials; complete holds still require stability review.", + "issues": "{count} incomplete attempts excluded from fitting. Inspect raw signals for stability.", "saved": "Saved new review: {path}", "col_trial": "Trial", "col_branch": "Phase", @@ -599,6 +629,7 @@ "col_mean": "Raw mean", "col_std": "Raw std", "col_duration": "Hold (s)", - "col_quality": "Quality" + "col_quality": "Quality", + "col_drift": "Drift (raw/s)" } } diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 88ab201..594edbf 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -488,6 +488,8 @@ "flow_missing": "本帧无有效位移信号" }, "overview": { + "capture_in_progress": "采集中", + "saved": "分析结果已保存;保存位置见提示。", "open_session": "打开标定会话…", "empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。", "title": "标定记录总览", @@ -496,36 +498,64 @@ "sensitivity": "法向灵敏度", "import_plan": "导入计划测点 CSV…", "clear_plan": "清除计划对照", - "load": "载入已保存复核…", - "save": "另存总览与复核", - "summary": "已采集 {collected} 点;计划 {planned} 点;未采 {missing};有未完成试验 {incomplete};待复核 {pending};已复核 {reviewed};计划外 {extra}。", - "no_plan": "未导入计划表,无法判断漏采点;序号仅用于计划对照,不作为采集 site_id。", + "load": "载入历史分析…", + "save": "保存分析结果", + "summary": "已采 {collected} 点/计划 {planned} 点;漏采 {missing};有未完成试验 {incomplete};待检查 {pending};已检查 {reviewed};计划外 {extra}。", + "no_plan": "未导入计划,暂不能判断漏采点。", "uncollected": "尚未采集", "incomplete": "有未完成试验", - "pending": "待复核", - "reviewed": "已复核", - "confirmed": "已确认", - "candidate": "候选", - "coverage_map": "红:有未完成试验;橙:待复核;绿:已复核;灰:未采", + "pending": "待检查", + "reviewed": "稳定段已检查", + "confirmed": "已检查", + "candidate": "待检查", + "coverage_map": "红:有未完成试验;橙:待检查;绿:已检查;灰:未采", "sensitivity_map": "已确认灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N
灰:无已确认拟合", - "details": "查看信号/复核窗口…", + "details": "检查保持段…", "trial_filter": "显示试验", "all_trials": "全部 trial", "site_fit": "测点加载拟合(全部符合条件的 trial)", "response_title": "法向响应:参考力 → 原始信号", - "selected_fit": "本测点初步灵敏度 {sensitivity} raw/N({basis});拟合范围和 trial 数见表格。原始信号与实际参考力仍须实机验证。", - "no_windows": "所选点/trial 无完整保持窗口;未完成试验不参与拟合,原始文件仍保留。", + "selected_fit": "灵敏度 {sensitivity} raw/N · {basis}。拟合按本点全部合格加载窗口计算;范围及试验数见测点提示。", + "no_windows": "所选试验没有可分析的完整窗口;可查看已有原始信号和结束原因。", "col_order": "计划序号", "col_site": "采集测点", "col_uv": "U / V (px)", "col_xyz": "传感器 XYZ (mm)", - "col_status": "状态", + "col_status": "分析准备状态", "col_trials": "完成/全部 trial", "col_windows": "确认/完整窗口", "col_levels": "已确认加载档位 (N)", "col_sensitivity": "灵敏度 (raw/N)/依据", "col_range": "拟合范围 (N)", - "col_fit_trials": "拟合 trial 数" + "col_fit_trials": "拟合 trial 数", + "tools": "计划/历史分析", + "identity": "型号:{model} SN:{sn} 采集时间:{date} 来源:{source} 采集:{status}", + "source_video": "视频重放(仅调试)", + "source_camera": "相机", + "timeline": "时间曲线/记录诊断", + "response": "加载/卸载响应", + "time_axis": "距各试验首个有效样本 (s)", + "reference_axis": "人工参考力 (N)", + "timeline_title": "蓝:FN_raw;橙:人工保持参考力(不是实时 FT)", + "no_trace": "没有可读的有效信号;请查看下方记录诊断", + "outline_snapshot": "灰:传感面轮廓;蓝:彩色 patch 区域轮廓(采集快照)", + "outline_surface_only": "传感面映射已核对;未保存彩色 patch 轮廓,无法恢复。", + "outline_unavailable": "找不到记录对应的 UV 轮廓;仅显示测点。", + "outline_mismatch": "UV 轮廓与记录映射不一致,已停用叠加。", + "analysis_reviewed": "稳定段已检查", + "analysis_pending": "待检查稳定段", + "analysis_no_analysis": "无可分析窗口", + "site_details": "XYZ (mm):{xyz}\n完成/全部试验:{attempts}\n拟合范围 (N):{range};拟合试验数:{trials}", + "inspect_incomplete": "查看中止记录", + "diagnostic": "{trial} · {status}|有效样本 {samples};无效/未关联 {invalid};录像丢帧 {dropped};最大帧间隔 {gap} s\n结束原因:{reason}", + "read_errors": "缺失或无法读取:{detail}", + "choose_trial": "请选择单次试验查看完整性、丢帧及结束原因;全部试验曲线按各自起点对齐。", + "unsaved": "窗口或计划已修改,尚未保存。", + "save_hint": "确认稳定只表示检查过信号,不代表标定精度合格。", + "guide_missing": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:此点尚未采集,请选择已采点或安排补采。", + "guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。", + "guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。", + "guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。" }, "quality": { "open": "重复性与验证…", @@ -562,9 +592,9 @@ "col_range_status": "范围/状态" }, "review": { - "load_selection": "载入已保存复核…", - "confirm_stable": "已检查此段稳定", - "apply_window": "应用窗口并重算", + "load_selection": "载入历史分析…", + "confirm_stable": "此时间段已检查稳定", + "apply_window": "确认选中时间段并重算", "start_offset": "起点", "end_offset": "终点", "verified": "人工已确认稳定", @@ -575,9 +605,9 @@ "trace_title": "选中保持段原始信号;竖线为分析窗口边界", "selection_unsaved": "窗口已应用;请另存分析结果以保留复核。", "open": "查看标定数据…", - "title": "标定数据初步分析", - "notice": "逐段查看原始信号,调整起止时间并确认稳定后应用。起止时间相对本段保持开始;人工复核不证明参考力准确或同步。视频重放仅供调试;拟合不是正式标定系数。", - "save": "另存分析结果", + "title": "保持段检查", + "notice": "选择保持段 → 检查曲线、标准差及漂移 → 调整起止时间 → 确认稳定并重算 → 保存。人工确认不是精度验收。", + "save": "保存分析结果", "no_sites": "会话没有测点", "candidate": "候选窗口", "insufficient_samples": "有效样本不足", @@ -590,7 +620,7 @@ "force_axis": "人工参考力 (N)", "chart_title": "各保持窗口均值:加载/卸载对照", "uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合", - "issues": "已跳过 {count} 个未完成 trial;完整保持窗口仍须复核稳定性。", + "issues": "{count} 次未完成试验不参与拟合;请按原始曲线检查稳定性。", "saved": "已另存:{path}", "col_trial": "试验", "col_branch": "阶段", @@ -599,6 +629,7 @@ "col_mean": "原始均值", "col_std": "原始标准差", "col_duration": "保持 (s)", - "col_quality": "质量说明" + "col_quality": "质量说明", + "col_drift": "漂移 (raw/s)" } } diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index 6d2cbc8..ba1f99f 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -1,6 +1,6 @@ # Autocal 标定操作与数据格式草稿 -状态:2026-10-11 修订,阶段一仅针对 finger,待实机验收。已接通手动加载/卸载、多测点采集、记录总览和窗口复核;FT 同步及正式灵敏度结果仍待实现。 +状态:2026-10-11 修订,仅 finger,待实机验收。手动采集、记录总览及稳定段检查已接通;FT 同步及正式系数待实现。 ## 范围与坐标 @@ -52,7 +52,7 @@ ## 目录与文件 -本场目录名为 `<产品型号>___<唯一标识>`,完整身份保存在 manifest。每个 trial 是一个测点的一次完整循环,档位用编号及时间窗口区分,不按力值拆原始视频目录。 +目录名为 `<产品型号>___<唯一标识>`,身份保存在 manifest。一个 trial 对应同点一次循环;档位用编号和时间区间区分,不拆视频。 ```text finger_F001_<时间>_<标识>/ @@ -73,7 +73,7 @@ finger_F001_<时间>_<标识>/ | 文件 | 内容及写入时机 | |---|---| -| 根 `session.json` | `format_version=autocal-session-1.0`、字符串 session_id、型号/SN、配置快照及哈希、UV 定义、sites、trials、状态;首次开始、每档保持结束、trial 结束和整场关闭时更新 | +| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态;开始、保持结束、trial 结束和整场关闭时更新 | | trial `session.json` | 共享录像元数据;另有 `calibration_session_id`、`trial_id`、`site_id`、`repeat_index`、site、plan、来源、配置、`trial_status`、`trial_reason`、steps;采集 session_id 是独立的整数编号 | | `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 | | `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 | @@ -108,15 +108,17 @@ finger_F001_<时间>_<标识>/ 主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。 -“标定记录”页打开会话目录;采集页“查看标定数据…”也跳转至此。点击 UV 点或表格,联动响应及 trial 筛选,再“查看信号/复核窗口…”。 +“标定记录”页打开会话,核对型号/SN、时间、来源和采集状态。选点及单次试验,先看时间曲线及诊断,再“检查保持段…”,最后“保存分析结果”。采集页“查看标定数据…”也跳转至此。 | 总览 | 规则 | |---|---| | 计划对照 | 导入模拟页 CSV;核对映射、原点、轴向、尺寸及三维位置,按 U/V 匹配;标记漏采和计划外点,同点重复序号合并 | -| 采集状态 | 未采、有未完成试验、待复核、已复核;完成次数及已确认窗口数分别列出,不把未完成 trial 用于拟合 | +| 状态 | 采集完整性与稳定段检查分别展示;未完成 trial 可查看曲线、结束原因及缺失文件,不参与拟合 | +| UV 轮廓 | 新采集的 uv_outline 保存映射身份及传感面/彩色 patch 外轮廓;旧记录仅恢复映射验证一致的几何轮廓,缺失或不匹配时提示;显示保持像素等比例 | +| 时间曲线 | FN_raw 及人工保持参考力;显示有效样本、无效/未关联数、录像丢帧、最大帧间隔;各 trial 独立时间原点,不代表 FT 同步 | | 法向响应 | 横轴实际参考 N,纵轴 FN_raw 均值;区分加载/卸载及候选/已确认,可筛选 trial;拟合仍基于本点全部合格加载窗口 | | 灵敏度图 | 仅已确认拟合按 raw/N 着色,其余灰色;不插值,表中另列拟合范围及 trial 数 | -| 保存恢复 | “另存总览与复核”保存计划快照及窗口;载入 review.json 同时恢复总览,无 overview.json 时仅恢复复核 | +| 保存恢复 | 保存计划及窗口快照;载入 review.json 恢复总览,无 overview.json 时仅恢复窗口;未保存修改有提示 | 拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。 @@ -124,12 +126,12 @@ finger_F001_<时间>_<标识>/ 实时 UV 图有真实背景、位移箭头、深度三个开关;关闭背景显示纯色画布。深度按当前帧有效 UV 有限值范围自适应,显示范围;跨帧同色不代表同值,算法单位不是毫米。缺失或过期字段不绘制并提示。显示不改变录像及坐标。 -复核时选中表格中的保持段,检查下方原始样本图;起点、终点以该段保持开始为 0 秒,只能在原保持段内调整。勾选“已检查此段稳定”,点击“应用窗口并重算”,最后“另存分析结果”。至少有两个不同载荷的已确认加载/零载窗口时,显示已确认窗口拟合;不足时明确显示候选拟合。人工确认只表示看过稳定性,不证明参考力准确或物理同步。 +检查保持段的原始曲线、样本数、标准差和漂移 raw/s;起止时间以保持开始为零,只能在原段内调整。勾选“此时间段已检查稳定”,点击“确认选中时间段并重算”,再保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合,否则显示候选拟合。确认稳定不代表精度合格;尚无自动验收阈值。 -分析结果保存 selections、候选与已确认拟合、原始样本轨迹、输入 CSV/manifest 的 SHA256、配置快照及分析包版本;每个已确认初始零载段保存基准图像及帧身份、图像哈希。点击“载入已保存复核…”选 review.json 恢复窗口并重算;输入身份或哈希改变时拒绝载入及保存。新增结果另存为独立目录,不修改 reference.csv 的采集声明。 +分析保存 selections、拟合、样本轨迹、输入 SHA256、配置及版本;确认的初始零载段保存基准图像、帧身份及哈希。“载入历史分析…”选 review.json 重算;输入身份或哈希变化时拒绝载入及保存。结果另存,不修改 reference.csv。 -“重复性与验证…”只用已确认窗口,结果保存在各 site 的 quality:同分支、相同人工参考力跨 trial 的极差/样本标准差;同 trial 同参考力的卸载减加载 raw 差;最终零载减初始零载。配对要求参考力完全相同,不插值;不足两次 trial 不计算重复性。 +“重复性与验证…”使用已确认窗口,site.quality 保存跨 trial 同分支/参考力的极差和样本标准差、同 trial 卸载减加载 raw 差及最终减初始零载。参考力须完全相同,不插值;不足两次不算重复性。 -独立验证逐次留出一个完整 trial,用其余 trial 的零载/加载窗口拟合,再预测留出 trial 的全部已确认窗口。每窗口等权,报告 RMSE/MAE/最大绝对误差及超出训练 raw 范围的数量;训练档位不足时列明原因。交叉验证仍不能代替另一场独立实验,也不生成正式实时系数。 +验证逐次留出完整 trial,用其余 trial 的零载/加载拟合预测留出窗口;窗口等权,报告 RMSE/MAE/最大误差及超出训练范围数量,训练不足时说明原因。交叉验证不能代替独立实验或正式实时系数。 跟踪器基准与算法历史尚未完整快照,录像不能保证从全新进程精确复算。相机、FT、实际平台反馈和容量/延迟须实机联调;自动加载、剪切、空间外推及实时部署留待后续。见[实施计划](implementation-plan.zh.md)。 diff --git a/apps/autocal/src/orisys_autocal/calibration_session.py b/apps/autocal/src/orisys_autocal/calibration_session.py index 710b134..fddcb1b 100644 --- a/apps/autocal/src/orisys_autocal/calibration_session.py +++ b/apps/autocal/src/orisys_autocal/calibration_session.py @@ -51,6 +51,7 @@ class CalibrationSession: "sensor_sn": metadata["sensor_sn"], "config_sha256": metadata["config_sha256"], "uv_definition": metadata["uv_definition"], + "uv_outline": metadata.get("uv_outline"), } def add_trial(self, metadata: dict) -> dict: diff --git a/apps/autocal/src/orisys_autocal/record_diagnostics.py b/apps/autocal/src/orisys_autocal/record_diagnostics.py new file mode 100644 index 0000000..5676eb0 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/record_diagnostics.py @@ -0,0 +1,56 @@ +"""Read trial evidence for inspection, including attempts excluded from fitting.""" + +import csv +import json +import math +from pathlib import Path + + +def trial_diagnostics(root: Path, trial: dict) -> dict: + result = {"trial_id": trial["trial_id"], "status": trial["status"], + "reason": trial.get("reason", ""), "trace": [], "references": [], "events": [], + "frames": 0, "invalid": 0, "dropped": 0, "max_gap_s": None, "errors": [], "breaks_ns": []} + path = (root / trial.get("directory", "__missing_trial__")).resolve() + if not path.is_relative_to(root.resolve()): + raise ValueError("trial directory is outside the calibration session") + + def rows(name): + try: + with (path / name).open(encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + except OSError: + result["errors"].append(name) + return [] + + frames = rows("frames.csv") + frame_by_id = {row["frame_id"]: row for row in frames if row.get("frame_id")} + result["frames"] = len(frames) + result["dropped"] = sum(r.get("dropped") == "1" for r in frames) + stamps = {} + try: + for row in frames: + if row.get("t_host_mono_ns") and row.get("dropped") == "0": + stamps[int(row["frame_id"])] = int(row["t_host_mono_ns"]) + ordered = sorted(stamps.values()) + result["max_gap_s"] = max((b - a for a, b in zip(ordered, ordered[1:])), default=0) / 1e9 + for row in rows("forces.csv"): + stamp = stamps.get(int(row["frame_id"])) + raw = float(row["FN_raw"]) if row.get("FN_raw") else math.nan + if row.get("valid") != "1" or not math.isfinite(raw) or stamp is None: + result["invalid"] += 1 + frame = frame_by_id.get(row["frame_id"]) + if frame and frame.get("t_host_mono_ns"): + result["breaks_ns"].append(int(frame["t_host_mono_ns"])) + else: + result["trace"].append([stamp, raw]) + result["trace"].sort() + result["breaks_ns"].sort() + result["references"] = rows("reference.csv") + result["events"] = rows("events.csv") + metadata = json.loads((path / "session.json").read_text(encoding="utf-8")) + result["source"] = metadata.get("input_source", {}) + result["time_basis"] = metadata.get("time_basis", "") + result["reason"] = metadata.get("trial_reason") or result["reason"] + except (OSError, ValueError, KeyError, TypeError) as exc: + result["errors"].append(str(exc)) + return result diff --git a/apps/autocal/src/orisys_autocal/sensor/uv_outline.py b/apps/autocal/src/orisys_autocal/sensor/uv_outline.py new file mode 100644 index 0000000..43445d7 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/sensor/uv_outline.py @@ -0,0 +1,45 @@ +"""Recorded UV region outlines; geometry recovery never substitutes a texture.""" + +import cv2 +import numpy as np + +from .export_bundle import load_bundle +from .uv_flat import mapping_sha256, uv_origin, rasterise + + +def outline_snapshot(flat) -> dict: + def contours(mask): + rings, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + return [cv2.approxPolyDP(ring, .5, True).reshape(-1, 2).tolist() + for ring in rings if len(ring) >= 3] + + valid = flat.face_index >= 0 + return { + "mapping_sha256": mapping_sha256(flat), "origin_raster_xy_px": list(uv_origin(flat)), + "raster_size_hw": list(flat.size_hw), "surface": contours(valid), + # External contours intentionally omit individual white marker cells. + "patch": contours(valid & (flat.image_rgb.min(axis=2) < 235)) if flat.textured else [], + "patch_available": bool(flat.textured), + } + + +def recorded_outline(review: dict) -> tuple[dict | None, str]: + definition = review["uv_definition"] + snapshot = review.get("uv_outline") + if snapshot: + if any(snapshot[key] != definition[key] for key in ( + "mapping_sha256", "origin_raster_xy_px", "raster_size_hw")): + return None, "mismatch" + return snapshot, "snapshot" if snapshot["patch_available"] else "surface_only" + site = next((s for s in review["sites"] if s.get("bundle_path")), None) + if site is None: + return None, "unavailable" + try: + flat = rasterise(load_bundle(site["bundle_path"]), tuple(definition["raster_size_hw"])) + snapshot = outline_snapshot(flat) + except (OSError, ValueError, KeyError, RuntimeError): + return None, "unavailable" + if any(snapshot[key] != definition[key] for key in ( + "mapping_sha256", "origin_raster_xy_px", "raster_size_hw")): + return None, "mismatch" + return snapshot, "surface_only" diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py index cedec4b..9055076 100644 --- a/apps/autocal/src/orisys_autocal/session_review.py +++ b/apps/autocal/src/orisys_autocal/session_review.py @@ -200,6 +200,10 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict: "trace": trace, "raw_mean": float(raw.mean()) if raw.size else None, "raw_std": float(raw.std()) if raw.size else None, + "raw_drift_per_s": float(np.polyfit( + np.array([(stamp - selected_start) / 1e9 for stamp, _, _ in trace + if selected_start <= stamp < selected_end]), raw, 1)[0]) + if raw.size >= 2 and len({stamp for stamp, _, _ in trace if selected_start <= stamp < selected_end}) >= 2 else None, "stability_verified": verified, "reference_alignment_possible": bool(metadata["reference_alignment_possible"]), "status": "candidate" if raw.size >= 2 else "insufficient_samples", @@ -224,6 +228,8 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict: "status": "exploratory", "sites": sites, "issues": issues, "selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs, "trials": manifest["trials"], "uv_definition": manifest["uv_definition"], + "sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"), + "started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"), } diff --git a/apps/autocal/src/orisys_autocal/trial.py b/apps/autocal/src/orisys_autocal/trial.py index 59fc585..5caa64e 100644 --- a/apps/autocal/src/orisys_autocal/trial.py +++ b/apps/autocal/src/orisys_autocal/trial.py @@ -84,6 +84,7 @@ class ManualTrial: source: dict, config_ref: str, fps: float, + uv_outline: dict | None = None, ) -> Path: if self.status in {"recording", "closing"}: raise RuntimeError("a trial is already active") @@ -141,7 +142,11 @@ class ManualTrial: "reference_alignment_possible": self.reference_alignment_possible, "reference_source": "manual_operator_confirmation", "reference_file": "reference.csv", + "uv_outline": uv_outline, } + if uv_outline is not None and any(uv_outline[key] != metadata["uv_definition"][key] + for key in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")): + raise ValueError("UV outline does not match the selected site mapping") recorder = ExperimentRecorder( RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True) ) diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py index f6116ef..5c54d5a 100644 --- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -27,6 +27,7 @@ from PySide6.QtWidgets import ( ) from ..sensor.uv_flat import site_at, uv_origin +from ..sensor.uv_outline import outline_snapshot from ..trial import ManualTrial, load_path from .sensor_panel import FlatSurfaceView from .live_force_view import LiveForceView @@ -199,6 +200,7 @@ class CalibrationPanel(QWidget): if self.trial.session is not None: raise RuntimeError(t("calibration.close_before_mapping")) self._flat = flat + self._uv_outline = outline_snapshot(flat) self._bundle_path = bundle_path self.flat_view.set_flat(flat) origin_x, origin_y = uv_origin(flat) @@ -275,6 +277,7 @@ class CalibrationPanel(QWidget): source=source, config_ref=self.source_panel.config_ref.text().strip(), fps=mode.fps, + uv_outline=self._uv_outline, ) self.views.setCurrentWidget(self.source_panel) self.result_label.setText( diff --git a/apps/autocal/src/orisys_autocal/ui/chart_helpers.py b/apps/autocal/src/orisys_autocal/ui/chart_helpers.py new file mode 100644 index 0000000..be50991 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/chart_helpers.py @@ -0,0 +1,30 @@ +"""Shared value axes for record and hold inspection charts.""" + +from PySide6.QtCharts import QValueAxis +from PySide6.QtCore import Qt + + +def clear_chart(chart): + chart.removeAllSeries() + for axis in chart.axes(): + axis.hide() + chart.removeAxis(axis) + axis.deleteLater() + + +def add_value_axes(chart): + points = [point for series in chart.series() for point in series.points()] + font = chart.font() + font.setPixelSize(14) + for horizontal in (True, False): + axis = QValueAxis() + axis.setLabelsFont(font) + axis.setTitleFont(font) + values = [p.x() if horizontal else p.y() for p in points] + if values: + low, high = min(values), max(values) + margin = max((high - low) * .1, .1) + axis.setRange(low - margin, high + margin) + chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft) + for series in chart.series(): + series.attachAxis(axis) diff --git a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py index 9e099f4..afde61f 100644 --- a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py @@ -1,19 +1,29 @@ """Recorded-site coverage and normal response, with optional operator CSV plan.""" from pathlib import Path +from datetime import datetime +import statistics +import math +from bisect import bisect_left, bisect_right from orisys_app_shared.i18n import t from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QPointF from PySide6.QtGui import QColor, QPainter from PySide6.QtWidgets import ( QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel, - QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTabWidget, + QSizePolicy, QToolButton, QMenu, + QGraphicsSimpleTextItem, + QScrollArea, QFrame, ) from ..record_overview import load_overview, overview_rows, read_plan, save_overview from ..session_review import review_session +from ..record_diagnostics import trial_diagnostics +from ..sensor.uv_outline import recorded_outline from .session_review_dialog import SessionReviewDialog +from .chart_helpers import add_value_axes, clear_chart class RecordOverviewPage(QWidget): @@ -27,12 +37,20 @@ class RecordOverviewPage(QWidget): self.open_button = QPushButton(self) self.open_button.setObjectName("openCalibrationRecords") self.open_button.clicked.connect(self._open) + self.open_button.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) + self.open_button.setStyleSheet("font-size: 14px; min-height: 22px; padding: 3px 10px;") self.path_label = QLabel(self) self.path_label.setWordWrap(True) self.message = QLabel(self) self.message.setWordWrap(True) - self.content_layout.addWidget(self.open_button) - self.content_layout.addWidget(self.path_label) + self.message.hide() + self.viewer = QScrollArea(self) + self.viewer.setWidgetResizable(True) + self.viewer.setFrameShape(QFrame.Shape.NoFrame) + header = QHBoxLayout() + header.addWidget(self.open_button) + header.addWidget(self.path_label, 1) + self.content_layout.addLayout(header) self.content_layout.addWidget(self.message) self.content_layout.addStretch(1) self.retranslate() @@ -52,19 +70,23 @@ class RecordOverviewPage(QWidget): overview = RecordOverviewDialog(directory, self) except (OSError, ValueError, KeyError, TypeError) as exc: self.message.setText(t("calibration.error").format(detail=exc)) + self.message.show() return overview.setWindowFlags(Qt.WindowType.Widget) if self.overview is not None: - self.content_layout.removeWidget(self.overview) + self.viewer.takeWidget() self.overview.hide() self.overview.deleteLater() else: self.content_layout.takeAt(self.content_layout.count() - 1) + self.content_layout.addWidget(self.viewer, 1) self.overview = overview self.directory = directory - self.path_label.setText(str(directory)) + self.path_label.setText(directory.name) + self.path_label.setToolTip(str(directory)) self.message.clear() - self.content_layout.addWidget(overview, 1) + self.message.hide() + self.viewer.setWidget(overview) overview.show() @@ -77,38 +99,79 @@ class RecordOverviewDialog(QDialog): super().__init__(parent) self.directory = directory self.review = review_session(directory) + self.diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]} + self.outline, self.outline_state = recorded_outline(self.review) + self._uv_bounds = None + self._uv_label = None + self._fitting_uv = False + self._dirty = False + self._selected_site_id = None self.plan = None self.rows = [] self.setWindowTitle(t("overview.title")) self.resize(1240, 850) + self.setObjectName("recordOverview") + self.setStyleSheet("#recordOverview QLabel, #recordOverview QTableWidget, #recordOverview QComboBox, " + "#recordOverview QPushButton, #recordOverview QToolButton {font-size: 14px;} " + "#recordOverview QPushButton, #recordOverview QComboBox {min-height: 22px; padding: 3px 10px;} " + "#recordOverview QHeaderView::section {font-size: 13px;}") layout = QVBoxLayout(self) - self.notice = QLabel(t("overview.notice"), self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(6) + self.identity = QLabel(self) + self.identity.setWordWrap(True) + layout.addWidget(self.identity) + self.notice = QLabel(self) self.notice.setWordWrap(True) + self.notice.setToolTip(t("overview.notice")) layout.addWidget(self.notice) toolbar = QHBoxLayout() self.map_mode = QComboBox(self) self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")]) toolbar.addWidget(self.map_mode) - for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), - ("load", self._load), ("save", self._save)): - button = QPushButton(t("overview." + name), self) - button.setObjectName("overview_" + name) - button.clicked.connect(slot) - toolbar.addWidget(button) + self.map_mode.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) + self.tools = QToolButton(self) + self.tools.setText(t("overview.tools")) + self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + menu = QMenu(self.tools) + for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), ("load", self._load)): + action = menu.addAction(t("overview." + name)) + action.setObjectName("overview_" + name) + action.triggered.connect(slot) + self.tools.setMenu(menu) + toolbar.addWidget(self.tools) + toolbar.addStretch(1) layout.addLayout(toolbar) self.summary = QLabel(self) self.summary.setWordWrap(True) layout.addWidget(self.summary) charts = QHBoxLayout() self.uv_chart, self.response_chart = QChart(), QChart() + self.timeline_chart = QChart() self.uv_view, self.response_view = QChartView(self.uv_chart), QChartView(self.response_chart) - for view in (self.uv_view, self.response_view): + self.timeline_view = QChartView(self.timeline_chart) + for view in (self.uv_view, self.response_view, self.timeline_view): view.setRenderHint(QPainter.RenderHint.Antialiasing) - charts.addWidget(self.uv_view, 1) - charts.addWidget(self.response_view, 2) + view.chart().setMinimumSize(0, 0) + view.setMinimumHeight(260) + font = view.chart().font() + font.setPixelSize(14) + view.chart().setFont(font) + uv_column = QVBoxLayout() + uv_column.addWidget(self.uv_view, 1) + self.outline_message = QLabel(t("overview.outline_" + self.outline_state), self) + self.outline_message.setWordWrap(True) + uv_column.addWidget(self.outline_message) + charts.addLayout(uv_column, 2) + self.plots = QTabWidget(self) + self.plots.addTab(self.timeline_view, t("overview.timeline")) + self.plots.addTab(self.response_view, t("overview.response")) + charts.addWidget(self.plots, 3) + self.uv_chart.plotAreaChanged.connect(self._fit_uv_axes) layout.addLayout(charts, 2) self.table = QTableWidget(0, 11, self) self.table.setObjectName("overviewSites") + self.table.setMinimumHeight(125) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) @@ -116,28 +179,54 @@ class RecordOverviewDialog(QDialog): "order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.table.horizontalHeader().setStretchLastSection(True) + for column in (0, 3, 9, 10): + self.table.setColumnHidden(column, True) layout.addWidget(self.table, 1) selection = QHBoxLayout() self.trials = QComboBox(self) self.trials.setObjectName("overviewTrial") self.details_button = QPushButton(t("overview.details"), self) selection.addWidget(QLabel(t("overview.trial_filter"), self)) - selection.addWidget(self.trials, 1) + selection.addWidget(self.trials) + selection.addStretch(1) selection.addWidget(self.details_button) + self.save_button = QPushButton(t("overview.save"), self) + self.save_button.setObjectName("overview_save") + self.save_button.clicked.connect(self._save) + selection.addWidget(self.save_button) layout.addLayout(selection) self.selected_summary = QLabel(self) self.selected_summary.setWordWrap(True) layout.addWidget(self.selected_summary) + self.diagnostic_summary = QLabel(self) + self.diagnostic_summary.setWordWrap(True) + layout.addWidget(self.diagnostic_summary) self.message = QLabel(self) self.message.setWordWrap(True) layout.addWidget(self.message) + self.selected_summary.hide() + self.plots.currentChanged.connect(lambda index: self.selected_summary.setVisible(index == 1)) self.table.itemSelectionChanged.connect(self._select_site) self.map_mode.currentIndexChanged.connect(self._show_uv) self.trials.currentIndexChanged.connect(self._show_response) + self.trials.currentIndexChanged.connect(self._show_timeline) self.details_button.clicked.connect(self._details) self._refresh() def _refresh(self): + sources = sorted({d.get("source", {}).get("kind", "") for d in self.diagnostics.values()} - {""}) + date = self.review.get("started_at") + if date: + try: + date = datetime.fromisoformat(date).astimezone().strftime("%Y-%m-%d %H:%M:%S %z") + except ValueError: + pass + self.identity.setText(t("overview.identity").format( + model=self.review.get("sensor_model") or "—", sn=self.review.get("sensor_sn") or "—", + date=date or "—", source=" / ".join(t("overview.source_" + s) for s in sources) or "—", + status=t("overview.capture_in_progress") if self.review["session_status"] == "in_progress" + else t("calibration." + self.review["session_status"]))) + self.identity.setToolTip(self.review["session_id"] + "\n" + str(self.directory)) selected = self.table.currentRow() self.rows = overview_rows(self.review, self.plan) self.table.blockSignals(True) @@ -152,25 +241,31 @@ class RecordOverviewDialog(QDialog): f"{row['confirmed_windows']}/{row['windows']}", ", ".join(f"{v:g}" for v in row["confirmed_levels_n"]) or "—", sensitivity, "–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])] + analysis = "reviewed" if row["windows"] and row["confirmed_windows"] == row["windows"] else "pending" if row["windows"] else "no_analysis" + values[4] = t("overview.analysis_" + analysis) for column, value in enumerate(values): self.table.setItem(index, column, QTableWidgetItem(value)) + self.table.item(index, 1).setToolTip(t("overview.site_details").format( + xyz=values[3], attempts=values[5], range=values[9], trials=values[10])) self.table.blockSignals(False) collected = sum(row["trials"] > 0 for row in self.rows) self.summary.setText(t("overview.summary").format( collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—", - missing=sum(r["status"] == "uncollected" for r in self.rows), + missing=sum(r["status"] == "uncollected" for r in self.rows) if self.plan else "—", incomplete=sum(r["status"] == "incomplete" for r in self.rows), pending=sum(r["status"] == "pending" for r in self.rows), reviewed=sum(r["status"] == "reviewed" for r in self.rows), extra=sum(not r["planned"] and r["trials"] > 0 for r in self.rows) if self.plan else "—", ) + ("\n" + t("overview.no_plan") if self.plan is None else "")) - self.message.setText(t("review.issues").format(count=len(self.review["issues"]))) + self.message.setText(t("overview.unsaved") if self._dirty else "") + self.message.setVisible(self._dirty) if self.rows: self.table.selectRow(min(max(selected, 0), len(self.rows) - 1)) self._select_site() def _select_site(self): index = self.table.currentRow() + previous = self.trials.currentData() self.trials.blockSignals(True) self.trials.clear() self.trials.addItem(t("overview.all_trials"), None) @@ -179,33 +274,22 @@ class RecordOverviewDialog(QDialog): for trial in self.review["trials"]: if site_id is not None and trial.get("site_id") == site_id: self.trials.addItem(trial["trial_id"] + " · " + t("calibration." + trial["status"]), trial["trial_id"]) + match = self.trials.findData(previous) if site_id == self._selected_site_id else -1 + self.trials.setCurrentIndex(match if match >= 0 else 1 if self.trials.count() > 1 else 0) + self._selected_site_id = site_id self.trials.blockSignals(False) self._show_uv() self._show_response() + self._show_timeline() - @staticmethod - def _clear_chart(chart): - chart.removeAllSeries() - for axis in chart.axes(): - axis.hide() - chart.removeAxis(axis) - axis.deleteLater() - - @staticmethod - def _axes(chart): - points = [point for series in chart.series() for point in series.points()] - for horizontal in (True, False): - axis = QValueAxis() - values = [p.x() if horizontal else p.y() for p in points] - if values: - low, high = min(values), max(values) - margin = max((high - low) * .1, .1) - axis.setRange(low - margin, high + margin) - chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft) - for series in chart.series(): - series.attachAxis(axis) + _clear_chart = staticmethod(clear_chart) + _axes = staticmethod(add_value_axes) def _show_uv(self): + self._uv_bounds = None + if self._uv_label is not None: + self.uv_chart.scene().removeItem(self._uv_label) + self._uv_label = None self._clear_chart(self.uv_chart) sensitivity = self.map_mode.currentIndex() == 1 values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]] @@ -226,21 +310,55 @@ class RecordOverviewDialog(QDialog): series.append(*row["uv_xy_px"]) series.clicked.connect(lambda _point, target=index: self.table.selectRow(target)) self.uv_chart.addSeries(series) + if index == self.table.currentRow(): + self._uv_label = QGraphicsSimpleTextItem(row["site_id"] or str(row["orders"]), self.uv_chart) + self._uv_label.setFont(self.uv_chart.font()) + self._uv_label.setZValue(10) + self._uv_label_point = QPointF(*row["uv_xy_px"]) self._axes(self.uv_chart) - definition = self.review["uv_definition"] - origin = definition.get("origin_raster_xy_px") - shape = definition.get("raster_size_hw") + bounds = [tuple(row["uv_xy_px"]) for row in self.rows] + [(0, 0)] + if self.outline: + ox, oy = self.outline["origin_raster_xy_px"] + for region, color in (("surface", "#687585"), ("patch", "#719fb8")): + for ring in self.outline[region]: + series = QLineSeries() + series.setColor(QColor(color)) + for x, y in ring + ring[:1]: + series.append(x - ox, oy - y) + bounds.append((x - ox, oy - y)) + self.uv_chart.addSeries(series) + for axis in self.uv_chart.axes(): + series.attachAxis(axis) + xs, ys = zip(*bounds) + self._uv_bounds = min(xs), max(xs), min(ys), max(ys) for axis in self.uv_chart.axes(Qt.Orientation.Horizontal): axis.setTitleText("U (px)") - if origin and shape: - axis.setRange(-origin[0] - .5, shape[1] - origin[0] - .5) + axis.setLabelFormat("%.0f") for axis in self.uv_chart.axes(Qt.Orientation.Vertical): axis.setTitleText("V (px)") - if origin and shape: - axis.setRange(origin[1] - shape[0] + .5, origin[1] + .5) + axis.setLabelFormat("%.0f") self.uv_chart.legend().hide() self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high) if sensitivity else t("overview.coverage_map")) + self._fit_uv_axes() + + def _fit_uv_axes(self, *_args): + area = self.uv_chart.plotArea() + if self._fitting_uv or self._uv_bounds is None or area.width() <= 0 or area.height() <= 0: + return + self._fitting_uv = True + try: + left, right, bottom, top = self._uv_bounds + scale = max(max(right - left, 2) / area.width(), max(top - bottom, 2) / area.height()) * 1.12 + for horizontal in (True, False): + axis = self.uv_chart.axes(Qt.Orientation.Horizontal if horizontal else Qt.Orientation.Vertical)[0] + center = (left + right) / 2 if horizontal else (bottom + top) / 2 + span = scale * (area.width() if horizontal else area.height()) + axis.setRange(center - span / 2, center + span / 2) + if self._uv_label is not None: + self._uv_label.setPos(self.uv_chart.mapToPosition(self._uv_label_point) + QPointF(8, -20)) + finally: + self._fitting_uv = False def _show_response(self): self._clear_chart(self.response_chart) @@ -249,7 +367,12 @@ class RecordOverviewDialog(QDialog): site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None) trial_id = self.trials.currentData() windows = [w for w in site["windows"] if trial_id is None or w["trial_id"] == trial_id] if site else [] - self.details_button.setEnabled(site is not None and bool(windows)) + attempts = [t for t in self.review["trials"] if site is not None and t.get("site_id") == site_id + and (trial_id is None or t["trial_id"] == trial_id)] + self.details_button.setEnabled(bool(attempts)) + self.details_button.setText(t("overview.details" if windows else "overview.inspect_incomplete")) + self.notice.setText(t("overview.guide_missing" if not attempts else "overview.guide_incomplete" if not windows + else "overview.guide_save" if self._dirty else "overview.guide_check")) for trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})): for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")): for confirmed in (True, False): @@ -286,6 +409,76 @@ class RecordOverviewDialog(QDialog): basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—", ) if windows else t("overview.no_windows")) + def _show_timeline(self): + self._clear_chart(self.timeline_chart) + index = self.table.currentRow() + site_id = self.rows[index]["site_id"] if index >= 0 else None + trial_id = self.trials.currentData() + reports = [self.diagnostics[t["trial_id"]] for t in self.review["trials"] + if site_id is not None and t.get("site_id") == site_id + and (trial_id is None or t["trial_id"] == trial_id)] + summaries, reference_series = [], [] + for report in reports: + trace = report["trace"] + summaries.append(t("overview.diagnostic").format( + trial=report["trial_id"], status=t("calibration." + report["status"]), + samples=len(trace), invalid=report["invalid"], dropped=report["dropped"], + gap=f"{report['max_gap_s']:.3g}" if report["max_gap_s"] is not None else "—", + reason=report["reason"] or "—")) + if report["errors"]: + summaries.append(t("overview.read_errors").format(detail=", ".join(report["errors"]))) + if not trace: + continue + origin = trace[0][0] + gaps = [(b[0] - a[0]) / 1e9 for a, b in zip(trace, trace[1:]) if b[0] > a[0]] + limit = max(statistics.median(gaps) * 5, .25) if gaps else .25 + series = None + previous = None + for stamp, raw in trace: + if series is None or (previous is not None and ((stamp - previous) / 1e9 > limit + or bisect_right(report["breaks_ns"], previous) < bisect_left(report["breaks_ns"], stamp))): + series = QLineSeries() + series.setName(report["trial_id"]) + series.setColor(QColor("#167ac6")) + series.setPointsVisible(True) + self.timeline_chart.addSeries(series) + series.append((stamp - origin) / 1e9, raw) + previous = stamp + for ref in report["references"]: + try: + start, end = int(ref["t_start_host_mono_ns"]), int(ref["t_end_host_mono_ns"]) + force = float(ref["F_ref_normal_N"]) + except (KeyError, ValueError, TypeError): + continue + if end <= start or not math.isfinite(force): + continue + series = QLineSeries() + series.setColor(QColor("#df8b13")) + series.append((start - origin) / 1e9, force) + series.append((end - origin) / 1e9, force) + reference_series.append(series) + self._axes(self.timeline_chart) + for axis in self.timeline_chart.axes(Qt.Orientation.Horizontal): + axis.setTitleText(t("overview.time_axis")) + for axis in self.timeline_chart.axes(Qt.Orientation.Vertical): + axis.setTitleText(t("review.raw_axis")) + if reference_series: + reference_axis = QValueAxis() + reference_axis.setTitleText(t("overview.reference_axis")) + forces = [p.y() for series in reference_series for p in series.points()] + reference_axis.setRange(min(0, min(forces)), max(1, max(forces)) * 1.1) + self.timeline_chart.addAxis(reference_axis, Qt.AlignmentFlag.AlignRight) + horizontal = self.timeline_chart.axes(Qt.Orientation.Horizontal)[0] + for series in reference_series: + self.timeline_chart.addSeries(series) + series.attachAxis(horizontal) + series.attachAxis(reference_axis) + self.timeline_chart.legend().hide() + self.timeline_chart.setTitle(t("overview.timeline_title") if any(r["trace"] for r in reports) else t("overview.no_trace")) + self.selected_summary.setToolTip("\n".join(summaries)) + self.diagnostic_summary.setText("\n".join(summaries) if len(reports) == 1 + else t("overview.choose_trial") if reports else t("overview.guide_missing")) + def _import_plan(self): path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)") if path: @@ -293,17 +486,26 @@ class RecordOverviewDialog(QDialog): plan = read_plan(Path(path), self.review["uv_definition"]) overview_rows(self.review, plan) self.plan = plan + self._dirty = True self._refresh() except (OSError, ValueError, KeyError, TypeError) as exc: self.message.setText(t("calibration.error").format(detail=exc)) + self.message.show() def _clear_plan(self): self.plan = None + self._dirty = True self._refresh() def _details(self): row = self.rows[self.table.currentRow()] index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"]) + trial = self.trials.currentData() + if not any(trial is None or w["trial_id"] == trial for w in self.review["sites"][index]["windows"]): + self.plots.setCurrentIndex(0) + self.notice.setText(t("overview.guide_incomplete")) + return + before = dict(self.review["selections"]) dialog = SessionReviewDialog(self.directory, self, review=self.review) dialog.sites.setCurrentIndex(index) trial = self.trials.currentData() @@ -314,14 +516,20 @@ class RecordOverviewDialog(QDialog): dialog.table.selectRow(match) dialog.exec() self.review = dialog.review + self._dirty = self._dirty or before != self.review["selections"] self._refresh() def _save(self): try: path = save_overview(self.review, self.plan) - self.message.setText(t("review.saved").format(path=path)) + self._dirty = False + self._show_response() + self.message.setText(t("overview.saved")) + self.message.show() + self.message.setToolTip(str(path)) except (OSError, ValueError, KeyError, TypeError) as exc: self.message.setText(t("calibration.error").format(detail=exc)) + self.message.show() def _load(self): path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)") @@ -329,6 +537,8 @@ class RecordOverviewDialog(QDialog): try: review, plan = load_overview(self.directory, Path(path)) self.review, self.plan = review, plan + self._dirty = False self._refresh() except (OSError, ValueError, KeyError, TypeError) as exc: self.message.setText(t("calibration.error").format(detail=exc)) + self.message.show() diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py index 44692a0..fc6a175 100644 --- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py @@ -10,10 +10,11 @@ from PySide6.QtCore import Qt, QPointF from PySide6.QtGui import QColor, QPainter from PySide6.QtWidgets import ( QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, - QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, + QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget, ) from ..session_review import review_session, save_review, load_review +from .chart_helpers import add_value_axes, clear_chart class SessionReviewDialog(QDialog): @@ -23,6 +24,10 @@ class SessionReviewDialog(QDialog): super().__init__(parent) self.setWindowTitle(t("review.title")) self.resize(1080, 760) + self.setObjectName("holdInspection") + self.setStyleSheet("#holdInspection QLabel, #holdInspection QPushButton, #holdInspection QComboBox, " + "#holdInspection QTableWidget, #holdInspection QCheckBox {font-size: 14px;} " + "#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}") self.review = review if review is not None else review_session(directory) self.directory = directory layout = QVBoxLayout(self) @@ -41,6 +46,12 @@ class SessionReviewDialog(QDialog): row.addWidget(self.save_button) row.addWidget(self.load_button) layout.addLayout(row) + self.pages = QTabWidget(self) + hold_page, response_page = QWidget(self), QWidget(self) + hold_layout, response_layout = QVBoxLayout(hold_page), QVBoxLayout(response_page) + self.pages.addTab(hold_page, t("overview.details")) + self.pages.addTab(response_page, t("overview.response")) + layout.addWidget(self.pages, 1) self.summary = QLabel(self) self.summary.setWordWrap(True) summary_row = QHBoxLayout() @@ -49,7 +60,7 @@ class SessionReviewDialog(QDialog): self.quality_button.setObjectName("reviewQuality") self.quality_button.clicked.connect(self._quality) summary_row.addWidget(self.quality_button) - layout.addLayout(summary_row) + response_layout.addLayout(summary_row) self.chart = QChart() self.chart_view = QChartView(self.chart, self) self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -59,15 +70,15 @@ class SessionReviewDialog(QDialog): charts = QHBoxLayout() charts.addWidget(self.uv_view, 1) charts.addWidget(self.chart_view, 2) - layout.addLayout(charts, 1) - self.table = QTableWidget(0, 8, self) + response_layout.addLayout(charts, 1) + self.table = QTableWidget(0, 9, self) self.table.setObjectName("reviewWindows") self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.setHorizontalHeaderLabels([t(f"review.col_{key}") for key in ( - "trial", "branch", "load", "samples", "mean", "std", "duration", "quality")]) + "trial", "branch", "load", "samples", "mean", "std", "duration", "quality", "drift")]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self.table.horizontalHeader().setStretchLastSection(True) - layout.addWidget(self.table, 1) + hold_layout.addWidget(self.table, 1) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) editor = QHBoxLayout() @@ -84,11 +95,11 @@ class SessionReviewDialog(QDialog): editor.addWidget(self.end_s) editor.addWidget(self.verified) editor.addWidget(self.apply_button) - layout.addLayout(editor) + hold_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.trace_view.setMinimumHeight(240) + hold_layout.addWidget(self.trace_view, 2) self.message = QLabel(self) self.message.setWordWrap(True) layout.addWidget(self.message) @@ -102,9 +113,7 @@ class SessionReviewDialog(QDialog): def _show_site(self) -> None: index = self.sites.currentIndex() self.quality_button.setEnabled(index >= 0) - self.chart.removeAllSeries() - for axis in self.chart.axes(): - self.chart.removeAxis(axis) + clear_chart(self.chart) if index < 0: self.summary.setText(t("review.no_sites")) return @@ -112,14 +121,14 @@ class SessionReviewDialog(QDialog): windows = site["windows"] self.table.setRowCount(len(windows)) for row, window in enumerate(windows): - quality = t("review." + window["status"]) + quality = t("review." + window["status"]) if window["status"] != "candidate" else t( + "review.verified" if window["stability_verified"] else "review.unverified") if not window["reference_alignment_possible"]: quality += " · " + t("review.replay") - quality += " · " + t("review.verified" if window["stability_verified"] else "review.unverified") values = [window["trial_id"], t("calibration." + window["branch"]), f"{window['reference_n']:g}", str(window["samples"]), self._number(window["raw_mean"]), self._number(window["raw_std"]), - f"{window['duration_s']:g}", quality] + f"{window['duration_s']:g}", quality, self._number(window["raw_drift_per_s"])] for column, value in enumerate(values): self.table.setItem(row, column, QTableWidgetItem(value)) fit = site["confirmed_fit"] or site["fit"] @@ -144,12 +153,11 @@ class SessionReviewDialog(QDialog): for value in (min(raw), max(raw)): line.append(value, fit["gain_n_per_raw"] * value + fit["offset_n"]) self.chart.addSeries(line) - self.chart.createDefaultAxes() + add_value_axes(self.chart) for axis in self.chart.axes(Qt.Orientation.Horizontal): axis.setTitleText(t("review.raw_axis")) for axis in self.chart.axes(Qt.Orientation.Vertical): axis.setTitleText(t("review.force_axis")) - self._pad_axes(self.chart) self.chart.setTitle(t("review.chart_title")) self._show_uv(index) self.message.setText(t("review.issues").format(count=len(self.review["issues"]))) @@ -172,9 +180,7 @@ class SessionReviewDialog(QDialog): def _show_window(self): window = self._current_window() - self.trace_chart.removeAllSeries() - for axis in self.trace_chart.axes(): - self.trace_chart.removeAxis(axis) + clear_chart(self.trace_chart) self.apply_button.setEnabled(window is not None) if window is None: return @@ -199,7 +205,7 @@ class SessionReviewDialog(QDialog): line.append((stamp - origin) / 1e9, low - margin) line.append((stamp - origin) / 1e9, high + margin) self.trace_chart.addSeries(line) - self.trace_chart.createDefaultAxes() + add_value_axes(self.trace_chart) for axis in self.trace_chart.axes(Qt.Orientation.Horizontal): axis.setRange(0, duration) axis.setTitleText(t("review.hold_time_axis")) @@ -243,9 +249,7 @@ class SessionReviewDialog(QDialog): self.message.setText(str(exc)) def _show_uv(self, selected: int) -> None: - self.uv_chart.removeAllSeries() - for axis in self.uv_chart.axes(): - self.uv_chart.removeAxis(axis) + clear_chart(self.uv_chart) 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) @@ -263,22 +267,14 @@ class SessionReviewDialog(QDialog): series.append(*site["uv_xy_px"]) series.clicked.connect(lambda _point, target=index: self.sites.setCurrentIndex(target)) self.uv_chart.addSeries(series) - self.uv_chart.createDefaultAxes() + add_value_axes(self.uv_chart) for axis in self.uv_chart.axes(Qt.Orientation.Horizontal): axis.setTitleText("U (px)") for axis in self.uv_chart.axes(Qt.Orientation.Vertical): axis.setTitleText("V (px)") - self._pad_axes(self.uv_chart) self.uv_chart.legend().hide() self.uv_chart.setTitle(t("review.uv_title").format(low=low, high=high)) - @staticmethod - def _pad_axes(chart: QChart) -> None: - for axis in chart.axes(): - low, high = axis.min(), axis.max() - margin = max((high - low) * .1, .1) - axis.setRange(low - margin, high + margin) - @staticmethod def _number(value) -> str: return "—" if value is None else f"{value:.6g}" diff --git a/apps/autocal/tests/test_binding_purity.py b/apps/autocal/tests/test_binding_purity.py index 3310b35..f305114 100644 --- a/apps/autocal/tests/test_binding_purity.py +++ b/apps/autocal/tests/test_binding_purity.py @@ -61,6 +61,7 @@ _SCOPED_ENUMS = { "TextElideMode", "TextFlag", "AspectRatioMode", + "WindowType", ), "QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"), "QSizePolicy": ("Policy",), @@ -160,6 +161,12 @@ def test_no_module_uses_an_unscoped_enum(): ) +def test_window_type_guard_accepts_scoped_values_but_rejects_legacy_values(tmp_path): + path = tmp_path / "window_flags.py" + path.write_text("flag = Qt.WindowType.Widget\nlegacy = Qt.Widget\n", encoding="utf-8") + assert _unscoped_enum_references(path) == ["Qt.Widget"] + + def test_the_controller_interface_is_qt_free(): """The controller and its guards stay importable from a headless process. diff --git a/apps/autocal/tests/test_manual_trial.py b/apps/autocal/tests/test_manual_trial.py index f1a75ed..ae48d13 100644 --- a/apps/autocal/tests/test_manual_trial.py +++ b/apps/autocal/tests/test_manual_trial.py @@ -111,6 +111,27 @@ def test_uv_coordinates_are_centered_and_masked(flat): site_at(flat, -3, 0) +def test_trial_persists_colour_region_outline_and_rejects_mismatched_mapping(trial_setup, flat): + from orisys_autocal.sensor.uv_outline import outline_snapshot + + flat.textured = True + flat.image_rgb[:] = 255 + flat.image_rgb[2:4, 2:5] = [20, 80, 200] + snapshot = outline_snapshot(flat) + worker = Worker() + trial = ManualTrial(worker) + with pytest.raises(ValueError, match="outline"): + trial.start(**trial_setup, uv_outline={**snapshot, "mapping_sha256": "wrong"}) + directory = trial.start(**trial_setup, uv_outline=snapshot) + worker.emit() + trial.finish(aborted=True, reason="test ended") + trial.recorder.stop(wait=True) + trial.poll() + manifest = json.loads((directory.parent.parent / "session.json").read_text(encoding="utf-8")) + assert manifest["uv_outline"] == snapshot and snapshot["patch"] + trial.finish_session() + + def test_manual_trial_records_video_stamps_and_load_unload_events(trial_setup, clock): worker = Worker() trial = ManualTrial(worker) @@ -277,6 +298,9 @@ def test_gui_hold_controls_and_session_lifecycle(make_window, trial_setup, clock panel.levels.setText("5, 10") panel.start_button.click() assert panel.trial.status == "recording" + manifest = json.loads((panel.trial.session.directory / "session.json").read_text(encoding="utf-8")) + assert manifest["uv_outline"]["surface"] + assert manifest["uv_outline"]["mapping_sha256"] == manifest["uv_definition"]["mapping_sha256"] worker.emit() assert panel.views.currentWidget() is panel.source_panel assert not panel.sensor_sn.isEnabled() diff --git a/apps/autocal/tests/test_record_diagnostics.py b/apps/autocal/tests/test_record_diagnostics.py new file mode 100644 index 0000000..a7feaf2 --- /dev/null +++ b/apps/autocal/tests/test_record_diagnostics.py @@ -0,0 +1,84 @@ +"""Operator evidence remains inspectable without admitting incomplete fits.""" + +import json +import csv +from copy import deepcopy + +import pytest +import numpy as np +from test_session_review import session as session +from test_manual_trial import flat as flat + +from orisys_autocal.calibration_session import write_json +from orisys_autocal.record_diagnostics import trial_diagnostics +from orisys_autocal.session_review import review_session +from orisys_autocal.sensor.uv_outline import outline_snapshot, recorded_outline + + +def test_incomplete_trace_counts_and_reference_evidence_are_available(session): + manifest = json.loads((session / "session.json").read_text(encoding="utf-8")) + trial = manifest["trials"][0] + trial.update(status="incomplete", reason="operator stopped") + write_json(session / "session.json", manifest) + report = trial_diagnostics(session, trial) + assert report["frames"] == 25 and report["dropped"] == 5 + assert report["invalid"] == 10 and len(report["trace"]) == 15 + assert report["max_gap_s"] == 10 + assert report["trace"][0] == [1_000_000_000, 1] + assert len(report["breaks_ns"]) == 10 + assert report["reason"] == "operator stopped" + assert len(report["references"]) == 5 + result = review_session(session) + assert result["sites"][0]["windows"] == [] and result["sites"][0]["fit"] is None + + +def test_diagnostic_missing_files_and_external_path(session): + report = trial_diagnostics(session, {"trial_id": "missing", "status": "incomplete"}) + assert report["trace"] == [] and "frames.csv" in report["errors"] + with pytest.raises(ValueError, match="outside"): + trial_diagnostics(session, {"trial_id": "bad", "status": "incomplete", "directory": "../outside"}) + + +def test_hold_drift_uses_selected_valid_timestamps(session): + path = session / "trials/trial-001/forces.csv" + with path.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + next(row for row in rows if row["frame_id"] == "2")["FN_raw"] = "3" + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + window = review_session(session)["sites"][0]["windows"][0] + assert window["samples"] == 2 and window["raw_mean"] == 2 + assert window["raw_drift_per_s"] == pytest.approx(2) + + +def test_outline_snapshot_preserves_region_coordinates_and_mapping(flat): + flat.textured = True + flat.image_rgb[:] = 255 + flat.image_rgb[2:4, 2:5] = [20, 80, 200] + snapshot = outline_snapshot(flat) + assert snapshot["surface"] and snapshot["patch"] + assert snapshot["origin_raster_xy_px"] == [3, 3] + assert snapshot["patch"][0] == [[2, 2], [2, 3], [4, 3], [4, 2]] + definition = {k: snapshot[k] for k in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")} + review = {"uv_definition": definition, "uv_outline": snapshot, "sites": []} + assert recorded_outline(review) == (snapshot, "snapshot") + changed = deepcopy(review) + changed["uv_definition"]["mapping_sha256"] = "different" + assert recorded_outline(changed) == (None, "mismatch") + assert recorded_outline({"uv_definition": definition, "sites": []}) == (None, "unavailable") + + +def test_legacy_geometry_recovery_checks_actual_mapping(flat, monkeypatch): + from orisys_autocal.sensor import uv_outline + + snapshot = outline_snapshot(flat) + review = {"uv_definition": {k: snapshot[k] for k in ( + "mapping_sha256", "origin_raster_xy_px", "raster_size_hw")}, "sites": [{"bundle_path": "recorded-bundle"}]} + monkeypatch.setattr(uv_outline, "load_bundle", lambda path: path) + monkeypatch.setattr(uv_outline, "rasterise", lambda bundle, shape: flat) + result, state = recorded_outline(review) + assert state == "surface_only" and not result["patch_available"] + flat.positions_mm[1, 2] += np.array([0, 0, 1]) + assert recorded_outline(review) == (None, "mismatch") diff --git a/apps/autocal/tests/test_record_overview.py b/apps/autocal/tests/test_record_overview.py index 1e4f4ca..78cf133 100644 --- a/apps/autocal/tests/test_record_overview.py +++ b/apps/autocal/tests/test_record_overview.py @@ -6,6 +6,7 @@ from copy import deepcopy import pytest from PySide6.QtWidgets import QPushButton +from PySide6.QtGui import QAction from test_session_review import repeated_session as repeated_session, session as session from orisys_autocal.calibration_session import write_json @@ -20,6 +21,7 @@ def overview_session(repeated_session): definition = {"mapping_sha256": "test-map", "origin_raster_xy_px": [5, 8], "raster_size_hw": [12, 12], "u_positive": "right", "v_positive": "up", "unit": "px"} manifest["uv_definition"] = definition + manifest.update(sensor_model="finger", sensor_sn="F-TEST", started_at="2026-10-11T09:00:00+00:00") for index, site in enumerate(manifest["sites"]): site["position_sensor_mm"] = [index + 1., 2., 3.] for trial in manifest["trials"]: @@ -27,6 +29,7 @@ def overview_session(repeated_session): path = root / trial["directory"] / "session.json" metadata = json.loads(path.read_text(encoding="utf-8")) metadata["uv_definition"] = definition + metadata["input_source"] = {"kind": "video", "value": "test.mp4"} write_json(path, metadata) trial["plan"] = {"steps": list(range(5))} manifest["sites"].append({"site_id": "site-003", "uv_xy_px": [3, 2], "position_sensor_mm": [3., 2., 3.]}) @@ -109,7 +112,7 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi dialog.review = review_session(root, selections=selections) dialog._refresh() monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(plan_path), "")) - dialog.findChild(QPushButton, "overview_import_plan").click() + dialog.findChild(QAction, "overview_import_plan").trigger() assert dialog.table.rowCount() == 4 assert dialog.table.item(0, 1).text() == "site-002" dialog.uv_chart.series()[1].clicked.emit(dialog.uv_chart.series()[1].at(0)) @@ -135,7 +138,9 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi dialog.table.selectRow(3) dialog.trials.setCurrentIndex(1) assert not dialog.response_chart.series() - assert not dialog.details_button.isEnabled() + assert dialog.details_button.isEnabled() + dialog.details_button.click() + assert dialog.plots.currentIndex() == 0 dialog.table.selectRow(2) dialog.map_mode.setCurrentIndex(1) assert len(dialog.uv_chart.series()) == 4 @@ -151,10 +156,10 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi assert dialog.grab().save(str(tmp_path / "overview-response.png")) dialog.findChild(QPushButton, "overview_save").click() saved = next(root.glob("derived/*/review.json")) - dialog.findChild(QPushButton, "overview_clear_plan").click() + dialog.findChild(QAction, "overview_clear_plan").trigger() assert dialog.table.rowCount() == 3 monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), "")) - dialog.findChild(QPushButton, "overview_load").click() + dialog.findChild(QAction, "overview_load").trigger() assert dialog.table.rowCount() == 4 dialog.close() @@ -198,4 +203,43 @@ def test_records_tab_browse_error_and_reload(make_window, overview_session, monk QTest.keyClick(page.overview, Qt.Key.Key_Escape) assert page.overview.isVisible() assert window.grab().save(str(tmp_path / "records-tab.png")) + window.resize(1280, 900) + qt_app.processEvents() + page.viewer.ensureWidgetVisible(page.overview.save_button) + assert page.overview.save_button.isVisible() + assert window.grab().save(str(tmp_path / "records-tab-small.png")) window.close() + + +def test_operator_outline_identity_incomplete_inspection_and_equal_scale(qt_app, overview_session, tmp_path): + from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog + from PySide6.QtCore import Qt + + root, _, _ = overview_session + manifest = json.loads((root / "session.json").read_text(encoding="utf-8")) + manifest["uv_outline"] = { + **{k: manifest["uv_definition"][k] for k in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")}, + "surface": [[[1, 1], [10, 1], [10, 10], [1, 10]]], + "patch": [[[2, 5], [9, 5], [9, 9], [2, 9]]], "patch_available": True, + } + manifest["trials"][0].update(status="incomplete", reason="operator stopped") + write_json(root / "session.json", manifest) + dialog = RecordOverviewDialog(root) + assert "F-TEST" in dialog.identity.text() and "finger" in dialog.identity.text() + assert dialog.outline_state == "snapshot" + assert dialog.trials.currentData() == "trial-001" + assert dialog.details_button.isEnabled() + assert dialog.diagnostics["trial-001"]["trace"] + assert "operator stopped" in dialog.diagnostic_summary.text() + dialog.details_button.click() + assert dialog.plots.currentIndex() == 0 + assert not dialog.response_chart.series() + dialog.show() + qt_app.processEvents() + area = dialog.uv_chart.plotArea() + x = dialog.uv_chart.axes(Qt.Orientation.Horizontal)[0] + y = dialog.uv_chart.axes(Qt.Orientation.Vertical)[0] + assert (x.max() - x.min()) / area.width() == pytest.approx((y.max() - y.min()) / area.height(), rel=.02) + assert len(dialog.uv_chart.series()) == 5 + assert dialog.grab().save(str(tmp_path / "operator-incomplete.png")) + dialog.close() diff --git a/apps/autocal/tests/test_session_review.py b/apps/autocal/tests/test_session_review.py index 266005a..7d09b55 100644 --- a/apps/autocal/tests/test_session_review.py +++ b/apps/autocal/tests/test_session_review.py @@ -110,6 +110,9 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session dialog = SessionReviewDialog(session) assert dialog.sites.count() == 2 assert dialog.table.rowCount() == 5 + assert dialog.pages.currentIndex() == 0 and dialog.pages.count() == 2 + assert len(dialog.trace_chart.axes()) == 2 + assert all(len(series.attachedAxes()) == 2 for series in dialog.trace_chart.series()) assert len(dialog.chart.series()) == 4 assert len(dialog.uv_chart.series()) == 2 dialog.sites.setCurrentIndex(1)