From b362e9bb405f2ea2de741f5022ff3be1a60f8b5d Mon Sep 17 00:00:00 2001 From: lxp <2770281812@qq.com> Date: Tue, 22 Sep 2026 23:17:15 +0800 Subject: [PATCH] aaa --- .../O30_RAW_PROTOCOL_IMPLEMENTATION.md | 69 ++++++++++----- .../O30_RIGHT_CALIBRATION.md | 24 +++++- src/linkerhand_calibration/TESTING.md | 25 ++++++ .../core/fitting/raw_joint_fit.py | 37 +++++++- .../core/fitting/raw_joint_selection.py | 80 ++++++++++++++++++ .../linkerhand_calibration/extrinsics_node.py | 2 + .../artifacts/raw_joint_finalization.py | 13 ++- .../runtime/artifacts/raw_joint_report.py | 1 + .../runtime/coordinator.py | 3 +- .../runtime/optical_verification.py | 4 +- .../linkerhand_calibration/runtime/resume.py | 5 +- .../o30_training_candidate_parameters.npz | Bin 0 -> 7299 bytes .../test/optical_fixture.py | 2 +- .../test/test_raw_joint_candidate_gates.py | 38 +++++++++ .../test/test_raw_joint_release.py | 20 +++++ .../test/test_raw_resume.py | 22 +++++ 16 files changed, 312 insertions(+), 33 deletions(-) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_selection.py create mode 100644 src/linkerhand_calibration/test/fixtures/o30_training_candidate_parameters.npz create mode 100644 src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py diff --git a/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md b/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md index 7da7f65..be73e6c 100644 --- a/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md +++ b/src/linkerhand_calibration/O30_RAW_PROTOCOL_IMPLEMENTATION.md @@ -1,30 +1,55 @@ -# O30 原始图像协议实现状态 +# O30 轻量标定实现与验收边界 -2026-09-22。本文件记录实现边界,不是标定通过报告。 +2026-09-22。产品默认入口已切换为 `raw_joint_2_plus_1` / `raw_joint_images_v1`。软件流程已经接通;本文不是实机标定通过证明。 -## 已实现 +## 正式流程 -- 独立 `raw_joint_2_plus_1` / `raw_joint_images_v1` 协议身份,固定训练轮 0、1 和独立验证轮 3;沿用 17 个任务、108 个方向单元和原稳态停点。 -- 新协议采集不请求在线关节几何初始化,不执行旧零位准备往返,不生成接受关节角或物理零位记录。 -- 原始角点、PnP 候选、指令/反馈、同步误差和运动身份落盘。采集覆盖复用现有扫描/稳态门限;几何分支冲突不暂停移动标签采集,固定参考冲突仍保护停止。 -- 不足的方向移至队尾补采一次;再次不足记录缺项,继续后续任务,归位后失败。 -- 离线原始证据分区重新计算覆盖,拒绝协议混用、重复图像、重试预算越界及未完成的最新尝试;冻结训练/验证数据。该检查只证明原始采集完整性,不能授权 URDF 发布。 -- 提取 `finger_chain_images.py` / `kinematic_chain_images.py` 共享数学核心;旧诊断模块保留兼容导入,未改变其发布权限。 +实测光学核验 → 一次启动检查 → 17 个任务、20 个关节连续两轮训练及一轮验证 → 安全归位 → 离线联合拟合 → 冻结独立验收 → JSON/URDF 原子发布。 -## 尚未完成,不能启用为产品默认入口 +- 使用原运动执行器、避让路径、四指侧摆分段、9 个训练停点与双向稳态采样。108 个方向单元,训练轮身份 0、1,验证轮身份 3。验证轮编号 3 不表示执行四轮。 +- 原始角点、合格 PnP 候选、同帧标签、指令、反馈、时间、方向及参考证据保存。所有实际发送指令另存 `raw_command_sent`,统计包括到位、避让、归位及重试。 +- 新入口不请求在线父模型、唯一移动标签分支、零位准备往返、辅助消歧或歧义回访。采集只检查同步、覆盖、端点、稳态和检测。 +- 缺失单元移至队尾补采一次;再次不足记录缺项,继续其他任务,归位后报告失败。硬件停滞、反馈失联、参考移动仍保护停止。 +- 新断点只接收同协议、同受保护输入、同参考/可核验安装条件。中断的尝试消耗原补采预算;第一单元尚未通过也保留断点。拒绝不兼容数据,不静默重采。不可见安装参考明确拒绝恢复。 -- 同协议、同安装参考的正式断点恢复,以及跨进程保持队尾补采预算。 -- 四指与非平行拇指的完整训练联合求解、五根轴恢复掌坐标、绝对零位和双向映射绑定。 -- 候选集合及相关协方差向最终运动学输出传播,冻结第三轮前向预测和原精度门限验收。 -- 新协议正式 finalizer、JSON/URDF 审计与原子发布接入。 -- 全手实际指令测试、ID5/9/12 多候选和 ID10 缺测数据正式回归。 -- 相机光学核验、新协议全手实测及实机姿态对照。 +## 离线求解与发布 -产品默认配置及 CLI 未切换到新协议。不要直接通过修改配置启动新协议实机流程:现有正式 finalizer 和恢复入口仍属于旧证据链。没有生成新协议通过产物,没有修改 `latest_passed`。 +- `UrdfCommandImages` 复用共享图像优化入口,直接沿原 CAD 拓扑计算四指及非平行拇指。一个掌变换、每个实体标签一个共享安装;不引入各阶段独立六自由度变换。 +- 五根轴的方向与空间布局初始化掌坐标,子轴补充非平行约束;所有允许零位和双向单调指令映射在原始图像目标中联合求解。首次观测及行程端点不作为物理零位。 +- 最多 64 个初始化,每个初始化的安装预优化与联合优化合计最多 200 次求值。正式后台进程沿用 600 秒上限;预算耗尽保留失败原因,不自动增加动作。 +- 训练候选先检查原重投影门限,再对不同数值解族做有分辨率余量的配对图像损失检验(0.03 px、Holm 家族错误率 0.01)。同一保持姿态和重复初始化不重复计为独立证据。所有未被训练证据排除的候选均保留;发布代表按物理输出的最坏误差选择,不按最低像素分数选零位。 +- 完整相关协方差和候选间差异传播到零位、绝对指令角、轴方向、轴位置、FK 位置及旋转。保留原角度/空间门限;标签安装不唯一不单独否决。审计的 FK 范围为训练网格和单关节姿态,不宣称任意多轴组合已验收。 +- 第三轮冻结全部共享几何、安装、零位和指令映射。仅为独立测量估计每张图自己的角度;按图像块批量运算,各图之间不共享测量变量。第三轮不能参与训练、候选选择或参考修正。 +- 沿用现有文件名和 `latest_passed`。仅授权修改 `origin.rpy` 与限位;拇指 IP、四指 DIP 保留 CAD 零位。检查 JSON 重建、实际文件 FK、授权字段及标准 robot_state_publisher 加载后,才允许原子发布。 -## 已运行的软件验证 +## 使用与结果 -- 原始采集、旧分支保护、诊断覆盖组:59 项通过;6 项旧测试因 `SimpleNamespace` 夹具缺少 `reference_check` 失败。改用正式 `MotionCommand` 夹具后,6 项重跑全部通过(0.96 秒),未放宽生产保护。 -- 共享整指核心、冻结验证、候选分族:24 项通过(4.34 秒)。 -- 队尾补采状态显示:1 项通过(0.55 秒)。 -- coordinator 虚拟实机用例包含小指三个任务、18 个方向,各关节统计全部发送指令恰好三次往返;无在线求解调用,最终归位。该结果不能替代全手实机验收。 +首次新协议必须显式新采集,旧会话及原 66 个通过单元只保留回归用途: + +```bash +ros2 run linkerhand_calibration calibrate_hand \ + --config src/linkerhand_calibration/config/o30_right_product.yaml \ + --no-resume --camera-optical-observations /绝对路径/optical_observations.json +``` + +后续恢复省略 `--no-resume`,仍需提供当前安装条件下的光学观测。若安装参考不可核验,按拒绝原因处理,不能改哈希绕过。 + +现有 `three_camera_extrinsics.launch.py` 增加 `verification_extrinsics_file`:指定受保护外参文件、另设 JSON `output_file`,沿用棋盘格采集及保存操作。该模式不重拟合相机参数,保存同步多姿态角点,并验证冻结内外参;每个机位至少 15 组,跨机位同步不超过 50 ms,同时检查图像/倾角覆盖及原重投影门限。正常外参标定模式不变。核验模式的角点 JSON 用于上述启动参数。 + +关键文件: + +- `raw_joint_result.json`:分别记录原始采集完整、拟合完成、精度通过;包含缺失单元、失败原因和禁止自动几何重采标志。 +- `raw_joint_training.json` / `raw_joint_training_selection.json`:初始化预算、训练排除依据、全部候选审计。 +- `raw_joint_candidates.npz`:保留候选参数和完整协方差。 +- `raw_joint_holdout.json`:每个保留候选的冻结验证。 +- `calibration_report.json` / `release_manifest.json`:协议、原始证据、安装参考、光学观测及候选集合身份;最终发布证据。 + +## 已验证与尚需实机完成 + +- 全手运动效果测试覆盖 17 个任务、三轮正式往返、原避让和归位;实际 coordinator 虚拟时钟覆盖小指 18 个方向、全部已发送指令,无在线求解。 +- 有界队尾补采、跨恢复预算、首单元中断发现、协议/参考拒绝、固定参考保护、光学缺失时禁止启动均有测试。 +- ID5、ID9、ID12 的真实历史双候选角点可作为采集数据;ID10 真实缺测不能变成完整扫描。 +- 独立 XML FK 真值:64 个初始化全部收敛,约 193 秒;32 个超过原图像门限,16 个被训练配对证据排除,剩余 16 个全部通过候选集合不确定度及第三轮验收。该批次生成合成 JSON/URDF,并通过标准 ROS 加载和最终文件 FK 回读。 +- 合成产物位于 `calibration_output/software_review_20260922_raw_joint/known_truth/`,仅有软件测试指针 `synthetic_only_passed`;没有更新实机 `latest_passed`。 +- 该软件产物保留生成时的检验审计;当前更严格的 0.01 家族错误率复核另存 `current_training_selection.json`,保留候选集合完全相同,未重复运行优化器或改写原验收文件。 +- **尚需实机验收**:当前相机安装的实际光学核验、新协议首次全手采集、实机姿态对照。本次没有重新启动电机,不能将合成通过当作实际机械手的准确性证明。 diff --git a/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md b/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md index 55530fd..05c6030 100644 --- a/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md +++ b/src/linkerhand_calibration/O30_RIGHT_CALIBRATION.md @@ -7,15 +7,35 @@ Profile 为 `O30/right/o30_right_18/v1`,20 个关节全部主动;原始 URDF 三相机序列号及内参指纹均与现有文件一致。 软件测试包含合成数据和已记录的真实图像回归,不能作为整手现场精度报告。 +## 当前默认:连续采集、离线联合求解、验收后修正 URDF + +默认协议已切换为 `raw_joint_2_plus_1`。17 个任务各两轮训练加一轮独立验证, +不再执行旧的局部几何准备往返,不在采集中等待唯一父标签分支。 +缺项队尾最多补采一次;硬件异常、反馈失联、参考移动仍保护停止。 +归位后进行联合拟合、候选集合不确定度验收、第三轮冻结验证及最终文件检查。 + +首次新协议需要全手新采集和当前相机安装的实测光学核验: + +```bash +ros2 run linkerhand_calibration calibrate_hand \ + --config src/linkerhand_calibration/config/o30_right_product.yaml \ + --no-resume --camera-optical-observations /绝对路径/optical_observations.json +``` + +完整实现、核验工具、断点条件和验收记录见 [新协议说明](O30_RAW_PROTOCOL_IMPLEMENTATION.md)。 +软件合成验收已生成 JSON 和修正 URDF;没有更新实机 `latest_passed`,尚需新协议实测及姿态对照。 + +## 历史任务式采集说明(显式选择旧协议时适用) + ## 2026-09-22:从头按每任务两轮训练+一轮验证采集 -**当前实现边界:**下方正式入口的“两轮+验证”仍指扫描轮数,旧的逐级求解准备 +**历史实现边界(已被新默认入口替代):**下方旧入口的“两轮+验证”仍指扫描轮数,旧的逐级求解准备 动作尚未整体移除。现有小指延后求解采集已改为实际共三次完整往返,第一轮回程 保存参考观测,缺标签和硬件异常仍停止;该修改已通过虚拟运行测试,尚未实测。 不能把小指采集侧修改称为全手正式“采集→离线拟合→发布”已贯通,不能将原始 诊断记录改标为正式零位证据。下面命令也不是新采集优先链路的完成声明。 -O30 配置默认使用 `taskwise_2_plus_1`;旧 `fixed` 仅在显式选择时使用三轮训练+一轮验证。 +当时 O30 配置使用 `taskwise_2_plus_1`;旧 `fixed` 仅在显式选择时使用三轮训练+一轮验证。 新策略按每个任务连续完成训练 1、训练 2、独立验证,再进入下一个任务, 不先扫完整手、不自动补第三轮训练。内部验证轮编号仍为 3,不能当作训练数据。 17 个任务共 108 个方向/分段扫描单元(旧 fixed 为 144 个),不含必要归位与避让。 diff --git a/src/linkerhand_calibration/TESTING.md b/src/linkerhand_calibration/TESTING.md index 69463ff..95ad9e0 100644 --- a/src/linkerhand_calibration/TESTING.md +++ b/src/linkerhand_calibration/TESTING.md @@ -551,3 +551,28 @@ Schur 协方差与完整逆矩阵一致、冻结模型拒绝无法解释的阶 这不是正式标定稳定性通过:轴距采用精确 URDF 约束,验证仍逐帧估角,且反向数据 用于候选检验。尚缺物理零位绑定、冻结双向指令映射、未使用第三轮、完整输出误差 及 JSON/URDF 往返验收。新模块为非授权离线模型,未绕过正式父参考或发布门限。 + +## O30 raw_joint 正式链路(2026-09-22) + +新默认协议及完整边界见 `O30_RAW_PROTOCOL_IMPLEMENTATION.md`。 + +针对性验证(不同组有重叠,不相加宣称一次全量回归): + +- 原始采集、断点恢复、存储回归:35 passed,44.24 秒。覆盖首单元尚未通过的中断发现、跨恢复补采预算、实际 coordinator 三轮指令、缺失光学观测禁止运动。 +- 全手运动效果及显式旧协议路径:3 passed,4.12 秒。原有两个接受关节角夹具改为显式 `TASKWISE_TWO_ROUND`,不修改新协议质量门限。 +- 光学核验及候选集合门限:8 passed,5.84 秒。错误内外参、覆盖、序列号、同步拒绝;物理超差拒绝,标签安装不唯一不额外否决。 +- 共享 CAD 图像导数、历史失败角点和发布回读:10 passed,51.30 秒;批量独立角度验证修正稀疏索引读取后,发布组 3 passed,10.92 秒。 +- 外参工具、原子发布、状态及 O30 launch 公共契约:30 passed,2.32 秒。 +- 最终候选门禁、共享整指/运动链及真实失败回归:23 passed,12.25 秒。正式终结入口缺少来源证据时,拒绝求解并保存失败状态:1 passed,4.76 秒。 +- 光学核验采集同步上限统一为 50 ms 后,外参工具受影响组:10 passed,0.57 秒。源码编译与 `git diff --check` 通过。 +- ID5/9/12 与 ID10 历史回归来自 `o30_raw_failure_observations.json.gz`,仅用于旧角点观察能力回归,不能恢复为新协议数据。 +- `o30_training_candidate_parameters.npz` 保存两个合成训练解的参数,供配对损失/保留候选边界测试复用,不含任何实机标定授权。 + +完整合成真值数值回归:64 个初始化全部收敛,约 192.82 秒;每个候选总求值不超过 200。 +训练像素门限排除 32 个,训练分层配对检验排除 16 个,剩余 16 个全部通过候选协方差与第三轮验证。 +训练检验使用原 0.03 px 分辨率余量和 0.01 Holm 家族门限;第三轮不参与选择。 +保留候选的零位三倍标准差与候选差异联合包络约 0.100°,绝对指令角约 0.192°,轴位置约 0.125 mm,FK 位置约 0.260 mm。 +产物通过实际 robot_state_publisher、结构授权、JSON 重建及最终 FK 回读;后半程验收和发布约 10.38 秒。 +只写入 `calibration_output/software_review_20260922_raw_joint/known_truth/` 和独立软件测试指针;实机 `latest_passed` 未修改。 + +未进行本次新协议全手实测或实机姿态对照;不能把以上软件结果当作现场精度证明。 diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_fit.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_fit.py index e114e85..254a99c 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_fit.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_fit.py @@ -26,6 +26,7 @@ class RawJointFit: representative: int uncertainty: dict training_audit: tuple + training_selection: dict | None = None def physical_outputs(bundle, parameters): @@ -88,9 +89,33 @@ def certify_candidate_union(bundle, candidates): for k, v in limits.items()]), axis=0))) for key, limit in limits.items(): radius = radii[key][representative] - audit[key] = dict(maximum_union_radius=radius, limit=limit, passed=radius <= limit) + audit[key] = dict(maximum_union_radius=radius, limit=float(limit), passed=bool(radius <= limit)) if radius > limit: failures.append(key) + values = np.asarray([item[0][key] for item in evaluated]) + widths = np.asarray([item[1][key] for item in evaluated]) + difference = values-values[representative] + difference = np.linalg.norm(difference,axis=-1) if difference.ndim == 3 else np.abs(difference) + affected = np.flatnonzero(np.max(difference+widths,axis=0) > limit) + if key in {'zero_rad','axis','axis_point_m'}: + names = {bundle.joints[int(index)] for index in affected} + elif key == 'command_rad': + labels = [name for name in bundle.joints for _ in range(2*len(bundle.knots[name]))] + names = {labels[int(index)] for index in affected} + else: + probes = 1+sum(len(nodes) for nodes in bundle.knots.values()) + links = sorted(set(bundle.tag_links.values())) + affected_links = {links[(int(index)//probes)%len(links)] for index in affected} + names = {name for name in bundle.joints if bundle.model.joints[bundle.cad_names[name]].child in affected_links} + audit[key]['affected_joints'] = sorted(names) + audit[key]['required_observations'] = [dict(joint=name, + task=next(task.key for task in bundle.profile.motion.tasks if name in task.joints), + view=bundle.profile.measurement.measurements[name].view, + tag_roles=[bundle.profile.measurement.measurements[name].parent_role, + bundle.profile.measurement.measurements[name].child_role], + need=('bidirectional_steady_command_support' if key == 'command_rad' else + 'simultaneous_link_observations_with_distinct_axis_or_held_pose_geometry'), + repeat_identical_trajectory_automatically=False) for name in sorted(names)] if any(abs(v) > np.radians(20) for candidate in candidates for v in bundle.zero_offsets(candidate.parameters).values()): failures.append('absolute_zero_exceeds_20deg') @@ -141,5 +166,11 @@ def fit_raw_joint_candidates(bundle, *, cancelled=lambda: False, candidate_compl candidate_completed(row) if any(not row['accepted_for_uncertainty'] and not row.get('training_excluded') for row in audit): raise ValueError('raw_joint_unresolved_training_candidates:see_raw_joint_training.json') - uncertainty, representative = certify_candidate_union(bundle, tuple(candidates)) - return RawJointFit(tuple(candidates), representative, uncertainty, tuple(audit)) + from .raw_joint_selection import reject_training_distinguishable_candidates + candidates, selection = reject_training_distinguishable_candidates(bundle, tuple(candidates)) + excluded = {tuple(seed) for seed in selection.get('excluded_seeds', ())} + for row in audit: + if tuple(row['seed']) in excluded: + row.update(accepted_for_uncertainty=False, training_excluded='paired_training_loss_holm') + uncertainty, representative = certify_candidate_union(bundle, candidates) + return RawJointFit(candidates, representative, uncertainty, tuple(audit), selection) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_selection.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_selection.py new file mode 100644 index 0000000..c60b203 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/raw_joint_selection.py @@ -0,0 +1,80 @@ +"""Training-only rejection of distinguishable image hypotheses. + +Repeated holds and optimizer seeds are not independent observations. Compare +one median paired loss per distinct command/direction pose, with the existing +0.03 px resolution margin and Holm familywise correction. Survivors, including +physically different tied solutions, all proceed to covariance propagation. +""" +from collections import defaultdict + +import numpy as np +from scipy.stats import ttest_1samp, wilcoxon + +from ..geometry.tag_pose.image_model_selection import holm_adjusted_probabilities +from ..geometry.tag_pose.parameters import DEFAULT_REPROJECTION_TIE_PX + + +def _families(bundle, candidates): + families = [] + for index, candidate in enumerate(candidates): + for family in families: + if all(np.max(np.abs(candidate.parameters-candidates[other].parameters)/bundle.scale) <= 1e-5 + for other in family): + family.append(index) + break + else: + families.append([index]) + return families + + +def _probability(differences): + if len(differences) < 6 or not np.all(np.isfinite(differences)) or np.mean(differences) <= 0: + return 1. + if np.ptp(differences) < 1e-12: + return 2.**-len(differences) + mean = float(ttest_1samp(differences,0.,alternative='greater').pvalue) + rank = float(wilcoxon(differences,alternative='greater',zero_method='wilcox').pvalue) + return max(mean,rank) if np.isfinite(mean) and np.isfinite(rank) else 1. + + +def reject_training_distinguishable_candidates(bundle, candidates): + if not candidates: + return (), dict(policy='training_paired_pose_losses_holm_v1', families=[], comparisons=[]) + families = _families(bundle,candidates) + anchor = min(range(len(candidates)),key=lambda i:candidates[i].rms_px) + predictions = [bundle.project_observations(c.parameters,bundle.commands,bundle.directions,bundle.observations) + for c in candidates] + probabilities, comparisons = [], [] + for family_index,family in enumerate(families): + if anchor in family: + continue + for role,(indices,measured,_matrices,_objects) in bundle.observations.items(): + base = np.sqrt(np.mean(np.sum((predictions[anchor][role]-measured)**2,axis=2),axis=1)) + # Only commanded ancestors move this role. Other fingers' repeated + # holds cannot multiply the evidence for its installation branch. + ancestors = {joint.name for joint in bundle.chains[role]} + channels = sorted(bundle.channels[name] for name in bundle.joints if bundle.cad_names[name] in ancestors) + strata = defaultdict(list) + for local,index in enumerate(indices): + key = tuple((float(bundle.commands[index,ch]),int(bundle.directions[index,ch])) for ch in channels) + strata[key].append(local) + pvalues = [] + for member in family: + other = np.sqrt(np.mean(np.sum((predictions[member][role]-measured)**2,axis=2),axis=1)) + losses = other**2-(base+DEFAULT_REPROJECTION_TIE_PX)**2 + differences = np.asarray([np.median(losses[items]) for items in strata.values()]) + pvalues.append(_probability(differences)) + index = len(comparisons) + probabilities.append((index,max(pvalues))) + comparisons.append(dict(family=family_index,role=role,pose_strata=len(strata), + probability=max(pvalues))) + excluded = set() + for index,adjusted in holm_adjusted_probabilities(probabilities): + comparisons[index]['holm_probability'] = adjusted + if adjusted < .01: + excluded.update(families[comparisons[index]['family']]) + retained = tuple(c for i,c in enumerate(candidates) if i not in excluded) + return retained, dict(policy='training_paired_pose_losses_holm_v1', + training_comparison_anchor=list(candidates[anchor].seed), pixel_margin=DEFAULT_REPROJECTION_TIE_PX, + familywise_alpha=.01, families=[[list(candidates[i].seed) for i in family] for family in families], + excluded_seeds=[list(candidates[i].seed) for i in sorted(excluded)], comparisons=comparisons) diff --git a/src/linkerhand_calibration/linkerhand_calibration/extrinsics_node.py b/src/linkerhand_calibration/linkerhand_calibration/extrinsics_node.py index 54b0d75..47d3549 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/extrinsics_node.py +++ b/src/linkerhand_calibration/linkerhand_calibration/extrinsics_node.py @@ -645,6 +645,8 @@ class ThreeCameraExtrinsicsNode(Node): self.maximum_pair_skew_ns = int( float(value("maximum_pair_skew_ms")) * 1_000_000.0 ) + if self.verification_extrinsics_file: + self.maximum_pair_skew_ns = min(self.maximum_pair_skew_ns, 50_000_000) self.maximum_reprojection_rms_px = float( value("maximum_reprojection_rms_px") ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py index b42a750..0f2bd10 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_finalization.py @@ -78,7 +78,9 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur if hashlib.sha256(extrinsics_path.read_bytes()).hexdigest() != protected_inputs['camera_extrinsics_sha256']: raise ValueError('raw_joint_camera_extrinsics_changed') extrinsics = load_camera_extrinsics(extrinsics_path, required_views=profile.vision.view_names, - reference_view=profile.vision.extrinsic_reference_view) + reference_view=profile.vision.extrinsic_reference_view, + quality_limits=profile.vision.extrinsics_quality_limits, + minimum_capture_counts=profile.vision.minimum_capture_counts) checks = [r for r in records if r.get('kind') == 'measured_optical_verification'] if not checks: raise ValueError('raw_joint_measured_optical_verification_missing') @@ -93,6 +95,10 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur maximum_initializations=64, maximum_evaluations_per_initialization=200, candidates=audit)) check() fit = fit_raw_joint_candidates(bundle, cancelled=cancelled, candidate_completed=progress, workers=1) + atomic_write_json(directory/'raw_joint_training_selection.json', fit.training_selection) + atomic_write_json(directory/'raw_joint_training.json', dict(completed=len(fit.training_audit), + maximum_initializations=64, maximum_evaluations_per_initialization=200, + candidates=fit.training_audit)) np.savez_compressed(directory/'raw_joint_candidates.npz', parameters=np.asarray([c.parameters for c in fit.candidates]), covariance=np.asarray([c.covariance for c in fit.candidates]), @@ -100,6 +106,8 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur state.update(fit_complete=True, candidate_uncertainty=fit.uncertainty) save(); phase_changed('fit_complete'); check() if not fit.uncertainty['passed']: + state['required_observations'] = [item for value in fit.uncertainty.values() + if isinstance(value,dict) for item in value.get('required_observations',())] raise ValueError('raw_joint_candidate_union_exceeds_original_precision') validations = [] for candidate in fit.candidates: @@ -115,6 +123,9 @@ def finalize_raw_joint_session(*, profile, session_dir, serial_number, source_ur phase_changed('holdout_complete'); check() report, plan = prepare_raw_report(bundle, fit, evidence, validation, serial_number=serial_number, protected_inputs=protected_inputs) + from ...core.domain.capture_plan import evidence_digest + report['raw_joint_audit'].update(installation_reference_sha256=evidence_digest(reference), + optical_observations_sha256=optical['observations_sha256']) naming = dict(serial_number=serial_number, side=profile.key.side, model=profile.key.model.lower()) json_path = directory/profile.artifacts.calibration_filename.format(**naming) urdf_path = directory/profile.artifacts.corrected_urdf_filename.format(**naming) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py index 7109bea..e258799 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/raw_joint_report.py @@ -72,6 +72,7 @@ def prepare_raw_report(bundle, fit, evidence, validation, *, serial_number, prot raw_joint_audit=dict(protocol='raw_joint_images_v1', capture_sha256=evidence.sha256, training_sha256=training_digest, candidate_set_sha256=candidate_digest, candidate_uncertainty=fit.uncertainty, candidate_count=len(fit.candidates), + training_selection=fit.training_selection, representative=fit.representative, validation_parameters_updated=False), quality=dict(release_basis='steady_command', feedback_mapping_required=False, training_cycles=[0, 1], holdout_cycle=3, command_training_cycles=[0, 1], command_holdout_cycle=3, diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py index 0e6fd25..e6616d1 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py @@ -98,7 +98,8 @@ class CalibrationCoordinator: self._command_directions = [""]*self.command_count self._actual_command_count = 0 self._actual_direction_changes = [0]*self.command_count - self.finalization = finalization or FinalizationController(profile, parameters.session_dir) + self.finalization = finalization or FinalizationController(profile, parameters.session_dir, + isolated=self.raw_joint) from .journal_durability import JournalDurabilityWorker self.journal_durability = JournalDurabilityWorker() from .capture_index import CaptureRecordIndex diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/optical_verification.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/optical_verification.py index b48a16e..20a13ab 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/optical_verification.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/optical_verification.py @@ -77,7 +77,7 @@ def verify_optical_observations(dataset, extrinsics, matrices, *, extrinsics_sha normals[view].append(camera[:3, 2]) coverage = {} for view, count in counts.items(): - if count < 8: + if count < 15: raise ValueError('optical_pose_coverage_insufficient:' + view) identity = extrinsics.cameras[view] span = np.ptp(np.asarray(corners[view]), axis=0)/[identity.width, identity.height] @@ -89,7 +89,7 @@ def verify_optical_observations(dataset, extrinsics, matrices, *, extrinsics_sha limit = DEFAULT_POSE_TRACKING_PARAMETERS.maximum_reprojection_error_px rms = float(np.sqrt(np.mean(np.square(errors)))) p95 = float(np.percentile(errors, 95)) - if max(rms, p95) > limit: + if rms > 1.2 or p95 > limit: raise ValueError('optical_measured_reprojection_failed') return dict(protocol='rectified_optical_check_v1', passed=True, observations_sha256=evidence_digest(dataset), camera_extrinsics_sha256=extrinsics_sha256, diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py index 81593fe..887528e 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py @@ -73,6 +73,7 @@ def discover_resume_candidate( start = None reference_found = False completed_found = False + raw_capture_found = False completed = {} try: summary_path = candidate / "calibration_summary_zh.json" @@ -92,6 +93,8 @@ def discover_resume_candidate( raise ValueError("invalid checkpoint record") reject_diagnostic_capture((row,)) kind = str(row.get("kind", "")) + if training_policy == 'raw_joint_2_plus_1' and kind in {'raw_observation_frame', 'scan_unit_complete'}: + raw_capture_found = True if kind == "session_start": from ..core.artifacts.evidence_journal import REFERENCE_KEY, decode_envelope if REFERENCE_KEY in row: @@ -133,7 +136,7 @@ def discover_resume_candidate( if start is not None and start.get("resume_checkpoint_requested") is False: break continue - if start is None or not reference_found or not completed_found: + if start is None or not reference_found or not (completed_found or raw_capture_found): continue count = sum(passed for _attempt, passed in completed.values()) if count > best_completed: diff --git a/src/linkerhand_calibration/test/fixtures/o30_training_candidate_parameters.npz b/src/linkerhand_calibration/test/fixtures/o30_training_candidate_parameters.npz new file mode 100644 index 0000000000000000000000000000000000000000..505458547c927e7de36169edff5b960b1896e2b8 GIT binary patch literal 7299 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 literal 0 HcmV?d00001 diff --git a/src/linkerhand_calibration/test/optical_fixture.py b/src/linkerhand_calibration/test/optical_fixture.py index 853cb08..8201c2b 100644 --- a/src/linkerhand_calibration/test/optical_fixture.py +++ b/src/linkerhand_calibration/test/optical_fixture.py @@ -8,7 +8,7 @@ def optical_dataset(extrinsics, matrices, digest): for x in np.linspace(-.16, .16, 5)]) captures = [] for i, (rx, ry) in enumerate(((-.4, -.4),(-.4,.4),(.4,-.4),(.4,.4), - (-.3,0.),(.3,0.),(0.,-.3),(0.,.3))): + (-.3,0.),(.3,0.),(0.,-.3),(0.,.3))*2): pose = np.eye(4) pose[:3,:3] = Rotation.from_euler('xyz', [rx, ry, 0.]).as_matrix() pose[:3,3] = [.13*(-1 if i%2 else 1), .12*(-1 if (i//2)%2 else 1), .75] diff --git a/src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py b/src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py new file mode 100644 index 0000000..dcc2d3c --- /dev/null +++ b/src/linkerhand_calibration/test/test_raw_joint_candidate_gates.py @@ -0,0 +1,38 @@ +from dataclasses import replace +from pathlib import Path + +import numpy as np + +from test_urdf_command_images import graph +from linkerhand_calibration.core.fitting.raw_joint_fit import RawJointCandidate, certify_candidate_union +from linkerhand_calibration.core.fitting.raw_joint_selection import reject_training_distinguishable_candidates + + +def test_candidate_union_rejects_physical_difference_without_demanding_unique_mount(graph): + bundle, truth = graph + original = RawJointCandidate((0,)*6, truth, np.eye(len(truth))*1e-14, 0.) + installed = truth.copy() + column = next(iter(bundle.mount_columns.values())) + installed[column:column+3] += [.1,.2,.3] + accepted, _ = certify_candidate_union(bundle,(original,replace(original,parameters=installed))) + assert accepted['passed'] + changed = truth.copy() + changed[bundle.zero_columns['thumb_cmc_yaw']] += np.radians(5) + rejected, _ = certify_candidate_union(bundle,(original,replace(original,parameters=changed))) + assert not rejected['passed'] and 'zero_rad' in rejected['failures'] + + +def test_training_loss_rejection_keeps_all_indistinguishable_seeds(graph): + bundle, _ = graph + path = Path(__file__).parent/'fixtures/o30_training_candidate_parameters.npz' + stored = np.load(path) + candidates = tuple(RawJointCandidate((i,)*6,x,np.eye(len(x))*1e-14,float(rms)) + for i,(x,rms) in enumerate(zip(stored['parameters'],stored['rms']))) + # Exact numerical duplicates are still retained, not ranked for release. + duplicate = replace(candidates[0],seed=(2,)*6) + retained, audit = reject_training_distinguishable_candidates(bundle,(*candidates,duplicate)) + assert [c.seed for c in retained] == [candidates[0].seed,duplicate.seed] + assert audit['excluded_seeds'] == [list(candidates[1].seed)] + assert audit['familywise_alpha'] == .01 + assert any(r['holm_probability'] < .01 for r in audit['comparisons']) + assert not any(row['cycle'] == 3 for row in bundle.rows) diff --git a/src/linkerhand_calibration/test/test_raw_joint_release.py b/src/linkerhand_calibration/test/test_raw_joint_release.py index 07290de..ffdbd1f 100644 --- a/src/linkerhand_calibration/test/test_raw_joint_release.py +++ b/src/linkerhand_calibration/test/test_raw_joint_release.py @@ -69,3 +69,23 @@ def test_report_rebuild_and_final_file_fk(graph, frozen_validation, tmp_path): atomic_write_json(json_path, altered) with pytest.raises(ValueError, match='serialized_report_changed'): validator(json_path, urdf_path) + + +def test_formal_finalizer_rejects_missing_provenance_before_fitting(graph, tmp_path, monkeypatch): + import json + from linkerhand_calibration.runtime.artifacts import raw_joint_finalization as finalization + + bundle, _ = graph + monkeypatch.setattr(finalization, 'fit_raw_joint_candidates', + lambda *args, **kwargs: pytest.fail('unverified capture reached the solver')) + protected = {key: 'a'*64 for key in bundle.profile.artifacts.protected_input_fields} + directory = tmp_path/'incomplete' + with pytest.raises(ValueError, match='requires_unique_session_and_locked_reference'): + finalization.finalize_raw_joint_session(profile=bundle.profile, session_dir=directory, + serial_number='OFFLINE_TRUTH', source_urdf=bundle.model.source, + protected_inputs=protected, records=(), camera_extrinsics_file=tmp_path/'missing.json', + standard_loader=lambda _: pytest.fail('invalid capture reached artifact loading')) + state = json.loads((directory/'raw_joint_result.json').read_text()) + assert not any(state[key] for key in ('raw_capture_complete', 'fit_complete', + 'accuracy_passed', 'publication_allowed', 'automatic_geometry_recapture')) + assert not (tmp_path/bundle.profile.artifacts.publication_pointer).exists() diff --git a/src/linkerhand_calibration/test/test_raw_resume.py b/src/linkerhand_calibration/test/test_raw_resume.py index fbe79fb..c3c8b39 100644 --- a/src/linkerhand_calibration/test/test_raw_resume.py +++ b/src/linkerhand_calibration/test/test_raw_resume.py @@ -64,3 +64,25 @@ def test_reordered_checkpoint_cannot_skip_work(): checkpoint = RawCheckpoint({}, {}, (completion(second),), frozenset((second.identity,))) with pytest.raises(ValueError, match='schedule_order_changed'): checkpoint.restore(session) + + +def test_discovery_preserves_interrupted_first_attempt_without_any_passed_unit(tmp_path): + import json + from linkerhand_calibration.core.domain.capture_plan import CapturePlan + from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION + from linkerhand_calibration.runtime.engine import CalibrationEngine, ACQUISITION_POLICY_VERSION + from linkerhand_calibration.runtime.resume import discover_resume_candidate + selected = profile() + hashes = {key:'a'*64 for key in selected.artifacts.protected_input_fields} + directory = tmp_path/'20260922_first_attempt'; directory.mkdir() + header = dict(kind='session_start', profile_id=selected.key.profile_id, serial_number='RAW_TEST', + acquisition_policy_version=ACQUISITION_POLICY_VERSION, + pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION, + capture_schedule_version=CalibrationEngine(selected).capture_schedule_version, + capture_plan=CapturePlan.from_profile(selected).as_dict(), resume_checkpoint_requested=False, **hashes) + row = {**completion(CalibrationEngine(selected).scan_units()[0]),'kind':'raw_observation_frame'} + records = [header,dict(kind='fixed_base_reference_locked'),row] + (directory/'raw_samples.jsonl').write_text('\n'.join(json.dumps(r) for r in records)+'\n') + found = discover_resume_candidate(tmp_path,profile_id=selected.key.profile_id,serial_number='RAW_TEST', + protected_hashes=hashes,training_policy=RAW_JOINT) + assert found == directory