完善 Autocal 多测点标定流程及会话复核

统一测点坐标、保持窗口和试验目录,补齐逐点初步分析。定向测试与真实视频闭环通过;两项轮询失败在修改前版本同样复现。
This commit is contained in:
ykcheungab
2026-10-11 17:43:16 +08:00
parent 7828bc1fe9
commit 04adf17f60
17 changed files with 1347 additions and 403 deletions
+72 -20
View File
@@ -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"
}
}
+72 -20
View File
@@ -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": "质量说明"
}
}
+85 -132
View File
@@ -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)。
+7 -3
View File
@@ -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 协议或硬件等待。当前计划不启动子代理。
+5
View File
@@ -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
+114 -97
View File
@@ -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
+217 -103
View File
@@ -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(
+124
View File
@@ -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):
+36
View File
@@ -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