This commit is contained in:
lxp
2026-09-22 23:17:15 +08:00
parent d6c83dd23d
commit b362e9bb40
16 changed files with 312 additions and 33 deletions
@@ -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`,保留候选集合完全相同,未重复运行优化器或改写原验收文件。
- **尚需实机验收**:当前相机安装的实际光学核验、新协议首次全手采集、实机姿态对照。本次没有重新启动电机,不能将合成通过当作实际机械手的准确性证明。
@@ -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 个),不含必要归位与避让。
+25
View File
@@ -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` 未修改。
未进行本次新协议全手实测或实机姿态对照;不能把以上软件结果当作现场精度证明。
@@ -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)
@@ -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)
@@ -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")
)
@@ -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)
@@ -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,
@@ -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
@@ -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,
@@ -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:
@@ -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]
@@ -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)
@@ -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()
@@ -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