From 04adf17f60c45a39f38dec8ff78287b40ecd4a39 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 11 Oct 2026 17:43:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20Autocal=20=E5=A4=9A?= =?UTF-8?q?=E6=B5=8B=E7=82=B9=E6=A0=87=E5=AE=9A=E6=B5=81=E7=A8=8B=E5=8F=8A?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=A4=8D=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一测点坐标、保持窗口和试验目录,补齐逐点初步分析。定向测试与真实视频闭环通过;两项轮询失败在修改前版本同样复现。 --- apps/autocal/config/locales/en_US.json | 92 +++-- apps/autocal/config/locales/zh_CN.json | 92 +++-- .../docs/calibration-data-format.zh.md | 217 +++++------- apps/autocal/docs/implementation-plan.zh.md | 10 +- apps/autocal/docs/sequence-format.md | 5 + .../src/orisys_autocal/calibration_session.py | 93 +++++ .../src/orisys_autocal/sensor/uv_flat.py | 58 ++++ .../src/orisys_autocal/session_review.py | 124 +++++++ apps/autocal/src/orisys_autocal/trial.py | 211 ++++++------ .../orisys_autocal/ui/calibration_panel.py | 115 +++++-- .../src/orisys_autocal/ui/sensor_panel.py | 62 +++- .../ui/session_review_dialog.py | 168 +++++++++ apps/autocal/src/orisys_autocal/ui/window.py | 2 + apps/autocal/tests/test_manual_trial.py | 320 ++++++++++++------ apps/autocal/tests/test_session_review.py | 124 +++++++ .../orisys_app_shared/record/experiment.py | 21 +- shared/tests/test_experiment_recorder.py | 36 ++ 17 files changed, 1347 insertions(+), 403 deletions(-) create mode 100644 apps/autocal/src/orisys_autocal/calibration_session.py create mode 100644 apps/autocal/src/orisys_autocal/session_review.py create mode 100644 apps/autocal/src/orisys_autocal/ui/session_review_dialog.py create mode 100644 apps/autocal/tests/test_session_review.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index 0250ad5..74aa175 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -271,25 +271,24 @@ "uv": "U / V (px)", "position": "Mesh position", "levels": "Load levels (N)", - "hold": "Planned hold", - "hold_tip": "The planned hold time is saved; levels and recording do not advance automatically.", + "hold": "Hold per level", + "hold_tip": "Count down after arrival confirmation. End the hold window and prompt the next level; video continues. Time alone does not verify stability.", "reference": "Manual reference force (N)", - "start": "Record this site", - "start_tip": "Create one trial folder for this site and continuously record images and sensor output. This does not move the platform. One load-unload cycle uses this folder.", - "confirm": "Mark load reached", - "confirm_tip": "After reaching the target and entering the manual reference force, mark this level's start and begin its hold timer. Images are already recording.", - "finish": "Stop and complete", - "finish_tip": "After all levels are marked, stop recording, flush the files, and check this trial.", - "abort": "Abort this recording", - "abort_tip": "Stop recording, keep this folder, and mark it incomplete. Starting again creates a new folder; it does not resume this one.", + "start": "Start this trial", + "start_tip": "Create a trial in this calibration session. Record initial zero, loading, unloading and recovery continuously; no platform motion is issued.", + "confirm": "Target reached: start hold", + "confirm_tip": "Reach the current target and enter the actual reference load. Keep the load unchanged until the countdown ends.", + "finish": "Complete this trial", + "finish_tip": "After initial zero and every loading/unloading hold, stop recording, check and save the trial.", + "abort": "End early (keep incomplete data)", + "abort_tip": "End this trial immediately and keep its incomplete data. A retry creates a new trial in the same calibration session.", "load": "Load", "unload": "Unload", - "next_step": "Next: {branch} to {load:g} N", - "before_start": "Select a site and start the input, then begin recording", - "ready_to_finish": "All levels marked; recording can be stopped", + "next_step": "{branch} to {load:g} N; enter actual reference load and start hold", + "before_start": "Select a site and start input, then start the trial with initial zero", + "ready_to_finish": "All hold windows complete; complete this trial", "no_step": "No step pending", "progress": "Progress {done}/{total} · {status}", - "held": " · Current level held {seconds:.1f} s", "source_state": "Input: {status} · frame {frame} · capture drops {dropped}", "replay_note": "Video replay is for debugging; manual reference loads cannot be synced to the original experiment.", "idle": "Idle", @@ -303,7 +302,17 @@ "invalid_site": "Select a valid UV site first", "start_input": "Start camera or video input first", "reference_required": "Enter the actual reference load", - "output_required": "Choose an output directory" + "output_required": "Choose an output directory", + "zero": "Initial zero", + "holding": "Hold {branch} {load:g} N · {seconds:.1f} s remaining; do not change load", + "close_session": "Finish calibration session", + "close_session_tip": "Close the session containing multiple sites and trial attempts. Incomplete trials keep the session incomplete. The next trial creates a new session.", + "session_none": "No calibration session yet; the first trial creates it.", + "session_active": "Calibration session: {sites} sites · {trials} attempts\n{path}", + "session_closed": "Calibration session closed ({status}): {path}", + "close_before_mapping": "Finish the calibration session before changing UV mapping or bundle", + "path": "Trial path (N): {path}", + "invalid_levels": "Enter strictly increasing levels greater than 0 and at most 30 N" }, "finger": { "title": "Finger calibration-point debug", @@ -362,13 +371,28 @@ "preview_no_gl": "No OpenGL context: {error}", "preview_draw_failed": "Drawing failed: {error}", "preview_solve_failed": "Solve failed: {error}", - "col_x": "x", "col_y": "y", "col_z": "z", - "col_nx": "nx", "col_ny": "ny", "col_nz": "nz", - "col_X": "X", "col_Y": "Y", "col_Z": "Z", - "col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "residual(°)", + "col_x": "x", + "col_y": "y", + "col_z": "z", + "col_nx": "nx", + "col_ny": "ny", + "col_nz": "nz", + "col_X": "X", + "col_Y": "Y", + "col_Z": "Z", + "col_yaw": "yaw", + "col_pitch": "pitch", + "col_residual": "residual(°)", "loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit", "load_failed": "Load failed: {error}", - "picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°" + "picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°", + "export_sites": "Export operator site list…", + "export_sites_tip": "Export all points in displayed order, with UV pixels and sensor/platform millimetres. UV origin matches calibration.", + "export_no_points": "Pick UV sites first", + "export_edited_point": "Point {order} was edited in 3D and no longer matches its UV pixel; pick it again before exporting", + "sites_exported": "Operator site list exported: {path}", + "col_u_px": "U (px)", + "col_v_px": "V (px)" }, "sequence": { "title": "Test sequence design", @@ -446,5 +470,33 @@ "bas_loaded": "BAS loaded", "yes": "Yes", "no": "No" + }, + "review": { + "open": "Review calibration data…", + "title": "Exploratory calibration review", + "notice": "Inspection only: manual hold windows have not been verified stable. Replay does not establish original experiment timing. Fits are exploratory and never written to sensor configuration.", + "save": "Save new review", + "no_sites": "No sites in this session", + "candidate": "Candidate window", + "insufficient_samples": "Insufficient valid samples", + "replay": "Replay debug", + "unverified": "Stability unverified", + "fit": "Exploratory loading fit: sensitivity {sensitivity_raw_per_n:.6g} raw/N; gain {gain_n_per_raw:.6g} N/raw; offset {offset_n:.6g} N; fit RMSE {rmse_n:.4g} N (not independent validation error)", + "no_fit": "Insufficient load levels or raw-signal variation for a fit.", + "fit_line": "Exploratory loading fit", + "raw_axis": "Raw normal signal (raw)", + "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.", + "saved": "Saved new review: {path}", + "col_trial": "Trial", + "col_branch": "Phase", + "col_load": "Reference (N)", + "col_samples": "Valid samples", + "col_mean": "Raw mean", + "col_std": "Raw std", + "col_duration": "Hold (s)", + "col_quality": "Quality" } } diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index f99e92b..3b93185 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -271,25 +271,24 @@ "uv": "U / V (px)", "position": "网格坐标", "levels": "加载档位 (N)", - "hold": "计划保持", - "hold_tip": "仅保存计划保持时间;不会自动切换档位或结束录制。", + "hold": "每档保持时间", + "hold_tip": "到达目标力并确认后倒计时;到时结束该档保持窗口并提示下一档,录像持续。计时不代表已验证稳定。", "reference": "人工参考力 (N)", - "start": "开始本测点录制", - "start_tip": "为当前测点新建试验目录,持续录制图像与传感器输出;不会移动平台。一次加载—卸载循环共用此目录。", - "confirm": "标记到档时刻", - "confirm_tip": "达到目标载荷并填写人工参考力后点击;记录本档起点并开始保持计时。图像此前已开始持续录制。", - "finish": "结束录制并完成", - "finish_tip": "全部档位标记后停止录制、完成写盘并检查本次试验。", - "abort": "中止本次录制", - "abort_tip": "停止录制并保留当前目录,标记为未完成;再次开始会创建新目录,不会续录。", + "start": "开始本次试验", + "start_tip": "在本场标定下新建一次测点试验,连续记录初始零载、加载、卸载和恢复。不会移动平台。", + "confirm": "已到目标力,开始保持", + "confirm_tip": "达到当前目标并填写实际参考力后点击。保持期间不要换档;倒计时结束后再按提示调整。", + "finish": "完成本次试验", + "finish_tip": "初始零载及全部加载、卸载保持完成后停止录像,检查并保存本次结果。", + "abort": "提前结束(保留未完成数据)", + "abort_tip": "立即结束本次试验并保留未完成数据。重试创建新的 trial,仍归入本场标定。", "load": "加载", "unload": "卸载", - "next_step": "下一档:{branch}至 {load:g} N", - "before_start": "先选测点并启动输入,再开始录制", - "ready_to_finish": "全部档位已标记,可结束录制", + "next_step": "当前目标:{branch} {load:g} N;到达后填写实际参考力并开始保持", + "before_start": "先选测点并启动输入,再开始试验;先记录初始零载", + "ready_to_finish": "所有保持窗口已完成,请完成本次试验", "no_step": "无待确认档位", "progress": "进度 {done}/{total} · {status}", - "held": " · 当前档已保持 {seconds:.1f} s", "source_state": "输入:{status} · 帧 {frame} · 采集丢帧 {dropped}", "replay_note": "视频重放仅用于调试;本次人工参考力不能与原实验时间同步。", "idle": "未开始", @@ -303,7 +302,17 @@ "invalid_site": "请先选择有效的 UV 测点", "start_input": "请先启动相机或视频输入", "reference_required": "请输入实际参考力", - "output_required": "请选择保存目录" + "output_required": "请选择保存目录", + "zero": "初始零载", + "holding": "正在保持:{branch} {load:g} N · 剩余 {seconds:.1f} s;请勿换档", + "close_session": "结束本场标定", + "close_session_tip": "关闭包含多个测点和重复试验的会话;有未完成试验则保留未完成状态。之后开始试验会新建一场。", + "session_none": "尚未建立本场标定;首次开始试验时创建。", + "session_active": "本场标定:{sites} 个测点 · {trials} 次试验\n{path}", + "session_closed": "本场标定已结束({status}):{path}", + "close_before_mapping": "请先结束本场标定,再更换 UV 映射或导出包", + "path": "本次路径 (N):{path}", + "invalid_levels": "请输入严格递增的加载档位,每档大于 0 且不超过 30 N" }, "finger": { "title": "手指标定点调试", @@ -362,13 +371,28 @@ "preview_no_gl": "无 OpenGL 上下文:{error}", "preview_draw_failed": "绘制失败:{error}", "preview_solve_failed": "求解失败:{error}", - "col_x": "x", "col_y": "y", "col_z": "z", - "col_nx": "nx", "col_ny": "ny", "col_nz": "nz", - "col_X": "X", "col_Y": "Y", "col_Z": "Z", - "col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "残差(°)", + "col_x": "x", + "col_y": "y", + "col_z": "z", + "col_nx": "nx", + "col_ny": "ny", + "col_nz": "nz", + "col_X": "X", + "col_Y": "Y", + "col_Z": "Z", + "col_yaw": "yaw", + "col_pitch": "pitch", + "col_residual": "残差(°)", "loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm", "load_failed": "载入失败: {error}", - "picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°" + "picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°", + "export_sites": "导出测点操作表…", + "export_sites_tip": "按当前列表顺序导出全部测点的 U/V 像素、传感器与平台毫米坐标;与标定页共用 UV 原点。", + "export_no_points": "请先在 UV 图上选择测点", + "export_edited_point": "第 {order} 点的三维位置已改动,与原 UV 像素不一致;请重新在图上选点后导出", + "sites_exported": "已导出测点操作表:{path}", + "col_u_px": "U (px)", + "col_v_px": "V (px)" }, "sequence": { "title": "测试序列设计", @@ -446,5 +470,33 @@ "bas_loaded": "BAS 已加载", "yes": "是", "no": "否" + }, + "review": { + "open": "查看标定数据…", + "title": "标定数据初步分析", + "notice": "仅供检查:人工参考保持窗口尚未复核稳定性。视频重放不能证明原实验的时间同步;以下拟合不是正式标定系数,不会写入传感器配置。", + "save": "另存分析结果", + "no_sites": "会话没有测点", + "candidate": "候选窗口", + "insufficient_samples": "有效样本不足", + "replay": "视频调试", + "unverified": "稳定性未复核", + "fit": "加载分支初步拟合:灵敏度 {sensitivity_raw_per_n:.6g} raw/N;增益 {gain_n_per_raw:.6g} N/raw;偏置 {offset_n:.6g} N;拟合 RMSE {rmse_n:.4g} N(不是独立验证误差)", + "no_fit": "有效载荷或原始信号变化不足,无法拟合。", + "fit_line": "加载初步拟合", + "raw_axis": "原始法向信号 (raw)", + "force_axis": "人工参考力 (N)", + "chart_title": "各保持窗口均值:加载/卸载对照", + "uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合", + "issues": "已跳过 {count} 个未完成 trial;完整保持窗口仍须复核稳定性。", + "saved": "已另存:{path}", + "col_trial": "试验", + "col_branch": "阶段", + "col_load": "参考力 (N)", + "col_samples": "有效样本", + "col_mean": "原始均值", + "col_std": "原始标准差", + "col_duration": "保持 (s)", + "col_quality": "质量说明" } } diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index 79ac561..b505a09 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -1,158 +1,111 @@ # Autocal 标定操作与数据格式草稿 -状态:待评审,2026-10-11。阶段一仅针对 finger;单点手动记录已接通,跨点 session、FT 参考及灵敏度图待实现。每 trial 保存视频、`session.json`、`frames.csv`、`forces.csv`、`reference.csv`、`events.csv`。目录名为 `_<产品型号>__session<采集会话号>_<唯一标识>`;完整型号和 SN 存于 `session.json`。 +状态:2026-10-11 修订,待实机验收。阶段一仅针对 finger。多测点会话、手动加载/卸载保持、视频和相机输入、模拟页测点操作表导出已接通;已可重开会话查看保持窗口、加载/卸载曲线及 UV 测点初步灵敏度;FT 同步、图像基准快照及正式灵敏度结果仍待实现。软件检查不代表设备或标定精度通过。 -## 范围与软件现状 +## 范围与坐标 -| 项目 | 当前约定 | +| 项目 | 约定 | |---|---| -| 标定范围 | 多测点法向力,0–30 N;剪切及组合载荷后续再做 | -| 传感器 | 首版固定为 finger;其余类型须另行验证几何、采集和处理契约 | +| 载荷 | 法向压缩 0–30 N;剪切及组合载荷后续再做 | | 压头 | 直径 6 mm、球面半径 3 mm 的半球压头 | -| 参考 | 砝码或 50 N/2 N·m FT sensor,首版先接通一种 | -| 图像 | 每测试事件一个连续视频,档位间不切段 | -| 调试输入 | 已有视频与实时相机编号共用 finger worker 和 `SdkPacket`;相机实机尚未验收 | -| 交付 | 单个 session 能重新打开、追溯参考载荷,计算并显示不同测点的灵敏度 | +| 参考 | 当前人工填写实际参考力;计划使用砝码或 50 N/2 N·m FT sensor | +| 图像 | 每 trial 保存一个连续视频,保留初始零载、换档、加载、卸载及恢复 | +| UV | 整数 pixel;原点为最靠近图像中线的有效列底部像素;U 向右、V 向上,允许负 V | +| 三维 | 传感器坐标用 mm;平台坐标经过安装和姿态转换,须明确区分 | +| 映射身份 | 保存原点、栅格尺寸和 `mapping_sha256`;哈希覆盖实际位置查找表及有效掩膜 | +| 层级 | `session → site → trial → step → samples`;同点重复及中止重试均创建新 trial | -[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。 - -现有模块与待接工作见[实施计划](implementation-plan.zh.md);旧 `.oryseq` 格式见 [sequence-format.md](sequence-format.md),其本身不包含下文的载荷循环。 +[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。设备准备、运动边界和停机沿用 [workflow.md](workflow.md)。本页不会自动移动平台。 ## 操作员逐步流程 -设备操作沿用 [workflow.md](workflow.md) 的准备、运动边界及停机流程。以下不新增自动运动授权;尚未接通的步骤不能靠占位按钮完成。 - -| 步骤 | 操作员动作 | 软件记录/通过条件 | +| 步骤 | 操作 | 软件行为 | |---|---|---| -| 1. 建立会话 | 填写产品型号、传感器 SN、gel ID、实验台、压头和参考设备;选保存位置 | 创建唯一 `session_id`;保存身份和配置快照 | -| 2. 检查设备 | 完成设备设置、回零及既定检查,核对相机和参考输入 | 状态可读;运动边界按既定流程确认 | -| 3. 确认测点 | 在有效 UV 区域选点,核对三维位置和姿态 | 分配 `site_id`,UV→3D 映射有效 | -| 4. 设置循环 | 定义加载范围、档位、保持时间和重复数,选择手动模式 | 保存计划;自动加载留到下一阶段 | -| 5. 建立基准 | 无接触时检查零载荷,保存参考图像和必要历史 | 保存归零/基准事件及 `baseline_id` | -| 6. 开始事件 | 到达确认的测点姿态后,在加载前启动记录 | 新建 `trial_id` 和视频;确认视频与参考记录正常后加载 | -| 7. 逐级加载 | 用现有手动控制或砝码到目标档位,确认实际到达并保持 | 每档有 `step_id`、目标及有效参考力、分支和时间范围 | -| 8. 逐级卸载 | 按相同档位下降到零,保留恢复过程 | 连续记录卸载分支;循环内不重新归零或重置图像基准 | -| 9. 结束/重复 | 检查恢复与记录状态,停止事件;重复或移到下一点 | 完成写入后标记状态;每次重复使用新 trial 和视频 | -| 10. 计算与复核 | 重新打开会话,选择有效保持窗口,查看每点曲线及 UV 灵敏度图 | 显示加载/卸载差异、误差、无效原因;留完整 trial 独立验证 | +| 1. 准备 | 填型号、SN、保存位置,载入 finger 导出包和配置 | 首次开始试验时建立本场 session;型号、SN、配置和映射在本场固定 | +| 2. 预览 | 选视频或相机编号,启动输入并检查二维 UV 图像 | 两种来源共用 finger worker;输入预览可贯穿多个 trial | +| 3. 选点 | 点击 UV 粗选,再修改 U/V 到精确像素 | 显示传感器三维位置;拒绝掩膜外点 | +| 4. 设循环 | 输入严格递增的正载荷档位及每档 10–30 s 保持 | 展示完整路径;如 `0 → 5 → 10 → 20 → 30 → 20 → 10 → 5 → 0 N` | +| 5. 开始 | 无载时点击“开始本次试验” | 新建 trial 并连续录像;先提示初始零载 | +| 6. 保持 | 到目标后输入实际参考力,点击“已到目标力,开始保持” | 当前目标不变,倒计时期间禁止再次确认或完成试验 | +| 7. 换档 | 倒计时结束后按提示手动调整至下一档 | 自动结束本档窗口;换档继续录像,但不沿用上一档恒定参考力 | +| 8. 完成 | 最终卸载至零并完成保持,点击“完成本次试验” | 停止本 trial 录像,排空写入并检查完整性;输入预览继续 | +| 9. 重复/换点 | 重复同点或选下一点,再开始试验 | 同场目录内新增 trial;自动关联 site 和重复编号 | +| 10. 结束整场 | 所有试验结束后点击“结束本场标定” | 所有 trial 完整才标 complete;有中止或失败则保留 incomplete | -推荐路径为 `0 → F1 → F2 → Fmax → F2 → F1 → 0`,具体档位待定。逐级卸载提供与加载分支相同载荷的对照;各档采用相同保持规则。保持时间先支持 10–30 s 配置,人工选择分析窗口并记录原因;到时不自动等于稳定。异常时按既定流程停止,保留未完成记录,重测用新 trial。 +“提前结束(保留未完成数据)”立即停止本次试验;不删除数据,不续录旧 trial。视频播放结束或写入停止会中止当前试验。关闭程序时结束当前试验并关闭会话;异常退出留下的 `in_progress` 不代表已完成。目前不能恢复旧会话继续采集。 -## 坐标与关联 +保持倒计时只结束档位窗口,整段录像到完成或提前结束才停止。到时不等于稳定;`stability_verified=0`,正式分析仍须复核窗口。初始和最终零载当前使用相同保持时间,后续可根据恢复实测调整。 -| 对象 | 保存内容 | 约定 | -|---|---|---| -| 测点 | `uv_xy_px`、`raster_xy_px`、`position_sensor_mm` | 当前 U/V 为整数 pixel;网格坐标为 mm | -| UV 定义 | `uv_origin_raster_xy_px`、图像尺寸、轴正方向 | 当前取最靠近图像中线的有效列底部像素为原点;U 向右、V 向上,允许负 V | -| 几何 | `geometry_id`、mesh/mask/映射文件及哈希 | 固定映射版本,二维与三维值可相互核对 | -| 姿态 | `T_platform_from_sensor`、`pose_source`、激励方向 | 记录传感器→平台变换,区分实测和指令;normal 不必单独保存 | -| 关联层级 | `session → site → trial → step → samples` | trial 是完整循环,step 是循环内档位;帧与参考流按时间关联 | +## 模拟页与操作表 -## 文件及内部参数 +模拟页和标定页共用 UV 映射。在模拟页选点、调整列表顺序后点击“导出测点操作表…”,得到 UTF-8 CSV。操作员按表中 U/V 手动复现;当前不提供序列导入。三维位置被手工改动而与原像素不一致时拒绝导出,须重新在图上选点。 -以下为新格式建议,不是现有共享记录器文件的原样清单。`sites`、`trials` 和 `steps` 先内嵌于 `session.json`。 - -| 文件 | 主要字段/数据 | 写入时机 | -|---|---|---| -| `session.json` | `format_version`、`session_id`、`sensor_type`、`input_source`、身份、配置快照、坐标定义、文件清单、sites、trials、状态 | 会话建立、事件完成及结束时 | -| `sequence.oryseq` | 实际使用的测点计划;遵循现有格式 | 开始采集前保存快照 | -| `video/.` | 算法入口图像、完整循环及恢复 | 每 trial 开始至结束 | -| `frames.csv` | 当前按帧身份、采集时间、视频位置、状态排序;包含 `frame_id`、`source_frame_id`、`t_rel_s`、`t_host_mono_ns`、`t_host_utc_ns`、`acquired_at`、`time_basis`、`media_time_ms`、`segment`、`segment_frame`、`in_video`、`dropped`、`capture_dropped` | 每帧,时间与帧身份同时绑定;当前与 Studio 共用录制格式 | -| `baseline/` | 基准图像、初始状态或预录引用、`baseline_id` | 建立/重置基准时 | -| `reference.csv` | 当前为 `step_index`、`branch`、`target_load_n`、区间起止电脑单调时间、`F_ref_normal_N`、预留的 `F_ref_x_N`/`F_ref_y_N`/`F_ref_z_N`、`force_frame`、`source`、`stability_verified` | 当前每个手工确认档位一行;FT/ECAT 逐样本参考尚未接通 | -| `motion.csv` | 时间、目标/反馈位姿、有效性 | 有实际反馈时保存;缺少反馈须注明 | -| `events.csv` | `t_host_mono_ns`、trial/step、事件类型、参考有效时段、原因 | 到档确认、阶段切换、归零、异常及中断 | -| `derived//` | 逐点灵敏度表、加载/卸载曲线、误差、分析窗口和处理配置 | 分析时另存;不覆盖原始文件 | - -| 参数归属 | 主要参数 | 单位/填写来源 | -|---|---|---| -| session 身份 | `sensor_model`、`sensor_sn`、`gel_id`、`bench_id`、`indenter_id`、`reference_source` | 操作员填写或设备读取;当前型号和 SN 必填 | -| session 采集 | 分辨率、像素格式、编码、版本、滤波与归零配置 | 软件快照;编码待实测确认 | -| 输入来源 | `input_source.kind` 为 `video` 或 `camera`;`value` 分别为视频路径或相机整数编号;视频另存文件哈希 | 传给同一 `FingerAcquisitionWorker.start`;来源文件与 trial 输出视频不可混淆 | -| session 时间 | 同进程 `clock_id`、单调时钟原点、UTC 对照、时间来源、延迟及同步误差 | 电脑采集/接收时记录;不能按行号配对 | -| site | `site_id`、UV、三维位置、几何关联 | pixel/mm;软件映射 | -| trial | `trial_id`、`site_id`、重复编号、姿态、`baseline_id`、视频、起止时间、状态 | 软件分配并记录实际执行 | -| trial 计划 | `load_levels_n`、`hold_s`、`repeats` | N/s/次数;操作员设置 | -| step | `step_id`、`target_load_n`、`branch`、实际起止时刻、`analysis_window_s` | N/电脑单调时间;branch 为 load/unload/zero | -| step 砝码参考 | 质量、重力加速度、传力方向、有效时段及 `reference_load_n` | kg/m/s²/N;仅在传力条件成立时使用 m×g | -| 质量 | `valid`、`invalid_reason`、丢帧/中断状态 | 未知不补零;算法失败不等于图像不可用 | -| 逐点结果 | `site_id`、`gain_n_per_raw`、`sensitivity_raw_per_n`、有效档位数、误差及滞后 | 仅对有效独立参考与原始法向信号拟合;缺数据标无效 | - -实时采集以电脑收到**该帧**时的单调时间为主时间。采集线程需把 `t_host_mono_ns` 与帧序号、图像原子配对;记录器再保存 UTC 对照,处理结束时间不能代替采集时间。FT/ECAT 样本也在电脑收到时打同源时间戳,按时间关联,并记录可测得的延迟与失同步;低延迟不等于零延迟。砝码的人工到档时刻只定义候选区间,稳定窗口仍需检查。 - -当前 `reference.csv` 的起点是操作员确认时刻,终点是下一次确认或结束时刻。`F_ref_normal_N` 是人工声明的正向压缩载荷;未测量的 `F_ref_x_N`、`F_ref_y_N`、`F_ref_z_N` 及对应 `force_frame` 留空,不写零,也不将法向值直接当作平台 Z 分量。`stability_verified=0` 表示整个声明区间尚未验证稳定,后续分析必须另选稳定窗口。`forces.csv` 只保存 finger 图像处理输出,不含独立参考力。 - -旧视频没有原始逐帧电脑时间时,重放取得的是本次解码时间,`time_basis=host_replay`;它可验证处理链,不能还原原实验与 FT 的同步。finger worker 和录制器已保存主机采集时间、源帧编号及可用的媒体时间;来源路径、哈希、配置和媒体帧率仍需在 manifest 中核对。 - -目标事件需标明 trial、step、事件时刻及来源;按钮时刻不能冒充设备实际到档。当前 `events.csv` 的 `detail` 保存 `t_host_mono_ns` 和档位信息。 - -| 事件 | 触发与状态 | +| 操作表字段 | 含义 | |---|---| -| `trial_started` | 首帧可用且输出已打开后,trial 进入 `recording` | -| `step_reached` | 操作员确认到档或有效反馈确认;记录实际时刻和参考有效性 | -| `step_ended` | 本档保持及分析窗口结束,保留实际持续时间 | -| `trial_finished` | 视频与索引排空并通过完整性检查后,trial 进入 `complete` | -| `trial_aborted` | 输入、写入或操作异常;trial 进入 `incomplete`,保留已写数据及原因 | +| `order`、`site_id` | 表内顺序及测点编号;采集会话按首次实测顺序另行分配 site_id,以 U/V 和映射身份核对 | +| `u_px`、`v_px` | 与标定页相同原点、方向和单位 | +| `x_sensor_mm`/`y_sensor_mm`/`z_sensor_mm` | 传感器三维位置 | +| `x_platform_mm`/`y_platform_mm`/`z_platform_mm`、`yaw_deg`、`pitch_deg` | 当前模拟安装设置下的平台目标及角度 | +| `origin_raster_x_px`/`origin_raster_y_px`、栅格宽高、轴方向、`mapping_sha256` | 像素坐标复现所需的映射身份 | -`session` 在所有 trial 完成并检查文件后才进入 `complete`;任何缺失必须保留 `incomplete` 与原因。每次重试使用新的 `trial_id`,不覆盖旧视频。 +序列页的 [`.oryseq`](sequence-format.md)仍为平台毫米坐标的几何/运动计划;它与模拟页操作表分别导出,不代表已接通载荷执行。 -逐点灵敏度先用有效保持窗口中的 `normal_force_raw` 和独立参考力拟合 `F_ref = gain_n_per_raw × raw + offset_n`;`sensitivity_raw_per_n` 为非零斜率的倒数。每个测点单独保存拟合范围、有效档位数、误差和加载/卸载差异。原始通道缺失、参考无效或载荷档位不足时不生成看似有效的系数。界面显示各点散点/拟合曲线,并在 UV 测点图着色;阶段一不从稀疏点外推整面精度。 +## 目录与文件 -以下是目标 `session.json` 的最小示意,供记录与分析模块共用字段;`video/` 是本次 trial 的输出,不是 `input_source` 原文件。例子不代表写入器已经实现。 +本场目录名为 `<产品型号>___<唯一标识>`,完整身份保存在 manifest。每个 trial 是一个测点的一次完整循环,档位用编号及时间窗口区分,不按力值拆原始视频目录。 -```json -{ - "format_version": "0.1-draft", - "session_id": "session-001", - "sensor_type": "finger", - "input_source": { - "kind": "video", - "value": "D:/samples/finger-input.mp4", - "sha256": "<待采集时计算>", - "clock_id": "host_replay", - "time_basis": "host_replay", - "configured_fps": 30.0, - "source_fps": 30.0 - }, - "config_ref": "config/sensors/finger/sensor.json", - "status": "incomplete", - "sites": [{"site_id": "site-001", "uv_xy_px": [120, -4], "position_sensor_mm": [1.2, 0.8, 3.4]}], - "trials": [{ - "trial_id": "trial-001", - "site_id": "site-001", - "baseline_id": "baseline-001", - "video": "video/trial-001.mp4", - "status": "incomplete", - "steps": [ - {"step_id": "step-001", "branch": "load", "target_load_n": 5.0}, - {"step_id": "step-002", "branch": "unload", "target_load_n": 0.0} - ] - }] -} +```text +finger_F001_<时间>_<标识>/ + session.json + trials/ + trial-001/ + session.json + raw_0001.mp4 + frames.csv + forces.csv + reference.csv + events.csv + trial-002/ + ... ``` -## 软件数据流与责任 +10 个点、每点 3 次正常循环共 30 个 trial 目录;中止和重试各占一次,档位数不增加目录数。 -### 阶段一待接通链路 - -| 顺序 | 输入 → 处理 → 输出 | 负责模块 | -|---|---|---| -| 1 | 操作员设测点/载荷路径,可选导入 `.oryseq` → session 快照 | Autocal 会话与事件控制 | -| 2 | UV + 固定映射 → mesh 位置 + 平台姿态 → site/trial | 几何及现有姿态计算能力 | -| 3 | 视频路径先调试、相机随后 → 同一 finger 处理链;电脑采集时刻与帧绑定 → `SdkPacket`、trial 视频和索引 | SDK capture + shared worker;Autocal 不导入 Studio 的 UI 或控制器 | -| 4 | FT/ECAT 样本或砝码有效时段 + 手动控制反馈/确认 → 同源电脑时间流 | Autocal 参考输入及事件控制;与图像并行 | -| 5 | 事件结束 → 完成写入 + 完整性检查 → trial 状态 | 文件记录器及会话控制 | -| 6 | 视频解码 + 基准/配置 → 原始信号;时间对齐参考 → 逐点拟合、误差和 UV 着色 | Autocal 分析与可视化;结果存 derived | - -保留现有算法通道值时可沿用 `forces.csv`,但它不是独立参考力。业务层组织试验,SDK 负责图像算法,共享记录器负责文件写入;不让 SDK 依赖 Autocal UI。 - -## 首版验收与待定事项 - -| 验收 | 通过条件 | +| 文件 | 内容及写入时机 | |---|---| -| 数据关联 | 每个视频可追溯到测点、姿态、基准、加载/卸载档位及参考力 | -| 可回放 | 重新打开后能解码完整 trial,恢复处理状态并重算选定窗口 | -| 有效性 | 丢帧、参考缺失、中断及失败显式可见;原始记录不被重处理覆盖 | -| 最小验证 | 至少留出完整 trial,分别比较加载/卸载的法向误差;阈值待定 | -| 逐点呈现 | 多测点各有灵敏度、有效性、参考力—原始信号曲线;UV 图能定位差异 | +| 根 `session.json` | `format_version=autocal-session-1.0`、字符串 session_id、型号/SN、配置快照及哈希、UV 定义、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` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 | +| `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 | +| `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 | +| `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 | +| `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 | -开跑前仍须确定载荷档位、轴方向及精确原点、参考采集与同步、视频编码和恢复窗口。先实测单 trial 的容量及丢帧,再估算整场存储。视频空档不承诺连续回放。自动加载、空间模型及长期可靠性验证后续再做;本流程不证明剪切或多接触精度。 +| 参数/字段 | 定义 | +|---|---| +| site | `site_id`、`uv_xy_px`、`position_sensor_mm`、`face_index`、栅格像素、几何路径及哈希 | +| trial 计划 | `levels_n`、`hold_s`、有序 steps;step_index 从 0 开始,branch 为 zero/load/unload | +| trial 状态 | 先写 incomplete,排空录像并通过完整性检查才写 complete;根清单同时保存结果和原因 | +| 执行 steps | 目标和实际参考力、保持起止单调时间、complete/incomplete、稳定验证标志 | +| `reference.csv` | `step_index`、`branch`、`target_load_n`、`t_start_host_mono_ns`、`t_end_host_mono_ns`、`F_ref_normal_N`、预留 XYZ、`force_frame`、`source`、`stability_verified`、`hold_complete` | +| 参考有效性 | 人工声明压缩为正;未测 XYZ 和坐标系留空,不补零,不直接把法向值当平台 Z;hold_complete=0 的窗口不能用于正式拟合 | +| `frames.csv` | `frame_id`、`source_frame_id`、`t_rel_s`、`t_host_mono_ns`、`t_host_utc_ns`、`acquired_at`、`time_basis`、`media_time_ms`、`segment`、`segment_frame`、`in_video`、`dropped`、`capture_dropped` | + +参考窗口采用起点包含、终点不包含的时间范围。换档没有恒定力标签,视频和算法输出仍保存。`trial_finished` 表示请求正常结束;最终是否 complete 以排空后的 manifest 为准。保存失败不能当作成功;重试不覆盖原文件。 + +## 软件数据流及后续 + +| 输入 → 处理 → 输出 | 所有者 | +|---|---| +| UV 像素 → 固定映射 → 传感器毫米坐标/模拟平台姿态 → 操作表或 site | Autocal sensor 几何与模拟界面 | +| 视频/相机 → 帧采集时间 → finger 处理 → `SdkPacket` | SDK capture、shared acquisition | +| 手工参考 + 保持状态 → 有效候选窗口及事件 | Autocal trial | +| session/site/trial 关联 → 根清单;数据包 → 视频及 CSV | Autocal 会话;shared recorder 负责文件写入 | +| 完整 trial + 帧身份/电脑时间 → 保持窗口原始均值 → 加载/卸载对照及 UV 测点着色 | Autocal 初步分析;稳定性和正式验证仍后置 | + +主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。 + +点击“查看标定数据…”选择会话根目录;在独立窗口切换测点,检查每档有效样本、均值、标准差和质量说明。初步拟合仅使用初始零载及加载分支的窗口均值,各窗口等权;卸载点保留用于对照,不混入加载拟合。缺样本、档位或信号变化时不生成系数。UV 图仅显示已测点,不外推整面;拟合 RMSE 不是独立验证误差。 + +正式分析拟合 `F_ref = gain_n_per_raw × raw + offset_n`,保存有效档位、范围、误差及加载/卸载差异,并保留独立 trial 验证。基准图像与算法历史尚未完整快照,因此当前录像不承诺从全新进程精确复算同样信号。FT、实际平台反馈、视频容量与驱动延迟仍须联调;自动加载、剪切、空间外推及实时部署留到后续。实施顺序见[实施计划](implementation-plan.zh.md)。 diff --git a/apps/autocal/docs/implementation-plan.zh.md b/apps/autocal/docs/implementation-plan.zh.md index efa5124..34010da 100644 --- a/apps/autocal/docs/implementation-plan.zh.md +++ b/apps/autocal/docs/implementation-plan.zh.md @@ -1,6 +1,6 @@ # Autocal finger 标定实施计划 -状态:2026-10-11 修订。用途:方案。阶段一的交付为操作员手动控制完成整条标定流程;M1 已接通视频与相机编号输入,并把主机采集时间绑定到 finger 帧。单测点手动 trial 已能记录;相机实机、跨点会话与灵敏度页面尚未验收。操作与目标文件字段以[数据格式草稿](calibration-data-format.zh.md)为准。 +状态:2026-10-11 修订。用途:方案。阶段一的交付为操作员手动控制完成整条标定流程;M1 已接通视频与相机编号输入,并把主机采集时间绑定到 finger 帧。跨点 session、保持倒计时和模拟页测点操作表已接通;已接通会话重开、保持窗口统计、逐点曲线和 UV 初步灵敏度;正式系数及相机实机尚未验收。操作与目标文件字段以[数据格式草稿](calibration-data-format.zh.md)为准。 ## 阶段一目标 @@ -33,9 +33,13 @@ M1 的视频与相机输入现共用带时间的数据包;M2 记录器与 M3 手动 UI 可在接口固定后并行。M4 可先用合成 session 开发,但阶段一只有在真实记录上重新打开并计算逐测点结果才算完成。Studio 的 `SessionController`、widget 和 `RecordingCoordinator` 保留在 Studio;Autocal 直接复用 shared `FingerAcquisitionWorker`、`SdkPacket` 和 sink,不跨应用导入私有模块。只有出现两端实际共用的逻辑时才移到 shared;SDK 不依赖 UI。 -界面复核:原先测点、计划和输入分散在“手指调试”“测试序列”“传感器”页。现在“标定采集”页串起单次 trial;全局“运行”按钮尚未负责标定,页面内有独立的“开始试验并录制”。 +界面复核:原先测点、计划和输入分散在“手指调试”“测试序列”“传感器”页。现在“标定采集”页串起单次 trial;全局“运行”按钮尚未负责标定,页面内有独立的“开始本次试验”。 -已实现的最小闭环在“标定采集”页:点击 UV 图粗选、以 U/V 整数像素精确修改、切换到实时二维 UV 图像,逐档填写实际参考力并确认。每个 trial 单独使用 shared `ExperimentRecorder` 生成目录、视频、帧索引、力数据和事件;人工参考另存 `reference.csv`,仅声明档位区间,尚无可自动判定的稳定窗口。未实现 FT 同步、跨点 session 汇总、灵敏度拟合和实体相机验收。手动 Z 控制仍使用既有设备界面,专用弹窗需先确定操作边界。 +已实现的最小闭环在“标定采集”页:点击 UV 图粗选、以 U/V 整数像素精确修改、切换到实时二维 UV 图像。首个 trial 建立本场 session,同点重复和新测点试验归入其 trials 子目录;每档人工确认后倒计时,初始/最终零载也需保持。参考窗口在计时终点结束,换档只保留视频和信号,不标成恒定参考力;中止保留 incomplete。 + +模拟页可按选点顺序导出与标定页相同 U/V 的 CSV 操作表,导入仍后置。未实现 FT 同步、稳定性复核、图像基准快照、正式标定和实体相机验收。手动 Z 控制仍使用既有设备界面,专用弹窗需先确定操作边界。 + +目前 M4 已提供独立会话复核窗口:按帧身份和采集时间取完整保持窗口,排除无效帧和换档,显示加载/卸载散点、加载分支初步线性拟合及 UV 测点着色。结果可另存 derived;未复核稳定性的人工参考及视频调试不升级为正式系数。 主代理负责接口、M3 和集成;一名子代理负责 M1/M2,另一名负责 M4 与合成会话验证。建议总计主代理加两名子代理,约 4–6 个工程工作日并留设备联调时间;单人约 6–9 日。估算不包括设备、FT 协议或硬件等待。当前计划不启动子代理。 diff --git a/apps/autocal/docs/sequence-format.md b/apps/autocal/docs/sequence-format.md index 65f934d..25dfea1 100644 --- a/apps/autocal/docs/sequence-format.md +++ b/apps/autocal/docs/sequence-format.md @@ -4,6 +4,11 @@ A calibration sequence is a small JSON document: the **profile** that generated the points, plus the **points** themselves. It is written by `导出序列…` in the Sequence tab and read by `导入序列…`. +The Finger simulation page separately exports an operator CSV list with centred +UV pixels, sensor/platform millimetres and mapping identity. It uses the +[manual calibration coordinate convention](calibration-data-format.zh.md); +that list is not an `.oryseq` and is not imported into the acquisition page. + Why JSON and not a binary container: a sequence is at most a few hundred points of text. It is read by a human, diffed in review, and emailed to whoever runs the bench. Binary would buy nothing and cost all three. (The *acquired data* of a run diff --git a/apps/autocal/src/orisys_autocal/calibration_session.py b/apps/autocal/src/orisys_autocal/calibration_session.py new file mode 100644 index 0000000..710b134 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/calibration_session.py @@ -0,0 +1,93 @@ +"""Autocal's multi-site manifest; the shared recorder owns each trial's files.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + + +def write_json(path: Path, payload: dict) -> None: + """Replace a manifest atomically, keeping the previous version on failure.""" + temporary = path.with_suffix(".json.tmp") + temporary.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + temporary.replace(path) + + +class CalibrationSession: + """One sensor/configuration/mapping identity, with independent trial attempts.""" + + def __init__(self, root: Path, metadata: dict) -> None: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + identity = [] + for key in ("sensor_model", "sensor_sn"): + value = re.sub(r"[^\w-]+", "-", metadata[key]).strip("-_")[:48] + if not value: + raise ValueError("sensor model and SN need letters or numbers") + identity.append(value) + self.directory = root / f"{'_'.join(identity)}_{stamp}_{uuid4().hex[:12]}" + self.identity = self.identity_of(metadata) + self.document = { + "format_version": "autocal-session-1.0", + "session_id": self.directory.name, + **self.identity, + "config_snapshot": metadata["config_snapshot"], + "started_at": datetime.now(timezone.utc).isoformat(), + "status": "in_progress", + "sites": [], + "trials": [], + } + self.directory.mkdir(parents=True, exist_ok=False) + self.save() + + @staticmethod + def identity_of(metadata: dict) -> dict: + return { + "sensor_model": metadata["sensor_model"], + "sensor_sn": metadata["sensor_sn"], + "config_sha256": metadata["config_sha256"], + "uv_definition": metadata["uv_definition"], + } + + def add_trial(self, metadata: dict) -> dict: + if self.document["status"] != "in_progress": + raise RuntimeError("calibration session is closed") + if self.identity_of(metadata) != self.identity: + raise ValueError("finish this session before changing sensor, config or UV mapping") + site = metadata["site"] + sites = self.document["sites"] + stored = next((item for item in sites if item["uv_xy_px"] == site["uv_xy_px"]), None) + if stored is None: + stored = {"site_id": f"site-{len(sites) + 1:03d}", **site} + sites.append(stored) + trials = self.document["trials"] + trial_id = f"trial-{len(trials) + 1:03d}" + entry = { + "trial_id": trial_id, + "site_id": stored["site_id"], + "repeat_index": 1 + sum(item["site_id"] == stored["site_id"] for item in trials), + "directory": f"trials/{trial_id}", + "status": "incomplete", + "reason": "trial has not finished", + "plan": metadata["plan"], + } + trials.append(entry) + self.save() + return entry + + def save(self) -> None: + write_json(self.directory / "session.json", self.document) + + def close(self) -> str: + trials = self.document["trials"] + self.document["status"] = ( + "complete" if trials and all(t["status"] == "complete" for t in trials) + else "incomplete" + ) + self.document["ended_at"] = datetime.now(timezone.utc).isoformat() + self.save() + return self.document["status"] diff --git a/apps/autocal/src/orisys_autocal/sensor/uv_flat.py b/apps/autocal/src/orisys_autocal/sensor/uv_flat.py index 7f7be30..223aaca 100644 --- a/apps/autocal/src/orisys_autocal/sensor/uv_flat.py +++ b/apps/autocal/src/orisys_autocal/sensor/uv_flat.py @@ -17,6 +17,7 @@ direction) and triangle structure rather than photorealism. from __future__ import annotations +import hashlib from dataclasses import dataclass from pathlib import Path @@ -96,6 +97,63 @@ class FlatSurface: return max(0, min(self.width - 1, x)), max(0, min(self.height - 1, y)) +@dataclass(frozen=True) +class Site: + u_px: int + v_px: int + position_sensor_mm: tuple[float, float, float] + face_index: int + raster_xy_px: tuple[int, int] + uv_origin_raster_xy_px: tuple[int, int] + raster_size_hw: tuple[int, int] + mapping_sha256: str + + +def uv_origin(flat) -> tuple[int, int]: + """Lowest valid pixel in the valid column nearest the image midpoint.""" + rows, columns = np.nonzero(flat.face_index >= 0) + if not len(rows): + raise ValueError("UV map has no valid sensor pixels") + middle = (flat.width - 1) / 2 + column = int(columns[np.argmin(np.abs(columns - middle))]) + row = int(rows[columns == column].max()) + return column, row + + +def site_at( + flat, u_px: int, v_px: int, *, origin: tuple[int, int] | None = None, + mapping_digest: str | None = None, +) -> Site: + """Resolve a precise raster pixel; empty mask pixels cannot become sites.""" + if flat is None: + raise ValueError("UV map is not loaded") + origin_x, origin_y = origin if origin is not None else uv_origin(flat) + raster_x, raster_y = origin_x + int(u_px), origin_y - int(v_px) + sample = flat.sample(raster_x, raster_y) + if sample is None: + raise ValueError("UV pixel is outside the valid sensor surface") + point, _normal, face = sample + return Site( + int(u_px), + int(v_px), + tuple(float(v) for v in point), + int(face), + (raster_x, raster_y), + (origin_x, origin_y), + (flat.height, flat.width), + mapping_digest if mapping_digest is not None else mapping_sha256(flat), + ) + + +def mapping_sha256(flat) -> str: + """Identify the actual pixel-to-mesh lookup, including its validity mask.""" + digest = hashlib.sha256() + for array in (flat.positions_mm, flat.face_index): + digest.update(str((array.shape, array.dtype.str)).encode('ascii')) + digest.update(np.ascontiguousarray(array).tobytes()) + return digest.hexdigest() + + def load_uv_texture(path: str | Path) -> np.ndarray | None: """Decode a UV-space texture to ``[H, W, 4]`` uint8, or ``None``. diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py new file mode 100644 index 0000000..94d940d --- /dev/null +++ b/apps/autocal/src/orisys_autocal/session_review.py @@ -0,0 +1,124 @@ +"""Read-only diagnostics of recorded hold windows, never runtime calibration.""" + +from __future__ import annotations + +import csv +import json +import math +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import numpy as np + +from .calibration_session import write_json + + +def _rows(path: Path) -> list[dict]: + with path.open(encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + + +def review_session(directory: Path) -> dict: + """Join frame identity to host timestamps and inspect only complete windows. + + Manual reference values are declarations. Fits remain exploratory regardless + of sample count, and replay timestamps cannot establish physical alignment. + """ + root = Path(directory).resolve() + manifest = json.loads((root / "session.json").read_text(encoding="utf-8")) + if manifest.get("format_version") != "autocal-session-1.0": + raise ValueError("select a calibration session root, not an individual trial") + windows = [] + issues = [] + for trial in manifest["trials"]: + trial_id = trial["trial_id"] + if trial["status"] != "complete": + issues.append({"trial_id": trial_id, "reason": "incomplete_trial"}) + continue + path = (root / trial["directory"]).resolve() + if not path.is_relative_to(root): + raise ValueError("trial directory is outside the calibration session") + metadata = json.loads((path / "session.json").read_text(encoding="utf-8")) + if (metadata.get("trial_status") != "complete" + or metadata.get("trial_id") != trial_id + or metadata.get("site_id") != trial["site_id"] + or metadata.get("calibration_session_id") != manifest["session_id"] + or metadata.get("uv_definition") != manifest["uv_definition"]): + raise ValueError(f"trial identity or completion mismatch: {trial_id}") + frames = {} + for row in _rows(path / "frames.csv"): + if row["dropped"] != "0" or not row["t_host_mono_ns"]: + continue + frame_id = int(row["frame_id"]) + if frame_id in frames: + raise ValueError(f"duplicate frame identity in {trial_id}") + frames[frame_id] = int(row["t_host_mono_ns"]) + samples = [] + for row in _rows(path / "forces.csv"): + if row["valid"] != "1" or not row["FN_raw"]: + continue + if int(row["session_id"]) != metadata["session_id"]: + raise ValueError(f"acquisition session mismatch in {trial_id}") + stamp = frames.get(int(row["frame_id"])) + raw = float(row["FN_raw"]) + if stamp is not None and math.isfinite(raw): + samples.append((stamp, raw)) + for reference in _rows(path / "reference.csv"): + if reference["hold_complete"] != "1": + continue + start = int(reference["t_start_host_mono_ns"]) + end = int(reference["t_end_host_mono_ns"]) + load = float(reference["F_ref_normal_N"]) + if end <= start or not math.isfinite(load) or not 0 <= load <= 30: + raise ValueError(f"invalid reference window in {trial_id}") + raw = np.array([v for stamp, v in samples if start <= stamp < end], dtype=float) + windows.append({ + "trial_id": trial_id, "site_id": trial["site_id"], + "step_index": int(reference["step_index"]), + "branch": reference["branch"], "reference_n": load, + "t_start_host_mono_ns": start, "t_end_host_mono_ns": end, + "duration_s": (end - start) / 1e9, "samples": int(raw.size), + "raw_mean": float(raw.mean()) if raw.size else None, + "raw_std": float(raw.std()) if raw.size else None, + "stability_verified": reference["stability_verified"] == "1", + "reference_alignment_possible": bool(metadata["reference_alignment_possible"]), + "status": "candidate" if raw.size >= 2 else "insufficient_samples", + }) + sites = [] + for site in manifest["sites"]: + selected = [w for w in windows if w["site_id"] == site["site_id"]] + # Loading and unloading stay separate: a shared runtime piecewise curve + # merges repeated loads and would hide the hysteresis under inspection. + training = [w for w in selected if w["branch"] in {"zero", "load"} + and w["status"] == "candidate"] + raw = np.array([w["raw_mean"] for w in training], dtype=float) + loads = np.array([w["reference_n"] for w in training], dtype=float) + fit = None + if (len(set(loads)) >= 2 and raw.size >= 2 + and not np.allclose(raw, raw[0], rtol=1e-9, atol=1e-12)): + gain, offset = np.polyfit(raw, loads, 1) + if math.isfinite(gain) and math.isfinite(offset) and abs(gain) > 1e-12: + fit = { + "gain_n_per_raw": float(gain), "offset_n": float(offset), + "sensitivity_raw_per_n": float(1 / gain), + "rmse_n": float(np.sqrt(np.mean((gain * raw + offset - loads) ** 2))), + "windows": len(training), "status": "exploratory", + } + sites.append({**site, "windows": selected, "fit": fit}) + return { + "format_version": "autocal-review-1.0", "session_id": manifest["session_id"], + "source_directory": str(root), "session_status": manifest["status"], + "status": "exploratory", "sites": sites, "issues": issues, + } + + +def save_review(result: dict) -> Path: + """Save a new derived artifact; never replace acquired measurements.""" + root = Path(result["source_directory"]) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + directory = root / "derived" / f"review_{stamp}_{uuid4().hex[:8]}" + directory.mkdir(parents=True, exist_ok=False) + path = directory / "review.json" + write_json(path, result) + return path diff --git a/apps/autocal/src/orisys_autocal/trial.py b/apps/autocal/src/orisys_autocal/trial.py index a6b4716..59fc585 100644 --- a/apps/autocal/src/orisys_autocal/trial.py +++ b/apps/autocal/src/orisys_autocal/trial.py @@ -9,14 +9,15 @@ from __future__ import annotations import csv import hashlib import json -import re +import math import time -from dataclasses import dataclass from pathlib import Path -import numpy as np from orisys_app_shared.record import ExperimentRecorder, RecordConfig +from .calibration_session import CalibrationSession, write_json +from .sensor.uv_flat import Site + REFERENCE_HEADER = ( "step_index", @@ -31,56 +32,10 @@ REFERENCE_HEADER = ( "force_frame", "source", "stability_verified", + "hold_complete", ) -def _folder_part(value: str) -> str: - return re.sub(r"[^\w-]+", "-", value.strip()).strip("-_ ")[:48] - - -@dataclass(frozen=True) -class Site: - u_px: int - v_px: int - position_sensor_mm: tuple[float, float, float] - face_index: int - raster_xy_px: tuple[int, int] - uv_origin_raster_xy_px: tuple[int, int] - - -def uv_origin(flat) -> tuple[int, int]: - """Lowest valid pixel in the valid column nearest the image midpoint.""" - rows, columns = np.nonzero(flat.face_index >= 0) - if not len(rows): - raise ValueError("UV map has no valid sensor pixels") - middle = (flat.width - 1) / 2 - column = int(columns[np.argmin(np.abs(columns - middle))]) - row = int(rows[columns == column].max()) - return column, row - - -def site_at( - flat, u_px: int, v_px: int, *, origin: tuple[int, int] | None = None -) -> Site: - """Resolve a precise raster pixel; empty mask pixels cannot become sites.""" - if flat is None: - raise ValueError("UV map is not loaded") - origin_x, origin_y = origin if origin is not None else uv_origin(flat) - raster_x, raster_y = origin_x + int(u_px), origin_y - int(v_px) - sample = flat.sample(raster_x, raster_y) - if sample is None: - raise ValueError("UV pixel is outside the valid sensor surface") - point, _normal, face = sample - return Site( - int(u_px), - int(v_px), - tuple(float(v) for v in point), - int(face), - (raster_x, raster_y), - (origin_x, origin_y), - ) - - def load_path(levels: list[float]) -> list[tuple[str, float]]: """One continuous ascent and descent, ending at zero.""" values = [float(value) for value in levels] @@ -88,7 +43,7 @@ def load_path(levels: list[float]) -> list[tuple[str, float]]: raise ValueError("load levels must be in (0, 30] N") if values != sorted(set(values)): raise ValueError("load levels must be strictly increasing") - return [("load", value) for value in values] + [ + return [("zero", 0.0)] + [("load", value) for value in values] + [ ("unload", value) for value in [*reversed(values[:-1]), 0.0] ] @@ -98,6 +53,11 @@ class ManualTrial: def __init__(self, worker) -> None: self.worker = worker + self.session: CalibrationSession | None = None + self.entry: dict | None = None + self.hold_s = 20.0 + self.completed_steps: list[dict] = [] + self.reason = "" self.recorder: ExperimentRecorder | None = None self.directory: Path | None = None self.steps: list[tuple[str, float]] = [] @@ -133,18 +93,12 @@ class ManualTrial: raise ValueError("sensor SN is required") if not sensor_model.strip(): raise ValueError("sensor model is required") - model_part = _folder_part(sensor_model) - sn_part = _folder_part(sensor_sn) - if not model_part or not sn_part: - raise ValueError( - "sensor model and SN need letters or numbers for the folder" - ) - label = f"{model_part}_{sn_part}" - if not float(fps) > 0.0: + if not math.isfinite(float(fps)) or not float(fps) > 0.0: raise ValueError("input frame rate must be positive") - self.steps = load_path(levels) + steps = load_path(levels) if not 10.0 <= float(hold_s) <= 30.0: raise ValueError("hold time must be between 10 and 30 seconds") + self.hold_s = float(hold_s) packet = self.worker.latest() if packet.capture_mono_ns is None: raise RuntimeError("the input has no frame-bound host timestamp") @@ -167,24 +121,45 @@ class ManualTrial: "uv_origin_raster_xy_px": list(site.uv_origin_raster_xy_px), "bundle_path": bundle_path, "mesh_sha256": mesh_sha256, + "raster_size_hw": list(site.raster_size_hw), }, - "plan": {"levels_n": levels, "hold_s": float(hold_s)}, + "uv_definition": { + "unit": "px", "u_positive": "right", "v_positive": "up", + "origin_raster_xy_px": list(site.uv_origin_raster_xy_px), + "raster_size_hw": list(site.raster_size_hw), + "mapping_sha256": site.mapping_sha256, + }, + "plan": {"levels_n": levels, "hold_s": float(hold_s), "steps": [ + {"step_index": i, "branch": branch, "target_load_n": target} + for i, (branch, target) in enumerate(steps) + ]}, "input_source": source, "config_ref": config_ref, "config_sha256": config_sha256, + "config_snapshot": json.loads(config_path.read_text(encoding="utf-8")), "time_basis": packet.time_basis, "reference_alignment_possible": self.reference_alignment_possible, "reference_source": "manual_operator_confirmation", "reference_file": "reference.csv", } recorder = ExperimentRecorder( - RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0) + RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True) ) + if self.session is None: + self.session = CalibrationSession(Path(root).resolve(), metadata) + elif Path(root).resolve() != self.session.directory.parent: + raise ValueError("finish this session before changing the output root") + entry = self.session.add_trial(metadata) + metadata.update({ + "calibration_session_id": self.session.document["session_id"], + "trial_id": entry["trial_id"], "site_id": entry["site_id"], + "repeat_index": entry["repeat_index"], "trial_status": "incomplete", + }) directory = recorder.start( - root=Path(root), + root=self.session.directory / "trials", session_id=packet.session_id, metadata=metadata, - directory_label=label, + directory_name=entry["trial_id"], ) reference_file = None try: @@ -199,6 +174,10 @@ class ManualTrial: reference_file.close() recorder.stop(wait=True) raise + self.entry = entry + self.steps = steps + self.completed_steps = [] + self.reason = "" self.recorder = recorder self.directory = directory self._reference_file = reference_file @@ -236,14 +215,15 @@ class ManualTrial: recorder.log_event( kind, json.dumps( - {**detail, "t_host_mono_ns": now_mono, "t_host_utc_ns": time.time_ns()}, + {**detail, "t_host_mono_ns": now_mono, + "t_host_utc_ns": time.time_ns() + now_mono - time.perf_counter_ns()}, ensure_ascii=False, ), mono_t=event_mono_t, frame_id=frame_id, ) - def _end_reference(self, end_ns: int) -> None: + def _end_reference(self, end_ns: int, *, complete: bool) -> None: if self._active_reference is None: return step_index, branch, target, reference, start_ns = self._active_reference @@ -262,6 +242,7 @@ class ManualTrial: "", "manual_operator_confirmation", 0, + int(complete), ) ) self._reference_file.flush() @@ -278,22 +259,8 @@ class ManualTrial: if not 0.0 <= reference <= 30.0: raise ValueError("reference load must be between 0 and 30 N") now_ns = time.perf_counter_ns() - if self._last_confirm_ns is not None: - self._end_reference(now_ns) - if self._reference_error: - self.finish( - aborted=True, - reason=f"reference write failed: {self._reference_error}", - ) - raise OSError(self._reference_error) - self._event( - "step_ended", - { - "step_index": self.step_index - 1, - "held_s": (now_ns - self._last_confirm_ns) / 1e9, - }, - now_ns=now_ns, - ) + if self._active_reference is not None: + raise RuntimeError("wait for the current hold to finish") self._event( "step_reached", { @@ -306,15 +273,51 @@ class ManualTrial: now_ns=now_ns, ) self._active_reference = (self.step_index, branch, target, reference, now_ns) - self.step_index += 1 self._last_confirm_ns = now_ns + self._event("hold_started", {"step_index": self.step_index}, now_ns=now_ns) return branch, target @property - def held_s(self) -> float | None: - if self._last_confirm_ns is None or self.status != "recording": - return None - return (time.perf_counter_ns() - self._last_confirm_ns) / 1e9 + def holding(self) -> bool: + return self._active_reference is not None + + @property + def remaining_s(self) -> float: + if not self.holding: + return 0.0 + return max(0.0, self.hold_s - (time.perf_counter_ns() - self._last_confirm_ns) / 1e9) + + def _complete_hold(self, end_ns: int, *, complete: bool) -> None: + index, branch, target, reference, start_ns = self._active_reference + self._end_reference(end_ns, complete=complete) + step = { + "step_index": index, "branch": branch, "target_load_n": target, + "reference_load_n": reference, + "t_start_host_mono_ns": start_ns, "t_end_host_mono_ns": end_ns, + "status": "complete" if complete and not self._reference_error else "incomplete", + "stability_verified": False, + } + self.completed_steps.append(step) + self._event("hold_ended", step, now_ns=end_ns) + self._last_confirm_ns = None + if complete and not self._reference_error: + self.step_index += 1 + if self.session is not None and self.entry is not None: + self.entry["steps"] = list(self.completed_steps) + try: + self.session.save() + except OSError as exc: + self._reference_error = f"session write failed: {exc}" + + def finish_session(self) -> tuple[Path, str]: + if self.status in {"recording", "closing"}: + raise RuntimeError("finish the current trial first") + if self.session is None: + raise RuntimeError("no calibration session") + directory = self.session.directory + status = self.session.close() + self.session = None + return directory, status def finish(self, *, aborted: bool = False, reason: str = "") -> None: if self.status != "recording" or self.recorder is None: @@ -322,16 +325,8 @@ class ManualTrial: if not aborted and self.next_step is not None: raise RuntimeError("complete the load/unload path before finishing") now_ns = time.perf_counter_ns() - if self._last_confirm_ns is not None: - self._end_reference(now_ns) - self._event( - "step_ended", - { - "step_index": self.step_index - 1, - "held_s": (now_ns - self._last_confirm_ns) / 1e9, - }, - now_ns=now_ns, - ) + if self.holding: + self._complete_hold(now_ns, complete=False) try: self._reference_file.close() except OSError as exc: @@ -344,6 +339,7 @@ class ManualTrial: {"reason": reason}, now_ns=now_ns, ) + self.reason = reason self.aborted = aborted self.worker.remove_sink(self.recorder.on_packet) self.recorder.stop(wait=False) @@ -353,6 +349,11 @@ class ManualTrial: if self.status == "recording" and self.recorder is not None: if not self.worker.is_running or not self.recorder.is_recording: self.finish(aborted=True, reason="input or writer stopped") + elif self.holding and self.remaining_s <= 0: + deadline = self._last_confirm_ns + round(self.hold_s * 1e9) + self._complete_hold(deadline, complete=True) + if self._reference_error: + self.finish(aborted=True, reason=self._reference_error) if self.status == "closing" and self.recorder is not None: report = self.recorder.report if report is not None: @@ -370,4 +371,20 @@ class ManualTrial: ) else "incomplete" ) + self.entry.update({ + "status": self.status, + "reason": self.reason or ("" if self.status == "complete" else "recording integrity check failed"), + "steps": list(self.completed_steps), + }) + try: + trial_path = self.directory / "session.json" + document = json.loads(trial_path.read_text(encoding="utf-8")) + document.update({"trial_status": self.status, "steps": self.completed_steps, + "trial_reason": self.entry["reason"]}) + write_json(trial_path, document) + self.session.save() + except (OSError, ValueError): + self.status = "incomplete" + self.entry["status"] = "incomplete" + raise return self.status diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py index 526a859..8d28ee8 100644 --- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -1,7 +1,8 @@ -"""Single-site manual finger trial: precise UV site, live view and recording.""" +"""Manual finger session: precise UV sites, live view and trial recording.""" from __future__ import annotations +import logging from pathlib import Path from orisys_app_shared.i18n import t @@ -23,7 +24,8 @@ from PySide6.QtWidgets import ( QWidget, ) -from ..trial import ManualTrial, site_at, uv_origin +from ..sensor.uv_flat import site_at, uv_origin +from ..trial import ManualTrial, load_path from .sensor_panel import FlatSurfaceView @@ -63,6 +65,19 @@ class CalibrationPanel(QWidget): outer.addLayout(header) self.source_state = QLabel(self) outer.addWidget(self.source_state) + session_row = QHBoxLayout() + self.session_label = QLabel(self) + self.session_label.setWordWrap(True) + self.close_session_button = QPushButton(self) + self.close_session_button.setObjectName("calibrationCloseSession") + self.close_session_button.clicked.connect(self._close_session) + self.review_button = QPushButton(self) + self.review_button.setObjectName("calibrationReview") + self.review_button.clicked.connect(self._review) + session_row.addWidget(self.session_label, 1) + session_row.addWidget(self.review_button) + session_row.addWidget(self.close_session_button) + outer.addLayout(session_row) body = QHBoxLayout() self.views = QTabWidget(self) @@ -106,6 +121,9 @@ class CalibrationPanel(QWidget): self.hold.setValue(20) self.hold.setSuffix(" s") form.addRow(self.hold_label, self.hold) + self.path_label = QLabel(controls) + self.path_label.setWordWrap(True) + form.addRow(self.path_label) self.target_label = QLabel(controls) form.addRow(self.target_label) self.reference_label = QLabel(controls) @@ -136,6 +154,7 @@ class CalibrationPanel(QWidget): self.output_browse.clicked.connect(self._choose_output) self.u_spin.valueChanged.connect(self._site_changed) self.v_spin.valueChanged.connect(self._site_changed) + self.levels.textChanged.connect(self._sync) self.start_button.clicked.connect(self._start) self.confirm_button.clicked.connect(self._confirm) self.finish_button.clicked.connect(self._finish) @@ -148,6 +167,8 @@ class CalibrationPanel(QWidget): self._sync() def set_flat(self, flat, bundle_path: str) -> None: + if self.trial.session is not None: + raise RuntimeError(t("calibration.close_before_mapping")) self._flat = flat self._bundle_path = bundle_path self.flat_view.set_flat(flat) @@ -265,7 +286,12 @@ class CalibrationPanel(QWidget): def _poll(self) -> None: previous = self.trial.status - current = self.trial.poll() + try: + current = self.trial.poll() + except (OSError, ValueError) as exc: + self.result_label.setText(t("calibration.error").format(detail=exc)) + self._sync() + return if current != previous and current in {"complete", "incomplete"}: result_key = ( "calibration.incomplete_result" @@ -284,12 +310,46 @@ class CalibrationPanel(QWidget): ) self._sync() + def _close_session(self) -> None: + try: + directory, status = self.trial.finish_session() + self.result_label.setText( + t("calibration.session_closed").format( + status=t(f"calibration.{status}"), path=directory + ) + ) + except (OSError, RuntimeError) as exc: + self.result_label.setText(t("calibration.error").format(detail=exc)) + self._sync() + + def _review(self) -> None: + from .session_review_dialog import SessionReviewDialog + + session = self.trial.session + directory = QFileDialog.getExistingDirectory( + self, t("review.open"), + str(session.directory) if session is not None else self.output_path.text(), + ) + if directory: + try: + dialog = SessionReviewDialog(Path(directory), self) + dialog.exec() + except (OSError, ValueError, KeyError, TypeError) as exc: + self.result_label.setText(t("calibration.error").format(detail=exc)) + def shutdown(self) -> None: self._timer.stop() - if self.trial.status == "recording": - self.trial.finish(aborted=True, reason="application closed") - if self.trial.status == "closing" and self.trial.recorder is not None: - self.trial.recorder.stop(wait=True) + try: + if self.trial.status == "recording": + self.trial.finish(aborted=True, reason="application closed") + if self.trial.status == "closing" and self.trial.recorder is not None: + self.trial.recorder.stop(wait=True) + self.trial.poll() + if self.trial.session is not None: + self.trial.finish_session() + except (OSError, RuntimeError, ValueError) as exc: + # A failed manifest must not prevent the window stopping the equipment. + logging.getLogger(__name__).exception("Calibration shutdown failed: %s", exc) def _sync(self) -> None: recording = self.trial.status == "recording" @@ -299,21 +359,24 @@ class CalibrationPanel(QWidget): self.v_spin, self.levels, self.hold, - self.sensor_sn, - self.sensor_model, - self.output_path, - self.output_browse, self.bundle_button, ): widget.setEnabled(not locked) + for widget in (self.sensor_sn, self.sensor_model, self.output_path, + self.output_browse, self.bundle_button): + widget.setEnabled(not locked and self.trial.session is None) self.start_button.setEnabled(not locked and self._site is not None) - self.confirm_button.setEnabled(recording and self.trial.next_step is not None) + can_confirm = recording and self.trial.next_step is not None and not self.trial.holding + self.confirm_button.setEnabled(can_confirm) + self.reference.setEnabled(can_confirm) self.finish_button.setEnabled(recording and self.trial.next_step is None) self.abort_button.setEnabled(recording) step = self.trial.next_step if recording and step is not None: - target_text = t("calibration.next_step").format( - branch=t(f"calibration.{step[0]}"), load=step[1] + key = "calibration.holding" if self.trial.holding else "calibration.next_step" + target_text = t(key).format( + branch=t(f"calibration.{step[0]}"), load=step[1], + seconds=self.trial.remaining_s, ) elif recording: target_text = t("calibration.ready_to_finish") @@ -328,11 +391,22 @@ class CalibrationPanel(QWidget): total=len(self.trial.steps), status=t(f"calibration.{self.trial.status}"), ) - + ( - t("calibration.held").format(seconds=self.trial.held_s) - if self.trial.held_s is not None - else "" - ) + ) + try: + steps = load_path([float(v.strip()) for v in self.levels.text().split(",")]) + self.path_label.setText(t("calibration.path").format( + path=" → ".join(f"{t('calibration.' + branch)} {force:g}" for branch, force in steps) + )) + except ValueError: + self.path_label.setText(t("calibration.invalid_levels")) + session = self.trial.session + self.close_session_button.setEnabled(session is not None and not locked) + self.review_button.setEnabled(not locked) + self.session_label.setText( + t("calibration.session_active").format( + path=session.directory, sites=len(session.document["sites"]), + trials=len(session.document["trials"]), + ) if session is not None else t("calibration.session_none") ) worker = self.source_panel.worker packet = worker.latest() @@ -353,6 +427,9 @@ class CalibrationPanel(QWidget): self.sn_label.setText(t("calibration.sensor_sn")) self.output_label.setText(t("calibration.output")) self.output_browse.setText(t("source.browse")) + self.close_session_button.setText(t("calibration.close_session")) + self.close_session_button.setToolTip(t("calibration.close_session_tip")) + self.review_button.setText(t("review.open")) self.views.setTabText(0, t("calibration.site_view")) self.views.setTabText(1, t("calibration.live_view")) self.bundle_button.setText(t("finger.load")) diff --git a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py index 5495d11..34d9d80 100644 --- a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py @@ -18,6 +18,7 @@ not. from __future__ import annotations +import csv import math from pathlib import Path @@ -28,6 +29,7 @@ from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap from PySide6.QtWidgets import ( QCheckBox, QDoubleSpinBox, + QFileDialog, QGridLayout, QGroupBox, QHBoxLayout, @@ -45,7 +47,7 @@ from PySide6.QtWidgets import ( from ..motion.kinematics import PlatformStack from ..sensor.export_bundle import SensorBundle from ..sensor.frames import describe as describe_frames -from ..sensor.uv_flat import FlatSurface +from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin #: How many picked points the list keeps before the oldest is dropped. MAX_POINTS = 200 @@ -237,7 +239,7 @@ class FingerPanel(QGroupBox): #: than typed into. COLUMNS: tuple[str, ...] = ( "x", "y", "z", "nx", "ny", "nz", - "X", "Y", "Z", "yaw", "pitch", "residual", + "X", "Y", "Z", "yaw", "pitch", "residual", "u_px", "v_px", ) #: How many of ``COLUMNS`` are inputs rather than solved outputs. INPUT_COLUMNS = 6 @@ -613,8 +615,12 @@ class FingerPanel(QGroupBox): self.up_button.clicked.connect(lambda: self.move_selected(-1)) self.down_button = QPushButton(box) self.down_button.clicked.connect(lambda: self.move_selected(1)) + self.export_sites_button = QPushButton(box) + self.export_sites_button.setObjectName("exportCalibrationSites") + self.export_sites_button.clicked.connect(self._export_sites_dialog) for button in (self.clear_button, self.remove_button, self.up_button, self.down_button): row.addWidget(button) + row.addWidget(self.export_sites_button) row.addStretch(1) self._selected_label = QLabel(box) self._selected_label.setObjectName("hintLabel") @@ -808,6 +814,50 @@ class FingerPanel(QGroupBox): """Every picked point, at full precision, for a later export.""" return [dict(row) for row in self._picked] + def export_sites(self, path: str | Path) -> Path: + """Export the displayed order, with the same UV convention as calibration.""" + flat = self.view.flat + if flat is None or not self._picked: + raise ValueError(t("finger.export_no_points")) + origin_x, origin_y = uv_origin(flat) + identity = mapping_sha256(flat) + rows = [] + for order, record in enumerate(self._picked, 1): + site = site_at( + flat, record["u_px"], record["v_px"], origin=(origin_x, origin_y), + mapping_digest=identity, + ) + point = [record[key] for key in ("x", "y", "z")] + if not np.allclose(point, site.position_sensor_mm, rtol=0, atol=1e-9): + raise ValueError(t("finger.export_edited_point").format(order=order)) + rows.append({ + "order": order, "site_id": f"site-{order:03d}", + "u_px": site.u_px, "v_px": site.v_px, + **{f"{axis}_sensor_mm": record[axis] for axis in ("x", "y", "z")}, + **{f"{axis.lower()}_platform_mm": record[axis] for axis in ("X", "Y", "Z")}, + "yaw_deg": record["yaw"], "pitch_deg": record["pitch"], + "origin_raster_x_px": origin_x, "origin_raster_y_px": origin_y, + "raster_width_px": flat.width, "raster_height_px": flat.height, + "u_positive": "right", "v_positive": "up", "mapping_sha256": identity, + }) + destination = Path(path) + with destination.open("w", newline="", encoding="utf-8-sig") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + return destination + + def _export_sites_dialog(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, t("finger.export_sites"), "calibration-sites.csv", "CSV (*.csv)" + ) + if path: + try: + self.export_sites(path) + self.status_label.setText(t("finger.sites_exported").format(path=path)) + except (OSError, ValueError) as exc: + self.status_label.setText(t("calibration.error").format(detail=exc)) + # ------------------------------------------------------------------ actions def handle_pick(self, x: int, y: int) -> dict[str, float] | None: @@ -825,6 +875,7 @@ class FingerPanel(QGroupBox): return None point, normal, face = sample + origin_x, origin_y = uv_origin(self.view.flat) pose = self._stack.solve(point, normal) # The click leaves a cross-hair where it landed *and* promotes the new row # to the selection, so the red dot marks the same point in both views. @@ -839,6 +890,7 @@ class FingerPanel(QGroupBox): "Z": float(pose.contact_z_mm), "yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg), "residual": float(pose.residual_deg), + "u_px": x - origin_x, "v_px": origin_y - y, } self._append_row(row) self._last_pose = pose @@ -896,7 +948,9 @@ class FingerPanel(QGroupBox): item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) item.setForeground(QColor("#5c6678")) self.table.setItem(index, column, item) - item.setText(f"{row[key]:.3f}") + item.setText( + f"{row[key]:.0f}" if key in {"u_px", "v_px"} else f"{row[key]:.3f}" + ) self._ensure_selector(index) finally: self._writing = False @@ -1269,6 +1323,8 @@ class FingerPanel(QGroupBox): [""] + [t(f"finger.col_{key}") for key in self._headers] ) self.clear_button.setText(t("finger.clear_points")) + self.export_sites_button.setText(t("finger.export_sites")) + self.export_sites_button.setToolTip(t("finger.export_sites_tip")) self.remove_button.setText(t("finger.remove_selected")) self.up_button.setText(t("finger.move_up")) self.down_button.setText(t("finger.move_down")) diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py new file mode 100644 index 0000000..ded15ff --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py @@ -0,0 +1,168 @@ +"""Exploratory hold-window table and per-site loading/unloading charts.""" + +from __future__ import annotations + +from pathlib import Path + +from orisys_app_shared.i18n import t +from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPainter +from PySide6.QtWidgets import ( + QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QTableWidget, QTableWidgetItem, QVBoxLayout, +) + +from ..session_review import review_session, save_review + + +class SessionReviewDialog(QDialog): + """Open persisted data independently of the acquisition page's state.""" + + def __init__(self, directory: Path, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle(t("review.title")) + self.resize(1080, 760) + self.review = review_session(directory) + layout = QVBoxLayout(self) + self.notice = QLabel(t("review.notice"), self) + self.notice.setWordWrap(True) + layout.addWidget(self.notice) + row = QHBoxLayout() + self.sites = QComboBox(self) + self.sites.setObjectName("reviewSite") + for site in self.review["sites"]: + u, v = site["uv_xy_px"] + self.sites.addItem(f"{site['site_id']} · U {u}, V {v} px") + self.save_button = QPushButton(t("review.save"), self) + row.addWidget(self.sites, 1) + row.addWidget(self.save_button) + layout.addLayout(row) + self.summary = QLabel(self) + self.summary.setWordWrap(True) + layout.addWidget(self.summary) + self.chart = QChart() + self.chart_view = QChartView(self.chart, self) + self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.uv_chart = QChart() + self.uv_view = QChartView(self.uv_chart, self) + self.uv_view.setRenderHint(QPainter.RenderHint.Antialiasing) + charts = QHBoxLayout() + charts.addWidget(self.uv_view, 1) + charts.addWidget(self.chart_view, 2) + layout.addLayout(charts, 1) + self.table = QTableWidget(0, 8, 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")]) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + self.table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.table, 1) + self.message = QLabel(self) + self.message.setWordWrap(True) + layout.addWidget(self.message) + self.sites.currentIndexChanged.connect(self._show_site) + self.save_button.clicked.connect(self._save) + self._show_site() + + def _show_site(self) -> None: + index = self.sites.currentIndex() + self.chart.removeAllSeries() + for axis in self.chart.axes(): + self.chart.removeAxis(axis) + if index < 0: + self.summary.setText(t("review.no_sites")) + return + site = self.review["sites"][index] + windows = site["windows"] + self.table.setRowCount(len(windows)) + for row, window in enumerate(windows): + quality = t("review." + window["status"]) + if not window["reference_alignment_possible"]: + quality += " · " + t("review.replay") + if not window["stability_verified"]: + quality += " · " + t("review.unverified") + values = [window["trial_id"], t("calibration." + window["branch"]), + f"{window['reference_n']:g}", str(window["samples"]), + self._number(window["raw_mean"]), self._number(window["raw_std"]), + f"{window['duration_s']:g}", quality] + for column, value in enumerate(values): + self.table.setItem(row, column, QTableWidgetItem(value)) + fit = site["fit"] + self.summary.setText( + t("review.fit").format(**fit) if fit else t("review.no_fit") + ) + for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")): + series = QScatterSeries() + series.setName(t("calibration." + branch)) + series.setColor(QColor(color)) + series.setMarkerSize(9) + for window in windows: + if window["branch"] == branch and window["status"] == "candidate": + series.append(window["raw_mean"], window["reference_n"]) + if series.count(): + self.chart.addSeries(series) + if fit: + raw = [w["raw_mean"] for w in windows if w["status"] == "candidate"] + line = QLineSeries() + line.setName(t("review.fit_line")) + 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() + 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"]))) + + def _show_uv(self, selected: int) -> None: + self.uv_chart.removeAllSeries() + for axis in self.uv_chart.axes(): + self.uv_chart.removeAxis(axis) + values = [s["fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["fit"]] + low, high = (min(values), max(values)) if values else (0, 0) + for index, site in enumerate(self.review["sites"]): + series = QScatterSeries() + series.setName(site["site_id"]) + color = QColor("#929aa7") + if site["fit"]: + value = site["fit"]["sensitivity_raw_per_n"] + ratio = (value - low) / (high - low) if high > low else .5 + color = QColor.fromHsvF((1 - ratio) * .65, .8, .85) + series.setColor(color) + series.setBorderColor(QColor("#151515") if index == selected else color) + series.setMarkerSize(15 if index == selected else 10) + 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() + 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}" + + def _save(self) -> None: + try: + path = save_review(self.review) + self.message.setText(t("review.saved").format(path=path)) + except OSError as exc: + self.message.setText(t("calibration.error").format(detail=exc)) diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index ce5ab88..2de2855 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -406,6 +406,8 @@ class AutoCalMainWindow(QMainWindow): export, a missing file or an unreadable mesh must leave the rest of the app alone and report the reason. """ + if self.calibration_panel.trial.session is not None: + raise ValueError(t("calibration.close_before_mapping")) bundle = load_bundle( path, # The design profile declares these for this revision; a mismatch diff --git a/apps/autocal/tests/test_manual_trial.py b/apps/autocal/tests/test_manual_trial.py index 2b5da96..d115251 100644 --- a/apps/autocal/tests/test_manual_trial.py +++ b/apps/autocal/tests/test_manual_trial.py @@ -5,6 +5,7 @@ from __future__ import annotations import csv import json import time +from dataclasses import replace from types import SimpleNamespace import numpy as np @@ -14,10 +15,10 @@ from orisys_autocal.trial import ( REFERENCE_HEADER, ManualTrial, load_path, - site_at, - uv_origin, ) +from orisys_autocal.sensor.uv_flat import FlatSurface, site_at, uv_origin + class Worker: def __init__(self) -> None: @@ -52,140 +53,253 @@ class Worker: for sink in self.sinks: sink(self.packet) + def stop(self, *, wait=True): + self.is_running = False -def test_uv_coordinates_are_centered_and_masked(): + +@pytest.fixture +def flat(): mask = np.full((5, 7), -1, dtype=np.int32) mask[1:4, 2:5] = 2 - flat = SimpleNamespace( - width=7, - height=5, - face_index=mask, - sample=lambda x, y: ( - (np.array([x, y, 3.0]), np.array([0, 0, 1]), 2) - if 0 <= x < 7 and 0 <= y < 5 and mask[y, x] >= 0 - else None - ), - ) + mask[4, 2] = 2 # A valid pixel may be below the centre-column origin. + positions = np.zeros((5, 7, 3), dtype=np.float64) + for y in range(5): + for x in range(7): + positions[y, x] = (x, y, 3) + normals = np.zeros_like(positions) + normals[:, :, 2] = 1 + return FlatSurface((5, 7), np.zeros((5, 7, 3), dtype=np.uint8), positions, + normals, mask, (0, 0), (1, 1)) + + +@pytest.fixture +def clock(monkeypatch): + from orisys_autocal import trial as trial_module + + class Clock: + now = time.perf_counter_ns() + + def advance(self, seconds): + self.now += round(seconds * 1e9) + + value = Clock() + monkeypatch.setattr(trial_module, "time", SimpleNamespace( + perf_counter_ns=lambda: value.now, time_ns=time.time_ns)) + return value + + +@pytest.fixture +def trial_setup(tmp_path, flat): + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "mesh.npz").write_bytes(b"mesh") + config = tmp_path / "sensor.json" + config.write_text("{}", encoding="utf-8") + return dict(root=tmp_path, site=site_at(flat, -1, 2), levels=[5, 10], + hold_s=20, sensor_model="Finger D40", sensor_sn="F-001", + bundle_path=str(bundle), source={"kind": "camera", "value": 0}, + config_ref=str(config), fps=30) + + +def test_uv_coordinates_are_centered_and_masked(flat): assert uv_origin(flat) == (3, 3) site = site_at(flat, -1, 2) assert site.raster_xy_px == (2, 1) assert site.position_sensor_mm == (2.0, 1.0, 3.0) + assert site_at(flat, -1, -1).raster_xy_px == (2, 4) with pytest.raises(ValueError): site_at(flat, -3, 0) -def test_manual_trial_records_video_stamps_and_load_unload_events(tmp_dir): +def test_manual_trial_records_video_stamps_and_load_unload_events(trial_setup, clock): worker = Worker() trial = ManualTrial(worker) - bundle = tmp_dir / "bundle" - bundle.mkdir() - (bundle / "mesh.npz").write_bytes(b"mesh") - config = tmp_dir / "sensor.json" - config.write_text("{}", encoding="utf-8") - site = SimpleNamespace( - u_px=0, - v_px=1, - position_sensor_mm=(1.0, 2.0, 3.0), - face_index=2, - raster_xy_px=(3, 2), - uv_origin_raster_xy_px=(3, 3), - ) assert load_path([5, 10]) == [ - ("load", 5), - ("load", 10), - ("unload", 5), - ("unload", 0), - ] - directory = trial.start( - root=tmp_dir, - site=site, - levels=[5, 10], - hold_s=20, - sensor_model="Finger D40", - sensor_sn="F-001", - bundle_path=str(bundle), - source={"kind": "camera", "value": 0}, - config_ref=str(config), - fps=30, - ) + ("zero", 0), ("load", 5), ("load", 10), ("unload", 5), ("unload", 0)] + directory = trial.start(**trial_setup) worker.emit() - for value in (5, 10, 5, 0): + for index, value in enumerate((0, 5, 10, 5, 0)): + assert trial.step_index == index trial.confirm(value) + assert trial.step_index == index + with pytest.raises(RuntimeError, match="hold"): + trial.confirm(value) + with pytest.raises(RuntimeError): + trial.finish() + clock.advance(19) + trial.poll() + assert trial.holding + assert trial.remaining_s == pytest.approx(1) + clock.advance(3) # Late UI polling must not extend the reference window. + trial.poll() + assert trial.step_index == index + 1 + assert not trial.holding + clock.advance(4) # Adjustment time must have no constant-force label. trial.finish() - report = trial.recorder.stop(wait=True) - assert report.ok + assert trial.recorder.stop(wait=True).ok assert trial.poll() == "complete" - assert "Finger-D40_F-001" in directory.name + assert directory.name == "trial-001" + assert "Finger-D40_F-001" in directory.parent.parent.name assert len(list(directory.glob("raw_*.mp4"))) == 1 with (directory / "frames.csv").open(encoding="utf-8", newline="") as handle: row = next(csv.DictReader(handle)) assert row["t_host_mono_ns"] == str(worker.packet.capture_mono_ns) assert row["source_frame_id"] == "7" assert row["time_basis"] == "host_camera" - assert row["capture_dropped"] == "0" with (directory / "reference.csv").open(encoding="utf-8", newline="") as handle: reader = csv.DictReader(handle) assert tuple(reader.fieldnames) == REFERENCE_HEADER references = list(reader) - assert [row["branch"] for row in references] == ["load", "load", "unload", "unload"] - assert [row["F_ref_normal_N"] for row in references] == [ - "5.0", - "10.0", - "5.0", - "0.0", - ] - assert all( - row["F_ref_x_N"] == row["F_ref_y_N"] == row["F_ref_z_N"] == "" - for row in references - ) - assert all(row["stability_verified"] == "0" for row in references) - assert all( - int(row["t_start_host_mono_ns"]) < int(row["t_end_host_mono_ns"]) - for row in references - ) - assert all( - previous["t_end_host_mono_ns"] == following["t_start_host_mono_ns"] - for previous, following in zip(references, references[1:]) - ) - with (directory / "events.csv").open(encoding="utf-8", newline="") as handle: - events = list(csv.DictReader(handle)) - reached = [event for event in events if event["kind"] == "step_reached"] - assert len(reached) == 4 - assert [json.loads(event["detail"])["branch"] for event in reached] == [ - "load", - "load", - "unload", - "unload", - ] + assert [row["branch"] for row in references] == ["zero", "load", "load", "unload", "unload"] + assert [float(row["F_ref_normal_N"]) for row in references] == [0, 5, 10, 5, 0] + assert all(row["F_ref_x_N"] == row["F_ref_y_N"] == row["F_ref_z_N"] == "" for row in references) + assert all(row["stability_verified"] == "0" and row["hold_complete"] == "1" for row in references) + assert all(int(r["t_end_host_mono_ns"]) - int(r["t_start_host_mono_ns"]) == 20_000_000_000 for r in references) + assert all(int(a["t_end_host_mono_ns"]) < int(b["t_start_host_mono_ns"]) for a, b in zip(references, references[1:])) with (directory / "session.json").open(encoding="utf-8") as handle: - session = json.load(handle) - assert session["site"]["uv_xy_px"] == [0, 1] - assert session["sensor_model"] == "Finger D40" - assert session["reference_file"] == "reference.csv" - assert session["site"]["mesh_sha256"] - assert len(list(tmp_dir.glob("*/session.json"))) == 1 + manifest = json.load(handle) + assert manifest["trial_status"] == "complete" + assert manifest["site"]["uv_xy_px"] == [-1, 2] + assert len(manifest["steps"]) == 5 + assert manifest["uv_definition"]["mapping_sha256"] == trial_setup["site"].mapping_sha256 + session_path = directory.parent.parent / "session.json" + root = json.loads(session_path.read_text(encoding="utf-8")) + assert root["status"] == "in_progress" + assert root["trials"][0]["status"] == "complete" + assert trial.finish_session()[1] == "complete" + assert json.loads(session_path.read_text(encoding="utf-8"))["status"] == "complete" - second_directory = trial.start( - root=tmp_dir, - site=site, - levels=[5, 10], - hold_s=20, - sensor_model="Finger D40", - sensor_sn="F-001", - bundle_path=str(bundle), - source={"kind": "camera", "value": 0}, - config_ref=str(config), - fps=30, - ) + +def test_retries_and_other_sites_share_one_session(trial_setup, clock): + worker = Worker() + trial = ManualTrial(worker) + directories = [] + for site in (trial_setup["site"], trial_setup["site"], + replace(trial_setup["site"], u_px=0, raster_xy_px=(3, 1), position_sensor_mm=(3, 1, 3))): + directory = trial.start(**{**trial_setup, "site": site}) + directories.append(directory) + worker.emit() + trial.confirm(0) + clock.advance(3) + trial.finish(aborted=True, reason="operator ended early") + trial.recorder.stop(wait=True) + assert trial.poll() == "incomplete" + rows = list(csv.DictReader((directory / "reference.csv").open(encoding="utf-8"))) + assert rows[0]["hold_complete"] == "0" + assert len({d.parent.parent for d in directories}) == 1 + root = trial.session.document + assert len(root["sites"]) == 2 + assert [t["repeat_index"] for t in root["trials"]] == [1, 2, 1] + assert [t["site_id"] for t in root["trials"]] == ["site-001", "site-001", "site-002"] + with pytest.raises(ValueError, match="finish this session"): + trial.start(**{**trial_setup, "sensor_sn": "F-002"}) + with pytest.raises(ValueError, match="finish this session"): + trial.start(**{**trial_setup, "site": replace(site, mapping_sha256="other")}) + assert trial.finish_session()[1] == "incomplete" + new_directory = trial.start(**trial_setup) + assert new_directory.parent.parent != directories[0].parent.parent + trial.finish(aborted=True) + trial.recorder.stop(wait=True) + trial.poll() + trial.finish_session() + + +def test_input_end_aborts_active_hold(trial_setup, clock): + worker = Worker() + worker.packet = replace(worker.packet, time_basis="host_replay") + trial = ManualTrial(worker) + directory = trial.start(**trial_setup) + assert not trial.reference_alignment_possible worker.emit() - trial.confirm(5) - trial.finish(aborted=True, reason="operator aborted") + trial.confirm(0) + clock.advance(2) + worker.is_running = False + trial.poll() trial.recorder.stop(wait=True) assert trial.poll() == "incomplete" - assert len(list(tmp_dir.glob("*/session.json"))) == 2 - assert second_directory != directory - with (second_directory / "reference.csv").open(encoding="utf-8", newline="") as handle: - assert [row["F_ref_normal_N"] for row in csv.DictReader(handle)] == ["5.0"] + assert trial.entry["reason"] == "input or writer stopped" + assert json.loads((directory / "session.json").read_text(encoding="utf-8"))["steps"][0]["status"] == "incomplete" + trial.finish_session() + + +def test_operator_export_reproduces_uv_sites_and_rejects_edited_xyz(qt_app, flat, tmp_path): + from orisys_autocal.sensor.export_bundle import SensorBundle + from orisys_autocal.ui.sensor_panel import FingerPanel + + panel = FingerPanel() + bundle = SensorBundle( + path=tmp_path, vertices_mm=np.array([[2, 1, 3], [4, 1, 3], [2, 4, 3]]), + faces=np.array([[0, 1, 2]]), uv_data=np.array([[0, 0], [1, 0], [0, 1]]), + loop_to_vertex=np.array([0, 1, 2]), + ) + panel.set_bundle(bundle, flat) + panel.handle_pick(2, 4) + panel.handle_pick(4, 1) + panel.table.setCurrentCell(1, 1) + panel.move_selected(-1) + destination = panel.export_sites(tmp_path / "sites.csv") + with destination.open(encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + assert [(int(r["u_px"]), int(r["v_px"])) for r in rows] == [(1, 2), (-1, -1)] + for row in rows: + site = site_at(flat, int(row["u_px"]), int(row["v_px"])) + assert site.position_sensor_mm == tuple(float(row[f"{axis}_sensor_mm"]) for axis in "xyz") + assert row["mapping_sha256"] == site.mapping_sha256 + panel.table.item(0, 1).setText("999") + with pytest.raises(ValueError): + panel.export_sites(tmp_path / "invalid.csv") + assert not (tmp_path / "invalid.csv").exists() + panel.deleteLater() + + +def test_gui_hold_controls_and_session_lifecycle(make_window, trial_setup, clock, flat, tmp_path, qt_app): + from orisys_app_shared.i18n import t + + window = make_window() + panel = window.calibration_panel + worker = Worker() + panel.source_panel.worker = worker + panel.trial.worker = worker + panel.source_panel.mode = SimpleNamespace(fps=30) + panel.source_panel.config_ref.setText(trial_setup["config_ref"]) + panel.set_flat(flat, trial_setup["bundle_path"]) + panel._picked(2, 1) + panel.output_path.setText(str(trial_setup["root"])) + panel.sensor_sn.setText("F-001") + panel.sensor_model.setText("finger") + panel.levels.setText("5, 10") + panel.start_button.click() + assert panel.trial.status == "recording" + worker.emit() + assert panel.views.currentWidget() is panel.source_panel + assert not panel.sensor_sn.isEnabled() + assert not panel.close_session_button.isEnabled() + for index, force in enumerate((0, 5, 10, 5, 0)): + panel.reference.setText(str(force)) + panel.confirm_button.click() + assert panel.trial.holding + assert not panel.confirm_button.isEnabled() + assert not panel.finish_button.isEnabled() + assert panel.trial.step_index == index + assert t("calibration.holding").split("{")[0] in panel.target_label.text() + if index == 0: + panel.resize(1120, 740) + panel.show() + qt_app.processEvents() + assert panel.grab().save(str(tmp_path / "operator-hold.png")) + clock.advance(20) + panel._poll() + assert panel.trial.step_index == index + 1 + assert panel.finish_button.isEnabled() + panel.finish_button.click() + panel.trial.recorder.stop(wait=True) + panel._poll() + assert panel.close_session_button.isEnabled() + assert not panel.sensor_sn.isEnabled() + panel.close_session_button.click() + assert panel.trial.session is None + assert panel.sensor_sn.isEnabled() + window.close() def test_calibration_page_keeps_precise_uv_site_when_switching_to_live_view( diff --git a/apps/autocal/tests/test_session_review.py b/apps/autocal/tests/test_session_review.py new file mode 100644 index 0000000..4d06cc4 --- /dev/null +++ b/apps/autocal/tests/test_session_review.py @@ -0,0 +1,124 @@ +"""Saved-session review uses frame identity and excludes ramps and invalid data.""" + +from __future__ import annotations + +import csv +import json + +import pytest +from orisys_autocal.calibration_session import write_json +from orisys_autocal.session_review import review_session, save_review + + +def _csv(path, rows): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + +@pytest.fixture +def session(tmp_path): + manifest = { + "format_version": "autocal-session-1.0", "session_id": "test-session", + "status": "incomplete", "uv_definition": {"mapping_sha256": "test-map"}, + "sites": [{"site_id": "site-001", "uv_xy_px": [1, -2]}, + {"site_id": "site-002", "uv_xy_px": [-3, 4]}], + "trials": [], + } + for i, sensitivity in enumerate((2.0, 4.0), 1): + trial_id, site_id = f"trial-{i:03d}", f"site-{i:03d}" + entry = {"trial_id": trial_id, "site_id": site_id, + "status": "complete", "directory": f"trials/{trial_id}"} + manifest["trials"].append(entry) + path = tmp_path / entry["directory"] + path.mkdir(parents=True) + write_json(path / "session.json", { + "trial_id": trial_id, "site_id": site_id, "session_id": 7, + "calibration_session_id": "test-session", "trial_status": "complete", + "reference_alignment_possible": False, "uv_definition": manifest["uv_definition"], + }) + frames, forces, references = [], [], [] + for step, (branch, load) in enumerate(( + ("zero", 0), ("load", 5), ("load", 10), ("unload", 5), ("unload", 0))): + start = (step * 20 + 1) * 1_000_000_000 + end = start + 10_000_000_000 + references.append({"step_index": step, "branch": branch, + "t_start_host_mono_ns": start, "t_end_host_mono_ns": end, + "F_ref_normal_N": load, "hold_complete": 1, "stability_verified": 0}) + raw = 1 + sensitivity * load + (1 if branch == "unload" and load else 0) + for offset, value, valid, dropped in ( + (0, raw, 1, 0), (1_000_000_000, raw, 1, 0), + (2_000_000_000, 999, 0, 0), (3_000_000_000, 999, 1, 1), + (10_000_000_000, 999, 1, 0)): + frame_id = len(frames) + 1 + frames.append({"frame_id": frame_id, "t_host_mono_ns": start + offset, + "dropped": dropped}) + forces.append({"session_id": 7, "frame_id": frame_id, "FN_raw": value, "valid": valid}) + _csv(path / "frames.csv", frames) + _csv(path / "forces.csv", list(reversed(forces))) + _csv(path / "reference.csv", references) + manifest["trials"].append({"trial_id": "trial-003", "status": "incomplete"}) + write_json(tmp_path / "session.json", manifest) + return tmp_path + + +def test_review_fits_each_site_without_ramps_invalid_samples_or_unloading_bias(session): + result = review_session(session) + assert result["status"] == "exploratory" + assert result["issues"] == [{"trial_id": "trial-003", "reason": "incomplete_trial"}] + first, second = result["sites"] + assert first["fit"]["gain_n_per_raw"] == pytest.approx(.5) + assert first["fit"]["offset_n"] == pytest.approx(-.5) + assert second["fit"]["sensitivity_raw_per_n"] == pytest.approx(4) + assert all(w["samples"] == 2 and w["raw_std"] == 0 for s in result["sites"] for w in s["windows"]) + assert first["windows"][3]["raw_mean"] == 12 + assert not first["windows"][0]["reference_alignment_possible"] + assert not first["windows"][0]["stability_verified"] + before = (session / "session.json").read_bytes() + a, b = save_review(result), save_review(result) + assert a != b and a.is_file() and b.is_file() + assert (session / "session.json").read_bytes() == before + + +def test_no_samples_does_not_produce_a_coefficient(session): + path = session / "trials/trial-001/forces.csv" + _csv(path, [{"session_id": 7, "frame_id": 1, "FN_raw": "", "valid": 0}]) + first = review_session(session)["sites"][0] + assert first["fit"] is None + assert all(w["raw_mean"] is None and w["status"] == "insufficient_samples" for w in first["windows"]) + + +def test_review_refuses_mismatched_identity_and_paths_outside_session(session): + path = session / "trials/trial-001/session.json" + document = json.loads(path.read_text(encoding="utf-8")) + document["site_id"] = "wrong-site" + write_json(path, document) + with pytest.raises(ValueError, match="mismatch"): + review_session(session) + manifest_path = session / "session.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["trials"][0]["directory"] = "../outside" + write_json(manifest_path, manifest) + with pytest.raises(ValueError, match="outside"): + review_session(session) + + +def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session, tmp_path): + from orisys_autocal.ui.session_review_dialog import SessionReviewDialog + + dialog = SessionReviewDialog(session) + assert dialog.sites.count() == 2 + assert dialog.table.rowCount() == 5 + assert len(dialog.chart.series()) == 4 + assert len(dialog.uv_chart.series()) == 2 + dialog.sites.setCurrentIndex(1) + assert "4" in dialog.summary.text() + dialog.uv_chart.series()[0].clicked.emit(dialog.uv_chart.series()[0].at(0)) + assert dialog.sites.currentIndex() == 0 + dialog.save_button.click() + assert len(list(session.glob("derived/*/review.json"))) == 1 + dialog.show() + qt_app.processEvents() + assert dialog.grab().save(str(tmp_path / "review.png")) + dialog.close() diff --git a/shared/src/orisys_app_shared/record/experiment.py b/shared/src/orisys_app_shared/record/experiment.py index 8729b5e..fc0769b 100644 --- a/shared/src/orisys_app_shared/record/experiment.py +++ b/shared/src/orisys_app_shared/record/experiment.py @@ -147,6 +147,9 @@ class RecordConfig: #: ``preroll_bytes`` so a large frame does not turn into a large buffer. preroll_frames: int = 30 preroll_bytes: int = 192 * 1024 * 1024 + #: Keep each delivered image for calibration. Container fps then describes + #: nominal playback; frames.csv carries the actual acquisition timing. + preserve_all_frames: bool = False def as_dict(self) -> dict: return { @@ -159,6 +162,7 @@ class RecordConfig: "max_queue_bytes": int(self.max_queue_bytes), "preroll_frames": int(self.preroll_frames), "preroll_bytes": int(self.preroll_bytes), + "preserve_all_frames": bool(self.preserve_all_frames), "lossy_note": ( "MP4 is a lossy container: this is the pre-processing camera " "image, not a lossless record of the raw sensor data." @@ -346,13 +350,12 @@ class ExperimentRecorder: calibration_version: str = "", preroll=(), directory_label: str = "", + directory_name: str = "", ) -> Path: """Create the experiment directory and start the writer thread.""" - if directory_label and ( - directory_label in {".", ".."} - or not all(char.isalnum() or char in "-_" for char in directory_label) - ): - raise ValueError("directory label must contain only letters, numbers, - or _") + for value in (directory_label, directory_name): + if value and not all(char.isalnum() or char in "-_" for char in value): + raise ValueError("directory names must contain only letters, numbers, - or _") # A previous writer that has not reached a terminal state still owns the # files and the statistics; reusing it would silently mix two sessions. if self._thread is not None and self._thread.is_alive(): @@ -370,7 +373,9 @@ class ExperimentRecorder: self.config = replace(config if config is not None else self.config) stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") label = f"_{directory_label}" if directory_label else "" - directory = Path(root) / f"{stamp}{label}_session{session_id:04d}_{uuid4().hex}" + directory = Path(root) / ( + directory_name or f"{stamp}{label}_session{session_id:04d}_{uuid4().hex}" + ) try: directory.mkdir(parents=True, exist_ok=False) except Exception as exc: # noqa: BLE001 - reported to the caller @@ -599,7 +604,11 @@ class ExperimentRecorder: A skipped frame is not lost data: every row is still in ``frames.csv``/``forces.csv``, and the count is reported separately. + Calibration may preserve every image instead and use the frame index's + host timestamps for time alignment rather than container playback time. """ + if self.config.preserve_all_frames: + return True try: stamp = float(mono_t) except (TypeError, ValueError): diff --git a/shared/tests/test_experiment_recorder.py b/shared/tests/test_experiment_recorder.py index ac78d85..7bf1b42 100644 --- a/shared/tests/test_experiment_recorder.py +++ b/shared/tests/test_experiment_recorder.py @@ -288,3 +288,39 @@ if __name__ == "__main__": # pragma: no cover - manual runner print(f" FAIL {name}: {type(exc).__name__}: {exc}") print(f"failures: {failures}") raise SystemExit(1 if failures else 0) + + +def test_named_trial_directory_is_safe_and_never_overwritten(tmp_path): + recorder = ExperimentRecorder(RecordConfig(video=False)) + for value in ("../trial", "a/b", "..", "C:\\trial"): + with pytest.raises(ValueError): + recorder.start(root=tmp_path, session_id=1, metadata={}, directory_name=value) + directory = recorder.start(root=tmp_path, session_id=1, metadata={}, directory_name="trial-001") + recorder.on_packet(_packet(0)) + recorder.stop(wait=True) + assert directory == tmp_path / "trial-001" + with pytest.raises(FileExistsError): + recorder.start(root=tmp_path, session_id=1, metadata={}, directory_name="trial-001") + assert (directory / "session.json").is_file() + + +@pytest.mark.parametrize("preserve", [False, True]) +def test_calibration_can_keep_burst_frames_without_changing_default_pacing(tmp_path, preserve): + from dataclasses import replace + + recorder = ExperimentRecorder(RecordConfig(fps=30, preserve_all_frames=preserve)) + directory = recorder.start(root=tmp_path, session_id=1, metadata={}) + for index in range(10): + recorder.on_packet(replace(_packet(index), mono_t=index / 300.0)) + report = recorder.stop(wait=True) + assert report.stats.frames == 10 + if preserve: + assert report.stats.video_frames == 10 + assert report.stats.resampled_video == 0 + assert report.ok + else: + assert report.stats.video_frames < 10 + assert report.stats.resampled_video > 0 + with (directory / "frames.csv").open(encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + assert sum(row["in_video"] == "1" for row in rows) == report.stats.video_frames