完善 Autocal 多测点标定流程及会话复核
统一测点坐标、保持窗口和试验目录,补齐逐点初步分析。定向测试与真实视频闭环通过;两项轮询失败在修改前版本同样复现。
This commit is contained in:
@@ -271,25 +271,24 @@
|
|||||||
"uv": "U / V (px)",
|
"uv": "U / V (px)",
|
||||||
"position": "Mesh position",
|
"position": "Mesh position",
|
||||||
"levels": "Load levels (N)",
|
"levels": "Load levels (N)",
|
||||||
"hold": "Planned hold",
|
"hold": "Hold per level",
|
||||||
"hold_tip": "The planned hold time is saved; levels and recording do not advance automatically.",
|
"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)",
|
"reference": "Manual reference force (N)",
|
||||||
"start": "Record this site",
|
"start": "Start this trial",
|
||||||
"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.",
|
"start_tip": "Create a trial in this calibration session. Record initial zero, loading, unloading and recovery continuously; no platform motion is issued.",
|
||||||
"confirm": "Mark load reached",
|
"confirm": "Target reached: start hold",
|
||||||
"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.",
|
"confirm_tip": "Reach the current target and enter the actual reference load. Keep the load unchanged until the countdown ends.",
|
||||||
"finish": "Stop and complete",
|
"finish": "Complete this trial",
|
||||||
"finish_tip": "After all levels are marked, stop recording, flush the files, and check this trial.",
|
"finish_tip": "After initial zero and every loading/unloading hold, stop recording, check and save the trial.",
|
||||||
"abort": "Abort this recording",
|
"abort": "End early (keep incomplete data)",
|
||||||
"abort_tip": "Stop recording, keep this folder, and mark it incomplete. Starting again creates a new folder; it does not resume this one.",
|
"abort_tip": "End this trial immediately and keep its incomplete data. A retry creates a new trial in the same calibration session.",
|
||||||
"load": "Load",
|
"load": "Load",
|
||||||
"unload": "Unload",
|
"unload": "Unload",
|
||||||
"next_step": "Next: {branch} to {load:g} N",
|
"next_step": "{branch} to {load:g} N; enter actual reference load and start hold",
|
||||||
"before_start": "Select a site and start the input, then begin recording",
|
"before_start": "Select a site and start input, then start the trial with initial zero",
|
||||||
"ready_to_finish": "All levels marked; recording can be stopped",
|
"ready_to_finish": "All hold windows complete; complete this trial",
|
||||||
"no_step": "No step pending",
|
"no_step": "No step pending",
|
||||||
"progress": "Progress {done}/{total} · {status}",
|
"progress": "Progress {done}/{total} · {status}",
|
||||||
"held": " · Current level held {seconds:.1f} s",
|
|
||||||
"source_state": "Input: {status} · frame {frame} · capture drops {dropped}",
|
"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.",
|
"replay_note": "Video replay is for debugging; manual reference loads cannot be synced to the original experiment.",
|
||||||
"idle": "Idle",
|
"idle": "Idle",
|
||||||
@@ -303,7 +302,17 @@
|
|||||||
"invalid_site": "Select a valid UV site first",
|
"invalid_site": "Select a valid UV site first",
|
||||||
"start_input": "Start camera or video input first",
|
"start_input": "Start camera or video input first",
|
||||||
"reference_required": "Enter the actual reference load",
|
"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": {
|
"finger": {
|
||||||
"title": "Finger calibration-point debug",
|
"title": "Finger calibration-point debug",
|
||||||
@@ -362,13 +371,28 @@
|
|||||||
"preview_no_gl": "No OpenGL context: {error}",
|
"preview_no_gl": "No OpenGL context: {error}",
|
||||||
"preview_draw_failed": "Drawing failed: {error}",
|
"preview_draw_failed": "Drawing failed: {error}",
|
||||||
"preview_solve_failed": "Solve failed: {error}",
|
"preview_solve_failed": "Solve failed: {error}",
|
||||||
"col_x": "x", "col_y": "y", "col_z": "z",
|
"col_x": "x",
|
||||||
"col_nx": "nx", "col_ny": "ny", "col_nz": "nz",
|
"col_y": "y",
|
||||||
"col_X": "X", "col_Y": "Y", "col_Z": "Z",
|
"col_z": "z",
|
||||||
"col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "residual(°)",
|
"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",
|
"loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
|
||||||
"load_failed": "Load failed: {error}",
|
"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": {
|
"sequence": {
|
||||||
"title": "Test sequence design",
|
"title": "Test sequence design",
|
||||||
@@ -446,5 +470,33 @@
|
|||||||
"bas_loaded": "BAS loaded",
|
"bas_loaded": "BAS loaded",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,25 +271,24 @@
|
|||||||
"uv": "U / V (px)",
|
"uv": "U / V (px)",
|
||||||
"position": "网格坐标",
|
"position": "网格坐标",
|
||||||
"levels": "加载档位 (N)",
|
"levels": "加载档位 (N)",
|
||||||
"hold": "计划保持",
|
"hold": "每档保持时间",
|
||||||
"hold_tip": "仅保存计划保持时间;不会自动切换档位或结束录制。",
|
"hold_tip": "到达目标力并确认后倒计时;到时结束该档保持窗口并提示下一档,录像持续。计时不代表已验证稳定。",
|
||||||
"reference": "人工参考力 (N)",
|
"reference": "人工参考力 (N)",
|
||||||
"start": "开始本测点录制",
|
"start": "开始本次试验",
|
||||||
"start_tip": "为当前测点新建试验目录,持续录制图像与传感器输出;不会移动平台。一次加载—卸载循环共用此目录。",
|
"start_tip": "在本场标定下新建一次测点试验,连续记录初始零载、加载、卸载和恢复。不会移动平台。",
|
||||||
"confirm": "标记到档时刻",
|
"confirm": "已到目标力,开始保持",
|
||||||
"confirm_tip": "达到目标载荷并填写人工参考力后点击;记录本档起点并开始保持计时。图像此前已开始持续录制。",
|
"confirm_tip": "达到当前目标并填写实际参考力后点击。保持期间不要换档;倒计时结束后再按提示调整。",
|
||||||
"finish": "结束录制并完成",
|
"finish": "完成本次试验",
|
||||||
"finish_tip": "全部档位标记后停止录制、完成写盘并检查本次试验。",
|
"finish_tip": "初始零载及全部加载、卸载保持完成后停止录像,检查并保存本次结果。",
|
||||||
"abort": "中止本次录制",
|
"abort": "提前结束(保留未完成数据)",
|
||||||
"abort_tip": "停止录制并保留当前目录,标记为未完成;再次开始会创建新目录,不会续录。",
|
"abort_tip": "立即结束本次试验并保留未完成数据。重试创建新的 trial,仍归入本场标定。",
|
||||||
"load": "加载",
|
"load": "加载",
|
||||||
"unload": "卸载",
|
"unload": "卸载",
|
||||||
"next_step": "下一档:{branch}至 {load:g} N",
|
"next_step": "当前目标:{branch} {load:g} N;到达后填写实际参考力并开始保持",
|
||||||
"before_start": "先选测点并启动输入,再开始录制",
|
"before_start": "先选测点并启动输入,再开始试验;先记录初始零载",
|
||||||
"ready_to_finish": "全部档位已标记,可结束录制",
|
"ready_to_finish": "所有保持窗口已完成,请完成本次试验",
|
||||||
"no_step": "无待确认档位",
|
"no_step": "无待确认档位",
|
||||||
"progress": "进度 {done}/{total} · {status}",
|
"progress": "进度 {done}/{total} · {status}",
|
||||||
"held": " · 当前档已保持 {seconds:.1f} s",
|
|
||||||
"source_state": "输入:{status} · 帧 {frame} · 采集丢帧 {dropped}",
|
"source_state": "输入:{status} · 帧 {frame} · 采集丢帧 {dropped}",
|
||||||
"replay_note": "视频重放仅用于调试;本次人工参考力不能与原实验时间同步。",
|
"replay_note": "视频重放仅用于调试;本次人工参考力不能与原实验时间同步。",
|
||||||
"idle": "未开始",
|
"idle": "未开始",
|
||||||
@@ -303,7 +302,17 @@
|
|||||||
"invalid_site": "请先选择有效的 UV 测点",
|
"invalid_site": "请先选择有效的 UV 测点",
|
||||||
"start_input": "请先启动相机或视频输入",
|
"start_input": "请先启动相机或视频输入",
|
||||||
"reference_required": "请输入实际参考力",
|
"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": {
|
"finger": {
|
||||||
"title": "手指标定点调试",
|
"title": "手指标定点调试",
|
||||||
@@ -362,13 +371,28 @@
|
|||||||
"preview_no_gl": "无 OpenGL 上下文:{error}",
|
"preview_no_gl": "无 OpenGL 上下文:{error}",
|
||||||
"preview_draw_failed": "绘制失败:{error}",
|
"preview_draw_failed": "绘制失败:{error}",
|
||||||
"preview_solve_failed": "求解失败:{error}",
|
"preview_solve_failed": "求解失败:{error}",
|
||||||
"col_x": "x", "col_y": "y", "col_z": "z",
|
"col_x": "x",
|
||||||
"col_nx": "nx", "col_ny": "ny", "col_nz": "nz",
|
"col_y": "y",
|
||||||
"col_X": "X", "col_Y": "Y", "col_Z": "Z",
|
"col_z": "z",
|
||||||
"col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "残差(°)",
|
"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",
|
"loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
|
||||||
"load_failed": "载入失败: {error}",
|
"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": {
|
"sequence": {
|
||||||
"title": "测试序列设计",
|
"title": "测试序列设计",
|
||||||
@@ -446,5 +470,33 @@
|
|||||||
"bas_loaded": "BAS 已加载",
|
"bas_loaded": "BAS 已加载",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
"no": "否"
|
"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": "质量说明"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +1,111 @@
|
|||||||
# Autocal 标定操作与数据格式草稿
|
# Autocal 标定操作与数据格式草稿
|
||||||
|
|
||||||
状态:待评审,2026-10-11。阶段一仅针对 finger;单点手动记录已接通,跨点 session、FT 参考及灵敏度图待实现。每 trial 保存视频、`session.json`、`frames.csv`、`forces.csv`、`reference.csv`、`events.csv`。目录名为 `<UTC时间>_<产品型号>_<SN>_session<采集会话号>_<唯一标识>`;完整型号和 SN 存于 `session.json`。
|
状态:2026-10-11 修订,待实机验收。阶段一仅针对 finger。多测点会话、手动加载/卸载保持、视频和相机输入、模拟页测点操作表导出已接通;已可重开会话查看保持窗口、加载/卸载曲线及 UV 测点初步灵敏度;FT 同步、图像基准快照及正式灵敏度结果仍待实现。软件检查不代表设备或标定精度通过。
|
||||||
|
|
||||||
## 范围与软件现状
|
## 范围与坐标
|
||||||
|
|
||||||
| 项目 | 当前约定 |
|
| 项目 | 约定 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 标定范围 | 多测点法向力,0–30 N;剪切及组合载荷后续再做 |
|
| 载荷 | 法向压缩 0–30 N;剪切及组合载荷后续再做 |
|
||||||
| 传感器 | 首版固定为 finger;其余类型须另行验证几何、采集和处理契约 |
|
|
||||||
| 压头 | 直径 6 mm、球面半径 3 mm 的半球压头 |
|
| 压头 | 直径 6 mm、球面半径 3 mm 的半球压头 |
|
||||||
| 参考 | 砝码或 50 N/2 N·m FT sensor,首版先接通一种 |
|
| 参考 | 当前人工填写实际参考力;计划使用砝码或 50 N/2 N·m FT sensor |
|
||||||
| 图像 | 每测试事件一个连续视频,档位间不切段 |
|
| 图像 | 每 trial 保存一个连续视频,保留初始零载、换档、加载、卸载及恢复 |
|
||||||
| 调试输入 | 已有视频与实时相机编号共用 finger worker 和 `SdkPacket`;相机实机尚未验收 |
|
| UV | 整数 pixel;原点为最靠近图像中线的有效列底部像素;U 向右、V 向上,允许负 V |
|
||||||
| 交付 | 单个 session 能重新打开、追溯参考载荷,计算并显示不同测点的灵敏度 |
|
| 三维 | 传感器坐标用 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;实机以铭牌及标定证书核对。
|
[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。设备准备、运动边界和停机沿用 [workflow.md](workflow.md)。本页不会自动移动平台。
|
||||||
|
|
||||||
现有模块与待接工作见[实施计划](implementation-plan.zh.md);旧 `.oryseq` 格式见 [sequence-format.md](sequence-format.md),其本身不包含下文的载荷循环。
|
|
||||||
|
|
||||||
## 操作员逐步流程
|
## 操作员逐步流程
|
||||||
|
|
||||||
设备操作沿用 [workflow.md](workflow.md) 的准备、运动边界及停机流程。以下不新增自动运动授权;尚未接通的步骤不能靠占位按钮完成。
|
| 步骤 | 操作 | 软件行为 |
|
||||||
|
|
||||||
| 步骤 | 操作员动作 | 软件记录/通过条件 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1. 建立会话 | 填写产品型号、传感器 SN、gel ID、实验台、压头和参考设备;选保存位置 | 创建唯一 `session_id`;保存身份和配置快照 |
|
| 1. 准备 | 填型号、SN、保存位置,载入 finger 导出包和配置 | 首次开始试验时建立本场 session;型号、SN、配置和映射在本场固定 |
|
||||||
| 2. 检查设备 | 完成设备设置、回零及既定检查,核对相机和参考输入 | 状态可读;运动边界按既定流程确认 |
|
| 2. 预览 | 选视频或相机编号,启动输入并检查二维 UV 图像 | 两种来源共用 finger worker;输入预览可贯穿多个 trial |
|
||||||
| 3. 确认测点 | 在有效 UV 区域选点,核对三维位置和姿态 | 分配 `site_id`,UV→3D 映射有效 |
|
| 3. 选点 | 点击 UV 粗选,再修改 U/V 到精确像素 | 显示传感器三维位置;拒绝掩膜外点 |
|
||||||
| 4. 设置循环 | 定义加载范围、档位、保持时间和重复数,选择手动模式 | 保存计划;自动加载留到下一阶段 |
|
| 4. 设循环 | 输入严格递增的正载荷档位及每档 10–30 s 保持 | 展示完整路径;如 `0 → 5 → 10 → 20 → 30 → 20 → 10 → 5 → 0 N` |
|
||||||
| 5. 建立基准 | 无接触时检查零载荷,保存参考图像和必要历史 | 保存归零/基准事件及 `baseline_id` |
|
| 5. 开始 | 无载时点击“开始本次试验” | 新建 trial 并连续录像;先提示初始零载 |
|
||||||
| 6. 开始事件 | 到达确认的测点姿态后,在加载前启动记录 | 新建 `trial_id` 和视频;确认视频与参考记录正常后加载 |
|
| 6. 保持 | 到目标后输入实际参考力,点击“已到目标力,开始保持” | 当前目标不变,倒计时期间禁止再次确认或完成试验 |
|
||||||
| 7. 逐级加载 | 用现有手动控制或砝码到目标档位,确认实际到达并保持 | 每档有 `step_id`、目标及有效参考力、分支和时间范围 |
|
| 7. 换档 | 倒计时结束后按提示手动调整至下一档 | 自动结束本档窗口;换档继续录像,但不沿用上一档恒定参考力 |
|
||||||
| 8. 逐级卸载 | 按相同档位下降到零,保留恢复过程 | 连续记录卸载分支;循环内不重新归零或重置图像基准 |
|
| 8. 完成 | 最终卸载至零并完成保持,点击“完成本次试验” | 停止本 trial 录像,排空写入并检查完整性;输入预览继续 |
|
||||||
| 9. 结束/重复 | 检查恢复与记录状态,停止事件;重复或移到下一点 | 完成写入后标记状态;每次重复使用新 trial 和视频 |
|
| 9. 重复/换点 | 重复同点或选下一点,再开始试验 | 同场目录内新增 trial;自动关联 site 和重复编号 |
|
||||||
| 10. 计算与复核 | 重新打开会话,选择有效保持窗口,查看每点曲线及 UV 灵敏度图 | 显示加载/卸载差异、误差、无效原因;留完整 trial 独立验证 |
|
| 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_id>.<ext>` | 算法入口图像、完整循环及恢复 | 每 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/<processing_id>/` | 逐点灵敏度表、加载/卸载曲线、误差、分析窗口和处理配置 | 分析时另存;不覆盖原始文件 |
|
|
||||||
|
|
||||||
| 参数归属 | 主要参数 | 单位/填写来源 |
|
|
||||||
|---|---|---|
|
|
||||||
| 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` |
|
| `order`、`site_id` | 表内顺序及测点编号;采集会话按首次实测顺序另行分配 site_id,以 U/V 和映射身份核对 |
|
||||||
| `step_reached` | 操作员确认到档或有效反馈确认;记录实际时刻和参考有效性 |
|
| `u_px`、`v_px` | 与标定页相同原点、方向和单位 |
|
||||||
| `step_ended` | 本档保持及分析窗口结束,保留实际持续时间 |
|
| `x_sensor_mm`/`y_sensor_mm`/`z_sensor_mm` | 传感器三维位置 |
|
||||||
| `trial_finished` | 视频与索引排空并通过完整性检查后,trial 进入 `complete` |
|
| `x_platform_mm`/`y_platform_mm`/`z_platform_mm`、`yaw_deg`、`pitch_deg` | 当前模拟安装设置下的平台目标及角度 |
|
||||||
| `trial_aborted` | 输入、写入或操作异常;trial 进入 `incomplete`,保留已写数据及原因 |
|
| `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` 原文件。例子不代表写入器已经实现。
|
本场目录名为 `<产品型号>_<SN>_<UTC时间>_<唯一标识>`,完整身份保存在 manifest。每个 trial 是一个测点的一次完整循环,档位用编号及时间窗口区分,不按力值拆原始视频目录。
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|
finger_F001_<时间>_<标识>/
|
||||||
"format_version": "0.1-draft",
|
session.json
|
||||||
"session_id": "session-001",
|
trials/
|
||||||
"sensor_type": "finger",
|
trial-001/
|
||||||
"input_source": {
|
session.json
|
||||||
"kind": "video",
|
raw_0001.mp4
|
||||||
"value": "D:/samples/finger-input.mp4",
|
frames.csv
|
||||||
"sha256": "<待采集时计算>",
|
forces.csv
|
||||||
"clock_id": "host_replay",
|
reference.csv
|
||||||
"time_basis": "host_replay",
|
events.csv
|
||||||
"configured_fps": 30.0,
|
trial-002/
|
||||||
"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}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 软件数据流与责任
|
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。
|
|
||||||
|
|
||||||
## 首版验收与待定事项
|
|
||||||
|
|
||||||
| 验收 | 通过条件 |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| 数据关联 | 每个视频可追溯到测点、姿态、基准、加载/卸载档位及参考力 |
|
| 根 `session.json` | `format_version=autocal-session-1.0`、字符串 session_id、型号/SN、配置快照及哈希、UV 定义、sites、trials、状态;首次开始、每档保持结束、trial 结束和整场关闭时更新 |
|
||||||
| 可回放 | 重新打开后能解码完整 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;不等于来源视频 |
|
||||||
| 最小验证 | 至少留出完整 trial,分别比较加载/卸载的法向误差;阈值待定 |
|
| `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 |
|
||||||
| 逐点呈现 | 多测点各有灵敏度、有效性、参考力—原始信号曲线;UV 图能定位差异 |
|
| `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)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Autocal finger 标定实施计划
|
# 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。
|
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 协议或硬件等待。当前计划不启动子代理。
|
主代理负责接口、M3 和集成;一名子代理负责 M1/M2,另一名负责 M4 与合成会话验证。建议总计主代理加两名子代理,约 4–6 个工程工作日并留设备联调时间;单人约 6–9 日。估算不包括设备、FT 协议或硬件等待。当前计划不启动子代理。
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
the points, plus the **points** themselves. It is written by
|
||||||
`导出序列…` in the Sequence tab and read 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
|
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
|
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
|
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -17,6 +17,7 @@ direction) and triangle structure rather than photorealism.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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))
|
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:
|
def load_uv_texture(path: str | Path) -> np.ndarray | None:
|
||||||
"""Decode a UV-space texture to ``[H, W, 4]`` uint8, or ``None``.
|
"""Decode a UV-space texture to ``[H, W, 4]`` uint8, or ``None``.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,14 +9,15 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import math
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||||
|
|
||||||
|
from .calibration_session import CalibrationSession, write_json
|
||||||
|
from .sensor.uv_flat import Site
|
||||||
|
|
||||||
|
|
||||||
REFERENCE_HEADER = (
|
REFERENCE_HEADER = (
|
||||||
"step_index",
|
"step_index",
|
||||||
@@ -31,53 +32,7 @@ REFERENCE_HEADER = (
|
|||||||
"force_frame",
|
"force_frame",
|
||||||
"source",
|
"source",
|
||||||
"stability_verified",
|
"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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +43,7 @@ def load_path(levels: list[float]) -> list[tuple[str, float]]:
|
|||||||
raise ValueError("load levels must be in (0, 30] N")
|
raise ValueError("load levels must be in (0, 30] N")
|
||||||
if values != sorted(set(values)):
|
if values != sorted(set(values)):
|
||||||
raise ValueError("load levels must be strictly increasing")
|
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]
|
("unload", value) for value in [*reversed(values[:-1]), 0.0]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -98,6 +53,11 @@ class ManualTrial:
|
|||||||
|
|
||||||
def __init__(self, worker) -> None:
|
def __init__(self, worker) -> None:
|
||||||
self.worker = worker
|
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.recorder: ExperimentRecorder | None = None
|
||||||
self.directory: Path | None = None
|
self.directory: Path | None = None
|
||||||
self.steps: list[tuple[str, float]] = []
|
self.steps: list[tuple[str, float]] = []
|
||||||
@@ -133,18 +93,12 @@ class ManualTrial:
|
|||||||
raise ValueError("sensor SN is required")
|
raise ValueError("sensor SN is required")
|
||||||
if not sensor_model.strip():
|
if not sensor_model.strip():
|
||||||
raise ValueError("sensor model is required")
|
raise ValueError("sensor model is required")
|
||||||
model_part = _folder_part(sensor_model)
|
if not math.isfinite(float(fps)) or not float(fps) > 0.0:
|
||||||
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:
|
|
||||||
raise ValueError("input frame rate must be positive")
|
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:
|
if not 10.0 <= float(hold_s) <= 30.0:
|
||||||
raise ValueError("hold time must be between 10 and 30 seconds")
|
raise ValueError("hold time must be between 10 and 30 seconds")
|
||||||
|
self.hold_s = float(hold_s)
|
||||||
packet = self.worker.latest()
|
packet = self.worker.latest()
|
||||||
if packet.capture_mono_ns is None:
|
if packet.capture_mono_ns is None:
|
||||||
raise RuntimeError("the input has no frame-bound host timestamp")
|
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),
|
"uv_origin_raster_xy_px": list(site.uv_origin_raster_xy_px),
|
||||||
"bundle_path": bundle_path,
|
"bundle_path": bundle_path,
|
||||||
"mesh_sha256": mesh_sha256,
|
"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,
|
"input_source": source,
|
||||||
"config_ref": config_ref,
|
"config_ref": config_ref,
|
||||||
"config_sha256": config_sha256,
|
"config_sha256": config_sha256,
|
||||||
|
"config_snapshot": json.loads(config_path.read_text(encoding="utf-8")),
|
||||||
"time_basis": packet.time_basis,
|
"time_basis": packet.time_basis,
|
||||||
"reference_alignment_possible": self.reference_alignment_possible,
|
"reference_alignment_possible": self.reference_alignment_possible,
|
||||||
"reference_source": "manual_operator_confirmation",
|
"reference_source": "manual_operator_confirmation",
|
||||||
"reference_file": "reference.csv",
|
"reference_file": "reference.csv",
|
||||||
}
|
}
|
||||||
recorder = ExperimentRecorder(
|
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(
|
directory = recorder.start(
|
||||||
root=Path(root),
|
root=self.session.directory / "trials",
|
||||||
session_id=packet.session_id,
|
session_id=packet.session_id,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
directory_label=label,
|
directory_name=entry["trial_id"],
|
||||||
)
|
)
|
||||||
reference_file = None
|
reference_file = None
|
||||||
try:
|
try:
|
||||||
@@ -199,6 +174,10 @@ class ManualTrial:
|
|||||||
reference_file.close()
|
reference_file.close()
|
||||||
recorder.stop(wait=True)
|
recorder.stop(wait=True)
|
||||||
raise
|
raise
|
||||||
|
self.entry = entry
|
||||||
|
self.steps = steps
|
||||||
|
self.completed_steps = []
|
||||||
|
self.reason = ""
|
||||||
self.recorder = recorder
|
self.recorder = recorder
|
||||||
self.directory = directory
|
self.directory = directory
|
||||||
self._reference_file = reference_file
|
self._reference_file = reference_file
|
||||||
@@ -236,14 +215,15 @@ class ManualTrial:
|
|||||||
recorder.log_event(
|
recorder.log_event(
|
||||||
kind,
|
kind,
|
||||||
json.dumps(
|
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,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
mono_t=event_mono_t,
|
mono_t=event_mono_t,
|
||||||
frame_id=frame_id,
|
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:
|
if self._active_reference is None:
|
||||||
return
|
return
|
||||||
step_index, branch, target, reference, start_ns = self._active_reference
|
step_index, branch, target, reference, start_ns = self._active_reference
|
||||||
@@ -262,6 +242,7 @@ class ManualTrial:
|
|||||||
"",
|
"",
|
||||||
"manual_operator_confirmation",
|
"manual_operator_confirmation",
|
||||||
0,
|
0,
|
||||||
|
int(complete),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._reference_file.flush()
|
self._reference_file.flush()
|
||||||
@@ -278,22 +259,8 @@ class ManualTrial:
|
|||||||
if not 0.0 <= reference <= 30.0:
|
if not 0.0 <= reference <= 30.0:
|
||||||
raise ValueError("reference load must be between 0 and 30 N")
|
raise ValueError("reference load must be between 0 and 30 N")
|
||||||
now_ns = time.perf_counter_ns()
|
now_ns = time.perf_counter_ns()
|
||||||
if self._last_confirm_ns is not None:
|
if self._active_reference is not None:
|
||||||
self._end_reference(now_ns)
|
raise RuntimeError("wait for the current hold to finish")
|
||||||
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,
|
|
||||||
)
|
|
||||||
self._event(
|
self._event(
|
||||||
"step_reached",
|
"step_reached",
|
||||||
{
|
{
|
||||||
@@ -306,15 +273,51 @@ class ManualTrial:
|
|||||||
now_ns=now_ns,
|
now_ns=now_ns,
|
||||||
)
|
)
|
||||||
self._active_reference = (self.step_index, branch, target, reference, now_ns)
|
self._active_reference = (self.step_index, branch, target, reference, now_ns)
|
||||||
self.step_index += 1
|
|
||||||
self._last_confirm_ns = now_ns
|
self._last_confirm_ns = now_ns
|
||||||
|
self._event("hold_started", {"step_index": self.step_index}, now_ns=now_ns)
|
||||||
return branch, target
|
return branch, target
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def held_s(self) -> float | None:
|
def holding(self) -> bool:
|
||||||
if self._last_confirm_ns is None or self.status != "recording":
|
return self._active_reference is not None
|
||||||
return None
|
|
||||||
return (time.perf_counter_ns() - self._last_confirm_ns) / 1e9
|
@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:
|
def finish(self, *, aborted: bool = False, reason: str = "") -> None:
|
||||||
if self.status != "recording" or self.recorder is 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:
|
if not aborted and self.next_step is not None:
|
||||||
raise RuntimeError("complete the load/unload path before finishing")
|
raise RuntimeError("complete the load/unload path before finishing")
|
||||||
now_ns = time.perf_counter_ns()
|
now_ns = time.perf_counter_ns()
|
||||||
if self._last_confirm_ns is not None:
|
if self.holding:
|
||||||
self._end_reference(now_ns)
|
self._complete_hold(now_ns, complete=False)
|
||||||
self._event(
|
|
||||||
"step_ended",
|
|
||||||
{
|
|
||||||
"step_index": self.step_index - 1,
|
|
||||||
"held_s": (now_ns - self._last_confirm_ns) / 1e9,
|
|
||||||
},
|
|
||||||
now_ns=now_ns,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
self._reference_file.close()
|
self._reference_file.close()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -344,6 +339,7 @@ class ManualTrial:
|
|||||||
{"reason": reason},
|
{"reason": reason},
|
||||||
now_ns=now_ns,
|
now_ns=now_ns,
|
||||||
)
|
)
|
||||||
|
self.reason = reason
|
||||||
self.aborted = aborted
|
self.aborted = aborted
|
||||||
self.worker.remove_sink(self.recorder.on_packet)
|
self.worker.remove_sink(self.recorder.on_packet)
|
||||||
self.recorder.stop(wait=False)
|
self.recorder.stop(wait=False)
|
||||||
@@ -353,6 +349,11 @@ class ManualTrial:
|
|||||||
if self.status == "recording" and self.recorder is not None:
|
if self.status == "recording" and self.recorder is not None:
|
||||||
if not self.worker.is_running or not self.recorder.is_recording:
|
if not self.worker.is_running or not self.recorder.is_recording:
|
||||||
self.finish(aborted=True, reason="input or writer stopped")
|
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:
|
if self.status == "closing" and self.recorder is not None:
|
||||||
report = self.recorder.report
|
report = self.recorder.report
|
||||||
if report is not None:
|
if report is not None:
|
||||||
@@ -370,4 +371,20 @@ class ManualTrial:
|
|||||||
)
|
)
|
||||||
else "incomplete"
|
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
|
return self.status
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
@@ -23,7 +24,8 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
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
|
from .sensor_panel import FlatSurfaceView
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +65,19 @@ class CalibrationPanel(QWidget):
|
|||||||
outer.addLayout(header)
|
outer.addLayout(header)
|
||||||
self.source_state = QLabel(self)
|
self.source_state = QLabel(self)
|
||||||
outer.addWidget(self.source_state)
|
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()
|
body = QHBoxLayout()
|
||||||
self.views = QTabWidget(self)
|
self.views = QTabWidget(self)
|
||||||
@@ -106,6 +121,9 @@ class CalibrationPanel(QWidget):
|
|||||||
self.hold.setValue(20)
|
self.hold.setValue(20)
|
||||||
self.hold.setSuffix(" s")
|
self.hold.setSuffix(" s")
|
||||||
form.addRow(self.hold_label, self.hold)
|
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)
|
self.target_label = QLabel(controls)
|
||||||
form.addRow(self.target_label)
|
form.addRow(self.target_label)
|
||||||
self.reference_label = QLabel(controls)
|
self.reference_label = QLabel(controls)
|
||||||
@@ -136,6 +154,7 @@ class CalibrationPanel(QWidget):
|
|||||||
self.output_browse.clicked.connect(self._choose_output)
|
self.output_browse.clicked.connect(self._choose_output)
|
||||||
self.u_spin.valueChanged.connect(self._site_changed)
|
self.u_spin.valueChanged.connect(self._site_changed)
|
||||||
self.v_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.start_button.clicked.connect(self._start)
|
||||||
self.confirm_button.clicked.connect(self._confirm)
|
self.confirm_button.clicked.connect(self._confirm)
|
||||||
self.finish_button.clicked.connect(self._finish)
|
self.finish_button.clicked.connect(self._finish)
|
||||||
@@ -148,6 +167,8 @@ class CalibrationPanel(QWidget):
|
|||||||
self._sync()
|
self._sync()
|
||||||
|
|
||||||
def set_flat(self, flat, bundle_path: str) -> None:
|
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._flat = flat
|
||||||
self._bundle_path = bundle_path
|
self._bundle_path = bundle_path
|
||||||
self.flat_view.set_flat(flat)
|
self.flat_view.set_flat(flat)
|
||||||
@@ -265,7 +286,12 @@ class CalibrationPanel(QWidget):
|
|||||||
|
|
||||||
def _poll(self) -> None:
|
def _poll(self) -> None:
|
||||||
previous = self.trial.status
|
previous = self.trial.status
|
||||||
|
try:
|
||||||
current = self.trial.poll()
|
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"}:
|
if current != previous and current in {"complete", "incomplete"}:
|
||||||
result_key = (
|
result_key = (
|
||||||
"calibration.incomplete_result"
|
"calibration.incomplete_result"
|
||||||
@@ -284,12 +310,46 @@ class CalibrationPanel(QWidget):
|
|||||||
)
|
)
|
||||||
self._sync()
|
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:
|
def shutdown(self) -> None:
|
||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
|
try:
|
||||||
if self.trial.status == "recording":
|
if self.trial.status == "recording":
|
||||||
self.trial.finish(aborted=True, reason="application closed")
|
self.trial.finish(aborted=True, reason="application closed")
|
||||||
if self.trial.status == "closing" and self.trial.recorder is not None:
|
if self.trial.status == "closing" and self.trial.recorder is not None:
|
||||||
self.trial.recorder.stop(wait=True)
|
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:
|
def _sync(self) -> None:
|
||||||
recording = self.trial.status == "recording"
|
recording = self.trial.status == "recording"
|
||||||
@@ -299,21 +359,24 @@ class CalibrationPanel(QWidget):
|
|||||||
self.v_spin,
|
self.v_spin,
|
||||||
self.levels,
|
self.levels,
|
||||||
self.hold,
|
self.hold,
|
||||||
self.sensor_sn,
|
|
||||||
self.sensor_model,
|
|
||||||
self.output_path,
|
|
||||||
self.output_browse,
|
|
||||||
self.bundle_button,
|
self.bundle_button,
|
||||||
):
|
):
|
||||||
widget.setEnabled(not locked)
|
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.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.finish_button.setEnabled(recording and self.trial.next_step is None)
|
||||||
self.abort_button.setEnabled(recording)
|
self.abort_button.setEnabled(recording)
|
||||||
step = self.trial.next_step
|
step = self.trial.next_step
|
||||||
if recording and step is not None:
|
if recording and step is not None:
|
||||||
target_text = t("calibration.next_step").format(
|
key = "calibration.holding" if self.trial.holding else "calibration.next_step"
|
||||||
branch=t(f"calibration.{step[0]}"), load=step[1]
|
target_text = t(key).format(
|
||||||
|
branch=t(f"calibration.{step[0]}"), load=step[1],
|
||||||
|
seconds=self.trial.remaining_s,
|
||||||
)
|
)
|
||||||
elif recording:
|
elif recording:
|
||||||
target_text = t("calibration.ready_to_finish")
|
target_text = t("calibration.ready_to_finish")
|
||||||
@@ -328,11 +391,22 @@ class CalibrationPanel(QWidget):
|
|||||||
total=len(self.trial.steps),
|
total=len(self.trial.steps),
|
||||||
status=t(f"calibration.{self.trial.status}"),
|
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
|
worker = self.source_panel.worker
|
||||||
packet = worker.latest()
|
packet = worker.latest()
|
||||||
@@ -353,6 +427,9 @@ class CalibrationPanel(QWidget):
|
|||||||
self.sn_label.setText(t("calibration.sensor_sn"))
|
self.sn_label.setText(t("calibration.sensor_sn"))
|
||||||
self.output_label.setText(t("calibration.output"))
|
self.output_label.setText(t("calibration.output"))
|
||||||
self.output_browse.setText(t("source.browse"))
|
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(0, t("calibration.site_view"))
|
||||||
self.views.setTabText(1, t("calibration.live_view"))
|
self.views.setTabText(1, t("calibration.live_view"))
|
||||||
self.bundle_button.setText(t("finger.load"))
|
self.bundle_button.setText(t("finger.load"))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ not.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
|||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
|
QFileDialog,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -45,7 +47,7 @@ from PySide6.QtWidgets import (
|
|||||||
from ..motion.kinematics import PlatformStack
|
from ..motion.kinematics import PlatformStack
|
||||||
from ..sensor.export_bundle import SensorBundle
|
from ..sensor.export_bundle import SensorBundle
|
||||||
from ..sensor.frames import describe as describe_frames
|
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.
|
#: How many picked points the list keeps before the oldest is dropped.
|
||||||
MAX_POINTS = 200
|
MAX_POINTS = 200
|
||||||
@@ -237,7 +239,7 @@ class FingerPanel(QGroupBox):
|
|||||||
#: than typed into.
|
#: than typed into.
|
||||||
COLUMNS: tuple[str, ...] = (
|
COLUMNS: tuple[str, ...] = (
|
||||||
"x", "y", "z", "nx", "ny", "nz",
|
"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.
|
#: How many of ``COLUMNS`` are inputs rather than solved outputs.
|
||||||
INPUT_COLUMNS = 6
|
INPUT_COLUMNS = 6
|
||||||
@@ -613,8 +615,12 @@ class FingerPanel(QGroupBox):
|
|||||||
self.up_button.clicked.connect(lambda: self.move_selected(-1))
|
self.up_button.clicked.connect(lambda: self.move_selected(-1))
|
||||||
self.down_button = QPushButton(box)
|
self.down_button = QPushButton(box)
|
||||||
self.down_button.clicked.connect(lambda: self.move_selected(1))
|
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):
|
for button in (self.clear_button, self.remove_button, self.up_button, self.down_button):
|
||||||
row.addWidget(button)
|
row.addWidget(button)
|
||||||
|
row.addWidget(self.export_sites_button)
|
||||||
row.addStretch(1)
|
row.addStretch(1)
|
||||||
self._selected_label = QLabel(box)
|
self._selected_label = QLabel(box)
|
||||||
self._selected_label.setObjectName("hintLabel")
|
self._selected_label.setObjectName("hintLabel")
|
||||||
@@ -808,6 +814,50 @@ class FingerPanel(QGroupBox):
|
|||||||
"""Every picked point, at full precision, for a later export."""
|
"""Every picked point, at full precision, for a later export."""
|
||||||
return [dict(row) for row in self._picked]
|
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
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
def handle_pick(self, x: int, y: int) -> dict[str, float] | None:
|
def handle_pick(self, x: int, y: int) -> dict[str, float] | None:
|
||||||
@@ -825,6 +875,7 @@ class FingerPanel(QGroupBox):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
point, normal, face = sample
|
point, normal, face = sample
|
||||||
|
origin_x, origin_y = uv_origin(self.view.flat)
|
||||||
pose = self._stack.solve(point, normal)
|
pose = self._stack.solve(point, normal)
|
||||||
# The click leaves a cross-hair where it landed *and* promotes the new row
|
# 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.
|
# 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),
|
"Z": float(pose.contact_z_mm),
|
||||||
"yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg),
|
"yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg),
|
||||||
"residual": float(pose.residual_deg),
|
"residual": float(pose.residual_deg),
|
||||||
|
"u_px": x - origin_x, "v_px": origin_y - y,
|
||||||
}
|
}
|
||||||
self._append_row(row)
|
self._append_row(row)
|
||||||
self._last_pose = pose
|
self._last_pose = pose
|
||||||
@@ -896,7 +948,9 @@ class FingerPanel(QGroupBox):
|
|||||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||||
item.setForeground(QColor("#5c6678"))
|
item.setForeground(QColor("#5c6678"))
|
||||||
self.table.setItem(index, column, item)
|
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)
|
self._ensure_selector(index)
|
||||||
finally:
|
finally:
|
||||||
self._writing = False
|
self._writing = False
|
||||||
@@ -1269,6 +1323,8 @@ class FingerPanel(QGroupBox):
|
|||||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
||||||
)
|
)
|
||||||
self.clear_button.setText(t("finger.clear_points"))
|
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.remove_button.setText(t("finger.remove_selected"))
|
||||||
self.up_button.setText(t("finger.move_up"))
|
self.up_button.setText(t("finger.move_up"))
|
||||||
self.down_button.setText(t("finger.move_down"))
|
self.down_button.setText(t("finger.move_down"))
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -406,6 +406,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
export, a missing file or an unreadable mesh must leave the rest of the
|
export, a missing file or an unreadable mesh must leave the rest of the
|
||||||
app alone and report the reason.
|
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(
|
bundle = load_bundle(
|
||||||
path,
|
path,
|
||||||
# The design profile declares these for this revision; a mismatch
|
# The design profile declares these for this revision; a mismatch
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -14,10 +15,10 @@ from orisys_autocal.trial import (
|
|||||||
REFERENCE_HEADER,
|
REFERENCE_HEADER,
|
||||||
ManualTrial,
|
ManualTrial,
|
||||||
load_path,
|
load_path,
|
||||||
site_at,
|
|
||||||
uv_origin,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from orisys_autocal.sensor.uv_flat import FlatSurface, site_at, uv_origin
|
||||||
|
|
||||||
|
|
||||||
class Worker:
|
class Worker:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -52,140 +53,253 @@ class Worker:
|
|||||||
for sink in self.sinks:
|
for sink in self.sinks:
|
||||||
sink(self.packet)
|
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 = np.full((5, 7), -1, dtype=np.int32)
|
||||||
mask[1:4, 2:5] = 2
|
mask[1:4, 2:5] = 2
|
||||||
flat = SimpleNamespace(
|
mask[4, 2] = 2 # A valid pixel may be below the centre-column origin.
|
||||||
width=7,
|
positions = np.zeros((5, 7, 3), dtype=np.float64)
|
||||||
height=5,
|
for y in range(5):
|
||||||
face_index=mask,
|
for x in range(7):
|
||||||
sample=lambda x, y: (
|
positions[y, x] = (x, y, 3)
|
||||||
(np.array([x, y, 3.0]), np.array([0, 0, 1]), 2)
|
normals = np.zeros_like(positions)
|
||||||
if 0 <= x < 7 and 0 <= y < 5 and mask[y, x] >= 0
|
normals[:, :, 2] = 1
|
||||||
else None
|
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)
|
assert uv_origin(flat) == (3, 3)
|
||||||
site = site_at(flat, -1, 2)
|
site = site_at(flat, -1, 2)
|
||||||
assert site.raster_xy_px == (2, 1)
|
assert site.raster_xy_px == (2, 1)
|
||||||
assert site.position_sensor_mm == (2.0, 1.0, 3.0)
|
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):
|
with pytest.raises(ValueError):
|
||||||
site_at(flat, -3, 0)
|
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()
|
worker = Worker()
|
||||||
trial = ManualTrial(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]) == [
|
assert load_path([5, 10]) == [
|
||||||
("load", 5),
|
("zero", 0), ("load", 5), ("load", 10), ("unload", 5), ("unload", 0)]
|
||||||
("load", 10),
|
directory = trial.start(**trial_setup)
|
||||||
("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,
|
|
||||||
)
|
|
||||||
worker.emit()
|
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)
|
trial.confirm(value)
|
||||||
|
assert trial.step_index == index
|
||||||
|
with pytest.raises(RuntimeError, match="hold"):
|
||||||
|
trial.confirm(value)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
trial.finish()
|
trial.finish()
|
||||||
report = trial.recorder.stop(wait=True)
|
clock.advance(19)
|
||||||
assert report.ok
|
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()
|
||||||
|
assert trial.recorder.stop(wait=True).ok
|
||||||
assert trial.poll() == "complete"
|
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
|
assert len(list(directory.glob("raw_*.mp4"))) == 1
|
||||||
with (directory / "frames.csv").open(encoding="utf-8", newline="") as handle:
|
with (directory / "frames.csv").open(encoding="utf-8", newline="") as handle:
|
||||||
row = next(csv.DictReader(handle))
|
row = next(csv.DictReader(handle))
|
||||||
assert row["t_host_mono_ns"] == str(worker.packet.capture_mono_ns)
|
assert row["t_host_mono_ns"] == str(worker.packet.capture_mono_ns)
|
||||||
assert row["source_frame_id"] == "7"
|
assert row["source_frame_id"] == "7"
|
||||||
assert row["time_basis"] == "host_camera"
|
assert row["time_basis"] == "host_camera"
|
||||||
assert row["capture_dropped"] == "0"
|
|
||||||
with (directory / "reference.csv").open(encoding="utf-8", newline="") as handle:
|
with (directory / "reference.csv").open(encoding="utf-8", newline="") as handle:
|
||||||
reader = csv.DictReader(handle)
|
reader = csv.DictReader(handle)
|
||||||
assert tuple(reader.fieldnames) == REFERENCE_HEADER
|
assert tuple(reader.fieldnames) == REFERENCE_HEADER
|
||||||
references = list(reader)
|
references = list(reader)
|
||||||
assert [row["branch"] for row in references] == ["load", "load", "unload", "unload"]
|
assert [row["branch"] for row in references] == ["zero", "load", "load", "unload", "unload"]
|
||||||
assert [row["F_ref_normal_N"] for row in references] == [
|
assert [float(row["F_ref_normal_N"]) for row in references] == [0, 5, 10, 5, 0]
|
||||||
"5.0",
|
assert all(row["F_ref_x_N"] == row["F_ref_y_N"] == row["F_ref_z_N"] == "" for row in references)
|
||||||
"10.0",
|
assert all(row["stability_verified"] == "0" and row["hold_complete"] == "1" for row in references)
|
||||||
"5.0",
|
assert all(int(r["t_end_host_mono_ns"]) - int(r["t_start_host_mono_ns"]) == 20_000_000_000 for r in references)
|
||||||
"0.0",
|
assert all(int(a["t_end_host_mono_ns"]) < int(b["t_start_host_mono_ns"]) for a, b in zip(references, references[1:]))
|
||||||
]
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
with (directory / "session.json").open(encoding="utf-8") as handle:
|
with (directory / "session.json").open(encoding="utf-8") as handle:
|
||||||
session = json.load(handle)
|
manifest = json.load(handle)
|
||||||
assert session["site"]["uv_xy_px"] == [0, 1]
|
assert manifest["trial_status"] == "complete"
|
||||||
assert session["sensor_model"] == "Finger D40"
|
assert manifest["site"]["uv_xy_px"] == [-1, 2]
|
||||||
assert session["reference_file"] == "reference.csv"
|
assert len(manifest["steps"]) == 5
|
||||||
assert session["site"]["mesh_sha256"]
|
assert manifest["uv_definition"]["mapping_sha256"] == trial_setup["site"].mapping_sha256
|
||||||
assert len(list(tmp_dir.glob("*/session.json"))) == 1
|
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,
|
def test_retries_and_other_sites_share_one_session(trial_setup, clock):
|
||||||
site=site,
|
worker = Worker()
|
||||||
levels=[5, 10],
|
trial = ManualTrial(worker)
|
||||||
hold_s=20,
|
directories = []
|
||||||
sensor_model="Finger D40",
|
for site in (trial_setup["site"], trial_setup["site"],
|
||||||
sensor_sn="F-001",
|
replace(trial_setup["site"], u_px=0, raster_xy_px=(3, 1), position_sensor_mm=(3, 1, 3))):
|
||||||
bundle_path=str(bundle),
|
directory = trial.start(**{**trial_setup, "site": site})
|
||||||
source={"kind": "camera", "value": 0},
|
directories.append(directory)
|
||||||
config_ref=str(config),
|
|
||||||
fps=30,
|
|
||||||
)
|
|
||||||
worker.emit()
|
worker.emit()
|
||||||
trial.confirm(5)
|
trial.confirm(0)
|
||||||
trial.finish(aborted=True, reason="operator aborted")
|
clock.advance(3)
|
||||||
|
trial.finish(aborted=True, reason="operator ended early")
|
||||||
trial.recorder.stop(wait=True)
|
trial.recorder.stop(wait=True)
|
||||||
assert trial.poll() == "incomplete"
|
assert trial.poll() == "incomplete"
|
||||||
assert len(list(tmp_dir.glob("*/session.json"))) == 2
|
rows = list(csv.DictReader((directory / "reference.csv").open(encoding="utf-8")))
|
||||||
assert second_directory != directory
|
assert rows[0]["hold_complete"] == "0"
|
||||||
with (second_directory / "reference.csv").open(encoding="utf-8", newline="") as handle:
|
assert len({d.parent.parent for d in directories}) == 1
|
||||||
assert [row["F_ref_normal_N"] for row in csv.DictReader(handle)] == ["5.0"]
|
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(0)
|
||||||
|
clock.advance(2)
|
||||||
|
worker.is_running = False
|
||||||
|
trial.poll()
|
||||||
|
trial.recorder.stop(wait=True)
|
||||||
|
assert trial.poll() == "incomplete"
|
||||||
|
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(
|
def test_calibration_page_keeps_precise_uv_site_when_switching_to_live_view(
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -147,6 +147,9 @@ class RecordConfig:
|
|||||||
#: ``preroll_bytes`` so a large frame does not turn into a large buffer.
|
#: ``preroll_bytes`` so a large frame does not turn into a large buffer.
|
||||||
preroll_frames: int = 30
|
preroll_frames: int = 30
|
||||||
preroll_bytes: int = 192 * 1024 * 1024
|
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:
|
def as_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -159,6 +162,7 @@ class RecordConfig:
|
|||||||
"max_queue_bytes": int(self.max_queue_bytes),
|
"max_queue_bytes": int(self.max_queue_bytes),
|
||||||
"preroll_frames": int(self.preroll_frames),
|
"preroll_frames": int(self.preroll_frames),
|
||||||
"preroll_bytes": int(self.preroll_bytes),
|
"preroll_bytes": int(self.preroll_bytes),
|
||||||
|
"preserve_all_frames": bool(self.preserve_all_frames),
|
||||||
"lossy_note": (
|
"lossy_note": (
|
||||||
"MP4 is a lossy container: this is the pre-processing camera "
|
"MP4 is a lossy container: this is the pre-processing camera "
|
||||||
"image, not a lossless record of the raw sensor data."
|
"image, not a lossless record of the raw sensor data."
|
||||||
@@ -346,13 +350,12 @@ class ExperimentRecorder:
|
|||||||
calibration_version: str = "",
|
calibration_version: str = "",
|
||||||
preroll=(),
|
preroll=(),
|
||||||
directory_label: str = "",
|
directory_label: str = "",
|
||||||
|
directory_name: str = "",
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Create the experiment directory and start the writer thread."""
|
"""Create the experiment directory and start the writer thread."""
|
||||||
if directory_label and (
|
for value in (directory_label, directory_name):
|
||||||
directory_label in {".", ".."}
|
if value and not all(char.isalnum() or char in "-_" for char in value):
|
||||||
or not all(char.isalnum() or char in "-_" for char in directory_label)
|
raise ValueError("directory names must contain only letters, numbers, - or _")
|
||||||
):
|
|
||||||
raise ValueError("directory label must contain only letters, numbers, - or _")
|
|
||||||
# A previous writer that has not reached a terminal state still owns the
|
# A previous writer that has not reached a terminal state still owns the
|
||||||
# files and the statistics; reusing it would silently mix two sessions.
|
# files and the statistics; reusing it would silently mix two sessions.
|
||||||
if self._thread is not None and self._thread.is_alive():
|
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)
|
self.config = replace(config if config is not None else self.config)
|
||||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
label = f"_{directory_label}" if directory_label else ""
|
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:
|
try:
|
||||||
directory.mkdir(parents=True, exist_ok=False)
|
directory.mkdir(parents=True, exist_ok=False)
|
||||||
except Exception as exc: # noqa: BLE001 - reported to the caller
|
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
|
A skipped frame is not lost data: every row is still in
|
||||||
``frames.csv``/``forces.csv``, and the count is reported separately.
|
``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:
|
try:
|
||||||
stamp = float(mono_t)
|
stamp = float(mono_t)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|||||||
@@ -288,3 +288,39 @@ if __name__ == "__main__": # pragma: no cover - manual runner
|
|||||||
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
|
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
|
||||||
print(f"failures: {failures}")
|
print(f"failures: {failures}")
|
||||||
raise SystemExit(1 if failures else 0)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user