aaa
This commit is contained in:
@@ -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 个),不含必要归位与避让。
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
+12
-1
@@ -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)
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user