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