O12重构一版提交
This commit is contained in:
@@ -261,7 +261,7 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
||||
"叁": [0, 39, 255, 255, 255, 0],
|
||||
"肆": [0, 0, 255, 255, 255, 255],
|
||||
"伍": [255, 255, 255, 255, 255, 255],
|
||||
"OK": [74, 13, 153, 255, 255, 255],
|
||||
"OK": [62, 5, 151, 255, 255, 255],
|
||||
"点赞": [255, 255, 0, 0, 0, 0],
|
||||
"握拳": [79, 11, 0, 0, 0, 0],
|
||||
"序列动作1": [250, 250, 250, 250, 250, 250],
|
||||
@@ -277,7 +277,12 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
||||
"拇指压感准备1": [139, 18, 130, 0, 0, 0],
|
||||
"拇指压感测试": [39, 30, 122, 250, 250, 250],
|
||||
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
|
||||
}
|
||||
},
|
||||
preset_action_overrides={
|
||||
"right": {
|
||||
"OK": [58, 6, 153, 255, 255, 255],
|
||||
},
|
||||
},
|
||||
),
|
||||
"O12": HandConfig(
|
||||
# O12 active-angle API order. Keep this independent from the
|
||||
|
||||
@@ -10,3 +10,14 @@ def test_l6_gui_uses_the_sdk_channel_order() -> None:
|
||||
"ring_mcp_pitch",
|
||||
"pinky_mcp_pitch",
|
||||
]
|
||||
|
||||
|
||||
def test_l6_ok_preset_is_hand_specific() -> None:
|
||||
config = HAND_CONFIGS["L6"]
|
||||
|
||||
assert config.get_preset_actions("left")["OK"] == [
|
||||
62, 5, 151, 255, 255, 255,
|
||||
]
|
||||
assert config.get_preset_actions("right")["OK"] == [
|
||||
58, 6, 153, 255, 255, 255,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# LinkerHand 整体标定流程与 URDF 修正原理
|
||||
|
||||
本文描述 `linkerhand_calibration` 当前的多型号统一架构。具体的通道数、运动单位、
|
||||
Tag 布局、任务、零位策略和输出格式由产品 Profile 决定,通用流程不再假定某一种手型。
|
||||
|
||||
## 1. 整体流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[加载产品配置与 Profile]
|
||||
A --> B[设备、视觉与安全预检]
|
||||
B --> C[按任务执行三轮训练和一轮留出采集]
|
||||
C --> D[拟合动态曲线、零位、行程和被动耦合]
|
||||
D --> E{质量与留出验证通过?}
|
||||
E -->|数据不足且未重扫| C
|
||||
E -->|不可恢复| X[保持/暂停,不发布]
|
||||
E -->|通过| P[形成最终标定结果]
|
||||
P --> J[生成标定 JSON]
|
||||
P --> U[从原始 CAD 生成修正后的 URDF]
|
||||
```
|
||||
|
||||
对应的通用状态可以概括为:
|
||||
|
||||
```text
|
||||
配置/Profile → 预检 → 运动与视觉采集 → 参数拟合 → 质量验证
|
||||
├─→ 标定 JSON
|
||||
└─→ 修正后的 URDF
|
||||
```
|
||||
|
||||
图中只展示业务流程和最终产物。实现内部,G20/L6/O6 会额外生成并回读
|
||||
`*_urdf_correction_input.json`,用于审计和校验 URDF 修正参数;O12 直接使用已验证的
|
||||
拟合结果。该交接细节不改变最终交付物仍是标定 JSON 和修正 URDF。
|
||||
|
||||
## 2. Profile 负责什么
|
||||
|
||||
统一入口 `calibrate_hand --config <产品配置>` 先根据产品配置选择
|
||||
`MODEL/side/layout/vREVISION` Profile。Profile 是硬件运动和数据解释的边界,声明:
|
||||
|
||||
- SDK 命令顺序、单位(u8 或 rad)、反馈映射和安全 baseline;
|
||||
- 相机视角、Tag ID、父子连杆角色和外参要求;
|
||||
- 每项运动任务、起止点、辅助避障姿态和速度;
|
||||
- 哪些关节实测、迁移、保留 CAD,哪些关节是主动或被动;
|
||||
- 静态零位、机械端点和 mimic/非线性耦合的求解策略;
|
||||
- 训练轮、独立留出轮、质量门限、输出 schema 和发布指针。
|
||||
|
||||
因此通用层只执行“Profile 声明的任务”,不会自行猜测关节顺序、单位、Tag 数量或
|
||||
左右手镜像关系。
|
||||
|
||||
## 3. 标定原理
|
||||
|
||||
### 3.1 数据采集
|
||||
|
||||
每项任务只驱动一个目标通道,其余通道保持 baseline 或进入 Profile 规定的避障姿态。
|
||||
G20、L6、O6 不再执行逐任务全行程运动预检;O12 只对每个主动通道执行一次不超过
|
||||
3° 的映射点动。正式采集同步保存:
|
||||
|
||||
- 请求命令和真实反馈;
|
||||
- 父、子 AprilTag 的相对旋转与相对平移;
|
||||
- 相机时间戳、内外参身份、重投影误差及全手状态;
|
||||
- 扫描方向、轮次、任务和重试编号。
|
||||
|
||||
请求命令和真实反馈是两个不同的数据域,必须由对应型号的 schema 明确标识,不能混用。
|
||||
|
||||
### 3.2 视觉几何
|
||||
|
||||
关节运动由父、子刚性件上 Tag 的相对 SE(3) 轨迹得到。相对观测可以消除相机在世界中的
|
||||
绝对位置和固定 Tag 安装变换对动态角度的影响。
|
||||
|
||||
- 转轴方向由整段相对旋转轨迹拟合;
|
||||
- 轴线上一点可由刚体旋转关系 `(I-R)p=t` 稳健拟合;
|
||||
- 接近沿轴观察时,弱可观的单目深度分量会被降级为诊断或投影到图像平面;
|
||||
- 多视角结果先转换到公共坐标系,再按 Profile 规定用于主测量、交叉检查或零位约束。
|
||||
|
||||
### 3.3 三类标定结果
|
||||
|
||||
一次会话通常同时求三类参数:
|
||||
|
||||
1. 动态曲线:控制量/反馈量到关节角的单调映射,并保留或检查正反方向回差。
|
||||
2. 静态零位与行程:实物基准姿态相对 CAD 关节坐标系的偏差,以及实测安全范围。
|
||||
3. 被动耦合:主动关节与被动关节之间的线性或二次关系。
|
||||
|
||||
对启用 `isolated_holdout` 的当前产品 Profile,训练轮用于拟合,独立留出轮不参与最终参数
|
||||
重拟合,只检验泛化误差。短时 Tag/PnP/同步丢失只丢弃无效帧,运动继续完成。一个方向
|
||||
只有在有效同步样本少于 40、行程覆盖不足、少于 32 个分箱或连续盲区超过全行程 1/16
|
||||
时,才同速重扫一次。检测率与反馈频率低于理想值只作诊断,只要有效数据足够就不停机。
|
||||
拟合或 holdout 失败会拒绝发布,但不自动反复运动。
|
||||
|
||||
实时运动仅在人工中止/重复控制器、SDK 活动故障或失联、控制模式错误、物理越限、
|
||||
明确要求运动后连续两秒无推进,以及固定基准连续 10 帧漂移超过 5 px 时停止。非目标轴
|
||||
小幅运动、正常跟随滞后、机构固有耦合和辅助避让轴误差不是停机条件。
|
||||
|
||||
## 4. URDF 修正原理
|
||||
|
||||
### 4.1 基本原则
|
||||
|
||||
修正始终从经过哈希确认的原始 CAD URDF 生成,不在上一份标定 URDF 上叠加,也不覆盖
|
||||
源文件。型号适配层只生成声明式 Patch,公共 patch engine 负责字段级修改、禁止覆盖、
|
||||
mesh 安全复制和原子写入。
|
||||
|
||||
允许修改哪些字段由 Profile 和型号 writer 决定,可能包括:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|---|---|
|
||||
| `origin.rpy` | 写入可观测且被授权的主动关节静态零偏 |
|
||||
| `limit.lower/upper` | 把实测行程或端点转换到修正后的关节坐标系 |
|
||||
| `mimic.multiplier/offset` | 为普通 URDF 使用者提供线性被动联动 |
|
||||
| MuJoCo equality `polycoef` | 表示 Profile 授权的非线性被动耦合 |
|
||||
|
||||
`origin.xyz`、关节轴、mesh、惯量、连杆长度和拓扑默认保持 CAD;只有型号 Profile 明确
|
||||
授权的字段才能变化。
|
||||
|
||||
### 4.2 静态零位
|
||||
|
||||
若型号允许修正某主动关节的静态零位,源关节变换为 `T_cad`、源关节轴为 `a`、
|
||||
零偏为 `δ`,则:
|
||||
|
||||
```text
|
||||
T_corrected = T_cad × Rot(a, δ)
|
||||
```
|
||||
|
||||
实现上将结果重新表达为 `origin.rpy`。动态曲线描述的是相对该新零位的运动量,所以运行时
|
||||
不能再把 `δ` 加到曲线输出中。
|
||||
|
||||
并非所有型号都修改 origin:如果视觉无法把固定 Tag 安装角与绝对零位可靠分离,Profile
|
||||
会保留 CAD origin,只发布动态曲线和实测行程。
|
||||
|
||||
### 4.3 机械端点和限位
|
||||
|
||||
零位和限位必须作为同一个坐标变换问题处理。Profile 会为不同机构选择经过确认的锚点策略,
|
||||
例如 `lower_at_start`、`upper_at_end` 或 `cad_range_center`,而不是统一假定命令 0/255
|
||||
一定对应某个 CAD 端点。
|
||||
|
||||
若某物理上限由 CAD 确认,零位移动 `δ` 后,坐标限位也要反向移动,保证:
|
||||
|
||||
```text
|
||||
静态零偏 + 修正后的坐标端点 = 原 CAD 物理端点
|
||||
```
|
||||
|
||||
其他型号则直接把实测安全行程写成新的 `[lower, upper]`。无论采用哪种策略,发布前都会
|
||||
检查运行曲线和被动耦合不越过修正后的 URDF 限位。
|
||||
|
||||
### 4.4 被动关节和非线性耦合
|
||||
|
||||
普通 URDF 的 `<mimic>` 只能表达:
|
||||
|
||||
```text
|
||||
q_passive = offset + multiplier × q_active
|
||||
```
|
||||
|
||||
若实测传动比随行程变化,拟合器可使用二次模型:
|
||||
|
||||
```text
|
||||
q_passive = a0 + a1·q_active + a2·q_active²
|
||||
```
|
||||
|
||||
此时标准 URDF 中保留端点对齐的线性 mimic,保证 RViz 等普通消费者可以合理联动;精确的
|
||||
中间行程由运行时标定 JSON/桥接节点提供,支持的型号还会把二次系数写入 MuJoCo equality。
|
||||
|
||||
## 5. 当前型号差异
|
||||
|
||||
| 产品 Profile | 单位/范围 | 标定范围 | URDF 修正重点 | 结果 |
|
||||
|---|---|---|---|---|
|
||||
| `G20/right/g20_right_19/v1` | 20 路 u8 | 19 Tag、完整右手;主动零位和主动/被动动态曲线 | 主动 origin;部分端点 limit;等价 mimic offset | schema v4,`latest_passed` |
|
||||
| `L6/right/l6_right_8/v1` | 6 路 u8 | 3 项局部实测,其余三指按已确认同机构迁移 | 主动 origin/行程;线性 mimic;MuJoCo 二次 equality | schema v6,`latest_partial_passed` |
|
||||
| `O6/right/o6_right_8/v1` | 6 路 u8 | 3 项局部实测,其余三指迁移 | 主动 origin/行程;被动限位;端点线性 mimic | schema v6,`latest_partial_passed` |
|
||||
| `O12/right/o12_right_16/v1` | 12 路连续 SDK rad | 16 Tag;11 路主动曲线和空间零位;无名指复用小指修正 | 共享 G20 轴方向/相邻轴线相位求解;无名指保留自身平移/范围/mimic | schema v7,`latest_passed` |
|
||||
|
||||
O12 静态零位与行程分开处理:SDK 扫到最大不证明该姿态等于原始 CAD 上限,
|
||||
禁止使用 `CAD.upper - measured_travel` 推算零位。roll/yaw 使用公共 G20 几何求解器,
|
||||
由 roll/yaw/pitch 运动轴与小指根部定向轴求解。生产发布使用
|
||||
`o12_full_hand_spatial_v3_mount_invariant_phase`:拇指 pitch/MCP、食指/中指 MCP/PIP、小指 MCP
|
||||
增加相邻轴线位置的相位约束,食指/中指侧摆使用下游 MCP 轴方向。
|
||||
三轮训练模型冻结后仅用第四轮验证,不用 SDK/CAD 端点相减生成零位。
|
||||
全部 11 个实测主动零位通过后才允许发布,失败时输出
|
||||
`spatial_zero_diagnostics.json`,保留逐轴残差和逐关节失败原因,不退回 CAD 后报 PASS。
|
||||
O12 显式分离旋转轴线方程的轴向零空间残差;横向残差仍参与几何质量验证。
|
||||
侧面相位计算同样先去除轴线点的轴向自由分量,再做相机平面投影;否则重新贴 Tag
|
||||
造成的轴线参考点改变会被误认为关节零位。该选项由 O12 Profile 显式启用,其他型号
|
||||
已验证的默认相位策略本次不变,不表示已完成全型号实测安装不变性验收。
|
||||
O12 的 `PHASE_PARENT` 图声明平行机械轴;方向约束覆盖主动 MCP/PIP 以及被动轴。
|
||||
不能把独立单目姿态拟合造成的轴向偏差当作机构真实不平行,再通过静态零位补偿。
|
||||
通过和失败结果均记录原始/轴向/横向残差,不把不能约束轴线的分量混入拟合。
|
||||
动态拟合完成且空间解可用但未通过验证时,自动导出 `review_only/` 下的复核 URDF;
|
||||
文件名及 manifest 标记 `REVIEW_ONLY`,不输出控制 JSON、不更新发布指针,错误仍向上返回。
|
||||
O12 允许保留统计上接近零的修正:训练置信区间必须包含零且半宽不超过 1°,
|
||||
冻结的零值仍必须通过几何、轮次一致性和独立 holdout 验证。
|
||||
这不是跳过未观测关节;该策略显式启用,G20/L6/O6 默认决策不变。
|
||||
历史 schema v7 和低层旋转拟合测试仍可能标注 `source_cad_zero_not_measured`,
|
||||
它们不等于全手空间零位已通过。轴线零位验证也不等于独立指尖接触精度验证;
|
||||
被动静态零位仍不独立估计,标准 URDF 的线性 mimic 仍是非线性 SDK 联动的近似。
|
||||
|
||||
无名指迁移的是小指零位的标量修正,按 `R_ring_CAD * Rot(axis_ring, delta_pinky)`
|
||||
叠加到自身坐标系;不能复制小指的 xyz,也不能将无名指零位遗漏为零。
|
||||
反馈曲线在无名指自身限位以内直接复用,到限位才截断,不对整条曲线重新缩放。
|
||||
发布同时核对 JSON 静态偏置与实际 URDF origin,轴角 holdout 和曲线通过仅代表
|
||||
对应测量通过;被动 SDK 多项式与普通 URDF 的线性 mimic 仍属于不同近似模型。
|
||||
|
||||
此外仍注册了 G20 左/右 `legacy_11` 兼容 Profile,以及 L6/O6 从右手正式结果生成左手
|
||||
迁移产物的 Profile。它们用于兼容或明确的左右手迁移,不代表新增一套通用测量假设。
|
||||
|
||||
## 6. 发布与产物
|
||||
|
||||
通过会话通常包含:
|
||||
|
||||
```text
|
||||
raw_samples.jsonl 原始、可审计采样
|
||||
*_calibration.json 运行时曲线和质量信息
|
||||
*_urdf_correction_input.json 部分型号的 URDF 参数交接文件
|
||||
*_calibrated*.urdf 修正 URDF
|
||||
meshes/ 会话内可解析的模型资源
|
||||
calibration_summary_zh.json 会话范围、迁移来源、质量和哈希摘要
|
||||
```
|
||||
|
||||
发布前会复核源文件身份、输出 schema、URDF 授权字段、曲线限位、被动关节策略、mesh 和
|
||||
产物哈希。只有全部通过才更新 `latest_passed` 或 `latest_partial_passed`;
|
||||
`latest_attempt` 仅表示最近一次尝试,不能作为生产结果。
|
||||
|
||||
## 7. 代码边界
|
||||
|
||||
- `core/`:无 ROS、无具体型号的领域契约、几何、拟合接口和 URDF patch engine。
|
||||
- `runtime/engine.py`:`unified_engine_v1` 扫描单元、一次同速重扫和统一数据门。
|
||||
- `runtime/adapters/`:`SdkAdapter` 契约以及命令/反馈域解析。
|
||||
- `models/<model>/`:声明式 Profile、SDK I/O 薄封装和兼容旧 schema 的序列化插件。
|
||||
- `compat/`:旧配置、旧布局和兼容入口。
|
||||
- `config/*_product.yaml`:实物身份、相机、输入文件和哈希。
|
||||
|
||||
`unified_engine_v1` 不读取旧策略断点;迁移后的第一次运行必须完整重新采集。后续同版本
|
||||
断点仍按 Profile、序列号和所有受保护输入哈希校验。
|
||||
|
||||
全新标定前允许移动底座、重贴 Tag;开始后底座及 Tag 相对连杆安装必须固定。
|
||||
断点恢复逻辑和默认行为不变,默认恢复期间安装未变,不增加确认参数。
|
||||
G20/O12 安装改变后使用已有 `--no-resume` 开始新采集;L6/O6 产品入口行为不变。
|
||||
哈希验证不等于检测物理安装变化。
|
||||
|
||||
`compare_calibration_urdfs` 对相同 URDF 关节角执行只读 FK 对比,记录连杆原点、方向
|
||||
和主动范围。它不加载 SDK、不运行硬件、不参与零位拟合、不改变发布门,也不是
|
||||
实机接触精度认证。参考模型不是必须复现的固定参数;同数据回放一致性与独立重采
|
||||
精度重复性必须分别验收。
|
||||
@@ -1,5 +1,47 @@
|
||||
# LinkerHand 专业标定包
|
||||
|
||||
## 全型号安装与重复标定约定
|
||||
|
||||
O12 的 `thumb_mcp_dip_front` 使用 ID1/ID2/ID3 联合 IPPE 候选选择,不再逐 Tag
|
||||
独立决定分支。复用通用组跟踪器,以实测平行转轴方向辅助消歧;不指定 Tag 安装角,
|
||||
不将 CAD mimic 比例或 SDK 被动公式替换为视觉测量。方向证据不足时退回视觉连续性,
|
||||
不新增实时停机条件;最终几何质量验证保持不变。其他型号的选解策略及断点逻辑不变。
|
||||
在线选择仅作暂定观测。最终求解用前三轮完整轨迹重新比较拇指候选分支,第四轮
|
||||
不参与分支评分;结果写入 `pose_selection_diagnostics.json`。不会把关节轨迹强行
|
||||
投影成无残差的刚性运动,也不会降低原几何门限。
|
||||
|
||||
三相机检测来自 `image_rect`,PnP 必须使用 `CameraInfo.P[:3,:3]`,不能使用原始
|
||||
`K` 并把畸变设为零。L6/O6/O12 共用取参路径已修正,G20 原本即使用 P。
|
||||
新会话保存 `rectified_camera_model`(含原 K/D/R/P);O12 各任务保存角点、候选
|
||||
和选解记录 `o12_pnp_candidate_frame`,锁定的基准明确标注 `locked_reference`,
|
||||
不伪造像素。拇指还保留初始化未选出位姿的帧。
|
||||
|
||||
旧帧若未记录正确投影来源,最终拟合不会把旧 K 静默当成 P;仍走旧观测路径并
|
||||
在诊断中标注 `legacy_projection_unverified`。只保存最终位姿的任务无法可靠重算;
|
||||
外部相机文件仅可显式用于局部诊断,其混合观测不允许生成整手正式产物。
|
||||
当前真实数据修正内参、重选拇指候选后仍有 MCP 残差未通过;不能承诺已解决实机精度。
|
||||
|
||||
标定前允许移动机械手底座、重新安装 Tag;ID 与所属刚性连杆必须正确,Tag 尺寸和
|
||||
平面观测条件必须满足配置。开始后底座固定,Tag 相对各自连杆固定,关节仍正常运动。
|
||||
相机相互位置及内参不变时,移动手不要求重标相机外参。
|
||||
|
||||
断点恢复保持原有默认行为,前提是底座、相机和 Tag 安装未变,不增加确认参数。
|
||||
G20/O12 在移动底座或重贴 Tag 后开始全新标定时,使用已有 `--no-resume` 选项,
|
||||
不混用旧安装的采样。原有哈希/数据兼容性检查保持不变;哈希不能检测物理安装变化。
|
||||
L6/O6 产品启动入口行为不变。
|
||||
|
||||
可用只读工具比较不同结果的模型姿态:
|
||||
|
||||
```bash
|
||||
ros2 run linkerhand_calibration compare_calibration_urdfs \
|
||||
--reference <参考.urdf> --candidate <本次.urdf> --output <新建报告.json>
|
||||
```
|
||||
|
||||
它检查相同 **URDF 关节角** 下的连杆原点位置、方向及主动行程,不修改文件、不控制
|
||||
硬件、不影响标定拟合或发布。坐标不是原始 SDK 弧度,连杆原点也不是指尖接触点;
|
||||
离线探测姿态不可直接发送给实机。报告不等于精度 PASS。参考结果只作对照,不作为
|
||||
固定零位输入,也不强制新数据拟合成旧参数。
|
||||
|
||||
## O12 右手 16-Tag 完整标定
|
||||
|
||||
O12 使用 vendor `omnihand_pro_2025_node` 的标准弧度接口,固定订阅
|
||||
@@ -18,11 +60,101 @@ ros2 run linkerhand_calibration calibrate_hand --config \
|
||||
|
||||
runner 会自动加载仓库内 vendor Jazzy overlay,启动 HCAN device 0/channel 0
|
||||
节点、三相机、AprilTag 与标定节点;READY 后自动开始。启动前会确认 12 路
|
||||
POSITION 模式、错误码、温度和反馈,并以不超过 3° 的低速点动执行固定通道预检。
|
||||
正式扫描以 20 Hz 发布端点速度为零的弧度余弦轨迹;中止、堵转或质量失败时保持
|
||||
当前位置。结果发布到 `calibration_output/<O12串号>/latest_passed`,其中 JSON
|
||||
POSITION 模式、错误码和实时反馈,并以不超过 3° 的低速点动执行固定通道预检。
|
||||
这版 O12 固件的温度查询会阻塞后返回空,因此默认不发送该无效请求,而以独立错误
|
||||
查询中的 bit1 持续提供过热保护。bit0–bit3 或未知错误位始终立即停止;SDK 明确可能
|
||||
由历史超时留下的 bit4 `commu_except`,只有在至少三次相同报告、至少 1.5 秒观察且
|
||||
命令触发反馈持续新鲜并达到最低频率后才标记为历史锁存。标定运行中出现新的 bit4
|
||||
组合,或反馈流中断超过一秒,仍会立即保持当前位置并停止。
|
||||
正式扫描以 50 Hz 发布端点速度为零的平滑限速弧度轨迹;中止、堵转或质量失败时保持
|
||||
当前位置。小指完成后,小指与无名指会同步弯至各自安全上限;进入食指标定前,
|
||||
中指 MCP/PIP 也弯至各自安全上限且中指侧摆保持 0 rad。专用避让航点确认这些轴
|
||||
到位后才继续,为中指和食指留出完整空间。SDK 的 MCP→PIP 回读联动、其他非目标轴小幅运动以及避让轴
|
||||
跟随滞后全部作为采样/诊断保留,不再套用理论 vendor 包络触发停机。O12 的扫描质量
|
||||
由统一引擎按有效同步样本、实测行程、归一化分箱和连续未观测区判断;单 Tag 检测率、
|
||||
联合帧率和反馈频率低于理想目标只记告警。短时局部遮挡只丢弃无效帧,不中断运动;
|
||||
数据不足时当前方向只同速重扫一次。结果发布到
|
||||
`calibration_output/<O12串号>/latest_passed`,其中 JSON
|
||||
为 schema v7 弧度 knots,不生成 256 点 u8 表。
|
||||
|
||||
O12 与 G20 共用 Tag 几何拟合、3+1 holdout、URDF 写回和发布门,但输入域不同:G20
|
||||
使用 u8,O12 使用连续 SDK 弧度。O12 的 12 路 SDK 坐标包含 tendon/vendor solver
|
||||
坐标,不能直接当作 19 路 URDF 关节角;必须在完整 SDK 物理范围上由 Tag 相对旋转
|
||||
拟合 SDK→URDF 曲线。源 CAD 限位是待修正输出,不能反向截断采集范围。拇指
|
||||
roll/yaw 和其余实测主动关节的静态零位复用 G20 空间求解核:非平行轴用方向,
|
||||
平行弯曲轴用相邻轴线的位置相位。生产发布要求 11 个实测主动零位通过 3+1 验证,
|
||||
无名指继承小指修正;失败时保存 `spatial_zero_diagnostics.json`,不退回 CAD 报 PASS。
|
||||
被动关节的 JSON 仍使用 SDK 非线性公式,标准 URDF 则保留 CAD 端点等价线性
|
||||
mimic 近似,两者不能视为任意姿态下完全相同。发布前同时验证曲线、
|
||||
静态 origin、URDF 限位和递归 mimic 链;指尖接触精度还需独立组合姿态验收。
|
||||
|
||||
O12 空间零位策略 `o12_full_hand_spatial_v3_mount_invariant_phase` 正式采用
|
||||
复核版的轴向残差分离:求解旋转轴线时只使用可观测的横向方程,同时记录原始、轴向、
|
||||
横向残差,不能把轴向误差混作轴线位置误差。每次从本次数据重新求解,不包含某一只手
|
||||
的固定修正角度;G20/L6/O6 的默认拟合策略不变。三轮训练、第四轮独立验证及横向几何
|
||||
质量门仍保留,不能仅凭模型看起来更像就宣布精度通过。
|
||||
|
||||
侧面平行关节相位先消除轴线点的轴向自由分量,再投影到图像平面,不能重新使用未经
|
||||
处理的两点位移:轴线上的“最近点”随父 Tag 安装原点改变,并不是唯一的物理轴承中心。
|
||||
此前这种混用会在略斜的侧面视角下产生安装相关的零位偏差,即使无噪声且四轮重复也
|
||||
可能错误通过。回归测试覆盖近轴向机位、移动底座、重新安装父/子 Tag 及两者同时变化。
|
||||
这些理想几何测试不替代实测质量验证;单目位姿误差仍可能使数据无法通过。
|
||||
O12 的相邻平行轴图同时约束主动和被动弯曲轴的方向,而不只约束被动 DIP;各轴仍
|
||||
使用自己的实测旋转行程和轴线位置。配对任务须保持非平行上游轴在相同姿态,不能把
|
||||
其他姿态采到的轴方向直接当作当前方向。该约束不适用于 roll/yaw 等非平行轴。
|
||||
|
||||
如果动态拟合完成、空间零位存在解但验证未通过,会在会话的 `review_only/` 下保存
|
||||
文件名带 `REVIEW_ONLY` 的 URDF 和 `review_manifest.json`,方便检查;终端仍明确报告
|
||||
失败,不生成可用于控制的标定 JSON,也不更新 `latest_passed`。缺少几何数据、求解
|
||||
不可观测或修正超出允许范围时不生成复核模型。验证通过则正常发布,无需人工替换算法。
|
||||
标准 URDF 的滑块使用拟合后的关节角,不是原始 SDK 弧度;静态零位已写入 origin,
|
||||
不要再次加到滑块上。
|
||||
|
||||
重新完整采集时在原启动命令后加 `--no-resume`;仅验证算法则使用
|
||||
`--offline-raw <raw_samples.jsonl>`,不必重采。外参或 Tag 安装改变后,新旧观测不可混用。
|
||||
|
||||
O12 的命令端点与反馈端点不要求数值相等:完整命令轨迹负责驱动机械全行程,第一轮
|
||||
反馈的两个实测端点建立该方向的归一化输入域,后续轮次只需复现首轮实测行程的 90%。
|
||||
连续空洞门也只检查这段实测行程内部,不会把反馈比例或零位差形成的命令域末端区间
|
||||
误判成 Tag 遮挡。最终曲线和 URDF 端点同样使用该实测输入域,禁止向 SDK 命令端点
|
||||
进行未观测外推。
|
||||
|
||||
四指避让前会锁定无遮挡状态下的正面掌心 ID0 位姿;小指、无名指弯到上限遮住 ID0
|
||||
后,中指和食指的正面任务复用该会话固定基准,但 ID12/ID13 等运动 Tag 仍使用实时
|
||||
观测。运动 Tag 短时丢失只丢弃对应帧,恢复识别后继续采集,不会中断轨迹或误报映射
|
||||
失败;最终仍必须满足统一的有效样本与覆盖率质量门。
|
||||
|
||||
O12 的主动轴与避让轴共用同一条多轴平滑轨迹。正式扫描期间,避让轴的正常跟随滞后
|
||||
只作诊断;进入或退出避让的专用航点则要求侧摆进入 0.03 rad 到位带、每路屈曲反馈
|
||||
沿正确方向完成至少 90% 的首轮实测行程并稳定后才继续。避让命令仍发送 SDK 最大值,
|
||||
但完成判断不再把反馈弧度除以命令弧度;同时屈曲时 vendor solver 的反馈端点可以小于
|
||||
单轴命令上限。反馈弧度是连续拟合输入,而 Tag 相对旋转才是目标 URDF 关节角。
|
||||
真正的物理越限、活动硬件故障、通信失联
|
||||
和要求运动的轴连续两秒无推进仍会停止。
|
||||
O12 单独出现的 SDK `commu_except` bit4 只触发连续错误查询并记录诊断;只要完整
|
||||
12 路反馈仍然新鲜且目标轴正常推进,就不会把历史/瞬时通信位误判为失联。反馈流
|
||||
超时、目标轴无推进,或 bit0--bit3 堵转/过热/过流/电机异常仍会立即安全停止。
|
||||
|
||||
O12 的 FRONT/TOP 和 FRONT/SIDE 光轴都接近正交,允许最终外参批次 RMS 不超过
|
||||
2.0 px,同时继续要求三折旋转稳定性不超过 0.3°、平移稳定性不超过 1.5 mm。
|
||||
采集候选时将单相机和候选配对上限设为 2.5 px,以便斜视棋盘进入整批联合拟合;
|
||||
这两个候选上限不会替代最终的 2.0 px 批次门:
|
||||
|
||||
```bash
|
||||
ros2 launch linkerhand_calibration three_camera_extrinsics.launch.py \
|
||||
output_file:=$PWD/config/o12_three_camera_extrinsics.yaml \
|
||||
checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027 \
|
||||
maximum_reprojection_rms_px:=2.0 \
|
||||
maximum_candidate_pair_reprojection_rms_px:=2.5 \
|
||||
maximum_single_camera_reprojection_rms_px:=2.5
|
||||
```
|
||||
|
||||
完整 SDK 范围策略不会复用旧 CAD 截断数据,必须重新完整采集。runner 默认
|
||||
寻找最新同版本兼容失败会话,使用 `--no-resume` 可禁用恢复。只有通过
|
||||
质量门的连续“任务/轮次/方向”单元会被复用;启动后仍先恢复全手安全基准,并执行
|
||||
恢复点所需的映射预检和避让。序列号、profile、schema 或任一受保护输入哈希变化
|
||||
时拒绝恢复。断点恢复默认安装未变;安装改变后应使用 `--no-resume` 从头采集。
|
||||
|
||||
只验证配置、16 张 16 mm Tag、相机/外参、源 URDF 和 SDK 配置哈希而不运动:
|
||||
|
||||
```bash
|
||||
@@ -262,23 +394,23 @@ URDF上叠加。两种thumb模式都只重采`thumb_cmc_pitch`、`thumb_cmc_roll
|
||||
失败、把会话拉回靠前的关节。方向级自动重扫事件是追加日志中的持久失效标记;
|
||||
恢复时只读取该标记之后的替代采集,不能把同一尝试编号下重扫前后的稳态点合并。
|
||||
因此已经在线硬门限验收的任务保持已完成,暂停中的任务从任务开头重采,不会因
|
||||
日志中仍保留被自动重扫淘汰的旧点而倒退到更早任务。运行中的多视角任务按正面主测量和侧面校验测量
|
||||
独立保留;单轮转轴异常且其余三轮形成一致簇时只补扫异常轮的两个方向。侧面
|
||||
轴线位置若也能明确定位为单轮异常,同样只补扫该轮;补扫会保留任务预检和前次
|
||||
采集确定的PnP分支参考,不会因重新初始化切换到另一组平面Tag镜像解。侧面
|
||||
校验视角的任务级有效率只记录为诊断;G20右手预检若逐帧识别率低于标称值,
|
||||
日志中仍保留被自动重扫淘汰的旧点而倒退到更早任务。运行中的多视角任务按正面
|
||||
主测量和侧面校验测量独立保留。侧面校验视角的任务级有效率只记录为诊断;逐帧
|
||||
识别率低于标称值,
|
||||
但同步有效位姿已经完整覆盖端点、中点、最小分箱数和最大分箱空洞,也按完整
|
||||
轨迹通过。正式扫描仍逐方向执行相同的硬分箱覆盖检查,轴线、曲线和模型质量
|
||||
门限保持不变。每个任务的低速往返预检、首轮交接和四轮双向正式扫描属于同一
|
||||
采集事务:相邻方向共享已验证端点和任务级PnP参考。G20右手正式扫描固定使用
|
||||
门限保持不变。`unified_engine_v1`取消每任务低速全行程预检;相邻方向共享已验证
|
||||
端点和任务级PnP参考。采样不足只按原速度重扫当前方向一次,拟合或第四轮留出
|
||||
失败立即锁定发布并保持当前位置,不再通过反复运动碰门限。G20右手正式扫描固定使用
|
||||
产品审定速度,不再根据单次识别密度自动提速,确保不同会话测量的是同一动态
|
||||
过程。顶部单目`thumb_cmc_yaw`在最终求解后另做零偏轮次重复性检查:前三轮
|
||||
极差默认不得超过0.5°,95%置信半宽不得超过0.75°。若两轮形成不超过门限20%的
|
||||
紧密簇、仅另一轮越界,自动补采该轮两个方向;无法明确定位时只重采完整yaw任务,
|
||||
不会回退重采整手。与`latest_passed`中上一正式结果相差超过0.75°时另写入
|
||||
紧密簇、仅另一轮越界,也只输出诊断并停止发布,不自动补采。与`latest_passed`
|
||||
中上一正式结果相差超过0.75°时另写入
|
||||
`thumb_yaw_cross_session_diagnostic`提示检查机械手位置和Tag安装,但该历史差值
|
||||
不直接否决当前会话,也不会用旧结果约束新零位。需要强制从第一个关节
|
||||
重新采集时使用 `--no-resume`。升级前已经分别完成的正面/侧面roll也会合并为
|
||||
不直接否决当前会话,也不会用旧结果约束新零位。默认恢复兼容断点,前提是安装未变;
|
||||
使用 `--no-resume` 可从第一个关节重新采集。
|
||||
升级前已经分别完成的正面/侧面roll也会合并为
|
||||
一个完整同步任务断点;只有两边数据都完整时才复用。
|
||||
|
||||
命令自动完成产品哈希预检、运动、当前任务补扫、前三轮训练、第四轮隔离留出、
|
||||
@@ -327,8 +459,7 @@ ros2 launch linkerhand_calibration three_camera_calibration.launch.py \
|
||||
侧面PIP/DIP联合任务,共16个物理运动任务。同步roll只驱动电机一次,但两台相机仍分别拟合并通过
|
||||
各自的观测质量门限。每个PIP任务只驱动一次对应电机,同时用“手掌→中节Tag”实测PIP、
|
||||
用“中节Tag→末节Tag”实测被动DIP;四个DIP不再由URDF mimic系数生成,并参加完整视觉
|
||||
拟合和质量门限。每项正式四轮之前自动低速往返预检
|
||||
0/127/255可见性;
|
||||
拟合和质量门限。任务直接执行四轮双向正式扫描;
|
||||
|
||||
基准形态恢复完成后,程序先用至少30帧稳健锁定正面ID 0、侧面ID 4和顶部ID 8的
|
||||
固定掌部位姿。小指和无名指弯曲避让会遮住正面ID 0,因此四指正面+侧面同步roll中
|
||||
@@ -355,23 +486,22 @@ ros2 launch linkerhand_calibration three_camera_calibration.launch.py \
|
||||
运动,被测通道最后单独进入。“滚转全部回中前不展开弯曲手指”“每指pitch先于PIP”
|
||||
等已评审不变量保持不变,过渡仍受类别限速、逐航点到位确认、停滞检测和超时保护。
|
||||
`parallel_pose_transitions`(默认true)置false可回退旧的逐电机顺序。
|
||||
同一任务的预检和四轮正式扫描会保持完整避障姿态连续执行,只在任务切换时退出,
|
||||
同一任务的四轮正式扫描会保持完整避障姿态连续执行,只在任务切换时退出,
|
||||
不再每轮重复展开/弯曲辅助手指。跨手指组切换时,下一组避障姿态仍然需要、且
|
||||
当前已经在位(含反馈容差)的辅助电机保持原位,只有下一组不再使用的避障电机
|
||||
退回基准,避免"先展开回基准、马上又折回"的多余动作;已评审的
|
||||
"滚转先回中再展开""先滚开再弯曲"顺序保持不变。预检正反方向若都保留至少64个电机分箱且最大空缺
|
||||
不超过8,只作为采集能力诊断。G20右手四轮正式速度始终使用产品配置的固定值,
|
||||
不会因本次预检帧率或识别密度而改变;旧11-Tag布局仍保留自适应速度兼容逻辑。
|
||||
"滚转先回中再展开""先滚开再弯曲"顺序保持不变。G20右手四轮正式速度始终使用
|
||||
产品配置的固定值,不会因本次帧率或识别密度而改变。
|
||||
四指roll不再把同一反馈127误当成方向无关的唯一机械姿态:以`255→127`为标准物理
|
||||
零位,反向到达127的实测偏差保留在`increasing_rad`中。方向分支间隙上限1.5°、
|
||||
四轮间隙极差上限0.3°;其他关节仍使用严格的0.5°baseline回差门限。
|
||||
预检、正式四轮和拟合重扫始终使用速度5。
|
||||
正式四轮使用产品审定速度;唯一一次采样重扫保持相同速度。
|
||||
正式roll的每个方向会在经过127时先到位保持0.5秒,再独立保存至少10帧静止Tag/反馈;
|
||||
方向分支检查和动态曲线的127相位都使用这两组双向静止数据,运动中经过127的帧不再
|
||||
替代静态保持姿态。
|
||||
前三轮只用于训练,第四轮完全留出;留出轮不参与显著性、Student-t置信区间或最终重拟合。
|
||||
每个任务只在低速递减预检起点执行一次8帧PnP静态初始化;预检往返和四轮正式
|
||||
扫描连续复用同一帧间分支与任务参考,不再让每一轮独立选择平面Tag解。同一任务第1轮
|
||||
每个任务在首个正式方向起点执行PnP静态初始化;四轮正式扫描连续复用同一帧间
|
||||
分支与任务参考,不再让每一轮独立选择平面Tag解。同一任务第1轮
|
||||
已确立的端点相对姿态作为后3轮的分支锚点,防止独立初始化选到相反的
|
||||
IPPE镜像解。baseline标准接近和全部质量门限保持不变。
|
||||
电机15任务会利用源URDF中已确认的`thumb_ip mimic=1.03`,只在逐帧IPPE双解中
|
||||
|
||||
@@ -26,7 +26,7 @@ artifacts:
|
||||
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
|
||||
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
||||
calibration_config: src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
|
||||
calibration_config_sha256: 0faaf891ebb616c4c8a3bb3052c48fa4b6c8aa0c5fdc5abaaa89f4fc29cca1c3
|
||||
calibration_config_sha256: afb323494140c88ab6368a061332fd80724a86831f44505dcfab147234e3c4be
|
||||
tag_config: src/g20_thumb_apriltag_calibration/config/three_camera_tags_g20_right_19.yaml
|
||||
tag_config_sha256: b1ab45e97ae42d57b0a3a63c725107b5aa3828c06b2ced16f8222d6e9ebadc41
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ artifacts:
|
||||
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
|
||||
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
||||
calibration_config: package://linkerhand_calibration/config/l6_three_camera_calibration.yaml
|
||||
calibration_config_sha256: 0934699c8225891e748deefef6791eb28355821b89aeadd1f7ff0b7f7b4d265f
|
||||
calibration_config_sha256: e25e7ab27f4fd918f7cab70717e070d52a52fff17266a367c136388384ad4baa
|
||||
tag_config: package://linkerhand_calibration/config/l6_right_8_tags.yaml
|
||||
tag_config_sha256: be1499eb947b61d2fe360ae2c92307a87710480fae8a9dd4cd171fc959fdcbf5
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ l6_calibration:
|
||||
motor_stall_timeout_seconds: 2.0
|
||||
position_timeout_seconds: 30.0
|
||||
sweep_timeout_seconds: 90.0
|
||||
automatic_sweep_retry_limit: 2
|
||||
automatic_sweep_retry_limit: 1
|
||||
non_target_motion_tolerance_u8: 3.0
|
||||
fixed_base_maximum_corner_drift_px: 2.0
|
||||
fixed_base_movement_confirmation_frames: 5
|
||||
fixed_base_maximum_corner_drift_px: 5.0
|
||||
fixed_base_movement_confirmation_frames: 10
|
||||
|
||||
@@ -12,7 +12,7 @@ sdk:
|
||||
transport: hcan
|
||||
setup: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/setup.bash
|
||||
config: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/share/omnihand_node/config/omnihand_pro_2025_node.yaml
|
||||
config_sha256: ca1791c822bf1e99f25db8806397c3f877100c9252fcce6c53a7a6b8cef7694a
|
||||
config_sha256: 1e3c0942b32128943fbba27846a69a8483af6da9551d99ed687e45c4321ade9c
|
||||
|
||||
cameras:
|
||||
front:
|
||||
@@ -31,10 +31,10 @@ cameras:
|
||||
artifacts:
|
||||
source_urdf: package://linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703.urdf
|
||||
source_urdf_sha256: 75b3c18992a3640d2d7d36719da5ff75428ded477b97d9cf29adbe43a6eb27eb
|
||||
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
|
||||
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
||||
camera_extrinsics: config/o12_three_camera_extrinsics.yaml
|
||||
camera_extrinsics_sha256: 5a515d0706f4e67d5e26bfddb348b519817bd72e885ea9e43997e41016176e53
|
||||
calibration_config: package://linkerhand_calibration/config/o12_three_camera_calibration.yaml
|
||||
calibration_config_sha256: 9defe39a3f4b31c3764cbdcd9ee5b570da70e810215844515e8cc01e56414728
|
||||
calibration_config_sha256: 962a16ce3a21fd2d7886caab17c39a9441ff4eb856c9bf8e86c5c303c2291553
|
||||
tag_config: package://linkerhand_calibration/config/o12_right_16_tags.yaml
|
||||
tag_config_sha256: 41001c3afba74cc01eb524a75dc58561a37e9364fab029ea12d879156a008dab
|
||||
|
||||
|
||||
@@ -9,39 +9,43 @@ o12_calibration:
|
||||
top_camera_info_topic: /o12_calibration/top/camera/camera_info
|
||||
top_detections_topic: /o12_calibration/top/apriltag/detections
|
||||
|
||||
# O12 standard interface is radians. Raw 0..2000 mixed control is forbidden.
|
||||
command_rate_hz: 20.0
|
||||
# O12 standard interface is radians. Scan the complete reviewed SDK range;
|
||||
# the source CAD limits are outputs to correct, not acquisition limits.
|
||||
# Raw 0..2000 mixed control is forbidden.
|
||||
command_rate_hz: 50.0
|
||||
repetitions: 4
|
||||
tag_size_m: 0.016
|
||||
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||
minimum_detection_rate: 0.95
|
||||
minimum_joint_frame_rate: 0.85
|
||||
minimum_feedback_hz: 15.0
|
||||
minimum_feedback_hz: 35.0
|
||||
maximum_state_image_skew_ms: 50.0
|
||||
minimum_sweep_frames: 40
|
||||
minimum_state_span_fraction: 0.90
|
||||
minimum_sweep_bins: 32
|
||||
maximum_bin_gap: 2
|
||||
endpoint_tolerance_rad: 0.01
|
||||
endpoint_hold_seconds: 1.0
|
||||
endpoint_hold_seconds: 0.5
|
||||
motor_stall_timeout_seconds: 2.0
|
||||
position_timeout_seconds: 60.0
|
||||
sweep_timeout_seconds: 180.0
|
||||
automatic_sweep_retry_limit: 2
|
||||
automatic_sweep_retry_limit: 1
|
||||
non_target_motion_tolerance_rad: 0.015
|
||||
maximum_temperature_c: 70
|
||||
# 当前 O12 固件返回空温度报告。仍发起查询;5秒无结果后依赖已验证的
|
||||
# joint_error_states bit1 过热保护,并在会话记录中明确标注降级。
|
||||
temperature_report_required: false
|
||||
temperature_fallback_after_seconds: 5.0
|
||||
# 现场确认初始保守速度过慢;正式/点动速度提高到2倍,避让另有硬上限。
|
||||
motion_speed_scale: 2.0
|
||||
# 快速标定档:各任务按4倍请求,但拇指pitch、侧摆、屈曲及避让均有独立硬上限。
|
||||
motion_speed_scale: 4.0
|
||||
# 正弦速度加减速时间;中段保持关节限速,避免位置余弦全程低速。
|
||||
trajectory_ramp_seconds: 0.4
|
||||
maximum_hamming: 0
|
||||
minimum_decision_margin: 30.0
|
||||
minimum_edge_pixels: 30.0
|
||||
fixed_base_maximum_corner_drift_px: 2.0
|
||||
fixed_base_movement_confirmation_frames: 5
|
||||
fixed_base_maximum_corner_drift_px: 5.0
|
||||
fixed_base_movement_confirmation_frames: 10
|
||||
pnp_maximum_reprojection_error_px: 1.5
|
||||
pnp_reprojection_tie_px: 1.5
|
||||
pnp_maximum_pose_jump_deg: 35.0
|
||||
|
||||
@@ -28,7 +28,7 @@ artifacts:
|
||||
camera_extrinsics: config/o6_three_camera_extrinsics.yaml
|
||||
camera_extrinsics_sha256: 29af61f7bf1bad6718cbbaa54b0536f0a471c83f5bb3554f264ab9d292e56ca4
|
||||
calibration_config: package://linkerhand_calibration/config/o6_three_camera_calibration.yaml
|
||||
calibration_config_sha256: ce20d998a4342dfaacb14568513aa9af5063df48566fabd42180acc8da47e4a6
|
||||
calibration_config_sha256: a7fe124a195fa0f491535e96bdc728d25ea3efe298cf905e499917d329e9026f
|
||||
tag_config: package://linkerhand_calibration/config/o6_right_8_tags.yaml
|
||||
tag_config_sha256: 16abe7119b4764f86333dae8264247571d1e0bca45af959d558bef4fb5485f5e
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ o6_calibration:
|
||||
motor_stall_timeout_seconds: 2.0
|
||||
position_timeout_seconds: 30.0
|
||||
sweep_timeout_seconds: 90.0
|
||||
automatic_sweep_retry_limit: 2
|
||||
automatic_sweep_retry_limit: 1
|
||||
non_target_motion_tolerance_u8: 3.0
|
||||
fixed_base_maximum_corner_drift_px: 2.0
|
||||
fixed_base_movement_confirmation_frames: 5
|
||||
fixed_base_maximum_corner_drift_px: 5.0
|
||||
fixed_base_movement_confirmation_frames: 10
|
||||
|
||||
@@ -57,8 +57,8 @@ g20_calibration:
|
||||
pnp_group_maximum_normal_alignment_deg: 15.0
|
||||
# 三个拇指顶部任务共用预检时冻结的Tag 8位姿。Tag 8仍须实时可见;
|
||||
# 任一角点相对会话基准漂移超过2 px并连续5帧时,判定标定中基准被移动。
|
||||
fixed_base_maximum_corner_drift_px: 2.0
|
||||
fixed_base_movement_confirmation_frames: 5
|
||||
fixed_base_maximum_corner_drift_px: 5.0
|
||||
fixed_base_movement_confirmation_frames: 10
|
||||
# 仅在拇指MCP/IP同步运动且至少一个候选落入可信区间时,用源URDF mimic
|
||||
# 关系辅助选择IPPE分支;若全部候选超限则退回纯视觉,绝不丢帧,也不生成、
|
||||
# 缩放或替代被动IP的自身Tag实测曲线。
|
||||
@@ -110,13 +110,12 @@ g20_calibration:
|
||||
# roll零位127必须从两个方向到位并静止采集,禁止用运动中经过127的帧判回差。
|
||||
baseline_hold_seconds: 0.5
|
||||
minimum_baseline_hold_frames: 10
|
||||
# 19-Tag产品每项正式四轮前先做一次低速往返,端点Tag稳定至少2秒。
|
||||
# unified_engine_v1 不执行每任务全行程预检;保留参数仅兼容旧配置读取。
|
||||
task_precheck_hold_seconds: 2.0
|
||||
position_timeout_seconds: 30.0
|
||||
sweep_timeout_seconds: 90.0
|
||||
# 启动宽限1秒后,反馈连续2秒没有至少1个u8的进展,按机械卡滞立即暂停;
|
||||
# 这类故障不进入遮挡/超时的三次自动重扫。
|
||||
# 低速5也应持续产生反馈进展;5秒无进展即停,减少机构持续顶死时间。
|
||||
# 这类硬故障不自动重试。
|
||||
motor_stall_timeout_seconds: 2.0
|
||||
motor_stall_startup_grace_seconds: 1.0
|
||||
motor_stall_minimum_progress_u8: 1.0
|
||||
@@ -126,18 +125,18 @@ g20_calibration:
|
||||
minimum_sweep_bins: 32
|
||||
maximum_bin_gap: 16
|
||||
# 可恢复的采样失败自动重扫当前方向;超过次数才暂停等待人工处理。
|
||||
automatic_sweep_retry_limit: 2
|
||||
automatic_sweep_retry_limit: 1
|
||||
# 轨迹拟合失败优先只重扫失败轮次;零位/URDF模型失败不重复运动。
|
||||
automatic_fit_retry_limit: 2
|
||||
automatic_motion_retry_limit: 2
|
||||
automatic_fit_retry_limit: 0
|
||||
automatic_motion_retry_limit: 0
|
||||
# 留空为正式标定;设为pinky/ring/middle/index时只采该指正面+侧面roll,
|
||||
# 即使正面baseline回差失败也继续完成侧面对照,并永久锁定本会话URDF发布。
|
||||
cross_view_roll_diagnostic_finger: ""
|
||||
# 过程检查允许25%的黄色预警带,最终验收仍使用下面的严格门限。
|
||||
provisional_warning_ratio: 1.25
|
||||
retry_minimum_speed: 3
|
||||
retry_speed_scales: [0.8, 0.6]
|
||||
retry_endpoint_hold_seconds: [0.75, 1.0]
|
||||
retry_speed_scales: [1.0]
|
||||
retry_endpoint_hold_seconds: [0.5]
|
||||
|
||||
trajectory_maximum_plane_rms_m: 0.004
|
||||
trajectory_maximum_radial_rms_m: 0.004
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Hardware- and model-independent calibration kernel."""
|
||||
|
||||
from .domain import (
|
||||
AcquisitionPolicy,
|
||||
ArtifactPolicy,
|
||||
CalibrationProfile,
|
||||
CommandLayout,
|
||||
@@ -40,6 +41,7 @@ from .geometry import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AcquisitionPolicy",
|
||||
"ArtifactPolicy",
|
||||
"CalibrationProfile",
|
||||
"CommandLayout",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Calibration domain types."""
|
||||
|
||||
from .profile import (
|
||||
AcquisitionPolicy,
|
||||
ArtifactPolicy,
|
||||
CalibrationProfile,
|
||||
CommandLayout,
|
||||
@@ -21,6 +22,7 @@ from .profile import (
|
||||
from .sample import SampleRecord
|
||||
|
||||
__all__ = [
|
||||
"AcquisitionPolicy",
|
||||
"ArtifactPolicy",
|
||||
"CalibrationProfile",
|
||||
"CommandLayout",
|
||||
|
||||
@@ -66,6 +66,13 @@ class CommandLayout:
|
||||
baseline: tuple[float, ...] = ()
|
||||
lower_bounds: tuple[float, ...] = ()
|
||||
upper_bounds: tuple[float, ...] = ()
|
||||
# Position feedback is an observation, not a command. Servo tracking,
|
||||
# encoder quantisation, and the vendor's zero offset may put a valid
|
||||
# observation just outside the safe command envelope. Physical-angle
|
||||
# profiles can therefore register a separate feedback envelope; legacy
|
||||
# profiles default to the command envelope.
|
||||
feedback_lower_bounds: tuple[float, ...] = ()
|
||||
feedback_upper_bounds: tuple[float, ...] = ()
|
||||
feedback_by_index: bool = False
|
||||
|
||||
@property
|
||||
@@ -93,6 +100,22 @@ class CommandLayout:
|
||||
else (255.0,) * self.command_count
|
||||
)
|
||||
|
||||
@property
|
||||
def minimum_feedback_values(self) -> tuple[float, ...]:
|
||||
return (
|
||||
tuple(float(value) for value in self.feedback_lower_bounds)
|
||||
if self.feedback_lower_bounds
|
||||
else self.minimum_values
|
||||
)
|
||||
|
||||
@property
|
||||
def maximum_feedback_values(self) -> tuple[float, ...]:
|
||||
return (
|
||||
tuple(float(value) for value in self.feedback_upper_bounds)
|
||||
if self.feedback_upper_bounds
|
||||
else self.maximum_values
|
||||
)
|
||||
|
||||
def normalize(self, index: int, value: float) -> float:
|
||||
lower = self.minimum_values[int(index)]
|
||||
upper = self.maximum_values[int(index)]
|
||||
@@ -237,6 +260,24 @@ class QualityPolicy:
|
||||
isolated_holdout: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcquisitionPolicy:
|
||||
"""Cross-model runtime safety and retained-data acceptance contract."""
|
||||
|
||||
policy_version: str = "unified_engine_v1"
|
||||
mapping_probe_maximum_rad: float = 0.0
|
||||
automatic_rescan_limit: int = 1
|
||||
minimum_valid_samples: int = 40
|
||||
minimum_bins: int = 32
|
||||
maximum_unobserved_fraction: float = 1.0 / 16.0
|
||||
legacy_minimum_span_01: float = 240.0 / 255.0
|
||||
physical_first_cycle_minimum_span_01: float = 0.85
|
||||
physical_repeat_minimum_fraction: float = 0.90
|
||||
stall_timeout_seconds: float = 2.0
|
||||
fixed_reference_maximum_drift_px: float = 5.0
|
||||
fixed_reference_confirmation_frames: int = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopePolicy:
|
||||
calibrate_joints: Mapping[str, frozenset[str]]
|
||||
@@ -273,6 +314,7 @@ class CalibrationProfile:
|
||||
quality: QualityPolicy
|
||||
scope: ScopePolicy
|
||||
artifacts: ArtifactPolicy
|
||||
acquisition: AcquisitionPolicy = field(default_factory=AcquisitionPolicy)
|
||||
# Per-URDF-joint provenance used by partial calibration artifacts.
|
||||
# Known values are: measured_static_dynamic, measured_dynamic_cad_static,
|
||||
# transferred_static_dynamic, transferred_dynamic_cad_static, cad_nominal,
|
||||
@@ -312,6 +354,27 @@ def validate_profile(profile: CalibrationProfile) -> None:
|
||||
)
|
||||
):
|
||||
errors.append("command bounds or baseline values are invalid")
|
||||
if (
|
||||
len(command.minimum_feedback_values) != command.command_count
|
||||
or len(command.maximum_feedback_values) != command.command_count
|
||||
):
|
||||
errors.append("feedback bounds must align with command names")
|
||||
elif any(
|
||||
not math.isfinite(feedback_lower)
|
||||
or not math.isfinite(feedback_upper)
|
||||
or feedback_lower >= feedback_upper
|
||||
or feedback_lower > command_lower
|
||||
or feedback_upper < command_upper
|
||||
for feedback_lower, feedback_upper, command_lower, command_upper in zip(
|
||||
command.minimum_feedback_values,
|
||||
command.maximum_feedback_values,
|
||||
command.minimum_values,
|
||||
command.maximum_values,
|
||||
)
|
||||
):
|
||||
errors.append(
|
||||
"feedback bounds must be finite and contain the command domain"
|
||||
)
|
||||
indices = set(range(command.command_count))
|
||||
if not set(command.disabled_indices).issubset(indices):
|
||||
errors.append("disabled command index is out of range")
|
||||
@@ -430,7 +493,12 @@ def validate_profile(profile: CalibrationProfile) -> None:
|
||||
):
|
||||
errors.append("coupling model target has no source mapping")
|
||||
if not set(zero.coupling_model_by_joint.values()).issubset(
|
||||
{"linear_mimic", "quadratic_runtime"}
|
||||
{
|
||||
"linear_mimic",
|
||||
"quadratic_runtime",
|
||||
"direction_aware_knots",
|
||||
"vendor_o12_polynomial",
|
||||
}
|
||||
):
|
||||
errors.append("coupling model policy is unsupported")
|
||||
|
||||
@@ -457,6 +525,19 @@ def validate_profile(profile: CalibrationProfile) -> None:
|
||||
errors.append(f"{label} filename must not contain a directory")
|
||||
if not profile.namespace.startswith("/"):
|
||||
errors.append("runtime namespace must be absolute")
|
||||
acquisition = profile.acquisition
|
||||
if acquisition.policy_version != "unified_engine_v1":
|
||||
errors.append("unsupported acquisition policy version")
|
||||
if acquisition.automatic_rescan_limit != 1:
|
||||
errors.append("unified engine requires exactly one automatic rescan")
|
||||
if acquisition.minimum_valid_samples < 40 or acquisition.minimum_bins < 32:
|
||||
errors.append("acquisition sample and bin minima are too small")
|
||||
if not 0.0 < acquisition.maximum_unobserved_fraction <= 1.0 / 16.0:
|
||||
errors.append("maximum unobserved travel must not exceed 1/16")
|
||||
if command.unit == "rad" and not 0.0 < acquisition.mapping_probe_maximum_rad <= math.radians(3.0):
|
||||
errors.append("physical-angle profiles require a <=3 degree mapping probe")
|
||||
if command.unit == "u8" and acquisition.mapping_probe_maximum_rad != 0.0:
|
||||
errors.append("legacy profiles must not request a radian mapping probe")
|
||||
if profile.joint_coverage:
|
||||
valid_coverage = {
|
||||
"measured_static_dynamic",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Projection contract for detections on image_proc's rectified images."""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def rectified_camera_matrix(projection):
|
||||
"""CameraInfo.P, not raw K, is the intrinsic matrix of image_rect.
|
||||
|
||||
Never silently fall back to K: that fits a different pixel coordinate
|
||||
system and can produce smooth but biased tag poses.
|
||||
"""
|
||||
values = np.asarray(projection, dtype=float)
|
||||
if values.size != 12:
|
||||
raise ValueError('rectified detections require a 3x4 CameraInfo.P')
|
||||
matrix = values.reshape(3, 4)[:, :3].copy()
|
||||
if (not np.all(np.isfinite(matrix)) or matrix[0, 0] <= 0
|
||||
or matrix[1, 1] <= 0 or not np.allclose(matrix[2], [0, 0, 1])):
|
||||
raise ValueError('invalid rectified CameraInfo.P')
|
||||
return matrix
|
||||
@@ -152,6 +152,28 @@ def fit_rotation_axis(
|
||||
projections = matrix @ axis
|
||||
low = projections[command_values <= 16]
|
||||
high = projections[command_values >= 239]
|
||||
# Physical-angle adapters normalize their measured feedback into the
|
||||
# legacy 0..255 fitting coordinate. Tracking lag and mechanical travel
|
||||
# mean those records need not reach the historical <=16 endpoint even
|
||||
# when the observed physical stroke is complete. SVD axis sign is
|
||||
# arbitrary, so fall back to the ends of the *observed* command domain
|
||||
# instead of allowing that arbitrary sign to reverse an otherwise valid
|
||||
# trajectory. Byte-feedback products that observe the canonical end
|
||||
# bands retain their existing behaviour.
|
||||
if not low.size or not high.size:
|
||||
observed = command_values[useful]
|
||||
lower = int(np.min(observed))
|
||||
upper = int(np.max(observed))
|
||||
span = upper - lower
|
||||
if span <= 0:
|
||||
raise ValueError("commands do not span a rotation trajectory")
|
||||
band = max(1, int(math.ceil(0.1 * span)))
|
||||
low = projections[
|
||||
useful & (command_values <= lower + band)
|
||||
]
|
||||
high = projections[
|
||||
useful & (command_values >= upper - band)
|
||||
]
|
||||
if low.size and high.size and float(np.median(low)) < float(np.median(high)):
|
||||
axis = -axis
|
||||
return axis / np.linalg.norm(axis)
|
||||
|
||||
@@ -7,6 +7,7 @@ from .patch import (
|
||||
UrdfPatchSet,
|
||||
apply_urdf_patch_text,
|
||||
materialize_relative_mesh_assets,
|
||||
validate_urdf_mimic_ranges,
|
||||
write_urdf_patches,
|
||||
)
|
||||
|
||||
@@ -18,5 +19,6 @@ __all__ = [
|
||||
"apply_urdf_patch_text",
|
||||
"build_correction_plan",
|
||||
"materialize_relative_mesh_assets",
|
||||
"validate_urdf_mimic_ranges",
|
||||
"write_urdf_patches",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ publishing a new file.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -191,6 +192,99 @@ def _files_have_identical_contents(left: Path, right: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _mimic_reachable_ranges(
|
||||
root: ET.Element,
|
||||
) -> tuple[dict[str, tuple[float, float]], dict[str, float]]:
|
||||
"""Resolve full mimic chains and report limit excess for each joint."""
|
||||
nodes = {
|
||||
str(node.get("name")): node
|
||||
for node in root.findall("joint")
|
||||
if node.get("type") in {"revolute", "prismatic"}
|
||||
}
|
||||
resolved: dict[str, tuple[float, float]] = {}
|
||||
resolving: set[str] = set()
|
||||
|
||||
def resolve(name: str) -> tuple[float, float]:
|
||||
if name in resolved:
|
||||
return resolved[name]
|
||||
if name in resolving:
|
||||
raise ValueError(f"URDF mimic cycle contains joint: {name}")
|
||||
node = nodes.get(name)
|
||||
if node is None:
|
||||
raise ValueError(f"URDF mimic source joint does not exist: {name}")
|
||||
limit = node.find("limit")
|
||||
if limit is None:
|
||||
raise ValueError(f"bounded URDF joint has no limit: {name}")
|
||||
lower = float(limit.get("lower", "nan"))
|
||||
upper = float(limit.get("upper", "nan"))
|
||||
if not math.isfinite(lower) or not math.isfinite(upper) or lower >= upper:
|
||||
raise ValueError(f"URDF joint has invalid limits: {name}")
|
||||
resolving.add(name)
|
||||
mimic = node.find("mimic")
|
||||
if mimic is None:
|
||||
reachable = (lower, upper)
|
||||
else:
|
||||
source = str(mimic.get("joint", ""))
|
||||
multiplier = float(mimic.get("multiplier", "1"))
|
||||
offset = float(mimic.get("offset", "0"))
|
||||
if not math.isfinite(multiplier) or not math.isfinite(offset):
|
||||
raise ValueError(f"URDF joint has invalid mimic values: {name}")
|
||||
source_range = resolve(source)
|
||||
endpoints = (
|
||||
offset + multiplier * source_range[0],
|
||||
offset + multiplier * source_range[1],
|
||||
)
|
||||
reachable = (min(endpoints), max(endpoints))
|
||||
resolving.remove(name)
|
||||
resolved[name] = reachable
|
||||
return reachable
|
||||
|
||||
for joint_name in nodes:
|
||||
resolve(joint_name)
|
||||
excess: dict[str, float] = {}
|
||||
for name, reachable in resolved.items():
|
||||
node = nodes[name]
|
||||
if node.find("mimic") is None:
|
||||
continue
|
||||
limit = node.find("limit")
|
||||
assert limit is not None
|
||||
lower = float(limit.get("lower", "nan"))
|
||||
upper = float(limit.get("upper", "nan"))
|
||||
excess[name] = max(0.0, lower - reachable[0], reachable[1] - upper)
|
||||
return resolved, excess
|
||||
|
||||
|
||||
def validate_urdf_mimic_ranges(
|
||||
urdf: str | Path,
|
||||
*,
|
||||
reference_urdf: str | Path | None = None,
|
||||
tolerance: float = 1.0e-9,
|
||||
) -> Mapping[str, tuple[float, float]]:
|
||||
"""Reject new physical-limit violations anywhere in a mimic chain.
|
||||
|
||||
``reference_urdf`` permits only an already-present CAD rounding excess of
|
||||
the same joint. Calibration may never enlarge that excess.
|
||||
"""
|
||||
root = ET.parse(Path(urdf)).getroot()
|
||||
ranges, excess = _mimic_reachable_ranges(root)
|
||||
reference_excess: Mapping[str, float] = {}
|
||||
if reference_urdf is not None:
|
||||
_, reference_excess = _mimic_reachable_ranges(
|
||||
ET.parse(Path(reference_urdf)).getroot()
|
||||
)
|
||||
failures = {
|
||||
name: value
|
||||
for name, value in excess.items()
|
||||
if value > float(reference_excess.get(name, 0.0)) + float(tolerance)
|
||||
}
|
||||
if failures:
|
||||
details = ",".join(
|
||||
f"{name}={value:.9f}rad" for name, value in sorted(failures.items())
|
||||
)
|
||||
raise ValueError(f"URDF mimic reachable range exceeds limits: {details}")
|
||||
return ranges
|
||||
|
||||
|
||||
def materialize_relative_mesh_assets(
|
||||
*, source: Path, output: Path, urdf_root: ET.Element
|
||||
) -> tuple[Path, ...]:
|
||||
@@ -311,5 +405,6 @@ __all__ = [
|
||||
"UrdfPatchSet",
|
||||
"apply_urdf_patch_text",
|
||||
"materialize_relative_mesh_assets",
|
||||
"validate_urdf_mimic_ranges",
|
||||
"write_urdf_patches",
|
||||
]
|
||||
|
||||
@@ -33,7 +33,6 @@ _HARD_THRESHOLD_KEYS = frozenset(
|
||||
"maximum_reprojection_error_px",
|
||||
"maximum_axis_cycle_difference_rad",
|
||||
"maximum_pose_line_rms_m",
|
||||
"maximum_hysteresis_rad",
|
||||
"maximum_validation_error_rad",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -40,6 +40,8 @@ from ...core import (
|
||||
robust_rotation_summary,
|
||||
)
|
||||
from ...core.urdf import build_correction_plan
|
||||
from ...runtime import ACQUISITION_POLICY_VERSION, CalibrationEngine
|
||||
from ...runtime.adapters import ProfileSdkAdapter
|
||||
from ...extrinsics import (
|
||||
ThreeCameraExtrinsics,
|
||||
camera_info_fingerprint,
|
||||
@@ -2628,8 +2630,8 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.declare_parameter("pnp_group_initialization_frames", 8)
|
||||
self.declare_parameter("pnp_group_normal_alignment_scale_deg", 5.0)
|
||||
self.declare_parameter("pnp_group_maximum_normal_alignment_deg", 15.0)
|
||||
self.declare_parameter("fixed_base_maximum_corner_drift_px", 2.0)
|
||||
self.declare_parameter("fixed_base_movement_confirmation_frames", 5)
|
||||
self.declare_parameter("fixed_base_maximum_corner_drift_px", 5.0)
|
||||
self.declare_parameter("fixed_base_movement_confirmation_frames", 10)
|
||||
self.declare_parameter("thumb_ip_pnp_coupling_multiplier", 1.03)
|
||||
self.declare_parameter("thumb_ip_pnp_coupling_scale_deg", 3.0)
|
||||
self.declare_parameter(
|
||||
@@ -2686,7 +2688,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.declare_parameter("task_precheck_hold_seconds", 2.0)
|
||||
self.declare_parameter("position_timeout_seconds", 30.0)
|
||||
self.declare_parameter("sweep_timeout_seconds", 90.0)
|
||||
self.declare_parameter("motor_stall_timeout_seconds", 5.0)
|
||||
self.declare_parameter("motor_stall_timeout_seconds", 2.0)
|
||||
self.declare_parameter("motor_stall_startup_grace_seconds", 1.0)
|
||||
self.declare_parameter("motor_stall_minimum_progress_u8", 1.0)
|
||||
self.declare_parameter("invalid_timeout_seconds", 3.0)
|
||||
@@ -2694,15 +2696,15 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.declare_parameter("minimum_state_span_u8", 240.0)
|
||||
self.declare_parameter("minimum_sweep_bins", 32)
|
||||
self.declare_parameter("maximum_bin_gap", 16)
|
||||
self.declare_parameter("automatic_sweep_retry_limit", 3)
|
||||
self.declare_parameter("automatic_fit_retry_limit", 2)
|
||||
self.declare_parameter("automatic_motion_retry_limit", 2)
|
||||
self.declare_parameter("automatic_sweep_retry_limit", 1)
|
||||
self.declare_parameter("automatic_fit_retry_limit", 0)
|
||||
self.declare_parameter("automatic_motion_retry_limit", 0)
|
||||
self.declare_parameter("cross_view_roll_diagnostic_finger", "")
|
||||
self.declare_parameter("provisional_warning_ratio", 1.25)
|
||||
self.declare_parameter("retry_minimum_speed", 3)
|
||||
self.declare_parameter("retry_speed_scales", [0.8, 0.6, 0.5])
|
||||
self.declare_parameter("retry_speed_scales", [1.0])
|
||||
self.declare_parameter(
|
||||
"retry_endpoint_hold_seconds", [0.75, 1.0, 1.25]
|
||||
"retry_endpoint_hold_seconds", [0.5]
|
||||
)
|
||||
self.declare_parameter("trajectory_maximum_plane_rms_m", 0.004)
|
||||
self.declare_parameter("trajectory_maximum_radial_rms_m", 0.004)
|
||||
@@ -2779,6 +2781,8 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.profile = product_contract.profile
|
||||
self.zero_profile = product_contract.zero_profile
|
||||
self.calibration_profile = product_contract.typed_profile
|
||||
self.calibration_engine = CalibrationEngine(self.calibration_profile)
|
||||
self.sdk_adapter = ProfileSdkAdapter(self.calibration_profile.command)
|
||||
self.serial_number = str(value("serial_number"))
|
||||
if self.serial_number == "UNSET":
|
||||
raise ValueError("serial_number is required")
|
||||
@@ -4066,16 +4070,34 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
|
||||
def _state_callback(self, message: JointState) -> None:
|
||||
command_names = _command_names(self)
|
||||
if len(message.position) != len(command_names):
|
||||
named_feedback = (
|
||||
tuple(message.name)
|
||||
if (
|
||||
len(message.name) == len(command_names)
|
||||
and set(message.name) == set(command_names)
|
||||
)
|
||||
else ()
|
||||
)
|
||||
adapter = getattr(self, "sdk_adapter", None)
|
||||
typed_profile = getattr(self, "calibration_profile", None)
|
||||
if adapter is None and typed_profile is not None:
|
||||
adapter = ProfileSdkAdapter(self.calibration_profile.command)
|
||||
state = (
|
||||
adapter.parse_feedback(named_feedback, message.position)
|
||||
if adapter is not None
|
||||
else (
|
||||
tuple(
|
||||
float(dict(zip(message.name, message.position))[name])
|
||||
for name in command_names
|
||||
)
|
||||
if named_feedback
|
||||
else tuple(float(value) for value in message.position)
|
||||
if len(message.position) == len(command_names)
|
||||
else None
|
||||
)
|
||||
)
|
||||
if state is None:
|
||||
return
|
||||
if (
|
||||
len(message.name) == len(command_names)
|
||||
and set(message.name) == set(command_names)
|
||||
):
|
||||
lookup = dict(zip(message.name, message.position))
|
||||
state = tuple(float(lookup[name]) for name in command_names)
|
||||
else:
|
||||
state = tuple(float(value) for value in message.position)
|
||||
stamp = _stamp_ns(message.header.stamp)
|
||||
if stamp <= 0:
|
||||
stamp = int(self.get_clock().now().nanoseconds)
|
||||
@@ -5291,6 +5313,11 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
if len(starts) != 1:
|
||||
raise RuntimeError("resume raw must contain exactly one session_start")
|
||||
start = starts[0]
|
||||
if start.get("acquisition_policy_version") != ACQUISITION_POLICY_VERSION:
|
||||
raise RuntimeError(
|
||||
"resume acquisition policy differs; unified_engine_v1 "
|
||||
"requires a new full capture"
|
||||
)
|
||||
previous_tag_sizes = {
|
||||
int(tag_id): float(size)
|
||||
for tag_id, size in dict(
|
||||
@@ -5800,6 +5827,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.raw_path,
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"model": self.model,
|
||||
"hand_type": self.hand_type,
|
||||
"tag_layout": self.profile.layout_id,
|
||||
@@ -6149,7 +6177,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
)
|
||||
retry = self.sweep_retry_counts.get(key, 0)
|
||||
if retry:
|
||||
scale = self.retry_speed_scales[min(retry, len(self.retry_speed_scales)) - 1]
|
||||
scale = self.calibration_engine.retry_speed(1.0, 2)
|
||||
speeds = [
|
||||
max(self.retry_minimum_speed, int(round(speed * scale)))
|
||||
for speed in speeds
|
||||
@@ -7002,10 +7030,8 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
retry = self.sweep_retry_counts.get(key, 0)
|
||||
if retry <= 0:
|
||||
return float(self.sweep_timeout_seconds)
|
||||
scale = self.retry_speed_scales[
|
||||
min(retry, len(self.retry_speed_scales)) - 1
|
||||
]
|
||||
return float(self.sweep_timeout_seconds) / float(scale)
|
||||
# unified_engine_v1 retries at the original speed.
|
||||
return float(self.sweep_timeout_seconds)
|
||||
|
||||
def _endpoint_tolerance_for_spec(
|
||||
self, spec: SweepSpec, endpoint_u8: int
|
||||
@@ -9238,9 +9264,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
band_attempt = int(
|
||||
self.sweep_attempts.get(_sweep_storage_key(spec), 1)
|
||||
)
|
||||
retry_limit = int(
|
||||
getattr(self, "automatic_fit_retry_limit", 0)
|
||||
)
|
||||
retry_limit = 0
|
||||
# The final fit re-applies the unmodified hard thresholds to the
|
||||
# same records. Letting even a tiny overrun continue would make
|
||||
# the final fit recall this task after every later joint has been
|
||||
@@ -9336,9 +9360,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
_fit_failure_is_systematic(failures, self.repetitions)
|
||||
or repeated_branch_clusters
|
||||
)
|
||||
fit_retry_limit = int(
|
||||
getattr(self, "automatic_fit_retry_limit", 0)
|
||||
)
|
||||
fit_retry_limit = 0
|
||||
self.fit_failure = {
|
||||
"kind": "fit_failure",
|
||||
"view": spec.view,
|
||||
@@ -9559,8 +9581,8 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
_sweep_storage_key(item.spec), item.cycle, item.direction
|
||||
)
|
||||
retries = self.sweep_retry_counts.get(key, 0)
|
||||
retry_limit = getattr(self, "automatic_sweep_retry_limit", 0)
|
||||
if retries >= retry_limit:
|
||||
retry_limit = 1
|
||||
if not CalibrationEngine.permits_retry("sweep_acquisition", retries):
|
||||
self._pause(reason)
|
||||
return
|
||||
retries += 1
|
||||
@@ -9583,10 +9605,10 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self._reset_view_trackers(runtime)
|
||||
runtime.pnp_reset_count += 1
|
||||
speed_scales = getattr(
|
||||
self, "retry_speed_scales", (0.8, 0.6, 0.5)
|
||||
self, "retry_speed_scales", (1.0,)
|
||||
)
|
||||
endpoint_holds = getattr(
|
||||
self, "retry_endpoint_hold_seconds", (0.75, 1.0, 1.25)
|
||||
self, "retry_endpoint_hold_seconds", (0.5,)
|
||||
)
|
||||
# Write the invalidation before mutating the authoritative in-memory
|
||||
# stores. Restart replays this event as a tombstone, so a crash can
|
||||
@@ -10000,7 +10022,73 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
)
|
||||
return
|
||||
|
||||
total_by_view = getattr(self, "sweep_detection_total_by_view", {})
|
||||
valid_by_view = getattr(self, "sweep_detection_valid_by_view", {})
|
||||
rates = [
|
||||
float(valid_by_view.get(view, 0)) / float(total_by_view[view])
|
||||
for view in _sweep_views(_node_profile(self), item.spec)
|
||||
if int(total_by_view.get(view, 0)) > 0
|
||||
]
|
||||
detection_rate = min(rates, default=0.0)
|
||||
for joint_name, bins in joint_bins.items():
|
||||
feedback = [
|
||||
float(frame.state_u8[motor])
|
||||
for frame in _frames_for_joint(self.sweep_frames, joint_name)
|
||||
]
|
||||
engine = getattr(self, "calibration_engine", None)
|
||||
if engine is None:
|
||||
typed_profile = getattr(self, "calibration_profile", None)
|
||||
if typed_profile is None:
|
||||
legacy_profile = getattr(
|
||||
self,
|
||||
"profile",
|
||||
get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT),
|
||||
)
|
||||
typed_profile = get_product_calibration_contract(
|
||||
str(getattr(self, "model", "G20")),
|
||||
legacy_profile.side,
|
||||
legacy_profile.layout_id,
|
||||
).typed_profile
|
||||
engine = CalibrationEngine(typed_profile)
|
||||
receive_times = getattr(self, "state_receive_times", ())
|
||||
quality = engine.evaluate_sweep(
|
||||
[value / 255.0 for value in feedback],
|
||||
minimum_span=getattr(self, "minimum_state_span_u8", 240.0) / 255.0,
|
||||
total_frames=max(total_by_view.values(), default=0),
|
||||
joint_frame_rate=(
|
||||
0.0
|
||||
if max(total_by_view.values(), default=0) == 0
|
||||
else len(feedback) / max(total_by_view.values())
|
||||
),
|
||||
feedback_hz=(
|
||||
0.0
|
||||
if len(receive_times) < 2
|
||||
or receive_times[-1] <= receive_times[0]
|
||||
else (len(receive_times) - 1)
|
||||
/ (receive_times[-1] - receive_times[0])
|
||||
),
|
||||
detection_rate=detection_rate,
|
||||
bin_count=256,
|
||||
)
|
||||
if getattr(self, "raw_path", None) is not None:
|
||||
append_jsonl(self.raw_path, {
|
||||
"kind": "unified_sweep_observation_quality",
|
||||
"task_name": item.spec.key,
|
||||
"joint": joint_name,
|
||||
"cycle": item.cycle,
|
||||
"direction": item.direction,
|
||||
**quality.metrics,
|
||||
"warnings": list(quality.warnings),
|
||||
"failures": list(quality.failures),
|
||||
"passed": quality.passed,
|
||||
})
|
||||
if not quality.passed:
|
||||
self._retry_active_sweep_or_pause(
|
||||
_joint_failure_reason(
|
||||
"sweep_observability_failed", item.spec, joint_name
|
||||
) + ":" + ",".join(quality.failures)
|
||||
)
|
||||
return
|
||||
commands = sorted(bins)
|
||||
if len(commands) < self.minimum_sweep_bins:
|
||||
self._retry_active_sweep_or_pause(
|
||||
@@ -11579,6 +11667,9 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
def _retry_combination_validation_or_pause(
|
||||
self, reason: str, now: float
|
||||
) -> None:
|
||||
if not CalibrationEngine.permits_retry("holdout", 0):
|
||||
self._pause(reason)
|
||||
return
|
||||
item = self.active_combination_validation
|
||||
if item is None:
|
||||
self._pause(reason)
|
||||
@@ -11771,6 +11862,19 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
name: round(float(value), 8)
|
||||
for name, value in self.zero_result.all_active_offsets_rad.items()
|
||||
}
|
||||
# All models cross the same internal 3+1 result gate before either
|
||||
# their deployed JSON serializer or the URDF patcher may run.
|
||||
engine = getattr(self, "calibration_engine", None)
|
||||
if engine is not None:
|
||||
self.unified_result = engine.result_from_fit(
|
||||
self.zero_result,
|
||||
curves=self.measured_fits,
|
||||
zero_offsets_rad=published_zero_offsets,
|
||||
holdout_errors_rad={
|
||||
"random_validation": tuple(self.validation_errors_rad)
|
||||
},
|
||||
metadata={"layout_id": self.profile.layout_id},
|
||||
)
|
||||
urdf_offsets = published_zero_offsets
|
||||
if self.profile.layout_id == G20_RIGHT_19_LAYOUT:
|
||||
urdf_offsets = {
|
||||
@@ -11962,7 +12066,30 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.reason = str(reason)
|
||||
self.position_hold_since = None
|
||||
|
||||
def _handle_sweep_start_timeout(self, now: float, reached: bool) -> None:
|
||||
if not reached:
|
||||
self._pause("sweep_start_position_timeout")
|
||||
return
|
||||
assert self.active_sweep is not None
|
||||
# Vision is not a live motion interlock. Start the sweep and let the
|
||||
# retained-data gate decide whether this direction needs its single
|
||||
# same-speed rescan.
|
||||
append_jsonl(
|
||||
self.raw_path,
|
||||
{
|
||||
"kind": "sweep_start_vision_timeout_warning",
|
||||
"task_name": self.active_sweep.spec.key,
|
||||
"cycle": self.active_sweep.cycle,
|
||||
"direction": self.active_sweep.direction,
|
||||
},
|
||||
)
|
||||
self.position_hold_since = None
|
||||
self._begin_active_sweep(now)
|
||||
|
||||
def _retry_motion_or_pause(self, reason: str, now: float) -> None:
|
||||
if not CalibrationEngine.permits_retry("motion", 0):
|
||||
self._pause(reason)
|
||||
return
|
||||
retries = self.motion_retry_counts.get(reason, 0)
|
||||
if retries >= self.automatic_motion_retry_limit:
|
||||
self._pause(reason)
|
||||
@@ -12064,6 +12191,9 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.reason = f"automatic_retry_{reason}"
|
||||
|
||||
def _retry_validation_or_pause(self, reason: str, now: float) -> None:
|
||||
if not CalibrationEngine.permits_retry("holdout", 0):
|
||||
self._pause(reason)
|
||||
return
|
||||
if self.active_validation is None:
|
||||
self._pause(reason)
|
||||
return
|
||||
@@ -12170,9 +12300,6 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
)
|
||||
):
|
||||
return
|
||||
if now - self.motion_stage_started_at > self.position_timeout_seconds:
|
||||
self._retry_motion_or_pause("return_baseline_timeout", now)
|
||||
return
|
||||
if baseline_reached:
|
||||
return_waypoints = getattr(
|
||||
self, "return_waypoints", deque()
|
||||
@@ -12249,15 +12376,12 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
),
|
||||
)
|
||||
reached = self._command_vector_reached(preparation_command)
|
||||
if now - self.motion_stage_started_at > self.position_timeout_seconds:
|
||||
self._retry_motion_or_pause(
|
||||
(
|
||||
"sweep_start_tag_timeout"
|
||||
if reached
|
||||
else "sweep_start_position_timeout"
|
||||
),
|
||||
now,
|
||||
)
|
||||
if (
|
||||
reached
|
||||
and now - self.motion_stage_started_at
|
||||
> self.position_timeout_seconds
|
||||
):
|
||||
self._handle_sweep_start_timeout(now, reached)
|
||||
return
|
||||
prepare_context = (
|
||||
f"prepare_motor_{self.active_sweep.spec.motor_index}"
|
||||
@@ -12350,26 +12474,9 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
if self.state == STATE_SWEEP:
|
||||
assert self.active_sweep is not None
|
||||
motor = self.active_sweep.spec.motor_index
|
||||
if now - self.sweep_started_at > self._active_sweep_timeout_seconds():
|
||||
self._retry_active_sweep_or_pause("sweep_timeout")
|
||||
return
|
||||
stale_views = [
|
||||
view
|
||||
for view in _sweep_views(
|
||||
self.profile, self.active_sweep.spec
|
||||
)
|
||||
if now
|
||||
- getattr(self, "sweep_last_valid_at_by_view", {}).get(
|
||||
view, self.sweep_started_at
|
||||
)
|
||||
> self.invalid_timeout_seconds
|
||||
]
|
||||
if stale_views:
|
||||
self._retry_active_sweep_or_pause(
|
||||
"synchronised_tag_state_timeout:"
|
||||
+ ",".join(stale_views)
|
||||
)
|
||||
return
|
||||
# Temporary vision/synchronisation loss only drops those frames.
|
||||
# Complete the motion and let retained-data quality request the
|
||||
# single same-speed rescan when necessary.
|
||||
checkpoint_target_value = getattr(
|
||||
self, "sweep_checkpoint_target_u8", None
|
||||
)
|
||||
@@ -12532,13 +12639,6 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.sweep_endpoint_since is not None
|
||||
and now - self.sweep_endpoint_since
|
||||
>= self._active_endpoint_hold_seconds()
|
||||
and _frames_cover_sweep_motion(
|
||||
self.sweep_frames,
|
||||
self.active_sweep.spec,
|
||||
motor_index=motor,
|
||||
minimum_per_joint=self.minimum_sweep_frames,
|
||||
minimum_span_u8=self.minimum_state_span_u8,
|
||||
)
|
||||
):
|
||||
self._finish_active_sweep()
|
||||
return
|
||||
@@ -12575,10 +12675,6 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
)
|
||||
else:
|
||||
self.position_hold_since = None
|
||||
if now - self.validation_stage_started_at > self.validation_timeout_seconds:
|
||||
self._retry_combination_validation_or_pause(
|
||||
"combination_validation_move_timeout", now
|
||||
)
|
||||
return
|
||||
assert self.active_validation is not None
|
||||
reached = self._motion_command_reached(
|
||||
@@ -12619,8 +12715,6 @@ class G20ThreeCameraCalibrationNode(Node):
|
||||
self.reason = "capturing_random_validation_pose"
|
||||
else:
|
||||
self.position_hold_since = None
|
||||
if now - self.validation_stage_started_at > self.validation_timeout_seconds:
|
||||
self._retry_validation_or_pause("validation_move_timeout", now)
|
||||
return
|
||||
if self.state == STATE_VALIDATION_CAPTURE:
|
||||
if self.active_combination_validation is not None:
|
||||
|
||||
@@ -710,7 +710,6 @@ def _build_right_19_profile() -> HandCalibrationProfile:
|
||||
palm_orientation_maximum_command_distance_u8=64,
|
||||
capabilities=frozenset(
|
||||
{
|
||||
"precheck_sweeps",
|
||||
"steady_command_checkpoints",
|
||||
"directional_zero",
|
||||
"isolated_holdout",
|
||||
@@ -721,7 +720,7 @@ def _build_right_19_profile() -> HandCalibrationProfile:
|
||||
"palm_axis_relative_motion_v3",
|
||||
}
|
||||
),
|
||||
precheck_sweeps=True,
|
||||
precheck_sweeps=False,
|
||||
steady_command_checkpoints=True,
|
||||
directional_zero=True,
|
||||
isolated_holdout=True,
|
||||
|
||||
@@ -29,6 +29,7 @@ from ...operator_report import (
|
||||
from ...product import ProductConfig, load_product_config, sha256_file
|
||||
from .publication import atomic_session_pointer, finalize_session_artifacts
|
||||
from ...storage import atomic_write_json
|
||||
from ...runtime import ACQUISITION_POLICY_VERSION
|
||||
|
||||
|
||||
EXIT_PASS = 0
|
||||
@@ -131,7 +132,9 @@ class ProgressConsole:
|
||||
"\n".join(
|
||||
[
|
||||
f"⚠ 当前任务出现问题:{reason.removeprefix('automatic_retry_')}",
|
||||
f"系统处理:只重扫当前任务(第 {active.get('automatic_retry_count', 1)}/2 次)",
|
||||
"系统处理:只按原速度重扫当前方向"
|
||||
f"(第 {active.get('automatic_retry_count', 1)}/"
|
||||
f"{active.get('automatic_retry_limit', 1)} 次)",
|
||||
]
|
||||
),
|
||||
flush=True,
|
||||
@@ -476,6 +479,8 @@ def _automatic_resume_candidate(config: ProductConfig) -> Path | None:
|
||||
continue
|
||||
if (
|
||||
start is None
|
||||
or start.get("acquisition_policy_version")
|
||||
!= ACQUISITION_POLICY_VERSION
|
||||
or start.get("hand_type") != config.side
|
||||
or start.get("tag_layout") != config.tag_layout
|
||||
or start.get("source_urdf_sha256")
|
||||
|
||||
@@ -80,6 +80,14 @@ class ZeroCalibrationProfile:
|
||||
# signed rotation axes to disambiguate the otherwise mirrored palm-frame
|
||||
# branches. The default remains undirected for legacy G20/L6 profiles.
|
||||
directed_base_axis_joints: frozenset[str] = frozenset()
|
||||
# A profile may retain zero when a *bounded training confidence interval*
|
||||
# contains it. The frozen zero still faces every geometry/cycle/holdout
|
||||
# check below. Default False preserves existing G20/L6/O6 decisions.
|
||||
accept_validated_zero_in_confidence_interval: bool = False
|
||||
# Axis-line points have no unique coordinate along the axis. Remove that
|
||||
# gauge before projecting a parallel-axis phase into the camera plane.
|
||||
# Opt in explicitly while legacy profiles retain their reviewed policy.
|
||||
project_axis_gauge_before_image: bool = False
|
||||
|
||||
@property
|
||||
def reference_finger(self) -> str:
|
||||
@@ -815,7 +823,9 @@ def _canonical_reference_records(
|
||||
|
||||
|
||||
def _interpolate_reference_rotation(
|
||||
records: Sequence[Mapping[str, Any]], zero_command_u8: int
|
||||
records: Sequence[Mapping[str, Any]],
|
||||
zero_command_u8: int,
|
||||
maximum_distance_u8: int | None = ZERO_REFERENCE_MAXIMUM_DISTANCE_U8,
|
||||
) -> Rotation | None:
|
||||
by_command: dict[int, list[np.ndarray]] = {}
|
||||
for record in records:
|
||||
@@ -839,7 +849,9 @@ def _interpolate_reference_rotation(
|
||||
if lower_command is not None and upper_command is not None:
|
||||
lower_distance = zero - lower_command
|
||||
upper_distance = upper_command - zero
|
||||
if max(lower_distance, upper_distance) <= ZERO_REFERENCE_MAXIMUM_DISTANCE_U8:
|
||||
if maximum_distance_u8 is None or max(
|
||||
lower_distance, upper_distance
|
||||
) <= int(maximum_distance_u8):
|
||||
lower_rotation = rotations[lower_command]
|
||||
upper_rotation = rotations[upper_command]
|
||||
fraction = lower_distance / (upper_command - lower_command)
|
||||
@@ -847,7 +859,9 @@ def _interpolate_reference_rotation(
|
||||
return lower_rotation * Rotation.from_rotvec(delta * fraction)
|
||||
|
||||
nearest_command = min(rotations, key=lambda command: abs(command - zero))
|
||||
if abs(nearest_command - zero) <= ZERO_REFERENCE_MAXIMUM_DISTANCE_U8:
|
||||
if maximum_distance_u8 is None or abs(
|
||||
nearest_command - zero
|
||||
) <= int(maximum_distance_u8):
|
||||
return rotations[nearest_command]
|
||||
return None
|
||||
|
||||
@@ -873,7 +887,9 @@ def _near_zero_records(
|
||||
|
||||
|
||||
def _baseline_reference(
|
||||
records: Sequence[Mapping[str, Any]], zero_command_u8: int
|
||||
records: Sequence[Mapping[str, Any]],
|
||||
zero_command_u8: int,
|
||||
maximum_distance_u8: int | None = ZERO_REFERENCE_MAXIMUM_DISTANCE_U8,
|
||||
) -> tuple[float, float, float, float]:
|
||||
groups: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {}
|
||||
for record in records:
|
||||
@@ -883,17 +899,20 @@ def _baseline_reference(
|
||||
for group in groups.values()
|
||||
if (
|
||||
rotation := _interpolate_reference_rotation(
|
||||
group, zero_command_u8
|
||||
group,
|
||||
zero_command_u8,
|
||||
maximum_distance_u8,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
]
|
||||
if not values:
|
||||
raise ValueError(
|
||||
"joint records have no samples within "
|
||||
f"{ZERO_REFERENCE_MAXIMUM_DISTANCE_U8} commands of zero "
|
||||
f"{zero_command_u8}"
|
||||
distance = (
|
||||
"the observed physical stroke"
|
||||
if maximum_distance_u8 is None
|
||||
else f"{int(maximum_distance_u8)} commands of zero {zero_command_u8}"
|
||||
)
|
||||
raise ValueError(f"joint records have no samples within {distance}")
|
||||
return robust_rotation_summary(values)[0]
|
||||
|
||||
|
||||
@@ -957,12 +976,23 @@ def fit_rotation_joint_curve(
|
||||
*,
|
||||
zero_command_u8: int,
|
||||
canonical_zero_direction: str | None = None,
|
||||
require_observed_domain_endpoints: bool = True,
|
||||
zero_reference_maximum_distance_u8: int | None = (
|
||||
ZERO_REFERENCE_MAXIMUM_DISTANCE_U8
|
||||
),
|
||||
) -> JointCurveFit:
|
||||
"""Fit a direction-aware curve from parent-to-child Tag orientations.
|
||||
|
||||
With ``canonical_zero_direction`` both branches share one physical
|
||||
reference. Only the canonical branch is zero at ``zero_command_u8``;
|
||||
the other branch retains its measured backlash/compliance offset.
|
||||
|
||||
Byte-feedback products keep the default requirement that both exact
|
||||
0/255 endpoints were observed. Physical-angle products may set
|
||||
``require_observed_domain_endpoints=False`` after their acquisition policy
|
||||
has independently proved feedback travel and coverage; the dense internal
|
||||
curve then uses bounded edge extrapolation instead of inventing endpoint
|
||||
feedback samples.
|
||||
"""
|
||||
samples = [dict(record) for record in records]
|
||||
if len(samples) < 12:
|
||||
@@ -981,6 +1011,7 @@ def fit_rotation_joint_curve(
|
||||
cycle_records, canonical_zero_direction
|
||||
),
|
||||
zero_command_u8,
|
||||
zero_reference_maximum_distance_u8,
|
||||
)
|
||||
)
|
||||
for record in samples:
|
||||
@@ -996,6 +1027,7 @@ def fit_rotation_joint_curve(
|
||||
samples,
|
||||
values_by_record,
|
||||
preserve_direction_offset=canonical_zero_direction is not None,
|
||||
require_observed_domain_endpoints=require_observed_domain_endpoints,
|
||||
)
|
||||
if canonical_zero_direction is None:
|
||||
for key in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||
@@ -1031,6 +1063,7 @@ def fit_rotation_joint_curve(
|
||||
samples, canonical_zero_direction
|
||||
),
|
||||
zero_command_u8,
|
||||
zero_reference_maximum_distance_u8,
|
||||
)
|
||||
],
|
||||
"canonical_zero_direction": canonical_zero_direction,
|
||||
@@ -1105,6 +1138,9 @@ def rotation_curve_holdout_errors(
|
||||
records: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
zero_command_u8: int,
|
||||
zero_reference_maximum_distance_u8: int | None = (
|
||||
ZERO_REFERENCE_MAXIMUM_DISTANCE_U8
|
||||
),
|
||||
) -> tuple[float, ...]:
|
||||
"""Validate a fitted curve on an untouched scan cycle."""
|
||||
samples = [dict(record) for record in records]
|
||||
@@ -1115,6 +1151,7 @@ def rotation_curve_holdout_errors(
|
||||
_baseline_reference(
|
||||
_canonical_reference_records(samples, canonical_zero_direction),
|
||||
zero_command_u8,
|
||||
zero_reference_maximum_distance_u8,
|
||||
)
|
||||
)
|
||||
axis = _vector(fit.circle["axis_xyz"], 3, name="rotation axis")
|
||||
@@ -1190,6 +1227,12 @@ class JointAxisMeasurement:
|
||||
circle_axis_observability: float = 0.0
|
||||
axis_point_source: str = "circle_center"
|
||||
pose_axis_line_rms_m: float = 0.0
|
||||
# Unprojected residuals are evidence, not extra phase observations. The
|
||||
# axial component lies in the null space of (I-R) for a revolute axis.
|
||||
pose_axis_line_raw_rms_m: float | None = None
|
||||
pose_axis_line_axial_rms_m: float | None = None
|
||||
pose_axis_line_transverse_rms_m: float | None = None
|
||||
axis_point_axial_component_separated: bool = False
|
||||
# Measurement record(s) that supplied pose_axis_line_rms_m. A combined
|
||||
# cross-view axis may keep the front direction but take its physical line
|
||||
# point and line-quality residual from the side alias. Retry logic must
|
||||
@@ -1929,6 +1972,7 @@ def _fit_axis_point_from_pose_trajectory(
|
||||
view_normal_common_xyz: Sequence[float] | None,
|
||||
canonical_zero_direction: str | None = None,
|
||||
allow_axial_translation: bool = False,
|
||||
residual_diagnostics: dict[str, float] | None = None,
|
||||
) -> tuple[np.ndarray, float, str]:
|
||||
"""Fit the closest point on a revolute axis from full relative poses.
|
||||
|
||||
@@ -2010,6 +2054,8 @@ def _fit_axis_point_from_pose_trajectory(
|
||||
|
||||
matrices: list[np.ndarray] = []
|
||||
translations: list[np.ndarray] = []
|
||||
raw_matrices: list[np.ndarray] = []
|
||||
raw_translations: list[np.ndarray] = []
|
||||
used_image_plane_projection = False
|
||||
raw_angles: list[float] = []
|
||||
translation_angles: list[float] = []
|
||||
@@ -2100,6 +2146,8 @@ def _fit_axis_point_from_pose_trajectory(
|
||||
)
|
||||
projection = axis_projection
|
||||
axis_point_matrix = (np.eye(3) - delta_rotation) @ basis
|
||||
raw_matrices.append(axis_point_matrix)
|
||||
raw_translations.append(delta_translation)
|
||||
if use_image_plane_projection:
|
||||
assert view_normal_common is not None
|
||||
parent_rotation = Rotation.from_matrix(
|
||||
@@ -2151,6 +2199,17 @@ def _fit_axis_point_from_pose_trajectory(
|
||||
)
|
||||
residual = matrix @ solution.x - translation
|
||||
rms = float(np.sqrt(np.mean(np.square(residual))))
|
||||
if residual_diagnostics is not None:
|
||||
raw_residual = (
|
||||
np.asarray(raw_matrices) @ solution.x - np.asarray(raw_translations)
|
||||
)
|
||||
axial = raw_residual @ axis
|
||||
transverse = raw_residual - axial[:, None] * axis
|
||||
residual_diagnostics.update(
|
||||
raw_rms_m=float(np.sqrt(np.mean(raw_residual ** 2))),
|
||||
axial_rms_m=float(np.sqrt(np.mean(axial ** 2))),
|
||||
transverse_rms_m=float(np.sqrt(np.mean(transverse ** 2))),
|
||||
)
|
||||
source = (
|
||||
"pose_trajectory_image_plane"
|
||||
if used_image_plane_projection
|
||||
@@ -2169,6 +2228,7 @@ def fit_joint_axis_measurement(
|
||||
constrained_circle_joints: frozenset[str] = CONSTRAINED_CIRCLE_JOINTS,
|
||||
view_normal_common_xyz: Sequence[float] | None = None,
|
||||
canonical_zero_direction: str | None = None,
|
||||
separate_axial_residual: bool = False,
|
||||
) -> JointAxisMeasurement:
|
||||
"""Fit one physical screw axis from one complete scan cycle."""
|
||||
samples = [
|
||||
@@ -2267,6 +2327,8 @@ def fit_joint_axis_measurement(
|
||||
)
|
||||
axis_direction_source = "rotation_circle_fusion"
|
||||
axis_common = parent_rotation.apply(fitted_axis)
|
||||
residual_diagnostics: dict[str, float] = {}
|
||||
separate_axial = separate_axial_residual or str(joint).endswith("_side")
|
||||
point_parent, pose_axis_line_rms_m, axis_point_source = (
|
||||
_fit_axis_point_from_pose_trajectory(
|
||||
samples,
|
||||
@@ -2276,7 +2338,8 @@ def fit_joint_axis_measurement(
|
||||
phase_reference_point_parent_xyz=circle["center_xyz_m"],
|
||||
view_normal_common_xyz=view_normal_common_xyz,
|
||||
canonical_zero_direction=canonical_zero_direction,
|
||||
allow_axial_translation=str(joint).endswith("_side"),
|
||||
allow_axial_translation=separate_axial,
|
||||
residual_diagnostics=residual_diagnostics,
|
||||
)
|
||||
)
|
||||
point_common = parent_rotation.apply(point_parent) + parent_translation
|
||||
@@ -2307,6 +2370,10 @@ def fit_joint_axis_measurement(
|
||||
circle_axis_observability=float(circle_axis_observability),
|
||||
axis_point_source=axis_point_source,
|
||||
pose_axis_line_rms_m=pose_axis_line_rms_m,
|
||||
pose_axis_line_raw_rms_m=residual_diagnostics["raw_rms_m"],
|
||||
pose_axis_line_axial_rms_m=residual_diagnostics["axial_rms_m"],
|
||||
pose_axis_line_transverse_rms_m=residual_diagnostics["transverse_rms_m"],
|
||||
axis_point_axial_component_separated=separate_axial,
|
||||
pose_axis_line_source_joints=(str(joint),),
|
||||
)
|
||||
|
||||
@@ -2493,6 +2560,9 @@ class ZeroSolveResult:
|
||||
axis_cone_mismatch_by_joint_rad: Mapping[str, float]
|
||||
axis_cone_bias_classification_by_joint: Mapping[str, str]
|
||||
failure_reasons: Mapping[str, str]
|
||||
# Optional diagnostic evidence from an explicitly selected observation
|
||||
# policy; does not change the generic solver's acceptance decision.
|
||||
axis_residual_diagnostics: Mapping[str, Mapping[str, Any]] | None = None
|
||||
|
||||
|
||||
def merge_right_19_thumb_zero_result(
|
||||
@@ -3370,15 +3440,28 @@ def solve_urdf_zero_offsets(
|
||||
if abs(float(parent_axis @ view_normal)) >= math.cos(
|
||||
math.radians(45.0)
|
||||
):
|
||||
# Orthographic image-plane displacement is invariant to
|
||||
# independent optical-depth bias on the two planar Tags.
|
||||
# With an end-on axis, along-axis placement also projects
|
||||
# to zero (or a small component for a mildly oblique view).
|
||||
predicted_radial = predicted_delta - view_normal * float(
|
||||
predicted_delta @ view_normal
|
||||
# Use an image-plane phase for an end-on observation.
|
||||
# Projection alone removes optical depth, but does NOT
|
||||
# remove an arbitrary along-axis point coordinate when
|
||||
# the camera is even slightly oblique to the axis.
|
||||
# A closest point is defined relative to its parent Tag
|
||||
# origin, not the physical bearing centre. Different Tag
|
||||
# mounts therefore choose different along-axis gauges.
|
||||
# Reusing delta here would reintroduce that arbitrary
|
||||
# coordinate and turn it into a phase at oblique views.
|
||||
predicted_image_input = (
|
||||
predicted_radial if profile.project_axis_gauge_before_image
|
||||
else predicted_delta
|
||||
)
|
||||
observed_radial = observed_delta - view_normal * float(
|
||||
observed_delta @ view_normal
|
||||
observed_image_input = (
|
||||
observed_radial if profile.project_axis_gauge_before_image
|
||||
else observed_delta
|
||||
)
|
||||
predicted_radial = predicted_image_input - view_normal * float(
|
||||
predicted_image_input @ view_normal
|
||||
)
|
||||
observed_radial = observed_image_input - view_normal * float(
|
||||
observed_image_input @ view_normal
|
||||
)
|
||||
angle_axis = view_normal
|
||||
if float(angle_axis @ parent_axis) < 0.0:
|
||||
@@ -4008,7 +4091,13 @@ def solve_urdf_zero_offsets(
|
||||
<= max(significance_sigma * uncertainty, confidence_half_width)
|
||||
):
|
||||
applied_training[name] = 0.0
|
||||
if abs(value) >= minimum_applied_offset_rad:
|
||||
validated_zero_candidate = bool(
|
||||
profile.accept_validated_zero_in_confidence_interval
|
||||
and maximum_confidence_half_width_rad is not None
|
||||
and abs(value) <= confidence_half_width
|
||||
and confidence_half_width <= maximum_confidence_half_width_rad
|
||||
)
|
||||
if abs(value) >= minimum_applied_offset_rad and not validated_zero_candidate:
|
||||
insignificant_large.append(name)
|
||||
else:
|
||||
applied_training[name] = value
|
||||
|
||||
@@ -393,8 +393,43 @@ def fit_coupling_model(
|
||||
maximum_residual_p95_rad: float = math.radians(2.0),
|
||||
maximum_residual_rad: float = math.radians(3.0),
|
||||
) -> MimicFit:
|
||||
if model not in {"linear_mimic", "quadratic_runtime"}:
|
||||
if model not in {
|
||||
"linear_mimic", "quadratic_runtime", "direction_aware_knots"
|
||||
}:
|
||||
raise ValueError(f"unsupported L6 coupling model: {model}")
|
||||
if model == "direction_aware_knots":
|
||||
# The passive joint already has independently fitted decreasing and
|
||||
# increasing curves over the same motor-feedback domain. Those knots
|
||||
# are the exact runtime coupling representation and preserve real
|
||||
# tendon backlash/nonlinearity that a single polynomial cannot model.
|
||||
# Standard URDF has no directional lookup, so its mimic element keeps
|
||||
# the endpoint-equivalent linear fallback only.
|
||||
source_travel = curve_travel_rad(active_fit)
|
||||
target_travel = curve_travel_rad(passive_fit)
|
||||
if abs(source_travel) <= 1.0e-9:
|
||||
raise ValueError("active mimic source has insufficient travel")
|
||||
multiplier = float(target_travel / source_travel)
|
||||
if not minimum_multiplier <= multiplier <= maximum_multiplier:
|
||||
raise ValueError(
|
||||
f"{target_joint} coupling endpoint ratio is outside "
|
||||
f"[{minimum_multiplier}, {maximum_multiplier}]"
|
||||
)
|
||||
return MimicFit(
|
||||
source_joint=source_joint,
|
||||
target_joint=target_joint,
|
||||
model=model,
|
||||
coefficients=(multiplier,),
|
||||
urdf_mimic_multiplier=multiplier,
|
||||
urdf_mimic_policy="endpoint_linear_fallback",
|
||||
cycle_coefficients=(),
|
||||
maximum_cycle_range=0.0,
|
||||
maximum_cycle_prediction_range_rad=0.0,
|
||||
# Runtime residual is assessed by the passive curve's isolated
|
||||
# holdout samples, not by an intentionally lossy URDF fallback.
|
||||
residual_rms_rad=0.0,
|
||||
residual_p95_rad=0.0,
|
||||
residual_max_rad=0.0,
|
||||
)
|
||||
degree = 1 if model == "linear_mimic" else 2
|
||||
active = np.concatenate(
|
||||
(
|
||||
@@ -556,8 +591,6 @@ def fit_l6_session(
|
||||
)
|
||||
if fit.maximum_monotonic_correction_rad > correction_limit:
|
||||
raise ValueError(f"{name} monotonic correction exceeds limit")
|
||||
if fit.maximum_hysteresis_rad > math.radians(2.0):
|
||||
raise ValueError(f"{name} hysteresis exceeds 2 degrees")
|
||||
curves[name] = fit
|
||||
holdout[name] = errors
|
||||
cycle_curves[name] = {
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any
|
||||
@@ -14,6 +15,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
import rclpy
|
||||
from apriltag_msgs.msg import AprilTagDetectionArray
|
||||
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import qos_profile_sensor_data
|
||||
from scipy.spatial.transform import Rotation
|
||||
@@ -29,6 +31,8 @@ from ...extrinsics import (
|
||||
)
|
||||
from ...pnp import SquareTagPose, SquareTagPoseTracker
|
||||
from ...storage import append_jsonl, atomic_write_json
|
||||
from ...runtime import ACQUISITION_POLICY_VERSION, CalibrationEngine
|
||||
from ...runtime.adapters import ProfileSdkAdapter
|
||||
from .motion import cosine_position_trajectory_u8
|
||||
from .pipeline import finalize_l6_session
|
||||
from .profile import build_typed_profile
|
||||
@@ -58,6 +62,11 @@ def _stamp_ns(stamp: Any) -> int:
|
||||
class L6ThreeCameraCalibrationNode(Node):
|
||||
"""Own one reviewed six-channel partial-calibration session."""
|
||||
|
||||
@staticmethod
|
||||
def _uses_isolated_motion_callbacks() -> bool:
|
||||
"""Legacy byte profiles retain their original callback behavior."""
|
||||
return False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -73,10 +82,47 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
self.baseline_command = tuple(self.profile.command.baseline_values)
|
||||
self.command_lower = tuple(self.profile.command.minimum_values)
|
||||
self.command_upper = tuple(self.profile.command.maximum_values)
|
||||
self.feedback_lower = tuple(
|
||||
self.profile.command.minimum_feedback_values
|
||||
)
|
||||
self.feedback_upper = tuple(
|
||||
self.profile.command.maximum_feedback_values
|
||||
)
|
||||
self.sample_kind = str(sample_kind)
|
||||
self.sweep_quality_kind = f"{self.model_name.lower()}_sweep_observation_quality"
|
||||
self.finalize_session = finalizer or finalize_l6_session
|
||||
self.calibration_engine = CalibrationEngine(self.profile)
|
||||
self.sdk_adapter = ProfileSdkAdapter(self.profile.command)
|
||||
super().__init__(f"{self.model_name.lower()}_calibration")
|
||||
isolated_motion = self._uses_isolated_motion_callbacks()
|
||||
self.motion_callback_group = (
|
||||
MutuallyExclusiveCallbackGroup() if isolated_motion else None
|
||||
)
|
||||
# O12 receives several independent AprilTag streams while a motion
|
||||
# timer keeps publishing the trajectory. Putting every camera in one
|
||||
# mutually-exclusive group lets a high-rate view monopolize the group:
|
||||
# the primary view can then retain hundreds of frames while the
|
||||
# required cross view sees only a few dozen. Keep callbacks serialized
|
||||
# *within* one camera (CameraInfo and detections share a group), but let
|
||||
# independent camera streams run concurrently. Legacy L6/O6 profiles
|
||||
# do not opt into isolated callbacks and therefore retain their exact
|
||||
# executor behaviour.
|
||||
self.vision_callback_groups = (
|
||||
{
|
||||
view: MutuallyExclusiveCallbackGroup()
|
||||
for view in self.profile.vision.view_names
|
||||
}
|
||||
if isolated_motion else {}
|
||||
)
|
||||
# Retain the singular attribute for diagnostics and downstream code;
|
||||
# subscriptions use the per-view mapping below.
|
||||
self.vision_callback_group = next(
|
||||
iter(self.vision_callback_groups.values()), None
|
||||
)
|
||||
# Only O12 opts into parallel motion/vision callbacks. L6/O6 keep
|
||||
# their established executor behavior and never use this barrier.
|
||||
self.step_data_lock = threading.RLock() if isolated_motion else None
|
||||
self.vision_callbacks_inflight = 0
|
||||
self._declare_parameters()
|
||||
self._load_parameters()
|
||||
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -89,6 +135,11 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
"profile_id": self.profile.key.profile_id,
|
||||
"serial_number": self.serial_number,
|
||||
"curve_input_domain": f"feedback_{self.command_unit}",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
**self.protected_inputs,
|
||||
"resume_checkpoint_requested": (
|
||||
self.resume_raw_samples_path is not None
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -102,7 +153,8 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
String, f"{self.profile.namespace}/status", 10
|
||||
)
|
||||
self.create_subscription(
|
||||
JointState, self.state_topic, self._state_callback, 30
|
||||
JointState, self.state_topic, self._state_callback, 30,
|
||||
callback_group=self.motion_callback_group,
|
||||
)
|
||||
self.camera_matrices: dict[str, np.ndarray] = {}
|
||||
self.image_sizes: dict[str, tuple[int, int]] = {}
|
||||
@@ -132,21 +184,34 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
selected, message
|
||||
),
|
||||
qos_profile_sensor_data,
|
||||
callback_group=self.vision_callback_groups.get(view),
|
||||
)
|
||||
self.create_subscription(
|
||||
AprilTagDetectionArray,
|
||||
self.detection_topics[view],
|
||||
lambda message, selected=view: self._detections_callback(
|
||||
selected, message
|
||||
(
|
||||
lambda message, selected=view:
|
||||
self._guarded_detections_callback(selected, message)
|
||||
) if isolated_motion else (
|
||||
lambda message, selected=view:
|
||||
self._detections_callback(selected, message)
|
||||
),
|
||||
qos_profile_sensor_data,
|
||||
callback_group=self.vision_callback_groups.get(view),
|
||||
)
|
||||
self.create_service(Trigger, f"{self.profile.namespace}/start", self._start)
|
||||
self.create_service(Trigger, f"{self.profile.namespace}/abort", self._abort)
|
||||
self.create_service(
|
||||
Trigger, f"{self.profile.namespace}/start", self._start,
|
||||
callback_group=self.motion_callback_group,
|
||||
)
|
||||
self.create_service(
|
||||
Trigger, f"{self.profile.namespace}/abort", self._abort,
|
||||
callback_group=self.motion_callback_group,
|
||||
)
|
||||
|
||||
self.latest_state_u8: tuple[float, ...] = ()
|
||||
self.state_history: deque[StateSample] = deque(maxlen=2000)
|
||||
self.state_receive_times: deque[float] = deque(maxlen=300)
|
||||
self.command_publish_times: deque[float] = deque(maxlen=500)
|
||||
self.raw_records: list[dict[str, Any]] = []
|
||||
self.steps: list[MotionStep] = []
|
||||
self.step_index = -1
|
||||
@@ -161,8 +226,11 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
self.step_speed_ready_at = 0.0
|
||||
self.step_requested_u8 = float("nan")
|
||||
self.step_start_state_u8: tuple[float, ...] = ()
|
||||
self.step_last_command_u8: tuple[int, ...] | None = None
|
||||
self.step_start_feedback_u8: tuple[float, ...] = ()
|
||||
self.last_published_command_u8: tuple[float, ...] | None = None
|
||||
self.step_last_command_u8: tuple[float, ...] | None = None
|
||||
self.step_trajectory_phase = 0.0
|
||||
self.step_trajectory_blend = 0.0
|
||||
self.step_trajectory_duration_seconds = 0.0
|
||||
self.step_moving_indices: frozenset[int] = frozenset()
|
||||
self.step_valid_frames = 0
|
||||
@@ -185,6 +253,7 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
1.0 / float(self.command_rate_hz)
|
||||
if self.command_unit == "rad" else 0.01,
|
||||
self._tick,
|
||||
callback_group=self.motion_callback_group,
|
||||
)
|
||||
self.create_timer(0.5, self._publish_status)
|
||||
|
||||
@@ -202,6 +271,7 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
"calibration_config_expected_sha256": "",
|
||||
"tag_config_expected_sha256": "",
|
||||
"sdk_config_expected_sha256": "",
|
||||
"resume_raw_samples_path": "",
|
||||
"command_topic": f"/{model}/cb_right_hand_control_cmd",
|
||||
"state_topic": f"/{model}/cb_right_hand_state",
|
||||
"setting_topic": f"/{model}/cb_hand_setting_cmd",
|
||||
@@ -231,7 +301,7 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
"motor_stall_timeout_seconds": 2.0,
|
||||
"position_timeout_seconds": 30.0,
|
||||
"sweep_timeout_seconds": 90.0,
|
||||
"automatic_sweep_retry_limit": 2,
|
||||
"automatic_sweep_retry_limit": 1,
|
||||
"non_target_motion_tolerance_u8": 3.0,
|
||||
"minimum_sweep_frames": 40,
|
||||
"minimum_state_span_u8": 240.0,
|
||||
@@ -247,8 +317,8 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
"maximum_hamming": 0,
|
||||
"minimum_decision_margin": 30.0,
|
||||
"minimum_edge_pixels": 30.0,
|
||||
"fixed_base_maximum_corner_drift_px": 2.0,
|
||||
"fixed_base_movement_confirmation_frames": 5,
|
||||
"fixed_base_maximum_corner_drift_px": 5.0,
|
||||
"fixed_base_movement_confirmation_frames": 10,
|
||||
"tag_size_m": 0.016,
|
||||
"pnp_maximum_reprojection_error_px": 1.5,
|
||||
"pnp_reprojection_tie_px": 1.5,
|
||||
@@ -299,6 +369,12 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
self.protected_inputs["sdk_config_sha256"] = str(
|
||||
value("sdk_config_expected_sha256")
|
||||
)
|
||||
resume_value = str(value("resume_raw_samples_path")).strip()
|
||||
self.resume_raw_samples_path = (
|
||||
None
|
||||
if not resume_value
|
||||
else Path(resume_value).expanduser().resolve()
|
||||
)
|
||||
if (
|
||||
set(self.protected_inputs)
|
||||
!= self.profile.artifacts.protected_input_fields
|
||||
@@ -371,31 +447,17 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
raise ValueError("minimum_joint_frame_rate must be in (0, 1]")
|
||||
|
||||
def _build_steps(self) -> list[MotionStep]:
|
||||
command_unit = getattr(self, "command_unit", "u8")
|
||||
steps = [
|
||||
MotionStep(
|
||||
"baseline", None, None, 255, int(self.baseline_speed_u8)
|
||||
)
|
||||
]
|
||||
for task in self.profile.motion.tasks:
|
||||
midpoint = 0.5 * (task.start_value + task.end_value)
|
||||
preflight_speed = (
|
||||
task.preflight_speed
|
||||
if command_unit == "rad"
|
||||
else task.preflight_speed_u8 or self.preflight_speed_u8
|
||||
)
|
||||
formal_speed = (
|
||||
task.formal_speed
|
||||
if command_unit == "rad"
|
||||
if getattr(self, "command_unit", self.profile.command.unit) == "rad"
|
||||
else self.formal_speed_u8
|
||||
)
|
||||
for target in (task.start_value, midpoint, task.end_value, task.start_value):
|
||||
steps.append(
|
||||
MotionStep(
|
||||
"preflight", task.key, task.command_index, target,
|
||||
float(preflight_speed),
|
||||
)
|
||||
)
|
||||
for cycle in (0, 1, 2, 3):
|
||||
steps.extend(
|
||||
[
|
||||
@@ -428,8 +490,9 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
|
||||
def _abort(self, _request: Trigger.Request, response: Trigger.Response) -> Trigger.Response:
|
||||
hold = (
|
||||
list(self.latest_state_u8)
|
||||
if self.command_unit == "rad" and len(self.latest_state_u8) == self.command_count
|
||||
list(self.last_published_command_u8)
|
||||
if self.command_unit == "rad"
|
||||
and self.last_published_command_u8 is not None
|
||||
else list(self.baseline_command)
|
||||
)
|
||||
self._publish_command(hold)
|
||||
@@ -444,32 +507,60 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
return response
|
||||
|
||||
def _camera_info_callback(self, view: str, message: CameraInfo) -> None:
|
||||
matrix = np.asarray(message.k, dtype=float).reshape(3, 3)
|
||||
if np.all(np.isfinite(matrix)) and matrix[0, 0] > 0 and matrix[1, 1] > 0:
|
||||
self.camera_matrices[view] = matrix
|
||||
self.image_sizes[view] = (int(message.width), int(message.height))
|
||||
from ...core.geometry.camera import rectified_camera_matrix
|
||||
try:
|
||||
matrix = rectified_camera_matrix(message.p)
|
||||
if message.width <= 0 or message.height <= 0:
|
||||
raise ValueError('invalid rectified image dimensions')
|
||||
except ValueError:
|
||||
self.camera_matrices.pop(view, None)
|
||||
self.image_sizes.pop(view, None)
|
||||
return
|
||||
previous = self.camera_matrices.get(view)
|
||||
self.camera_matrices[view] = matrix
|
||||
self.image_sizes[view] = (int(message.width), int(message.height))
|
||||
model = {
|
||||
'kind': 'rectified_camera_model', 'view': view,
|
||||
'matrix_source': 'CameraInfo.P[:3,:3]',
|
||||
'width': int(message.width), 'height': int(message.height),
|
||||
'raw_k': list(message.k), 'raw_d': list(message.d),
|
||||
'rectification_r': list(message.r), 'projection_p': list(message.p),
|
||||
'camera_matrix': matrix.tolist(), 'input_is_rectified': True,
|
||||
}
|
||||
if not hasattr(self, 'camera_models'):
|
||||
self.camera_models = {}
|
||||
if self.camera_models.get(view) != model:
|
||||
self.camera_models[view] = model
|
||||
if hasattr(self, 'raw_path'):
|
||||
append_jsonl(self.raw_path, model)
|
||||
if previous is not None and not np.allclose(previous, matrix):
|
||||
self.trackers[view].reset()
|
||||
group = getattr(self, '_thumb_pose_group', None)
|
||||
if view == 'front' and group is not None:
|
||||
group.reset()
|
||||
|
||||
def _state_callback(self, message: JointState) -> None:
|
||||
if len(message.position) != self.command_count:
|
||||
adapter = getattr(self, "sdk_adapter", ProfileSdkAdapter(self.profile.command))
|
||||
state = adapter.parse_feedback(message.name, message.position)
|
||||
if state is None:
|
||||
return
|
||||
if message.name and not self.profile.command.feedback_by_index:
|
||||
by_name = dict(zip((str(name) for name in message.name), message.position))
|
||||
for alias, canonical in self.profile.command.feedback_name_aliases.items():
|
||||
if alias in by_name and canonical not in by_name:
|
||||
by_name[canonical] = by_name[alias]
|
||||
if any(name not in by_name for name in self.command_names):
|
||||
return
|
||||
state = tuple(float(by_name[name]) for name in self.command_names)
|
||||
else:
|
||||
state = tuple(float(value) for value in message.position)
|
||||
if not all(math.isfinite(value) for value in state):
|
||||
return
|
||||
if any(
|
||||
value < self.command_lower[index] - 1.0e-6
|
||||
or value > self.command_upper[index] + 1.0e-6
|
||||
violation = next((
|
||||
(index, value)
|
||||
for index, value in enumerate(state)
|
||||
):
|
||||
self._pause("feedback_outside_registered_command_domain")
|
||||
if value < self.feedback_lower[index]
|
||||
or value > self.feedback_upper[index]
|
||||
), None)
|
||||
if violation is not None:
|
||||
index, value = violation
|
||||
# Preserve the offending observation for the operator diagnostic,
|
||||
# while the pause path continues to hold the last safe command.
|
||||
self.latest_state_u8 = state
|
||||
self._pause(
|
||||
"feedback_outside_registered_feedback_domain:"
|
||||
f"channel={self.command_names[index]}:value={value:.9f}:"
|
||||
f"lower={self.feedback_lower[index]:.9f}:"
|
||||
f"upper={self.feedback_upper[index]:.9f}"
|
||||
)
|
||||
return
|
||||
stamp = _stamp_ns(message.header.stamp)
|
||||
if stamp <= 0:
|
||||
@@ -478,22 +569,9 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
self.state_history.append(StateSample(stamp, state))
|
||||
self.latest_state_u8 = state
|
||||
self.state_receive_times.append(time.monotonic())
|
||||
step = self._current_step()
|
||||
if step is not None and step.task_key is not None:
|
||||
for index, actual in enumerate(state):
|
||||
if index == step.command_index or index in self.step_moving_indices:
|
||||
continue
|
||||
expected = (
|
||||
self.step_last_command_u8[index]
|
||||
if self.step_last_command_u8 is not None
|
||||
else self.baseline_command[index]
|
||||
)
|
||||
tolerance = float(self.non_target_motion_tolerance_u8)
|
||||
if abs(actual - expected) > tolerance:
|
||||
self._pause(
|
||||
f"non_target_motor_moved:channel={index}:feedback={actual:.2f}"
|
||||
)
|
||||
return
|
||||
# Non-target motion is retained in every sample for diagnostics and
|
||||
# coupling fitting. It is not a runtime stop condition: tendon hands
|
||||
# legitimately back-drive neighbouring SDK coordinates.
|
||||
|
||||
def _task(self, key: str):
|
||||
return next(task for task in self.profile.motion.tasks if task.key == key)
|
||||
@@ -620,6 +698,7 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
step = self._current_step()
|
||||
recording_this_view = bool(
|
||||
step is not None
|
||||
and self.step_command_sent
|
||||
and step.recording
|
||||
and self._task(str(step.task_key)).view == view
|
||||
)
|
||||
@@ -715,11 +794,48 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
f"fixed_base_tag_moved:{view}:drift_px={drift:.3f}"
|
||||
)
|
||||
return
|
||||
if not all(role in corners and good.get(role, False) for role in roles):
|
||||
# A model may freeze a fixed palm reference before a collision-
|
||||
# clearance pose intentionally occludes that Tag. The default is an
|
||||
# empty mapping, so legacy L6/O6 behaviour is byte-for-byte unchanged;
|
||||
# O12 supplies only its front palm pose for the affected finger tasks.
|
||||
locked_reference_hook = getattr(
|
||||
self, "_locked_reference_poses_for_capture", None
|
||||
)
|
||||
locked_reference_poses = (
|
||||
dict(locked_reference_hook(view, step) or {})
|
||||
if locked_reference_hook is not None
|
||||
else {}
|
||||
)
|
||||
locked_reference_poses = {
|
||||
str(role): pose
|
||||
for role, pose in locked_reference_poses.items()
|
||||
if str(role) in roles
|
||||
}
|
||||
live_roles = tuple(
|
||||
role for role in roles if role not in locked_reference_poses
|
||||
)
|
||||
if not all(
|
||||
role in corners and good.get(role, False)
|
||||
for role in live_roles
|
||||
):
|
||||
return
|
||||
stamp = _stamp_ns(message.header.stamp)
|
||||
selected: dict[str, SquareTagPose] = {}
|
||||
for role in roles:
|
||||
selected: dict[str, SquareTagPose] = dict(locked_reference_poses)
|
||||
# Optional model-specific articulated branch selector. Legacy models
|
||||
# keep the exact independent selection below when the hook is absent.
|
||||
pose_hook = getattr(self, "_select_articulated_capture_poses", None)
|
||||
joint_selection = (
|
||||
pose_hook(view, step, live_roles, corners, stamp)
|
||||
if pose_hook is not None else None
|
||||
)
|
||||
if joint_selection is not None:
|
||||
poses, reason = joint_selection
|
||||
if poses is None:
|
||||
if recording_this_view:
|
||||
self._count_step_rejection(f"pnp:group:{reason}")
|
||||
return
|
||||
selected.update(poses)
|
||||
for role in (() if joint_selection is not None else live_roles):
|
||||
pose, pnp_reason = self.trackers[view].estimate(
|
||||
role,
|
||||
corners[role],
|
||||
@@ -734,6 +850,10 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
)
|
||||
return
|
||||
selected[role] = pose
|
||||
evidence_hook = getattr(self, "_record_capture_pose_evidence", None)
|
||||
if evidence_hook is not None:
|
||||
evidence_hook(view, step, roles, corners, selected, stamp,
|
||||
locked_roles=tuple(locked_reference_poses))
|
||||
if recording_this_view:
|
||||
self.step_pnp_valid_frames += 1
|
||||
self.last_view_valid_at[view] = time.monotonic()
|
||||
@@ -754,12 +874,16 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
self.step_state_sync_frames += 1
|
||||
task = self._task(step.task_key)
|
||||
feedback = float(state_u8[task.command_index])
|
||||
lower = self.command_lower[task.command_index]
|
||||
upper = self.command_upper[task.command_index]
|
||||
lower = self.feedback_lower[task.command_index]
|
||||
upper = self.feedback_upper[task.command_index]
|
||||
if not lower <= feedback <= upper:
|
||||
self._count_step_rejection("feedback:outside_registered_domain")
|
||||
self._count_step_rejection("feedback:outside_registered_feedback_domain")
|
||||
return
|
||||
progress = self.profile.command.normalize(task.command_index, feedback)
|
||||
progress = float(np.clip(
|
||||
self.profile.command.normalize(task.command_index, feedback),
|
||||
0.0,
|
||||
1.0,
|
||||
))
|
||||
for joint in task.joints:
|
||||
measurement = self.profile.measurement.measurements[joint]
|
||||
parent = selected[str(measurement.parent_role)]
|
||||
@@ -840,14 +964,34 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
})
|
||||
self.raw_records.append(record)
|
||||
append_jsonl(self.raw_path, record)
|
||||
# This is a frame counter, not a joint-record counter. A frame can
|
||||
# emit active and passive joint records from the same synchronized
|
||||
# observation and must still contribute exactly once to the rate.
|
||||
self.step_valid_frames += 1
|
||||
|
||||
def _guarded_detections_callback(
|
||||
self, view: str, message: AprilTagDetectionArray
|
||||
) -> None:
|
||||
with self.step_data_lock:
|
||||
self.vision_callbacks_inflight += 1
|
||||
try:
|
||||
self._detections_callback(view, message)
|
||||
finally:
|
||||
with self.step_data_lock:
|
||||
self.vision_callbacks_inflight -= 1
|
||||
|
||||
def _feedback_hz(self) -> float:
|
||||
if len(self.state_receive_times) < 2:
|
||||
return 0.0
|
||||
elapsed = self.state_receive_times[-1] - self.state_receive_times[0]
|
||||
return 0.0 if elapsed <= 0 else (len(self.state_receive_times) - 1) / elapsed
|
||||
|
||||
def _command_hz(self) -> float:
|
||||
if len(self.command_publish_times) < 2:
|
||||
return 0.0
|
||||
elapsed = self.command_publish_times[-1] - self.command_publish_times[0]
|
||||
return 0.0 if elapsed <= 0 else (len(self.command_publish_times) - 1) / elapsed
|
||||
|
||||
def _publish_torque(self) -> None:
|
||||
if not self.commands_enabled or self.command_unit != "u8":
|
||||
return
|
||||
@@ -892,6 +1036,8 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
)
|
||||
message.position = bounded
|
||||
self.command_publisher.publish(message)
|
||||
self.last_published_command_u8 = tuple(bounded)
|
||||
self.command_publish_times.append(time.monotonic())
|
||||
|
||||
def _target_command(self, step: MotionStep) -> tuple[float, ...]:
|
||||
if step.target_command is not None:
|
||||
@@ -907,6 +1053,15 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
return tuple(target)
|
||||
|
||||
def _begin_step(self, step: MotionStep) -> None:
|
||||
if not self._uses_isolated_motion_callbacks():
|
||||
self._begin_step_without_vision_callback(step)
|
||||
return
|
||||
assert self.step_data_lock is not None
|
||||
with self.step_data_lock:
|
||||
if not self.vision_callbacks_inflight:
|
||||
self._begin_step_without_vision_callback(step)
|
||||
|
||||
def _begin_step_without_vision_callback(self, step: MotionStep) -> None:
|
||||
now = time.monotonic()
|
||||
if self.commanded_speed != step.speed_u8:
|
||||
self._publish_speed(step.speed_u8)
|
||||
@@ -917,7 +1072,18 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
return
|
||||
if len(self.latest_state_u8) != self.command_count:
|
||||
return
|
||||
self.step_start_state_u8 = tuple(float(value) for value in self.latest_state_u8)
|
||||
self.step_start_feedback_u8 = tuple(
|
||||
float(value) for value in self.latest_state_u8
|
||||
)
|
||||
# A radian feedback value is an observation to calibrate, not the last
|
||||
# command that was sent. O12 can report a stable non-zero feedback at
|
||||
# command zero, so trajectories must remain entirely in command space.
|
||||
self.step_start_state_u8 = (
|
||||
tuple(self.last_published_command_u8)
|
||||
if self.command_unit == "rad"
|
||||
and self.last_published_command_u8 is not None
|
||||
else self.step_start_feedback_u8
|
||||
)
|
||||
self.step_started_at = now
|
||||
self.step_last_progress_at = now
|
||||
self.step_last_feedback = (
|
||||
@@ -940,17 +1106,21 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
index for index, error in enumerate(errors)
|
||||
if error > float(self.non_target_motion_tolerance_u8)
|
||||
)
|
||||
self.step_last_distance_u8 = (
|
||||
command_distance = (
|
||||
float(sum(errors))
|
||||
if step.command_index is None
|
||||
else float(errors[step.command_index])
|
||||
)
|
||||
self.step_initial_distance_u8 = self.step_last_distance_u8
|
||||
self.step_last_distance_u8 = (
|
||||
0.0 if self.command_unit == "rad" else command_distance
|
||||
)
|
||||
self.step_initial_distance_u8 = command_distance
|
||||
maximum_distance = max(errors)
|
||||
if self.command_unit == "rad":
|
||||
self.step_trajectory_duration_seconds = (
|
||||
0.0 if maximum_distance <= 0.0 else
|
||||
math.pi * maximum_distance / (2.0 * float(step.speed_u8))
|
||||
_, _, self.step_trajectory_duration_seconds = (
|
||||
self._radian_trajectory_fraction(
|
||||
maximum_distance, 0.0, float(step.speed_u8)
|
||||
)
|
||||
)
|
||||
else:
|
||||
_, _, self.step_trajectory_duration_seconds = cosine_position_trajectory_u8(
|
||||
@@ -958,6 +1128,7 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
float(self.command_trajectory_full_range_seconds),
|
||||
)
|
||||
self.step_trajectory_phase = 0.0
|
||||
self.step_trajectory_blend = 0.0
|
||||
self.step_last_command_u8 = None
|
||||
self.step_hold_since = None
|
||||
roles: tuple[str, ...] = ()
|
||||
@@ -971,6 +1142,16 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
f"direction={step.direction}:target={step.target_u8}:attempt={step.attempt}"
|
||||
)
|
||||
|
||||
def _radian_trajectory_fraction(
|
||||
self, distance: float, elapsed: float, maximum_speed: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""Return blend, phase and duration for the default cosine profile."""
|
||||
if distance <= 0.0:
|
||||
return 1.0, 1.0, 0.0
|
||||
duration = math.pi * float(distance) / (2.0 * float(maximum_speed))
|
||||
phase = min(1.0, max(0.0, float(elapsed) / duration))
|
||||
return 0.5 - 0.5 * math.cos(math.pi * phase), phase, duration
|
||||
|
||||
def _advance_step_trajectory(self, step: MotionStep, now: float) -> None:
|
||||
if hasattr(self, "_target_command"):
|
||||
target_values = self._target_command(step)
|
||||
@@ -981,15 +1162,24 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
target[step.command_index] = float(step.target_u8)
|
||||
target_values = tuple(target)
|
||||
elapsed = max(0.0, float(now) - self.step_started_at)
|
||||
duration = float(
|
||||
getattr(
|
||||
self,
|
||||
"step_trajectory_duration_seconds",
|
||||
self.command_trajectory_full_range_seconds,
|
||||
if getattr(self, "command_unit", "u8") == "rad":
|
||||
maximum_distance = max(
|
||||
abs(target - start)
|
||||
for start, target in zip(self.step_start_state_u8, target_values)
|
||||
)
|
||||
)
|
||||
phase = 1.0 if duration <= 0.0 else min(1.0, elapsed / duration)
|
||||
blend = 0.5 - 0.5 * math.cos(math.pi * phase)
|
||||
blend, phase, duration = self._radian_trajectory_fraction(
|
||||
maximum_distance, elapsed, float(step.speed_u8)
|
||||
)
|
||||
else:
|
||||
duration = float(
|
||||
getattr(
|
||||
self,
|
||||
"step_trajectory_duration_seconds",
|
||||
self.command_trajectory_full_range_seconds,
|
||||
)
|
||||
)
|
||||
phase = 1.0 if duration <= 0.0 else min(1.0, elapsed / duration)
|
||||
blend = 0.5 - 0.5 * math.cos(math.pi * phase)
|
||||
values = [
|
||||
start + (target - start) * blend
|
||||
for start, target in zip(self.step_start_state_u8, target_values)
|
||||
@@ -1001,12 +1191,21 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
for index, value in enumerate(values)
|
||||
)
|
||||
self.step_trajectory_phase = phase
|
||||
self.step_trajectory_blend = blend
|
||||
self.step_requested_u8 = (
|
||||
float(np.mean(values))
|
||||
if step.command_index is None
|
||||
else float(values[step.command_index])
|
||||
)
|
||||
if command != self.step_last_command_u8:
|
||||
# O12 joint feedback is request-driven: every radian JointState command
|
||||
# triggers both the control write and a fresh readback. Keep streaming
|
||||
# the unchanged endpoint during holds so the next step never starts
|
||||
# from a stale feedback sample. Legacy byte profiles retain their
|
||||
# existing duplicate suppression.
|
||||
if (
|
||||
getattr(self, "command_unit", "u8") == "rad"
|
||||
or command != self.step_last_command_u8
|
||||
):
|
||||
self._publish_command(list(command))
|
||||
self.step_last_command_u8 = command
|
||||
|
||||
@@ -1030,9 +1229,24 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
gap = max((right - left for left, right in zip(bins, bins[1:])), default=256)
|
||||
maximum_gap = float(self.maximum_bin_gap)
|
||||
else:
|
||||
normalized_bin_count = int(
|
||||
getattr(self, "normalized_sweep_bin_count", 32)
|
||||
)
|
||||
if normalized_bin_count < 32:
|
||||
raise ValueError("normalized sweep bin count must be at least 32")
|
||||
normalized = sorted(
|
||||
set(
|
||||
min(31, max(0, int(self.profile.command.normalize(task.command_index, value) * 32.0)))
|
||||
min(
|
||||
normalized_bin_count - 1,
|
||||
max(
|
||||
0,
|
||||
int(
|
||||
self.profile.command.normalize(
|
||||
task.command_index, value
|
||||
) * normalized_bin_count
|
||||
),
|
||||
),
|
||||
)
|
||||
for value in feedback
|
||||
)
|
||||
)
|
||||
@@ -1041,40 +1255,41 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
float(np.ptp([self.profile.command.normalize(task.command_index, value) for value in feedback]))
|
||||
if feedback.size else 0.0
|
||||
)
|
||||
required_span = float(self.minimum_state_span_u8)
|
||||
gap = max((right - left for left, right in zip(bins, bins[1:])), default=32)
|
||||
span_policy = getattr(
|
||||
self, "_required_radian_feedback_span_fraction", None
|
||||
)
|
||||
required_span = (
|
||||
float(self.minimum_state_span_u8)
|
||||
if span_policy is None
|
||||
else float(span_policy(task, step, feedback))
|
||||
)
|
||||
gap = max(
|
||||
(right - left for left, right in zip(bins, bins[1:])),
|
||||
default=normalized_bin_count,
|
||||
)
|
||||
maximum_gap = float(self.maximum_bin_gap)
|
||||
observation = self._step_observation_metrics()
|
||||
detection_rate = float(observation["tag_detection_rate"])
|
||||
joint_frame_rate = float(observation["joint_frame_rate"])
|
||||
failures = []
|
||||
if len(rows) < int(self.minimum_sweep_frames):
|
||||
failures.append(f"frames={len(rows)}")
|
||||
if feedback.size == 0 or span < required_span:
|
||||
failures.append("feedback_span")
|
||||
if len(bins) < int(self.minimum_sweep_bins):
|
||||
failures.append(f"bins={len(bins)}")
|
||||
if gap > maximum_gap:
|
||||
failures.append(f"maximum_gap={gap}")
|
||||
if detection_rate < float(self.minimum_detection_rate):
|
||||
role_rates = observation["tag_detection_rate_by_role"]
|
||||
worst_role = min(role_rates, key=role_rates.get, default="unknown")
|
||||
tag_id = next(
|
||||
(
|
||||
tag.tag_id
|
||||
for view in self.profile.vision.views
|
||||
for tag in view.tags
|
||||
if tag.role == worst_role
|
||||
),
|
||||
"?",
|
||||
)
|
||||
failures.append(
|
||||
f"tag_rate[ID{tag_id}/{worst_role}]={detection_rate:.3f}"
|
||||
)
|
||||
if joint_frame_rate < float(self.minimum_joint_frame_rate):
|
||||
failures.append(f"joint_frame_rate={joint_frame_rate:.3f}")
|
||||
if self._feedback_hz() < float(self.minimum_feedback_hz):
|
||||
failures.append(f"feedback_hz={self._feedback_hz():.2f}")
|
||||
progress = (
|
||||
[self.profile.command.normalize(task.command_index, value) for value in feedback]
|
||||
if command_unit == "rad"
|
||||
else [float(value) / 255.0 for value in feedback]
|
||||
)
|
||||
engine = getattr(self, "calibration_engine", CalibrationEngine(self.profile))
|
||||
decision = engine.evaluate_sweep(
|
||||
progress,
|
||||
minimum_span=(required_span if command_unit == "rad" else required_span / 255.0),
|
||||
total_frames=self.step_total_frames,
|
||||
joint_frame_rate=joint_frame_rate,
|
||||
feedback_hz=self._feedback_hz(),
|
||||
detection_rate=detection_rate,
|
||||
bin_count=(
|
||||
int(getattr(self, "normalized_sweep_bin_count", 256))
|
||||
if command_unit == "rad" else 256
|
||||
),
|
||||
)
|
||||
failures = list(decision.failures)
|
||||
append_jsonl(
|
||||
self.raw_path,
|
||||
{
|
||||
@@ -1088,8 +1303,12 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
"valid_frames": len(rows),
|
||||
"total_frames": self.step_total_frames,
|
||||
"feedback_bins": len(bins),
|
||||
"feedback_span": round(span, 9),
|
||||
"required_feedback_span": round(required_span, 9),
|
||||
"maximum_bin_gap": gap,
|
||||
**observation,
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"warnings": list(decision.warnings),
|
||||
"failures": failures,
|
||||
},
|
||||
)
|
||||
@@ -1099,16 +1318,22 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
def _retry_step(self, step: MotionStep, reason: str) -> bool:
|
||||
key = (str(step.task_key), int(step.cycle), str(step.direction))
|
||||
retries = self.retry_counts.get(key, 0)
|
||||
if retries >= int(self.automatic_sweep_retry_limit):
|
||||
if not CalibrationEngine.permits_retry("sweep_acquisition", retries):
|
||||
return False
|
||||
attempt = retries + 2
|
||||
self.retry_counts[key] = retries + 1
|
||||
task = self._task(str(step.task_key))
|
||||
start = task.start_value if step.direction == "decreasing" else task.end_value
|
||||
retry_speed = float(step.speed_u8) * (0.75 if attempt == 2 else 0.5 / 0.75)
|
||||
engine = getattr(self, "calibration_engine", CalibrationEngine(self.profile))
|
||||
retry_speed = engine.retry_speed(step.speed_u8, attempt)
|
||||
# Preserve model-specific motion metadata on engine-generated retry
|
||||
# steps. O12 extends MotionStep with clearance/probe fields; replacing
|
||||
# it with the legacy L6 class makes the next common timer tick lose
|
||||
# that contract and can terminate the node at the retry boundary.
|
||||
step_type = type(step)
|
||||
replacement = [
|
||||
MotionStep("retry_prepare", step.task_key, step.command_index, start, retry_speed, step.cycle, attempt=attempt),
|
||||
MotionStep("sweep", step.task_key, step.command_index, step.target_u8, retry_speed, step.cycle, step.direction, attempt),
|
||||
step_type("retry_prepare", step.task_key, step.command_index, start, retry_speed, step.cycle, attempt=attempt),
|
||||
step_type("sweep", step.task_key, step.command_index, step.target_u8, retry_speed, step.cycle, step.direction, attempt),
|
||||
]
|
||||
self.steps[self.step_index + 1:self.step_index + 1] = replacement
|
||||
append_jsonl(
|
||||
@@ -1126,6 +1351,15 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
return True
|
||||
|
||||
def _finish_step(self, step: MotionStep) -> None:
|
||||
if not self._uses_isolated_motion_callbacks():
|
||||
self._finish_step_without_vision_callback(step)
|
||||
return
|
||||
assert self.step_data_lock is not None
|
||||
with self.step_data_lock:
|
||||
if not self.vision_callbacks_inflight:
|
||||
self._finish_step_without_vision_callback(step)
|
||||
|
||||
def _finish_step_without_vision_callback(self, step: MotionStep) -> None:
|
||||
if step.recording:
|
||||
try:
|
||||
self._qualify_recording_step(step)
|
||||
@@ -1138,6 +1372,77 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
if self.step_index >= len(self.steps):
|
||||
self._finalize()
|
||||
|
||||
def _radian_feedback_travel(self, step: MotionStep) -> float:
|
||||
"""Measure motion in feedback space without comparing it to commands."""
|
||||
if (
|
||||
len(self.latest_state_u8) != self.command_count
|
||||
or len(self.step_start_feedback_u8) != self.command_count
|
||||
):
|
||||
return 0.0
|
||||
if step.command_index is not None:
|
||||
index = int(step.command_index)
|
||||
return abs(
|
||||
float(self.latest_state_u8[index])
|
||||
- float(self.step_start_feedback_u8[index])
|
||||
)
|
||||
return float(sum(
|
||||
abs(
|
||||
float(self.latest_state_u8[index])
|
||||
- float(self.step_start_feedback_u8[index])
|
||||
)
|
||||
for index in self.step_moving_indices
|
||||
))
|
||||
|
||||
def _minimum_radian_feedback_travel(self, step: MotionStep) -> float:
|
||||
"""Return the safety evidence required before a non-recording move ends."""
|
||||
command_distance = float(self.step_initial_distance_u8)
|
||||
if command_distance <= 0.005 or step.recording:
|
||||
return 0.0
|
||||
if step.phase == "preflight":
|
||||
# The model-specific visual hook performs the stronger axis and
|
||||
# direction check after this electrical movement evidence.
|
||||
return min(0.004, 0.25 * command_distance)
|
||||
# Baseline/prepare/clearance/return must make most of their requested
|
||||
# move, but no absolute command-vs-feedback equality is assumed.
|
||||
return 0.60 * command_distance
|
||||
|
||||
def _tick_radian_motion(self, step: MotionStep, now: float) -> None:
|
||||
"""Advance a radian step with command/feedback domains kept separate."""
|
||||
travel = self._radian_feedback_travel(step)
|
||||
if travel >= float(self.step_last_distance_u8) + 0.001:
|
||||
self.step_last_distance_u8 = travel
|
||||
self.step_last_progress_at = now
|
||||
self.step_hold_since = None
|
||||
|
||||
command_distance = float(self.step_initial_distance_u8)
|
||||
commanded_travel = command_distance * self.step_trajectory_blend
|
||||
if self.step_trajectory_phase < 1.0:
|
||||
# Only call it a stall while the command trajectory is demanding
|
||||
# meaningful motion and feedback has provided almost none.
|
||||
if (
|
||||
commanded_travel > 0.02
|
||||
and travel < 0.10 * commanded_travel
|
||||
and now - self.step_last_progress_at
|
||||
> float(self.motor_stall_timeout_seconds)
|
||||
):
|
||||
self._pause(f"mechanical_stall:{self.reason}")
|
||||
return
|
||||
|
||||
minimum_travel = self._minimum_radian_feedback_travel(step)
|
||||
if travel + 0.001 < minimum_travel:
|
||||
if (
|
||||
now - self.step_last_progress_at
|
||||
> float(self.motor_stall_timeout_seconds)
|
||||
):
|
||||
self._pause(f"mechanical_stall:{self.reason}")
|
||||
return
|
||||
|
||||
if self.step_hold_since is None:
|
||||
self.step_hold_since = now
|
||||
return
|
||||
if now - self.step_hold_since >= float(self.endpoint_hold_seconds):
|
||||
self._finish_step(step)
|
||||
|
||||
def _tick(self) -> None:
|
||||
if self.state in {"PASSED", "PAUSED", "ABORTED"}:
|
||||
return
|
||||
@@ -1168,12 +1473,14 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
return
|
||||
now = time.monotonic()
|
||||
self._advance_step_trajectory(step, now)
|
||||
timeout = self.sweep_timeout_seconds if step.recording else self.position_timeout_seconds
|
||||
if now - self.step_started_at > float(timeout):
|
||||
self._pause(f"motion_timeout:{self.reason}")
|
||||
return
|
||||
# Absolute duration is diagnostic only. A slow but continuously
|
||||
# progressing joint is valid; the two-second no-progress watchdog is
|
||||
# the motion safety gate.
|
||||
if len(self.latest_state_u8) != self.command_count:
|
||||
return
|
||||
if self.command_unit == "rad":
|
||||
self._tick_radian_motion(step, now)
|
||||
return
|
||||
target_state = list(self._target_command(step))
|
||||
endpoint_errors = [
|
||||
abs(value - target_state[index])
|
||||
@@ -1220,10 +1527,6 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
if self.step_trajectory_phase < 1.0:
|
||||
self.step_hold_since = None
|
||||
return
|
||||
task_view = None if step.task_key is None else self._task(step.task_key).view
|
||||
if task_view is not None and now - self.last_view_valid_at.get(task_view, 0.0) > 0.25:
|
||||
self.step_hold_since = None
|
||||
return
|
||||
if self.step_hold_since is None:
|
||||
self.step_hold_since = now
|
||||
return
|
||||
@@ -1273,9 +1576,14 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
return
|
||||
self.state = "PAUSED"
|
||||
self.reason = str(reason)
|
||||
# Holding the latest position is safer than issuing an automatic move
|
||||
# after a stall, unexpected motor motion, or moved palm reference.
|
||||
if len(self.latest_state_u8) == self.command_count:
|
||||
# In radian mode, hold the last command rather than feeding an
|
||||
# uncalibrated feedback value back into the command domain.
|
||||
if (
|
||||
self.command_unit == "rad"
|
||||
and self.last_published_command_u8 is not None
|
||||
):
|
||||
self._publish_command(list(self.last_published_command_u8))
|
||||
elif len(self.latest_state_u8) == self.command_count:
|
||||
self._publish_command(
|
||||
[
|
||||
int(np.clip(round(value), 0, 255))
|
||||
@@ -1283,7 +1591,32 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
for value in self.latest_state_u8
|
||||
]
|
||||
)
|
||||
append_jsonl(self.raw_path, {"kind": "paused", "reason": self.reason})
|
||||
step = self._current_step()
|
||||
target = [] if step is None else list(self._target_command(step))
|
||||
errors = (
|
||||
[]
|
||||
if len(target) != len(self.latest_state_u8)
|
||||
else [
|
||||
abs(float(actual) - float(expected))
|
||||
for actual, expected in zip(self.latest_state_u8, target)
|
||||
]
|
||||
)
|
||||
append_jsonl(self.raw_path, {
|
||||
"kind": "paused",
|
||||
"reason": self.reason,
|
||||
"command_unit": self.command_unit,
|
||||
f"latest_state_{self.command_unit}": list(self.latest_state_u8),
|
||||
f"target_state_{self.command_unit}": target,
|
||||
f"channel_errors_{self.command_unit}": errors,
|
||||
"maximum_error_channel": (
|
||||
None
|
||||
if not errors
|
||||
else self.command_names[int(np.argmax(errors))]
|
||||
),
|
||||
f"maximum_error_{self.command_unit}": (
|
||||
None if not errors else max(errors)
|
||||
),
|
||||
})
|
||||
|
||||
def _status(self) -> dict[str, Any]:
|
||||
step = self._current_step()
|
||||
@@ -1305,16 +1638,19 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
]
|
||||
step_fraction = 0.0
|
||||
if step is not None and math.isfinite(feedback):
|
||||
current_distance = (
|
||||
float(sum(channel_errors))
|
||||
if step.command_index is None
|
||||
else channel_errors[step.command_index]
|
||||
)
|
||||
initial_distance = self.step_initial_distance_u8
|
||||
if math.isfinite(initial_distance) and initial_distance > 0.0:
|
||||
step_fraction = 1.0 - current_distance / initial_distance
|
||||
elif current_distance <= float(self.endpoint_tolerance_u8):
|
||||
step_fraction = 1.0
|
||||
if self.command_unit == "rad":
|
||||
step_fraction = self.step_trajectory_blend
|
||||
else:
|
||||
current_distance = (
|
||||
float(sum(channel_errors))
|
||||
if step.command_index is None
|
||||
else channel_errors[step.command_index]
|
||||
)
|
||||
initial_distance = self.step_initial_distance_u8
|
||||
if math.isfinite(initial_distance) and initial_distance > 0.0:
|
||||
step_fraction = 1.0 - current_distance / initial_distance
|
||||
elif current_distance <= float(self.endpoint_tolerance_u8):
|
||||
step_fraction = 1.0
|
||||
step_fraction = float(np.clip(step_fraction, 0.0, 1.0))
|
||||
elif self.state == "PASSED":
|
||||
step_fraction = 1.0
|
||||
@@ -1393,6 +1729,7 @@ class L6ThreeCameraCalibrationNode(Node):
|
||||
self.fixed_base_maximum_corner_drift_px
|
||||
),
|
||||
"feedback_hz": round(self._feedback_hz(), 3),
|
||||
"command_publish_hz": round(self._command_hz(), 3),
|
||||
"camera_info_views": sorted(self.camera_matrices),
|
||||
"state_publisher_count": self.count_publishers(self.state_topic),
|
||||
"command_publisher_count": self.count_publishers(self.command_topic),
|
||||
|
||||
@@ -8,6 +8,8 @@ import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from ...runtime.engine import CalibrationEngine
|
||||
|
||||
from .artifacts import (
|
||||
artifact_hashes,
|
||||
atomic_write_json,
|
||||
@@ -23,6 +25,7 @@ from .profile import (
|
||||
MEASURED_PASSIVE_JOINTS,
|
||||
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
|
||||
build_typed_profile,
|
||||
)
|
||||
from .urdf import L6UrdfCorrection, write_l6_corrected_urdf
|
||||
|
||||
@@ -148,6 +151,13 @@ def finalize_l6_session(
|
||||
accepted_records_by_joint(records),
|
||||
require_thumb_axis_zero=True,
|
||||
)
|
||||
CalibrationEngine(build_typed_profile()).result_from_fit(
|
||||
result,
|
||||
transfers={
|
||||
**TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
**TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
|
||||
},
|
||||
)
|
||||
# Validate the complete runtime schema before materializing any corrected
|
||||
# URDF. A fit/schema rejection therefore leaves only the node's failure
|
||||
# diagnostic and the immutable raw samples.
|
||||
|
||||
@@ -272,8 +272,8 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
),
|
||||
motion=MotionPolicy(
|
||||
tasks=tasks,
|
||||
precheck_sweeps=True,
|
||||
steady_command_checkpoints=True,
|
||||
precheck_sweeps=False,
|
||||
steady_command_checkpoints=False,
|
||||
speed_parameters={
|
||||
"preflight_u8": 1,
|
||||
"formal_u8": 1,
|
||||
@@ -313,7 +313,6 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
{
|
||||
"minimum_detection_rate",
|
||||
"maximum_state_image_skew_ms",
|
||||
"maximum_hysteresis_rad",
|
||||
"maximum_validation_error_rad",
|
||||
"maximum_mimic_residual_rad",
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ _STATE_LABELS = {
|
||||
"WAIT_DEVICES": "等待六通道反馈和三相机内参",
|
||||
"READY": "设备就绪",
|
||||
"RUNNING": "标定中",
|
||||
"FINALIZING": "拟合、验证并生成 URDF",
|
||||
"PASSED": "通过",
|
||||
"PAUSED": "已暂停",
|
||||
"ABORTED": "已中止",
|
||||
@@ -47,6 +48,7 @@ _PHASE_LABELS = {
|
||||
"preflight": "任务运动预检",
|
||||
"prepare": "扫描起点准备",
|
||||
"retry_prepare": "自动重扫起点准备",
|
||||
"resume_prepare": "断点恢复起点准备",
|
||||
"sweep": "正式扫描",
|
||||
"clearance": "手指避让",
|
||||
"clearance_outer": "小指/无名指避让",
|
||||
@@ -111,12 +113,6 @@ def _l6_reason_zh(
|
||||
"查看会话诊断中的具体 frames/bins/maximum_gap/tag_rate;先处理遮挡或"
|
||||
"反馈采样问题,再重新开始。程序已禁止发布本次结果。",
|
||||
)
|
||||
if reason.startswith("non_target_motor_moved:"):
|
||||
return (
|
||||
"MOTION-NONTARGET-304",
|
||||
"扫描期间检测到非目标电机离开保持位置。",
|
||||
"停止其他控制节点并检查机械耦合或反馈通道顺序,确认后重新开始。",
|
||||
)
|
||||
if reason.startswith("multiple_state_publishers:"):
|
||||
count = status.get("state_publisher_count", "?")
|
||||
return (
|
||||
@@ -275,28 +271,38 @@ def render_six_channel_progress_zh(
|
||||
)
|
||||
lines.append(
|
||||
f"运动:峰值 {float(speed_rad_s):.3f} rad/s;"
|
||||
f"本段 {trajectory_seconds:.1f} 秒余弦轨迹"
|
||||
f"本段 {trajectory_seconds:.1f} 秒平滑限速轨迹"
|
||||
)
|
||||
latest_state = status.get("latest_state_u8", [])
|
||||
latest_state = status.get(f"latest_state_{command_unit}", [])
|
||||
command_names = status.get("command_names", [])
|
||||
if (
|
||||
isinstance(latest_state, (list, tuple))
|
||||
and isinstance(command_names, (list, tuple))
|
||||
and len(latest_state) == len(command_names) == 6
|
||||
and len(latest_state) == len(command_names)
|
||||
and len(command_names) in {6, 12}
|
||||
and (phase == "baseline" or state in {"PAUSED", "ABORTED"})
|
||||
):
|
||||
digits = 3 if command_unit == "rad" else 1
|
||||
suffix = " rad" if command_unit == "rad" else ""
|
||||
feedback_text = ", ".join(
|
||||
f"{name}={float(value):.1f}"
|
||||
f"{name}={float(value):.{digits}f}{suffix}"
|
||||
for name, value in zip(command_names, latest_state)
|
||||
)
|
||||
maximum_error_channel = status.get("maximum_error_channel")
|
||||
maximum_error = status.get("maximum_error_u8")
|
||||
maximum_error = status.get(f"maximum_error_{command_unit}")
|
||||
error_text = (
|
||||
"未知"
|
||||
if maximum_error_channel is None or maximum_error is None
|
||||
else f"{maximum_error_channel}={float(maximum_error):.1f}"
|
||||
else (
|
||||
f"{maximum_error_channel}="
|
||||
f"{float(maximum_error):.{digits}f}{suffix}"
|
||||
)
|
||||
)
|
||||
channel_label = "六路" if len(command_names) == 6 else "十二路"
|
||||
error_label = "最大命令/反馈差" if command_unit == "rad" else "最大偏差"
|
||||
lines.append(
|
||||
f"{channel_label}反馈:{feedback_text} {error_label}:{error_text}"
|
||||
)
|
||||
lines.append(f"六路反馈:{feedback_text} 最大偏差:{error_text}")
|
||||
if state in {"PAUSED", "ABORTED"}:
|
||||
_code, problem, suggestion = reason_renderer(status)
|
||||
lines.extend((f"原因:{problem}", f"建议:{suggestion}"))
|
||||
@@ -367,6 +373,7 @@ def _launch_command(
|
||||
record_bag: bool,
|
||||
commands_enabled: bool,
|
||||
sdk_startup_speed_u8: int = 1,
|
||||
resume_from: Path | None = None,
|
||||
) -> list[str]:
|
||||
arguments = {
|
||||
"model": config.model,
|
||||
@@ -393,6 +400,10 @@ def _launch_command(
|
||||
"commands_enabled": str(commands_enabled).lower(),
|
||||
"record_bag": str(record_bag).lower(),
|
||||
}
|
||||
if resume_from is not None:
|
||||
arguments["resume_raw_samples_path"] = str(
|
||||
resume_from / "raw_samples.jsonl"
|
||||
)
|
||||
# HCAN/ZLG vendor products do not have a Linux SocketCAN interface.
|
||||
# Omitting the launch override also avoids the invalid token
|
||||
# ``can_interface:=`` when the reviewed product value is intentionally empty.
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from .fitting import O12FitResult, curve_input_knots_rad, curve_values_at_rad
|
||||
from .kinematics import (
|
||||
PASSIVE_POLYNOMIAL_BY_JOINT,
|
||||
PASSIVE_SDK_SOURCE_BY_JOINT,
|
||||
vendor_passive_curve,
|
||||
)
|
||||
from .profile import (
|
||||
ACTIVE_JOINTS,
|
||||
COMMAND_INDEX_BY_JOINT,
|
||||
@@ -22,10 +29,14 @@ from .profile import (
|
||||
SAFE_UPPER_RAD,
|
||||
SDK_LOWER_RAD,
|
||||
SDK_TO_URDF_JOINT,
|
||||
SDK_TO_URDF_SIGN,
|
||||
SDK_UPPER_RAD,
|
||||
STATIC_ZERO_EXCLUDED_JOINTS,
|
||||
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
build_typed_profile,
|
||||
)
|
||||
from .urdf import o12_endpoint_mimic_contract
|
||||
from .zero import SPATIAL_ZERO_POLICY
|
||||
|
||||
|
||||
ALL_REVOLUTE_JOINTS = frozenset(ACTIVE_JOINTS + PASSIVE_JOINTS)
|
||||
@@ -102,48 +113,107 @@ def build_o12_runtime_payload(
|
||||
passed: bool,
|
||||
) -> dict[str, Any]:
|
||||
profile = build_typed_profile()
|
||||
if result.full_hand_zero_result is not None and not result.full_hand_zero_result.passed:
|
||||
raise ValueError("O12 full-hand spatial zero validation has not passed")
|
||||
for name in STATIC_ZERO_EXCLUDED_JOINTS:
|
||||
if (
|
||||
not math.isclose(
|
||||
float(result.zero_offsets_rad.get(name, math.nan)),
|
||||
0.0,
|
||||
rel_tol=0.0,
|
||||
abs_tol=1.0e-12,
|
||||
)
|
||||
or result.zero_method_by_joint.get(name)
|
||||
!= "source_cad_zero_profile_excluded"
|
||||
):
|
||||
raise ValueError(
|
||||
f"O12 {name} must retain immutable source-CAD static zero"
|
||||
)
|
||||
source = _source_joint_metadata(source_urdf)
|
||||
endpoint_mimics = o12_endpoint_mimic_contract(source_urdf, result)
|
||||
curves: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, list[float]]] = {}
|
||||
joints: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for name in ACTIVE_JOINTS:
|
||||
donor = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name)
|
||||
fit = result.curves[donor]
|
||||
inputs = list(curve_input_knots_rad(donor))
|
||||
feedback_domain = result.feedback_domains_rad[donor]
|
||||
inputs = list(curve_input_knots_rad(
|
||||
donor, feedback_domain_rad=feedback_domain
|
||||
))
|
||||
motor = COMMAND_INDEX_BY_JOINT[name]
|
||||
sign = float(SDK_TO_URDF_SIGN[motor])
|
||||
decreasing = _zeroed(
|
||||
curve_values_at_rad(donor, fit, inputs, "decreasing_rad"), inputs
|
||||
curve_values_at_rad(
|
||||
donor, fit, inputs, "decreasing_rad", feedback_domain
|
||||
), inputs
|
||||
)
|
||||
increasing = _zeroed(
|
||||
curve_values_at_rad(donor, fit, inputs, "increasing_rad"), inputs
|
||||
curve_values_at_rad(
|
||||
donor, fit, inputs, "increasing_rad", feedback_domain
|
||||
), inputs
|
||||
)
|
||||
angle = 0.5 * (decreasing + increasing)
|
||||
# fit_rotation_joint_curve has an arbitrary SVD axis sign. Apply the
|
||||
# reviewed SDK-to-CAD direction contract after fitting so all curves
|
||||
# use the target URDF convention deterministically.
|
||||
observed_direction = float(np.sign(angle[-1] - angle[0]))
|
||||
if observed_direction and observed_direction != sign:
|
||||
angle = -angle
|
||||
decreasing = -decreasing
|
||||
increasing = -increasing
|
||||
if donor != name:
|
||||
cad_lower = float(source[name]["lower"])
|
||||
cad_upper = float(source[name]["upper"])
|
||||
# Same feedback means the same transferred correction until the
|
||||
# ring's own CAD/mimic range saturates. Rescaling all donor values
|
||||
# would invent a different gain for this unobserved finger.
|
||||
angle = np.clip(angle, cad_lower, cad_upper)
|
||||
decreasing = np.clip(decreasing, cad_lower, cad_upper)
|
||||
increasing = np.clip(increasing, cad_lower, cad_upper)
|
||||
curves[name] = (angle, decreasing, increasing, inputs)
|
||||
motor = COMMAND_INDEX_BY_JOINT[name]
|
||||
joint: dict[str, Any] = {
|
||||
"urdf_joint": name,
|
||||
"sdk_channel": COMMAND_NAMES[motor],
|
||||
"motor_index": motor,
|
||||
"passive": False,
|
||||
"calibration_status": profile.joint_coverage[name],
|
||||
"calibration_status": (
|
||||
"transferred_static_dynamic" if donor != name
|
||||
else "measured_dynamic_cad_static"
|
||||
if str(result.zero_method_by_joint[name]).startswith("source_cad_zero")
|
||||
else "measured_static_dynamic"
|
||||
),
|
||||
"curve_input_knots_rad": _round(inputs),
|
||||
"angle_rad": _round(angle),
|
||||
"decreasing_rad": _round(decreasing),
|
||||
"increasing_rad": _round(increasing),
|
||||
"zero_feedback_rad": 0.0,
|
||||
"urdf_sign_adapter": (
|
||||
"sdk_negative_to_urdf_positive"
|
||||
if SDK_UPPER_RAD[motor] <= 0.0 and SDK_LOWER_RAD[motor] < 0.0
|
||||
else "identity"
|
||||
"measured_feedback_domain_rad": [
|
||||
round(float(value), 10) for value in feedback_domain
|
||||
],
|
||||
"static_urdf_origin_offset_rad": round(
|
||||
float(result.zero_offsets_rad[name]), 10
|
||||
),
|
||||
"static_zero_method": str(result.zero_method_by_joint[name]),
|
||||
"sdk_to_urdf_direction": sign,
|
||||
"urdf_sign_adapter": "negate" if sign < 0.0 else "identity",
|
||||
"runtime_mapping_source": "tag_rotation_over_sdk_feedback_rad",
|
||||
"visual_arc_diagnostic_rad": round(
|
||||
float(result.visual_arc_diagnostics_rad[donor]), 10
|
||||
),
|
||||
"raw_increasing_curve_branch": (
|
||||
"decreasing"
|
||||
if _task_for_joint(donor).end_value > _task_for_joint(donor).start_value
|
||||
if _task_for_joint(donor).end_value
|
||||
> _task_for_joint(donor).start_value
|
||||
else "increasing"
|
||||
),
|
||||
}
|
||||
if donor != name:
|
||||
joint["transferred_from_joint"] = donor
|
||||
joint["transfer_policy"] = "pinky_feedback_correction_on_ring_cad"
|
||||
joint["transfer_policy"] = (
|
||||
"pinky_curve_clamped_to_ring_cad_range"
|
||||
)
|
||||
joint["static_zero_transfer_policy"] = "donor_offset_on_own_cad_frame"
|
||||
joints[name] = joint
|
||||
|
||||
for name in PASSIVE_JOINTS:
|
||||
@@ -152,20 +222,33 @@ def build_o12_runtime_payload(
|
||||
motor = _motor_index(source_name)
|
||||
if name in MEASURED_PASSIVE_JOINTS:
|
||||
fit = result.curves[name]
|
||||
inputs = list(curve_input_knots_rad(name))
|
||||
decreasing = _zeroed(
|
||||
curve_values_at_rad(name, fit, inputs, "decreasing_rad"), inputs
|
||||
) + float(metadata["offset"])
|
||||
increasing = _zeroed(
|
||||
curve_values_at_rad(name, fit, inputs, "increasing_rad"), inputs
|
||||
) + float(metadata["offset"])
|
||||
angle = 0.5 * (decreasing + increasing)
|
||||
coupling = result.mimic_fits[name]
|
||||
coefficients = [float(metadata["offset"]), *coupling.coefficients]
|
||||
feedback_domain = result.feedback_domains_rad[name]
|
||||
inputs = list(curve_input_knots_rad(
|
||||
name, feedback_domain_rad=feedback_domain
|
||||
))
|
||||
sdk_source = PASSIVE_SDK_SOURCE_BY_JOINT[name]
|
||||
sdk_motor = _motor_index(sdk_source)
|
||||
expected_direction = float(SDK_TO_URDF_SIGN[sdk_motor])
|
||||
vendor_values = np.asarray(
|
||||
vendor_passive_curve(
|
||||
name,
|
||||
inputs,
|
||||
sdk_to_urdf_sign=expected_direction,
|
||||
),
|
||||
dtype=float,
|
||||
)
|
||||
angle = vendor_values
|
||||
decreasing = vendor_values.copy()
|
||||
increasing = vendor_values.copy()
|
||||
observed = result.mimic_fits[name]
|
||||
coefficients = [
|
||||
expected_direction * float(value)
|
||||
for value in PASSIVE_POLYNOMIAL_BY_JOINT[name]
|
||||
]
|
||||
coefficients.extend([0.0] * (6 - len(coefficients)))
|
||||
coupling_model = coupling.model
|
||||
multiplier = coupling.urdf_mimic_multiplier
|
||||
policy = coupling.urdf_mimic_policy
|
||||
coupling_model = "vendor_o12_polynomial"
|
||||
multiplier = endpoint_mimics[name]
|
||||
policy = "source_cad_closed_endpoint"
|
||||
status = profile.joint_coverage[name]
|
||||
else:
|
||||
source_curve, source_dec, source_inc, inputs = curves[source_name]
|
||||
@@ -179,14 +262,14 @@ def build_o12_runtime_payload(
|
||||
policy = "cad_nominal_preserved"
|
||||
status = profile.joint_coverage[name]
|
||||
curves[name] = (angle, decreasing, increasing, list(inputs))
|
||||
joints[name] = {
|
||||
joint = {
|
||||
"urdf_joint": name,
|
||||
"sdk_channel": COMMAND_NAMES[motor],
|
||||
"motor_index": motor,
|
||||
"passive": True,
|
||||
"source_joint": source_name,
|
||||
"calibration_status": status,
|
||||
"static_zero_policy": "cad_preserved_tag_mount_ambiguous",
|
||||
"static_zero_policy": "cad_mechanical_endpoint",
|
||||
"curve_input_knots_rad": _round(inputs),
|
||||
"angle_rad": _round(angle),
|
||||
"decreasing_rad": _round(decreasing),
|
||||
@@ -195,12 +278,37 @@ def build_o12_runtime_payload(
|
||||
"coupling_coefficients": [round(float(v), 10) for v in coefficients],
|
||||
"mimic_multiplier": round(float(multiplier), 10),
|
||||
"urdf_mimic_policy": policy,
|
||||
"runtime_mapping_source": (
|
||||
"o12_vendor_solver_over_sdk_feedback_rad"
|
||||
if name in MEASURED_PASSIVE_JOINTS
|
||||
else "source_urdf_cad_mimic"
|
||||
),
|
||||
"raw_increasing_curve_branch": (
|
||||
"decreasing"
|
||||
if _task_for_joint(name).end_value > _task_for_joint(name).start_value
|
||||
if _task_for_joint(name).end_value
|
||||
> _task_for_joint(name).start_value
|
||||
else "increasing"
|
||||
),
|
||||
}
|
||||
if name in MEASURED_PASSIVE_JOINTS:
|
||||
joint.update({
|
||||
"measured_feedback_domain_rad": [
|
||||
round(float(value), 10) for value in feedback_domain
|
||||
],
|
||||
"visual_observation_role": "independent_dynamic_validation",
|
||||
"vendor_sdk_source_joint": sdk_source,
|
||||
"observed_coupling_model": observed.model,
|
||||
"observed_mimic_multiplier": round(
|
||||
float(observed.urdf_mimic_multiplier), 10
|
||||
),
|
||||
"observed_residual_rms_rad": round(
|
||||
float(observed.residual_rms_rad), 10
|
||||
),
|
||||
"observed_visual_arc_rad": round(
|
||||
float(result.visual_arc_diagnostics_rad[name]), 10
|
||||
),
|
||||
})
|
||||
joints[name] = joint
|
||||
|
||||
errors = np.abs(np.concatenate([
|
||||
np.asarray(values, dtype=float)
|
||||
@@ -213,7 +321,7 @@ def build_o12_runtime_payload(
|
||||
"model": "O12",
|
||||
"side": "right",
|
||||
"serial_number": str(serial_number),
|
||||
"calibration_scope": "full",
|
||||
"calibration_scope": "full_dynamic_except_thumb_mcp_static_zero",
|
||||
"publication_pointer": "latest_passed",
|
||||
"angle_unit": "rad",
|
||||
"curve_input_domain": "feedback_rad",
|
||||
@@ -243,8 +351,61 @@ def build_o12_runtime_payload(
|
||||
"validation_mae_rad": round(float(np.mean(errors)), 10),
|
||||
"validation_p95_rad": round(float(np.percentile(errors, 95.0)), 10),
|
||||
"validation_max_rad": round(float(np.max(errors)), 10),
|
||||
"roll_cross_view": {
|
||||
joint: {
|
||||
key: round(float(value), 10)
|
||||
for key, value in metrics.items()
|
||||
}
|
||||
for joint, metrics in result.cross_view_roll_metrics.items()
|
||||
},
|
||||
"thumb_root_spatial_zero": (
|
||||
None
|
||||
if result.thumb_root_zero_result is None
|
||||
else {
|
||||
"offsets_rad": {
|
||||
name: round(float(value), 10)
|
||||
for name, value in result.thumb_root_zero_result.direct_offsets_rad.items()
|
||||
},
|
||||
"validation_mae_rad": round(float(np.mean(np.abs(
|
||||
result.thumb_root_zero_result.validation_errors_rad
|
||||
))), 10),
|
||||
"validation_p95_rad": round(float(np.percentile(np.abs(
|
||||
result.thumb_root_zero_result.validation_errors_rad
|
||||
), 95.0)), 10),
|
||||
"validation_max_rad": round(float(np.max(np.abs(
|
||||
result.thumb_root_zero_result.validation_errors_rad
|
||||
))), 10),
|
||||
"axis_line_rms_m_diagnostic": round(float(
|
||||
result.thumb_root_zero_result.axis_line_rms_m
|
||||
), 10),
|
||||
"method": "roll_yaw_pitch_axes_with_pinky_palm_orientation",
|
||||
"validation_scope": "root_axis_angles_not_full_chain_position",
|
||||
}
|
||||
),
|
||||
"ring_transfer_source": "pinky_mcp_pitch",
|
||||
"full_hand_spatial_zero": (
|
||||
None if result.full_hand_zero_result is None else {
|
||||
**asdict(result.full_hand_zero_result),
|
||||
"policy": SPATIAL_ZERO_POLICY,
|
||||
"validation_scope": "active_zero_axis_geometry_not_fingertip_contact",
|
||||
}
|
||||
),
|
||||
"cad_static_joints_not_measured": [
|
||||
name for name, method in result.zero_method_by_joint.items()
|
||||
if str(method).startswith("source_cad_zero")
|
||||
],
|
||||
"static_zero_exclusions": {
|
||||
name: {
|
||||
"static_origin_source": "immutable_source_cad",
|
||||
"reason": "downstream_curved_shell_tag_not_absolute_axis_line_datum",
|
||||
"dynamic_curve_and_holdout_required": True,
|
||||
}
|
||||
for name in sorted(STATIC_ZERO_EXCLUDED_JOINTS)
|
||||
},
|
||||
"ring_cad_geometry_and_mimic_preserved": True,
|
||||
"active_angle_source": "tag_rotation_over_sdk_feedback_rad",
|
||||
"passive_runtime_source": "o12_vendor_solver_polynomial",
|
||||
"visual_measurement_role": "active_curve_and_passive_validation",
|
||||
},
|
||||
}
|
||||
validate_o12_runtime_payload(payload)
|
||||
@@ -306,8 +467,149 @@ def validate_o12_runtime_payload(payload: Mapping[str, Any]) -> None:
|
||||
raise ValueError(f"{name} has invalid O12 motor binding")
|
||||
if joint.get("raw_increasing_curve_branch") not in {"decreasing", "increasing"}:
|
||||
raise ValueError(f"{name} has invalid O12 direction binding")
|
||||
for name in ACTIVE_JOINTS:
|
||||
joint = joints[name]
|
||||
motor = COMMAND_INDEX_BY_JOINT[name]
|
||||
sign = float(SDK_TO_URDF_SIGN[motor])
|
||||
offset = float(joint.get("static_urdf_origin_offset_rad", math.nan))
|
||||
if not math.isfinite(offset) or abs(offset) > math.radians(20.0):
|
||||
raise ValueError(f"{name} has invalid O12 static origin offset")
|
||||
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||
differences = np.diff(np.asarray(joint[field], dtype=float))
|
||||
if np.any(sign * differences < -1.0e-7):
|
||||
raise ValueError(
|
||||
f"{name}.{field} disagrees with fixed O12 SDK/URDF direction"
|
||||
)
|
||||
for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items():
|
||||
if not math.isclose(
|
||||
float(joints[target]["static_urdf_origin_offset_rad"]),
|
||||
float(joints[donor]["static_urdf_origin_offset_rad"]),
|
||||
rel_tol=0.0, abs_tol=1.0e-9,
|
||||
):
|
||||
raise ValueError(f"{target} static zero differs from transfer donor {donor}")
|
||||
for name in PASSIVE_JOINTS:
|
||||
joint = joints[name]
|
||||
source_joint = str(joint.get("source_joint", ""))
|
||||
if source_joint != MIMIC_SOURCE_BY_JOINT[name]:
|
||||
raise ValueError(f"{name} has invalid O12 mimic source")
|
||||
multiplier = float(joint.get("mimic_multiplier", math.nan))
|
||||
coefficients = np.asarray(
|
||||
joint.get("coupling_coefficients", ()), dtype=float
|
||||
)
|
||||
if coefficients.shape != (6,) or not np.all(np.isfinite(coefficients)):
|
||||
raise ValueError(f"{name} has invalid O12 CAD mimic coefficients")
|
||||
if not math.isfinite(multiplier) or multiplier <= 0.0:
|
||||
raise ValueError(f"{name} has invalid O12 mimic multiplier")
|
||||
if name not in MEASURED_PASSIVE_JOINTS:
|
||||
offset = float(coefficients[0])
|
||||
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||
expected_values = offset + multiplier * np.asarray(
|
||||
joints[source_joint][field], dtype=float
|
||||
)
|
||||
if not np.allclose(
|
||||
np.asarray(joint[field], dtype=float),
|
||||
expected_values,
|
||||
rtol=0.0,
|
||||
atol=2.0e-9,
|
||||
):
|
||||
raise ValueError(
|
||||
f"{name}.{field} differs from the source CAD mimic chain"
|
||||
)
|
||||
if not bool(payload["quality"].get("passed")):
|
||||
raise ValueError("failed O12 calibration cannot be published")
|
||||
|
||||
|
||||
__all__ = ["ALL_REVOLUTE_JOINTS", "build_o12_runtime_payload", "validate_o12_runtime_payload"]
|
||||
def validate_o12_runtime_payload_against_urdf(
|
||||
payload: Mapping[str, Any], urdf: str | Path,
|
||||
*, source_urdf: str | Path | None = None,
|
||||
) -> None:
|
||||
"""Check curve bounds/mimics and, with original CAD, actual static frames.
|
||||
|
||||
This verifies the artifact contract, not full-hand physical accuracy.
|
||||
Passive runtime polynomials and linear URDF mimic are distinct models.
|
||||
"""
|
||||
validate_o12_runtime_payload(payload)
|
||||
metadata = _source_joint_metadata(urdf)
|
||||
joints = payload["joints"]
|
||||
for name, joint in joints.items():
|
||||
expected = metadata[name]
|
||||
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||
values = np.asarray(joint[field], dtype=float)
|
||||
allowance = 0.0
|
||||
if bool(joint.get("passive")):
|
||||
source_name = str(expected["source_joint"])
|
||||
if source_name != str(joint["source_joint"]):
|
||||
raise ValueError(
|
||||
f"{name} corrected URDF mimic source differs from payload"
|
||||
)
|
||||
if not math.isclose(
|
||||
float(expected["multiplier"]),
|
||||
float(joint["mimic_multiplier"]),
|
||||
rel_tol=0.0,
|
||||
abs_tol=2.0e-9,
|
||||
):
|
||||
raise ValueError(
|
||||
f"{name} corrected URDF mimic differs from payload"
|
||||
)
|
||||
if name not in MEASURED_PASSIVE_JOINTS:
|
||||
source_values = np.asarray(
|
||||
joints[source_name][field], dtype=float
|
||||
)
|
||||
cad_values = (
|
||||
float(expected["offset"])
|
||||
+ float(expected["multiplier"]) * source_values
|
||||
)
|
||||
if not np.allclose(
|
||||
values, cad_values, rtol=0.0, atol=2.0e-9
|
||||
):
|
||||
raise ValueError(
|
||||
f"{name}.{field} differs from preserved CAD mimic"
|
||||
)
|
||||
allowance = max(
|
||||
0.0,
|
||||
float(np.max(cad_values)) - float(expected["upper"]),
|
||||
float(expected["lower"]) - float(np.min(cad_values)),
|
||||
)
|
||||
if (
|
||||
float(np.min(values))
|
||||
< float(expected["lower"]) - allowance - 2.0e-9
|
||||
):
|
||||
raise ValueError(f"{name}.{field} is below the URDF physical limit")
|
||||
if (
|
||||
float(np.max(values))
|
||||
> float(expected["upper"]) + allowance + 2.0e-9
|
||||
):
|
||||
raise ValueError(f"{name}.{field} is above the URDF physical limit")
|
||||
if source_urdf is not None:
|
||||
original = ET.parse(source_urdf).getroot()
|
||||
corrected = ET.parse(urdf).getroot()
|
||||
for name in ACTIVE_JOINTS:
|
||||
before = original.find(f"joint[@name='{name}']")
|
||||
after = corrected.find(f"joint[@name='{name}']")
|
||||
if before is None or after is None:
|
||||
raise ValueError(f"{name} missing from static-frame validation")
|
||||
b_origin, a_origin = before.find("origin"), after.find("origin")
|
||||
if b_origin is None or a_origin is None:
|
||||
raise ValueError(f"{name} missing static origin")
|
||||
if b_origin.get("xyz") != a_origin.get("xyz"):
|
||||
raise ValueError(f"{name} static origin translation changed")
|
||||
axis_node = before.find("axis")
|
||||
if axis_node is None:
|
||||
raise ValueError(f"{name} missing CAD axis")
|
||||
axis = np.asarray([float(v) for v in axis_node.get("xyz").split()])
|
||||
axis /= np.linalg.norm(axis)
|
||||
b_rot = Rotation.from_euler("xyz", [float(v) for v in b_origin.get("rpy", "0 0 0").split()])
|
||||
a_rot = Rotation.from_euler("xyz", [float(v) for v in a_origin.get("rpy", "0 0 0").split()])
|
||||
expected_rot = b_rot * Rotation.from_rotvec(
|
||||
axis * float(joints[name]["static_urdf_origin_offset_rad"])
|
||||
)
|
||||
if (expected_rot.inv() * a_rot).magnitude() > 1.0e-8:
|
||||
raise ValueError(f"{name} static origin disagrees with payload")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALL_REVOLUTE_JOINTS",
|
||||
"build_o12_runtime_payload",
|
||||
"validate_o12_runtime_payload",
|
||||
"validate_o12_runtime_payload_against_urdf",
|
||||
]
|
||||
|
||||
@@ -2,26 +2,44 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..g20.profile import JointCurveFit
|
||||
from ..g20.zero_solver import fit_rotation_joint_curve, rotation_curve_holdout_errors
|
||||
from ..g20.profile import (
|
||||
HandCalibrationProfile,
|
||||
JointCurveFit,
|
||||
JointSpec,
|
||||
cross_view_roll_diagnostic_metrics,
|
||||
)
|
||||
from ..g20.zero_solver import (
|
||||
ZeroCalibrationProfile,
|
||||
ZeroSolveResult,
|
||||
fit_joint_axis_measurement,
|
||||
fit_rotation_joint_curve,
|
||||
rotation_curve_holdout_errors,
|
||||
solve_urdf_zero_offsets,
|
||||
with_depth_free_axis_projection,
|
||||
)
|
||||
from ..l6.fitting import MimicFit, fit_coupling_model
|
||||
from .profile import (
|
||||
CALIBRATED_ACTIVE_JOINTS,
|
||||
COMMAND_INDEX_BY_JOINT,
|
||||
COMMAND_NAMES,
|
||||
GEOMETRIC_ZERO_JOINTS,
|
||||
ROOT_GEOMETRIC_ZERO_JOINTS,
|
||||
MEASURED_PASSIVE_JOINTS,
|
||||
MIMIC_SOURCE_BY_JOINT,
|
||||
SDK_TO_URDF_JOINT,
|
||||
SDK_TO_URDF_SIGN,
|
||||
STATIC_ZERO_EXCLUDED_JOINTS,
|
||||
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
build_typed_profile,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class O12FitResult:
|
||||
curves: Mapping[str, JointCurveFit]
|
||||
@@ -29,6 +47,86 @@ class O12FitResult:
|
||||
travels_rad: Mapping[str, float]
|
||||
mimic_fits: Mapping[str, MimicFit]
|
||||
holdout_errors_rad: Mapping[str, tuple[float, ...]]
|
||||
cross_view_roll_metrics: Mapping[str, Mapping[str, float]]
|
||||
visual_arc_diagnostics_rad: Mapping[str, float]
|
||||
feedback_domains_rad: Mapping[str, tuple[float, float]]
|
||||
zero_method_by_joint: Mapping[str, str]
|
||||
thumb_root_zero_result: ZeroSolveResult | None
|
||||
full_hand_zero_result: ZeroSolveResult | None = None
|
||||
|
||||
|
||||
O12_THUMB_ROOT_AXIS_JOINTS: tuple[str, ...] = (
|
||||
"thumb_cmc_roll",
|
||||
"thumb_cmc_yaw",
|
||||
"thumb_cmc_pitch",
|
||||
"pinky_mcp_pitch",
|
||||
)
|
||||
|
||||
|
||||
def _thumb_root_zero_profile() -> ZeroCalibrationProfile:
|
||||
"""Declare the spatial graph that observes O12 thumb roll/yaw zeros.
|
||||
|
||||
A relative Tag rotation observes roll travel but not its constant phase.
|
||||
The downstream yaw screw axis rotates with that phase. The independently
|
||||
observed pinky MCP axis fixes palm orientation, leaving roll as the only
|
||||
fitted roll coordinate. The pitch axis in turn observes yaw phase.
|
||||
Parallel pitch/MCP axes do not identify their own phase from direction;
|
||||
those origins retain CAD values instead of inferring zeros from travel.
|
||||
"""
|
||||
specs = {
|
||||
name: JointSpec(
|
||||
name,
|
||||
COMMAND_INDEX_BY_JOINT[name],
|
||||
True,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
pose_axis_line_required=False,
|
||||
)
|
||||
for name in O12_THUMB_ROOT_AXIS_JOINTS
|
||||
}
|
||||
hand = HandCalibrationProfile(
|
||||
side="right",
|
||||
reference_finger="pinky",
|
||||
view_tags={},
|
||||
preflight_view_roles={},
|
||||
joint_specs=specs,
|
||||
sweep_specs=(),
|
||||
image_trajectory_joints=frozenset(),
|
||||
roll_clearance_commands={},
|
||||
thumb_pitch_clearance_commands={},
|
||||
layout_id="o12_right_16",
|
||||
model="O12",
|
||||
command_names=COMMAND_NAMES,
|
||||
baseline_command=(255,) * len(COMMAND_NAMES),
|
||||
directional_zero=True,
|
||||
isolated_holdout=True,
|
||||
# As in the G20 multi-view solver, a repeatable planar-PnP cone bias
|
||||
# is diagnostic rather than a zero-phase failure. Acceptance still
|
||||
# requires the per-cycle cone magnitude to be stable, the fitted axis
|
||||
# lines to pass, and the independent holdout to improve.
|
||||
stable_cross_view_cone_bias=True,
|
||||
)
|
||||
return ZeroCalibrationProfile(
|
||||
hand=hand,
|
||||
direct_zero_joints=("thumb_cmc_roll", "thumb_cmc_yaw"),
|
||||
axis_joints=O12_THUMB_ROOT_AXIS_JOINTS,
|
||||
inherited_zero_joints={},
|
||||
inherited_static_zero_joints={},
|
||||
constrained_circle_joints=frozenset(O12_THUMB_ROOT_AXIS_JOINTS),
|
||||
root_anchor_joints=frozenset({"thumb_cmc_roll"}),
|
||||
axis_parent_joint={"thumb_cmc_yaw": "thumb_cmc_roll", "thumb_cmc_pitch": "thumb_cmc_yaw"},
|
||||
phase_parent_joint={},
|
||||
offset_observer_joint={"thumb_cmc_roll": "thumb_cmc_yaw", "thumb_cmc_yaw": "thumb_cmc_pitch"},
|
||||
same_view_axis_pair_by_offset={},
|
||||
fixed_direct_zero_offsets_rad={},
|
||||
static_output_zero_offsets_rad={},
|
||||
base_pose_strategy="thumb_serial",
|
||||
orientation_anchor_joint="pinky_mcp_pitch",
|
||||
directed_base_axis_joints=frozenset({
|
||||
"thumb_cmc_roll", "pinky_mcp_pitch",
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
def _task_by_joint() -> dict[str, Any]:
|
||||
@@ -39,51 +137,168 @@ def _task_by_joint() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def feedback_rad_to_curve_index(joint: str, feedback_rad: float) -> float:
|
||||
"""Map a physical feedback angle to the fitter's normalized 255..0 axis."""
|
||||
def feedback_rad_to_curve_index(
|
||||
joint: str,
|
||||
feedback_rad: float,
|
||||
feedback_domain_rad: tuple[float, float] | None = None,
|
||||
) -> float:
|
||||
"""Map measured feedback to the fitter's normalized 255..0 axis."""
|
||||
task = _task_by_joint()[str(joint)]
|
||||
denominator = task.end_value - task.start_value
|
||||
if feedback_domain_rad is None:
|
||||
start, end = float(task.start_value), float(task.end_value)
|
||||
else:
|
||||
lower, upper = (float(value) for value in feedback_domain_rad)
|
||||
if task.end_value >= task.start_value:
|
||||
start, end = lower, upper
|
||||
else:
|
||||
start, end = upper, lower
|
||||
denominator = end - start
|
||||
if abs(denominator) <= 1.0e-12:
|
||||
raise ValueError(f"O12 task {task.key} has a degenerate range")
|
||||
phase = (float(feedback_rad) - task.start_value) / denominator
|
||||
phase = (float(feedback_rad) - start) / denominator
|
||||
return 255.0 * (1.0 - float(np.clip(phase, 0.0, 1.0)))
|
||||
|
||||
|
||||
def curve_input_knots_rad(joint: str, count: int = 65) -> tuple[float, ...]:
|
||||
def curve_input_knots_rad(
|
||||
joint: str,
|
||||
count: int = 65,
|
||||
feedback_domain_rad: tuple[float, float] | None = None,
|
||||
) -> tuple[float, ...]:
|
||||
task = _task_by_joint()[str(joint)]
|
||||
if feedback_domain_rad is None:
|
||||
lower, upper = sorted((task.start_value, task.end_value))
|
||||
else:
|
||||
lower, upper = sorted(float(value) for value in feedback_domain_rad)
|
||||
# Zero is the open/centred runtime command. Include it in the public
|
||||
# knot domain while clamping the tiny unobserved offset to the measured
|
||||
# endpoint instead of extrapolating a full SDK command range.
|
||||
lower, upper = min(0.0, lower), max(0.0, upper)
|
||||
return tuple(
|
||||
float(value)
|
||||
for value in np.linspace(
|
||||
min(task.start_value, task.end_value),
|
||||
max(task.start_value, task.end_value),
|
||||
lower,
|
||||
upper,
|
||||
int(count),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def curve_values_at_rad(
|
||||
joint: str, fit: JointCurveFit, inputs_rad: Sequence[float], branch: str
|
||||
joint: str,
|
||||
fit: JointCurveFit,
|
||||
inputs_rad: Sequence[float],
|
||||
branch: str,
|
||||
feedback_domain_rad: tuple[float, float] | None = None,
|
||||
) -> tuple[float, ...]:
|
||||
values = np.asarray(getattr(fit, branch), dtype=float)
|
||||
indices = np.arange(256, dtype=float)
|
||||
return tuple(
|
||||
float(np.interp(feedback_rad_to_curve_index(joint, value), indices, values))
|
||||
float(np.interp(
|
||||
feedback_rad_to_curve_index(joint, value, feedback_domain_rad),
|
||||
indices,
|
||||
values,
|
||||
))
|
||||
for value in inputs_rad
|
||||
)
|
||||
|
||||
|
||||
def _virtual_records(
|
||||
joint: str, rows: Sequence[Mapping[str, Any]]
|
||||
joint: str,
|
||||
rows: Sequence[Mapping[str, Any]],
|
||||
feedback_domain_rad: tuple[float, float] | None = None,
|
||||
feedback_domains_rad: Mapping[str, tuple[float, float]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for source in rows:
|
||||
row = dict(source)
|
||||
curve_index = feedback_rad_to_curve_index(joint, float(row["feedback_rad"]))
|
||||
curve_index = feedback_rad_to_curve_index(
|
||||
joint, float(row["feedback_rad"]), feedback_domain_rad
|
||||
)
|
||||
row["command_u8"] = int(np.clip(round(curve_index), 0, 255))
|
||||
if "state_rad" in row:
|
||||
state_rad = tuple(float(value) for value in row["state_rad"])
|
||||
if len(state_rad) != len(SDK_TO_URDF_JOINT):
|
||||
raise ValueError("O12 state_rad has the wrong channel count")
|
||||
state_u8 = []
|
||||
for channel, value in enumerate(state_rad):
|
||||
state_joint = SDK_TO_URDF_JOINT[channel]
|
||||
state_joint = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(
|
||||
state_joint, state_joint
|
||||
)
|
||||
state_u8.append(
|
||||
feedback_rad_to_curve_index(
|
||||
state_joint,
|
||||
value,
|
||||
(feedback_domains_rad or {}).get(state_joint),
|
||||
)
|
||||
)
|
||||
row["state_u8"] = state_u8
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def _measured_feedback_domain(
|
||||
joint: str, rows: Sequence[Mapping[str, Any]]
|
||||
) -> tuple[float, float]:
|
||||
"""Return the full-stroke feedback interval established by cycle zero."""
|
||||
values = [
|
||||
float(row["feedback_rad"])
|
||||
for row in rows
|
||||
if int(row.get("cycle", -1)) == 0
|
||||
and math.isfinite(float(row.get("feedback_rad", math.nan)))
|
||||
]
|
||||
if not values or max(values) - min(values) <= 1.0e-9:
|
||||
raise ValueError(f"{joint} cycle 0 has no measurable feedback travel")
|
||||
return float(min(values)), float(max(values))
|
||||
|
||||
|
||||
def _validate_cycle_repeatability(
|
||||
joint: str,
|
||||
rows: Sequence[Mapping[str, Any]],
|
||||
feedback_domain_rad: tuple[float, float],
|
||||
) -> None:
|
||||
"""Require later cycles to repeat measured travel, not command scale."""
|
||||
reference = feedback_domain_rad[1] - feedback_domain_rad[0]
|
||||
for cycle in (1, 2, 3):
|
||||
values = [
|
||||
float(row["feedback_rad"])
|
||||
for row in rows
|
||||
if int(row.get("cycle", -1)) == cycle
|
||||
and math.isfinite(float(row.get("feedback_rad", math.nan)))
|
||||
]
|
||||
span = max(values) - min(values) if values else 0.0
|
||||
if span < 0.90 * reference:
|
||||
raise ValueError(
|
||||
f"{joint} cycle {cycle} repeats only "
|
||||
f"{span / reference:.3f} of cycle-zero measured travel"
|
||||
)
|
||||
|
||||
|
||||
def _uniform_curve_holdout_errors(
|
||||
rows: Sequence[Mapping[str, Any]], errors: Sequence[float]
|
||||
) -> tuple[float, ...]:
|
||||
"""Give every observed feedback bin equal holdout weight.
|
||||
|
||||
O12 motor feedback can saturate before a passive tendon joint has fully
|
||||
settled. A camera then records many frames at one identical feedback bin.
|
||||
The curve fitter already uses one median per bin; validation must use the
|
||||
same curve-space measure instead of letting endpoint dwell duration
|
||||
multiply the P95 weight of that single coordinate.
|
||||
"""
|
||||
if len(rows) != len(errors):
|
||||
raise ValueError("O12 holdout rows and errors have different lengths")
|
||||
grouped: dict[tuple[str, int], list[float]] = {}
|
||||
for row, error in zip(rows, errors):
|
||||
grouped.setdefault(
|
||||
(str(row["direction"]), int(row["command_u8"])), []
|
||||
).append(float(error))
|
||||
if not grouped:
|
||||
raise ValueError("O12 holdout has no observed feedback bins")
|
||||
return tuple(
|
||||
float(np.median(grouped[key])) for key in sorted(grouped)
|
||||
)
|
||||
|
||||
|
||||
def _travel(fit: JointCurveFit) -> float:
|
||||
return 0.5 * (
|
||||
float(fit.decreasing_rad[0] - fit.decreasing_rad[255])
|
||||
@@ -91,11 +306,134 @@ def _travel(fit: JointCurveFit) -> float:
|
||||
)
|
||||
|
||||
|
||||
def measured_curve_bounds(
|
||||
name: str,
|
||||
fit: JointCurveFit,
|
||||
feedback_domain_rad: tuple[float, float],
|
||||
*,
|
||||
sign: float,
|
||||
) -> tuple[float, float]:
|
||||
"""Return the zero-referenced public URDF range of one O12 curve."""
|
||||
inputs = curve_input_knots_rad(name, feedback_domain_rad=feedback_domain_rad)
|
||||
branches = []
|
||||
for branch in ("decreasing_rad", "increasing_rad"):
|
||||
values = np.asarray(
|
||||
curve_values_at_rad(name, fit, inputs, branch, feedback_domain_rad),
|
||||
dtype=float,
|
||||
)
|
||||
values -= float(np.interp(0.0, np.asarray(inputs, dtype=float), values))
|
||||
branches.append(values)
|
||||
average = 0.5 * (branches[0] + branches[1])
|
||||
direction = float(np.sign(average[-1] - average[0]))
|
||||
if direction not in {0.0, float(sign)}:
|
||||
branches = [-values for values in branches]
|
||||
average = -average
|
||||
values = np.concatenate([average, *branches])
|
||||
return float(np.min(values)), float(np.max(values))
|
||||
|
||||
|
||||
def _has_complete_thumb_root_geometry(
|
||||
records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
|
||||
) -> bool:
|
||||
required = {
|
||||
"relative_translation_xyz_m",
|
||||
"parent_pose_common",
|
||||
"child_pose_common",
|
||||
"view_normal_common_xyz",
|
||||
"camera_center_common_xyz_m",
|
||||
"state_rad",
|
||||
}
|
||||
return all(
|
||||
rows and all(required.issubset(row) for row in rows)
|
||||
for name in O12_THUMB_ROOT_AXIS_JOINTS
|
||||
for rows in (records_by_joint.get(name, ()),)
|
||||
)
|
||||
|
||||
|
||||
def _fit_thumb_root_zero(
|
||||
source_urdf: str | Path,
|
||||
records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
|
||||
curves: Mapping[str, JointCurveFit],
|
||||
feedback_domains_rad: Mapping[str, tuple[float, float]],
|
||||
) -> ZeroSolveResult:
|
||||
"""Fit the O12 root thumb phases with the shared G20 geometry kernel."""
|
||||
profile = _thumb_root_zero_profile()
|
||||
measurements = []
|
||||
for cycle in range(4):
|
||||
for name in O12_THUMB_ROOT_AXIS_JOINTS:
|
||||
rows = _virtual_records(
|
||||
name,
|
||||
records_by_joint[name],
|
||||
feedback_domains_rad[name],
|
||||
feedback_domains_rad,
|
||||
)
|
||||
measurement = fit_joint_axis_measurement(
|
||||
name,
|
||||
rows,
|
||||
cycle=cycle,
|
||||
zero_command_u8=255,
|
||||
constrained_circle_joints=profile.constrained_circle_joints,
|
||||
view_normal_common_xyz=rows[0]["view_normal_common_xyz"],
|
||||
canonical_zero_direction="decreasing",
|
||||
)
|
||||
measurements.append(with_depth_free_axis_projection(
|
||||
measurement,
|
||||
rows[0]["camera_center_common_xyz_m"],
|
||||
))
|
||||
|
||||
result = solve_urdf_zero_offsets(
|
||||
source_urdf=source_urdf,
|
||||
measurements=measurements,
|
||||
curves=curves,
|
||||
motor_by_joint={
|
||||
name: COMMAND_INDEX_BY_JOINT[name]
|
||||
for name in O12_THUMB_ROOT_AXIS_JOINTS
|
||||
},
|
||||
training_cycles=(0, 1, 2),
|
||||
validation_cycle=3,
|
||||
maximum_offset_rad=math.radians(20.0),
|
||||
finger_maximum_offset_rad=math.radians(20.0),
|
||||
joint_maximum_offset_rad={
|
||||
name: math.radians(20.0) for name in ROOT_GEOMETRIC_ZERO_JOINTS
|
||||
},
|
||||
maximum_cycle_difference_rad=math.radians(0.75),
|
||||
minimum_applied_offset_rad=math.radians(0.1),
|
||||
maximum_validation_mae_rad=math.radians(1.0),
|
||||
maximum_validation_p95_rad=math.radians(2.0),
|
||||
maximum_validation_error_rad=math.radians(3.0),
|
||||
maximum_confidence_half_width_rad=math.radians(1.0),
|
||||
# Axis-line displacement is retained as a diagnostic. Roll phase is
|
||||
# determined by axis directions, so monocular depth/Tag placement is
|
||||
# not allowed to reject an otherwise repeatable angular solution.
|
||||
maximum_pose_axis_line_rms_m=0.0015,
|
||||
maximum_systematic_axis_cone_bias_rad=math.radians(15.0),
|
||||
hand_type="right",
|
||||
tag_layout="o12_right_16",
|
||||
zero_profile=profile,
|
||||
)
|
||||
if not result.passed:
|
||||
details = ",".join(
|
||||
f"{name}={reason}"
|
||||
for name, reason in sorted(result.failure_reasons.items())
|
||||
)
|
||||
raise ValueError("O12 thumb root spatial zero solve failed:" + details)
|
||||
return result
|
||||
|
||||
|
||||
def fit_o12_session(
|
||||
source_urdf: str | Path,
|
||||
records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
|
||||
*,
|
||||
cross_view_records_by_joint: Mapping[
|
||||
str, Sequence[Mapping[str, Any]]
|
||||
] | None = None,
|
||||
require_cross_view: bool = False,
|
||||
require_thumb_root_spatial_zero: bool = False,
|
||||
require_full_hand_spatial_zero: bool = False,
|
||||
) -> O12FitResult:
|
||||
del source_urdf # topology is validated by the immutable product loader
|
||||
# SDK feedback supplies the continuous radian input domain; Tag relative
|
||||
# rotation supplies the corresponding physical URDF coordinate and the
|
||||
# shared G20 geometry kernel solves observable static thumb phase.
|
||||
expected = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS
|
||||
if set(records_by_joint) != expected:
|
||||
missing = sorted(expected - set(records_by_joint))
|
||||
@@ -104,16 +442,40 @@ def fit_o12_session(
|
||||
curves: dict[str, JointCurveFit] = {}
|
||||
holdout: dict[str, tuple[float, ...]] = {}
|
||||
cycle_curves: dict[str, dict[int, JointCurveFit]] = {}
|
||||
feedback_domains = {
|
||||
joint: _measured_feedback_domain(joint, records_by_joint[joint])
|
||||
for joint in expected
|
||||
}
|
||||
virtual_by_joint = {
|
||||
joint: _virtual_records(
|
||||
joint,
|
||||
records_by_joint[joint],
|
||||
feedback_domains[joint],
|
||||
feedback_domains,
|
||||
)
|
||||
for joint in expected
|
||||
}
|
||||
for joint in sorted(expected):
|
||||
rows = _virtual_records(joint, records_by_joint[joint])
|
||||
rows = virtual_by_joint[joint]
|
||||
_validate_cycle_repeatability(joint, rows, feedback_domains[joint])
|
||||
training = [row for row in rows if int(row["cycle"]) in {0, 1, 2}]
|
||||
validation = [row for row in rows if int(row["cycle"]) == 3]
|
||||
if not training or not validation:
|
||||
raise ValueError(f"{joint} is missing training or holdout records")
|
||||
fit = fit_rotation_joint_curve(
|
||||
training, zero_command_u8=255, canonical_zero_direction="decreasing"
|
||||
training,
|
||||
zero_command_u8=255,
|
||||
canonical_zero_direction="decreasing",
|
||||
require_observed_domain_endpoints=False,
|
||||
zero_reference_maximum_distance_u8=None,
|
||||
)
|
||||
errors = rotation_curve_holdout_errors(fit, validation, zero_command_u8=255)
|
||||
frame_errors = rotation_curve_holdout_errors(
|
||||
fit,
|
||||
validation,
|
||||
zero_command_u8=255,
|
||||
zero_reference_maximum_distance_u8=None,
|
||||
)
|
||||
errors = _uniform_curve_holdout_errors(validation, frame_errors)
|
||||
absolute = np.abs(np.asarray(errors, dtype=float))
|
||||
if (
|
||||
float(np.mean(absolute)) > math.radians(1.0)
|
||||
@@ -121,8 +483,6 @@ def fit_o12_session(
|
||||
or float(np.max(absolute)) > math.radians(3.0)
|
||||
):
|
||||
raise ValueError(f"{joint} isolated holdout failed")
|
||||
if fit.maximum_hysteresis_rad > math.radians(2.0):
|
||||
raise ValueError(f"{joint} hysteresis exceeds 2 degrees")
|
||||
curves[joint] = fit
|
||||
holdout[joint] = tuple(float(value) for value in errors)
|
||||
cycle_curves[joint] = {
|
||||
@@ -130,6 +490,8 @@ def fit_o12_session(
|
||||
[row for row in training if int(row["cycle"]) == cycle],
|
||||
zero_command_u8=255,
|
||||
canonical_zero_direction="decreasing",
|
||||
require_observed_domain_endpoints=False,
|
||||
zero_reference_maximum_distance_u8=None,
|
||||
)
|
||||
for cycle in (0, 1, 2)
|
||||
}
|
||||
@@ -151,31 +513,176 @@ def fit_o12_session(
|
||||
target,
|
||||
curves[source],
|
||||
curves[target],
|
||||
model="quadratic_runtime",
|
||||
# Retain the Tag-derived coupling as an independent diagnostic.
|
||||
# Deployment uses the profile-declared vendor O12 polynomial.
|
||||
model="direction_aware_knots",
|
||||
cycle_curve_pairs=cycle_pairs,
|
||||
minimum_multiplier=0.5,
|
||||
maximum_multiplier=2.2,
|
||||
)
|
||||
|
||||
cross_view_rows = {
|
||||
str(name): tuple(values)
|
||||
for name, values in (cross_view_records_by_joint or {}).items()
|
||||
}
|
||||
required_roll = {"middle_mcp_roll", "index_mcp_roll"}
|
||||
if require_cross_view and set(cross_view_rows) != required_roll:
|
||||
raise ValueError(
|
||||
"O12 roll cross-view records are incomplete: "
|
||||
f"missing={sorted(required_roll - set(cross_view_rows))} "
|
||||
f"extra={sorted(set(cross_view_rows) - required_roll)}"
|
||||
)
|
||||
cross_view_metrics: dict[str, dict[str, float]] = {}
|
||||
for joint, source_rows in sorted(cross_view_rows.items()):
|
||||
if joint not in required_roll:
|
||||
raise ValueError(f"unexpected O12 roll cross-view joint: {joint}")
|
||||
rows = _virtual_records(
|
||||
joint,
|
||||
source_rows,
|
||||
feedback_domains[joint],
|
||||
feedback_domains,
|
||||
)
|
||||
training = [row for row in rows if int(row["cycle"]) in {0, 1, 2}]
|
||||
validation = [row for row in rows if int(row["cycle"]) == 3]
|
||||
if not training or not validation:
|
||||
raise ValueError(f"{joint} side view lacks training or holdout records")
|
||||
secondary = fit_rotation_joint_curve(
|
||||
training,
|
||||
zero_command_u8=255,
|
||||
canonical_zero_direction="decreasing",
|
||||
require_observed_domain_endpoints=False,
|
||||
zero_reference_maximum_distance_u8=None,
|
||||
)
|
||||
frame_errors = rotation_curve_holdout_errors(
|
||||
secondary,
|
||||
validation,
|
||||
zero_command_u8=255,
|
||||
zero_reference_maximum_distance_u8=None,
|
||||
)
|
||||
errors = _uniform_curve_holdout_errors(validation, frame_errors)
|
||||
absolute = np.abs(np.asarray(errors, dtype=float))
|
||||
if (
|
||||
float(np.mean(absolute)) > math.radians(1.0)
|
||||
or float(np.percentile(absolute, 95.0)) > math.radians(2.0)
|
||||
or float(np.max(absolute)) > math.radians(3.0)
|
||||
):
|
||||
raise ValueError(f"{joint} side-view isolated holdout failed")
|
||||
metrics = cross_view_roll_diagnostic_metrics(curves[joint], secondary)
|
||||
if metrics.get("direction_disagrees", 0.0):
|
||||
raise ValueError(f"{joint} cross-view roll direction disagrees")
|
||||
metrics.update({
|
||||
"holdout_mae_rad": float(np.mean(absolute)),
|
||||
"holdout_p95_rad": float(np.percentile(absolute, 95.0)),
|
||||
"holdout_max_rad": float(np.max(absolute)),
|
||||
"maximum_hysteresis_rad": float(
|
||||
secondary.maximum_hysteresis_rad
|
||||
),
|
||||
"sample_count": float(len(rows)),
|
||||
})
|
||||
cross_view_metrics[joint] = metrics
|
||||
|
||||
visual_arcs = {joint: _travel(curves[joint]) for joint in expected}
|
||||
travels = {
|
||||
joint: _travel(curves[joint]) for joint in CALIBRATED_ACTIVE_JOINTS
|
||||
joint: visual_arcs[joint] for joint in CALIBRATED_ACTIVE_JOINTS
|
||||
}
|
||||
for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items():
|
||||
travels[target] = travels[donor]
|
||||
# O12 active feedback is already a physical angle with open/centred zero.
|
||||
# Calibration publishes the visual transfer curves and measured travel;
|
||||
# it must not invent a static offset from an arbitrary Tag mounting angle.
|
||||
offsets = {joint: 0.0 for joint in travels}
|
||||
return O12FitResult(
|
||||
offsets = {name: 0.0 for name in SDK_TO_URDF_JOINT}
|
||||
zero_methods = {name: "source_cad_zero_not_measured" for name in offsets}
|
||||
for name in STATIC_ZERO_EXCLUDED_JOINTS:
|
||||
zero_methods[name] = "source_cad_zero_profile_excluded"
|
||||
if (
|
||||
require_thumb_root_spatial_zero
|
||||
and not _has_complete_thumb_root_geometry(records_by_joint)
|
||||
):
|
||||
raise ValueError(
|
||||
"O12 thumb root absolute zero requires common-frame roll, yaw, pitch "
|
||||
"and palm-orientation axis trajectories"
|
||||
)
|
||||
thumb_root_zero_result = None
|
||||
full_hand_zero_result = None
|
||||
spatial_error = None
|
||||
if require_full_hand_spatial_zero:
|
||||
from .zero import AXIS_JOINTS, O12SpatialZeroError, motor_index, solve_full_hand_zero
|
||||
required = {"relative_translation_xyz_m", "parent_pose_common", "child_pose_common",
|
||||
"view_normal_common_xyz", "camera_center_common_xyz_m", "state_rad"}
|
||||
missing = [name for name in AXIS_JOINTS if not records_by_joint.get(name)
|
||||
or any(not required.issubset(row) for row in records_by_joint[name])]
|
||||
if missing:
|
||||
raise O12SpatialZeroError(
|
||||
"O12 full-hand spatial zero requires pose observations:" + ",".join(missing),
|
||||
{"passed": False, "stage": "missing_geometry", "joints": missing},
|
||||
)
|
||||
# G20's geometry kernel uses a normalized 256-entry coordinate, not
|
||||
# literal u8 motor commands. Rebase that private curve at SDK feedback
|
||||
# zero and apply the same sign contract as the public radian mapper.
|
||||
solver_curves = {}
|
||||
for name in AXIS_JOINTS:
|
||||
fit = curves[name]
|
||||
domain = feedback_domains[name]
|
||||
baseline = feedback_rad_to_curve_index(name, 0., domain)
|
||||
lo = feedback_rad_to_curve_index(name, domain[0], domain)
|
||||
hi = feedback_rad_to_curve_index(name, domain[1], domain)
|
||||
values = np.asarray(fit.angle_rad, dtype=float)
|
||||
direction = np.sign(np.interp(hi, np.arange(256), values)
|
||||
- np.interp(lo, np.arange(256), values))
|
||||
multiplier = 1. if direction == SDK_TO_URDF_SIGN[motor_index(name)] else -1.
|
||||
fields = {}
|
||||
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||
values = np.asarray(getattr(fit, field), dtype=float)
|
||||
fields[field] = tuple(multiplier * (values - np.interp(baseline, np.arange(256), values)))
|
||||
solver_curves[name] = replace(fit, **fields)
|
||||
try:
|
||||
full_hand_zero_result = solve_full_hand_zero(
|
||||
source_urdf,
|
||||
{name: _virtual_records(name, records_by_joint[name], feedback_domains[name], feedback_domains)
|
||||
for name in AXIS_JOINTS},
|
||||
solver_curves,
|
||||
)
|
||||
except O12SpatialZeroError as error:
|
||||
if "result" not in error.diagnostics:
|
||||
raise # Missing/unobservable geometry has no review estimate.
|
||||
full_hand_zero_result = ZeroSolveResult(**error.diagnostics["result"])
|
||||
spatial_error = error
|
||||
for name in GEOMETRIC_ZERO_JOINTS:
|
||||
offsets[name] = float(full_hand_zero_result.direct_offsets_rad[name])
|
||||
zero_methods[name] = "urdf_serial_axis_geometry"
|
||||
elif _has_complete_thumb_root_geometry(records_by_joint):
|
||||
thumb_root_zero_result = _fit_thumb_root_zero(
|
||||
source_urdf,
|
||||
records_by_joint,
|
||||
curves,
|
||||
feedback_domains,
|
||||
)
|
||||
for name in ROOT_GEOMETRIC_ZERO_JOINTS:
|
||||
offsets[name] = float(thumb_root_zero_result.direct_offsets_rad[name])
|
||||
zero_methods[name] = "urdf_serial_axis_geometry"
|
||||
# Transfer the scalar correction, never the donor's origin transform.
|
||||
# The writer composes it with the ring's own CAD joint frame.
|
||||
for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items():
|
||||
offsets[target] = offsets[donor]
|
||||
zero_methods[target] = "transferred_static_zero_on_own_cad"
|
||||
result = O12FitResult(
|
||||
curves=curves,
|
||||
zero_offsets_rad=offsets,
|
||||
travels_rad=travels,
|
||||
mimic_fits=mimic_fits,
|
||||
holdout_errors_rad=holdout,
|
||||
cross_view_roll_metrics=cross_view_metrics,
|
||||
visual_arc_diagnostics_rad=visual_arcs,
|
||||
feedback_domains_rad=feedback_domains,
|
||||
zero_method_by_joint=zero_methods,
|
||||
full_hand_zero_result=full_hand_zero_result,
|
||||
thumb_root_zero_result=thumb_root_zero_result,
|
||||
)
|
||||
if spatial_error is not None:
|
||||
spatial_error.review_fit = result
|
||||
raise spatial_error
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"O12FitResult", "curve_input_knots_rad", "curve_values_at_rad",
|
||||
"feedback_rad_to_curve_index", "fit_o12_session",
|
||||
"O12FitResult", "O12_THUMB_ROOT_AXIS_JOINTS",
|
||||
"curve_input_knots_rad", "curve_values_at_rad",
|
||||
"feedback_rad_to_curve_index", "fit_o12_session", "measured_curve_bounds",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""O12-specific interpretation of the vendor motor error report.
|
||||
|
||||
The Pro2025 SDK documents ``commu_except`` as a possibly historical flag. It
|
||||
must therefore be interpreted together with fresh command-triggered feedback;
|
||||
the other four flags are active motor faults and are never downgraded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
ERROR_STALLED = 1 << 0
|
||||
ERROR_OVERHEAT = 1 << 1
|
||||
ERROR_OVER_CURRENT = 1 << 2
|
||||
ERROR_MOTOR_EXCEPTION = 1 << 3
|
||||
ERROR_COMMUNICATION_EXCEPTION = 1 << 4
|
||||
ACTIVE_MOTOR_FAULT_MASK = (
|
||||
ERROR_STALLED
|
||||
| ERROR_OVERHEAT
|
||||
| ERROR_OVER_CURRENT
|
||||
| ERROR_MOTOR_EXCEPTION
|
||||
)
|
||||
DOCUMENTED_ERROR_MASK = ACTIVE_MOTOR_FAULT_MASK | ERROR_COMMUNICATION_EXCEPTION
|
||||
|
||||
ERROR_BIT_NAMES = (
|
||||
"stalled",
|
||||
"overheat",
|
||||
"over_current",
|
||||
"motor_except",
|
||||
"commu_except",
|
||||
)
|
||||
|
||||
HISTORICAL_COMMUNICATION_MINIMUM_REPORTS = 3
|
||||
HISTORICAL_COMMUNICATION_CONFIRM_SECONDS = 1.5
|
||||
HEALTH_FEEDBACK_MAXIMUM_AGE_SECONDS = 0.25
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class O12ErrorAssessment:
|
||||
codes: tuple[int, ...]
|
||||
valid: bool
|
||||
active_fault_channels: tuple[int, ...]
|
||||
communication_channels: tuple[int, ...]
|
||||
unknown_bit_channels: tuple[int, ...]
|
||||
|
||||
@property
|
||||
def clear(self) -> bool:
|
||||
return self.valid and not any(self.codes)
|
||||
|
||||
@property
|
||||
def communication_only(self) -> bool:
|
||||
return bool(
|
||||
self.valid
|
||||
and self.communication_channels
|
||||
and not self.active_fault_channels
|
||||
and not self.unknown_bit_channels
|
||||
)
|
||||
|
||||
|
||||
def assess_o12_error_report(values) -> O12ErrorAssessment:
|
||||
"""Decode one complete 12-motor O12 report without hiding any bit."""
|
||||
try:
|
||||
codes = tuple(int(value) for value in values)
|
||||
except (TypeError, ValueError):
|
||||
codes = ()
|
||||
valid = len(codes) == 12 and all(0 <= value <= 0xFFFF for value in codes)
|
||||
if not valid:
|
||||
return O12ErrorAssessment(codes, False, (), (), ())
|
||||
return O12ErrorAssessment(
|
||||
codes=codes,
|
||||
valid=True,
|
||||
active_fault_channels=tuple(
|
||||
index for index, value in enumerate(codes)
|
||||
if value & ACTIVE_MOTOR_FAULT_MASK
|
||||
),
|
||||
communication_channels=tuple(
|
||||
index for index, value in enumerate(codes)
|
||||
if value & ERROR_COMMUNICATION_EXCEPTION
|
||||
),
|
||||
unknown_bit_channels=tuple(
|
||||
index for index, value in enumerate(codes)
|
||||
if value & ~DOCUMENTED_ERROR_MASK
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def decoded_faults(codes: tuple[int, ...]) -> tuple[dict[str, object], ...]:
|
||||
return tuple(
|
||||
{
|
||||
"motor_index": index,
|
||||
"code": int(value),
|
||||
"flags": tuple(
|
||||
name for bit, name in enumerate(ERROR_BIT_NAMES)
|
||||
if int(value) & (1 << bit)
|
||||
),
|
||||
}
|
||||
for index, value in enumerate(codes)
|
||||
if value
|
||||
)
|
||||
|
||||
|
||||
def historical_communication_latch_confirmed(
|
||||
*,
|
||||
assessment: O12ErrorAssessment,
|
||||
matching_report_count: int,
|
||||
observation_seconds: float,
|
||||
feedback_hz: float,
|
||||
feedback_age_seconds: float,
|
||||
minimum_feedback_hz: float,
|
||||
) -> bool:
|
||||
"""Require repeated reports and a fresh full-rate control feedback stream."""
|
||||
return bool(
|
||||
assessment.communication_only
|
||||
and matching_report_count >= HISTORICAL_COMMUNICATION_MINIMUM_REPORTS
|
||||
and observation_seconds >= HISTORICAL_COMMUNICATION_CONFIRM_SECONDS
|
||||
and feedback_hz >= minimum_feedback_hz
|
||||
and 0.0 <= feedback_age_seconds <= HEALTH_FEEDBACK_MAXIMUM_AGE_SECONDS
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACTIVE_MOTOR_FAULT_MASK",
|
||||
"ERROR_BIT_NAMES",
|
||||
"ERROR_COMMUNICATION_EXCEPTION",
|
||||
"HEALTH_FEEDBACK_MAXIMUM_AGE_SECONDS",
|
||||
"HISTORICAL_COMMUNICATION_CONFIRM_SECONDS",
|
||||
"HISTORICAL_COMMUNICATION_MINIMUM_REPORTS",
|
||||
"O12ErrorAssessment",
|
||||
"assess_o12_error_report",
|
||||
"decoded_faults",
|
||||
"historical_communication_latch_confirmed",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""O12 vendor kinematics used by calibration and URDF publication.
|
||||
|
||||
The O12 SDK exposes twelve active joint coordinates but the hand URDF has
|
||||
nineteen revolute joints. The seven passive coordinates are part of the
|
||||
vendor solver contract; they are not independent quantities that may be
|
||||
replaced by an arbitrary linear fit of a planar AprilTag pose.
|
||||
|
||||
The coefficients below are the public ``OmniHandPro2025Solver`` coefficients
|
||||
from the protected SDK input. Keeping this small, deterministic evaluator in
|
||||
the calibration package makes online and offline publication identical even
|
||||
when the binary Python wheel is only available inside the vendor overlay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
|
||||
# Polynomial order is constant, x, x**2, x**3, x**4. The input is the SDK
|
||||
# active-joint feedback in radians. Right-thumb MCP/PIP feedback is negative,
|
||||
# hence its passive DIP result is negative too; ``sdk_to_urdf_sign`` converts
|
||||
# both to the positive target-URDF convention.
|
||||
PASSIVE_POLYNOMIAL_BY_JOINT: Mapping[str, tuple[float, ...]] = {
|
||||
"thumb_dip": (0.0, 0.6359, -0.3539, -0.3066, -0.1240),
|
||||
"index_dip": (0.0, 1.0630, 0.08942, 0.1845, -0.2169),
|
||||
"middle_dip": (0.0, 1.1490, -0.2581, 0.6033, -0.3371),
|
||||
"ring_pip": (0.0, 0.7869, 0.3884, -0.4545, 0.1578),
|
||||
"ring_dip": (0.0, 0.8990, 0.3138, -0.1728, -0.03666),
|
||||
"pinky_pip": (0.0, 0.7869, 0.3884, -0.4545, 0.1578),
|
||||
"pinky_dip": (0.0, 0.8990, 0.3138, -0.1728, -0.03666),
|
||||
}
|
||||
|
||||
# Polynomial inputs are active SDK coordinates, not necessarily the immediate
|
||||
# linear URDF mimic source. In particular ring/pinky DIP is solved directly
|
||||
# from the single active MCP coordinate by the vendor implementation.
|
||||
PASSIVE_SDK_SOURCE_BY_JOINT: Mapping[str, str] = {
|
||||
"thumb_dip": "thumb_mcp",
|
||||
"index_dip": "index_pip",
|
||||
"middle_dip": "middle_pip",
|
||||
"ring_pip": "ring_mcp_pitch",
|
||||
"ring_dip": "ring_mcp_pitch",
|
||||
"pinky_pip": "pinky_mcp_pitch",
|
||||
"pinky_dip": "pinky_mcp_pitch",
|
||||
}
|
||||
|
||||
|
||||
def evaluate_vendor_passive_joint(
|
||||
joint: str,
|
||||
sdk_feedback_rad: float,
|
||||
*,
|
||||
sdk_to_urdf_sign: float,
|
||||
) -> float:
|
||||
"""Evaluate one passive O12 joint in the target URDF convention."""
|
||||
try:
|
||||
coefficients = PASSIVE_POLYNOMIAL_BY_JOINT[str(joint)]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"unknown O12 passive joint: {joint}") from error
|
||||
x = float(sdk_feedback_rad)
|
||||
sign = float(sdk_to_urdf_sign)
|
||||
if not math.isfinite(x) or sign not in {-1.0, 1.0}:
|
||||
raise ValueError("O12 vendor passive input/sign is invalid")
|
||||
value = 0.0
|
||||
power = 1.0
|
||||
for coefficient in coefficients:
|
||||
value += float(coefficient) * power
|
||||
power *= x
|
||||
result = sign * value
|
||||
if not math.isfinite(result):
|
||||
raise ValueError(f"O12 vendor passive result is invalid: {joint}")
|
||||
return result
|
||||
|
||||
|
||||
def vendor_passive_curve(
|
||||
joint: str,
|
||||
sdk_feedback_rad: Sequence[float],
|
||||
*,
|
||||
sdk_to_urdf_sign: float,
|
||||
) -> tuple[float, ...]:
|
||||
return tuple(
|
||||
evaluate_vendor_passive_joint(
|
||||
joint, value, sdk_to_urdf_sign=sdk_to_urdf_sign
|
||||
)
|
||||
for value in sdk_feedback_rad
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PASSIVE_POLYNOMIAL_BY_JOINT",
|
||||
"PASSIVE_SDK_SOURCE_BY_JOINT",
|
||||
"evaluate_vendor_passive_joint",
|
||||
"vendor_passive_curve",
|
||||
]
|
||||
@@ -31,6 +31,62 @@ def cosine_position_trajectory_rad(
|
||||
)
|
||||
|
||||
|
||||
def cosine_ramp_velocity_trajectory_rad(
|
||||
start_rad: float,
|
||||
target_rad: float,
|
||||
elapsed_seconds: float,
|
||||
maximum_speed_rad_s: float,
|
||||
ramp_seconds: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Velocity-limited trajectory with cosine ramps and a constant-speed core."""
|
||||
speed = float(maximum_speed_rad_s)
|
||||
ramp = float(ramp_seconds)
|
||||
if not math.isfinite(speed) or speed <= 0.0:
|
||||
raise ValueError("maximum_speed_rad_s must be positive")
|
||||
if not math.isfinite(ramp) or ramp <= 0.0:
|
||||
raise ValueError("ramp_seconds must be positive")
|
||||
start = float(start_rad)
|
||||
target = float(target_rad)
|
||||
distance = abs(target - start)
|
||||
if distance <= 0.0:
|
||||
return target, 1.0, 0.0
|
||||
|
||||
# For very short moves there is no room for a constant-speed section;
|
||||
# retain the bounded position-cosine trajectory.
|
||||
if distance <= speed * ramp:
|
||||
return cosine_position_trajectory_rad(
|
||||
start, target, elapsed_seconds, speed
|
||||
)
|
||||
|
||||
cruise_seconds = distance / speed - ramp
|
||||
duration = 2.0 * ramp + cruise_seconds
|
||||
elapsed = min(duration, max(0.0, float(elapsed_seconds)))
|
||||
ramp_distance = 0.5 * speed * ramp
|
||||
if elapsed < ramp:
|
||||
travelled = speed * (
|
||||
0.5 * elapsed
|
||||
- ramp * math.sin(math.pi * elapsed / ramp) / (2.0 * math.pi)
|
||||
)
|
||||
elif elapsed < ramp + cruise_seconds:
|
||||
travelled = ramp_distance + speed * (elapsed - ramp)
|
||||
else:
|
||||
down = elapsed - ramp - cruise_seconds
|
||||
travelled = (
|
||||
ramp_distance
|
||||
+ speed * cruise_seconds
|
||||
+ speed * (
|
||||
0.5 * down
|
||||
+ ramp * math.sin(math.pi * down / ramp) / (2.0 * math.pi)
|
||||
)
|
||||
)
|
||||
fraction = min(1.0, max(0.0, travelled / distance))
|
||||
return (
|
||||
start + (target - start) * fraction,
|
||||
min(1.0, max(0.0, elapsed / duration)),
|
||||
duration,
|
||||
)
|
||||
|
||||
|
||||
def build_calibration_motion_command(
|
||||
task: TaskSpec,
|
||||
command_rad: float,
|
||||
@@ -73,4 +129,5 @@ def build_calibration_return_waypoints(
|
||||
__all__ = [
|
||||
"build_calibration_motion_command", "build_calibration_preparation_waypoints",
|
||||
"build_calibration_return_waypoints", "cosine_position_trajectory_rad",
|
||||
"cosine_ramp_velocity_trajectory_rad",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
"""Offline thumb candidate resolution using training data only.
|
||||
|
||||
Re-selects actual corner-derived IPPE solutions; does not manufacture rigid
|
||||
poses, substitute SDK angles, or relax the spatial solver's acceptance gates.
|
||||
"""
|
||||
from itertools import product
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation as R
|
||||
|
||||
from ...pnp import solve_square_tag_ippe
|
||||
from .pnp import THUMB_ROLES
|
||||
|
||||
POLICY = 'o12_thumb_training_candidates_v1'
|
||||
MATRIX_SOURCE = 'CameraInfo.P[:3,:3]'
|
||||
|
||||
|
||||
def _rotation(pose):
|
||||
return R.from_quat(pose['quaternion_xyzw'])
|
||||
|
||||
|
||||
def _relative(parent, child):
|
||||
rp = _rotation(parent)
|
||||
return ((rp.inv()*_rotation(child)).as_quat(),
|
||||
rp.inv().apply(np.asarray(child['translation_xyz_m'])-parent['translation_xyz_m']))
|
||||
|
||||
|
||||
def _trajectory_score(parent, child):
|
||||
pairs = [_relative(a, b) for a, b in zip(parent, child)]
|
||||
rotations = R.from_quat([q for q, _ in pairs])
|
||||
points = np.asarray([p for _, p in pairs])
|
||||
vectors = (rotations*rotations[0].inv()).as_rotvec()
|
||||
axis = np.linalg.svd(vectors, full_matrices=False)[2][0]
|
||||
projected = R.from_rotvec((vectors@axis)[:, None]*axis)
|
||||
off_axis = float(np.sqrt(np.mean((projected.inv()*rotations*rotations[0].inv()).magnitude()**2)))
|
||||
# Free six-parameter fixed-pivot fit, no CAD zero or mounting-angle prior.
|
||||
a = np.concatenate((np.tile(np.eye(3), (len(points), 1, 1)), rotations.as_matrix()), axis=2).reshape(-1, 6)
|
||||
predicted = (a@np.linalg.lstsq(a, points.ravel(), rcond=None)[0]).reshape(-1, 3)
|
||||
rms = float(np.sqrt(np.mean(np.sum((points-predicted)**2, axis=1))))
|
||||
return {'pivot_rms_m': rms, 'off_axis_rms_rad': off_axis,
|
||||
'score': rms/.0015 + off_axis/math.radians(1)}
|
||||
|
||||
|
||||
def resolve_thumb_observations(records, *, projection_override=None):
|
||||
"""Return copied observations and auditable candidate-selection evidence.
|
||||
|
||||
Older captures did not save P. They remain on the legacy path; K is never
|
||||
guessed to be P. An explicit override is for isolated offline diagnosis
|
||||
and must be reported as such, not advertised as a verified whole session.
|
||||
"""
|
||||
rows = [dict(r) for r in records]
|
||||
joint_rows = [r for r in rows if r.get('kind') == 'o12_joint_sample'
|
||||
and r.get('task_name') == 'thumb_mcp_dip_front']
|
||||
evidence = {int(r['image_stamp_ns']): r for r in rows
|
||||
if r.get('kind') == 'o12_pnp_candidate_frame'
|
||||
and r.get('task_name') == 'thumb_mcp_dip_front'}
|
||||
report = {'policy': POLICY, 'training_cycles': [0, 1, 2], 'holdout_cycle': 3,
|
||||
'projection_override_used': projection_override is not None,
|
||||
'is_accuracy_certificate': False}
|
||||
if not evidence:
|
||||
return rows, {**report, 'status': 'legacy_no_corners'}
|
||||
if projection_override is None and any(
|
||||
r.get('camera_matrix_source') != MATRIX_SOURCE for r in evidence.values()):
|
||||
return rows, {**report, 'status': 'legacy_projection_unverified',
|
||||
'reason': 'Recorded matrix may be raw K; no silent K-to-P substitution.'}
|
||||
# Use only the same accepted attempt as the downstream fitter.
|
||||
latest = {}
|
||||
for r in joint_rows:
|
||||
key = (int(r['cycle']), r['direction'])
|
||||
latest[key] = max(latest.get(key, 0), int(r.get('attempt', 1)))
|
||||
chosen_rows = [r for r in joint_rows if int(r.get('attempt', 1)) == latest[(int(r['cycle']), r['direction'])]]
|
||||
stamps = sorted({int(r['image_stamp_ns']) for r in chosen_rows})
|
||||
if not stamps or any(s not in evidence for s in stamps):
|
||||
raise ValueError('O12 corner replay lacks evidence for accepted observations')
|
||||
training = np.asarray([int(evidence[s]['cycle']) in (0, 1, 2) for s in stamps])
|
||||
if sum(training) < 40 or not any(int(evidence[s]['cycle']) == 3 for s in stamps):
|
||||
raise ValueError('O12 corner replay requires training and independent holdout')
|
||||
if np.flatnonzero(training)[-1] > np.flatnonzero(~training)[0]:
|
||||
raise ValueError('O12 holdout must follow the complete training trajectory')
|
||||
paths = {role: [[], []] for role in THUMB_ROLES}
|
||||
for stamp in stamps:
|
||||
frame = evidence[stamp]
|
||||
matrix = np.asarray(frame['camera_matrix'] if projection_override is None else projection_override)
|
||||
for role in THUMB_ROLES:
|
||||
data = frame['roles'][role]
|
||||
poses = solve_square_tag_ippe(data['corners_xy'], tag_size_m=frame['tag_size_m'], camera_matrix=matrix)
|
||||
limit = float(data.get('maximum_reprojection_error_px', 1.5))
|
||||
poses = [p for p in poses if p.reprojection_error_px <= limit]
|
||||
if not poses:
|
||||
raise ValueError(f'O12 corner replay has no eligible pose:{stamp}:{role}')
|
||||
cs = [dict(quaternion_xyzw=list(p.quaternion_xyzw), translation_xyz_m=list(p.translation_xyz_m),
|
||||
reprojection_error_px=p.reprojection_error_px) for p in poses[:2]]
|
||||
if len(cs) == 1:
|
||||
cs.append(cs[0])
|
||||
if paths[role][0]:
|
||||
previous = [_rotation(paths[role][i][-1]) for i in range(2)]
|
||||
def cost(order):
|
||||
return sum((previous[i].inv()*_rotation(cs[order[i]])).magnitude() for i in range(2))
|
||||
order = min(((0, 1), (1, 0)), key=cost)
|
||||
cs = [cs[i] for i in order]
|
||||
for i, pose in enumerate(cs):
|
||||
paths[role][i].append(pose)
|
||||
scores = []
|
||||
for branches in product(range(2), repeat=3):
|
||||
train = [[p for p, keep in zip(paths[role][branch], training) if keep]
|
||||
for role, branch in zip(THUMB_ROLES, branches)]
|
||||
metrics = [_trajectory_score(train[i], train[i+1]) for i in (0, 1)]
|
||||
image_penalty = float(np.mean([p['reprojection_error_px'] for ps in train for p in ps]))
|
||||
scores.append({'branches': list(branches), 'score': sum(m['score'] for m in metrics)+.5*image_penalty,
|
||||
'training_pairs': metrics, 'mean_reprojection_px': image_penalty})
|
||||
selected = min(scores, key=lambda s: s['score'])
|
||||
lookup = {stamp: {role: paths[role][branch][i] for role, branch in zip(THUMB_ROLES, selected['branches'])}
|
||||
for i, stamp in enumerate(stamps)}
|
||||
changed = 0
|
||||
for row in chosen_rows:
|
||||
stamp = int(row['image_stamp_ns'])
|
||||
parent_role, child_role = THUMB_ROLES[:2] if row['joint'] == 'thumb_mcp' else THUMB_ROLES[1:]
|
||||
parent, child = lookup[stamp][parent_role], lookup[stamp][child_role]
|
||||
# Preserve the actual camera->common transform, not an assumed identity.
|
||||
old_camera_parent = evidence[stamp]['roles'][parent_role]['selected']
|
||||
if old_camera_parent is None:
|
||||
raise ValueError('Accepted joint row has no original selected parent pose')
|
||||
rc = _rotation(row['parent_pose_common'])*_rotation(old_camera_parent).inv()
|
||||
tc = np.asarray(row['parent_pose_common']['translation_xyz_m'])-rc.apply(old_camera_parent['translation_xyz_m'])
|
||||
quaternion, translation = _relative(parent, child)
|
||||
changed += int((_rotation({'quaternion_xyzw': row['relative_quaternion_xyzw']}).inv()*R.from_quat(quaternion)).magnitude() > 1e-7)
|
||||
row['relative_quaternion_xyzw'] = quaternion.tolist()
|
||||
row['relative_translation_xyz_m'] = translation.tolist()
|
||||
for field, pose in (('parent_pose_common', parent), ('child_pose_common', child)):
|
||||
row[field] = {'quaternion_xyzw': (rc*_rotation(pose)).as_quat().tolist(),
|
||||
'translation_xyz_m': (rc.apply(pose['translation_xyz_m'])+tc).tolist()}
|
||||
row['pnp_reprojection_error_px'] = max(parent['reprojection_error_px'], child['reprojection_error_px'])
|
||||
row['pose_selection_policy'] = POLICY
|
||||
if projection_override is not None:
|
||||
row['projection_reprocessing_scope'] = 'thumb_only_external_projection'
|
||||
return rows, {**report, 'status': 'resolved', 'selected_branches': selected['branches'],
|
||||
'hypotheses': scores, 'training_frames': int(sum(training)),
|
||||
'holdout_frames': int(sum(~training)), 'changed_joint_rows': changed}
|
||||
@@ -3,17 +3,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from dataclasses import asdict, replace
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from ...product import sha256_file
|
||||
from ...runtime.engine import CalibrationEngine
|
||||
from ...storage import atomic_write_json
|
||||
from .artifacts import build_o12_runtime_payload
|
||||
from .artifacts import (
|
||||
build_o12_runtime_payload,
|
||||
validate_o12_runtime_payload_against_urdf,
|
||||
)
|
||||
from .fitting import O12FitResult, fit_o12_session
|
||||
from .profile import CALIBRATED_ACTIVE_JOINTS, MEASURED_PASSIVE_JOINTS
|
||||
from .profile import (
|
||||
CALIBRATED_ACTIVE_JOINTS,
|
||||
MEASURED_PASSIVE_JOINTS,
|
||||
STATIC_ZERO_EXCLUDED_JOINTS,
|
||||
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
build_typed_profile,
|
||||
)
|
||||
from .urdf import O12UrdfCorrection, write_o12_corrected_urdf
|
||||
from .zero import O12SpatialZeroError, SPATIAL_ZERO_POLICY
|
||||
|
||||
|
||||
MEASURED_JOINTS = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS
|
||||
@@ -54,6 +67,36 @@ def accepted_records_by_joint(
|
||||
return result
|
||||
|
||||
|
||||
def accepted_roll_cross_view_records(
|
||||
records: Sequence[Mapping[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Select the latest complete-attempt O12 side observations by roll joint."""
|
||||
samples = [
|
||||
dict(row) for row in records
|
||||
if row.get("kind") == "o12_roll_cross_view_sample"
|
||||
and str(row.get("model_joint", ""))
|
||||
in {"middle_mcp_roll", "index_mcp_roll"}
|
||||
]
|
||||
latest: dict[tuple[str, int, str], int] = {}
|
||||
for row in samples:
|
||||
key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"]))
|
||||
latest[key] = max(latest.get(key, 0), int(row.get("attempt", 1)))
|
||||
result = {"middle_mcp_roll": [], "index_mcp_roll": []}
|
||||
for row in samples:
|
||||
key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"]))
|
||||
if int(row.get("attempt", 1)) != latest[key]:
|
||||
continue
|
||||
result[str(row["model_joint"])].append({
|
||||
"cycle": int(row["cycle"]),
|
||||
"direction": str(row["direction"]),
|
||||
"feedback_rad": float(row["feedback_rad"]),
|
||||
"relative_quaternion_xyzw": list(
|
||||
row["relative_quaternion_xyzw"]
|
||||
),
|
||||
})
|
||||
return {name: rows for name, rows in result.items() if rows}
|
||||
|
||||
|
||||
def load_o12_raw_samples(path: str | Path) -> list[dict[str, Any]]:
|
||||
source = Path(path).expanduser().resolve()
|
||||
if not source.is_file():
|
||||
@@ -97,7 +140,92 @@ def finalize_o12_session(
|
||||
) -> tuple[dict[str, Any], O12FitResult, O12UrdfCorrection]:
|
||||
directory = Path(session_dir).expanduser().resolve()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
result = fit_o12_session(source_urdf, accepted_records_by_joint(records))
|
||||
if any(row.get("projection_reprocessing_scope") == "thumb_only_external_projection"
|
||||
for row in records):
|
||||
raise ValueError("Partial external camera override is diagnostic-only; "
|
||||
"other task projections are unverified. Cannot finalize a whole-hand artifact.")
|
||||
# Reconsider ambiguous online choices with the complete TRAINING trajectory.
|
||||
# Fourth-cycle candidates are assigned using the frozen training choice;
|
||||
# the original raw file is never rewritten and all quality gates remain.
|
||||
from .observations import resolve_thumb_observations
|
||||
try:
|
||||
records, pose_selection = resolve_thumb_observations(records)
|
||||
except ValueError as error:
|
||||
atomic_write_json(directory / "pose_selection_diagnostics.json", {
|
||||
"status": "failed", "reason": str(error), "publication_allowed": False,
|
||||
})
|
||||
raise ValueError("O12 corner candidate resolution failed:" + str(error)) from error
|
||||
atomic_write_json(directory / "pose_selection_diagnostics.json", pose_selection)
|
||||
try:
|
||||
result = fit_o12_session(
|
||||
source_urdf,
|
||||
accepted_records_by_joint(records),
|
||||
cross_view_records_by_joint=accepted_roll_cross_view_records(records),
|
||||
require_cross_view=True,
|
||||
require_full_hand_spatial_zero=True,
|
||||
)
|
||||
if pose_selection['status'] == 'legacy_projection_unverified':
|
||||
# A successful fit cannot certify observations known to have an
|
||||
# unverified rectified-pixel projection contract. Keep a review
|
||||
# artifact, but never silently publish mixed K/P observations.
|
||||
spatial = replace(result.full_hand_zero_result, passed=False,
|
||||
failure_reasons={**result.full_hand_zero_result.failure_reasons,
|
||||
'camera_projection': 'legacy_projection_unverified'})
|
||||
error = O12SpatialZeroError(
|
||||
'O12 camera projection is unverified for legacy corner records; review only',
|
||||
{'passed': False, 'stage': 'camera_projection',
|
||||
'result': asdict(spatial), 'pose_selection': pose_selection})
|
||||
error.review_fit = replace(result, full_hand_zero_result=spatial)
|
||||
raise error
|
||||
except O12SpatialZeroError as error:
|
||||
# A review model is not a calibration PASS. Keep it out of the
|
||||
# publication directory's artifact set and never produce runtime JSON.
|
||||
if error.review_fit is not None:
|
||||
review = directory / "review_only"
|
||||
try:
|
||||
if any(not math.isfinite(value) or abs(value) > math.radians(20)
|
||||
for value in error.review_fit.zero_offsets_rad.values()):
|
||||
raise ValueError("review zero estimate is outside the physical correction budget")
|
||||
correction = write_o12_corrected_urdf(
|
||||
source_urdf=source_urdf, output_directory=review,
|
||||
serial_number=serial_number + "_REVIEW_ONLY",
|
||||
timestamp=timestamp or datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
result=error.review_fit,
|
||||
)
|
||||
atomic_write_json(review / "review_manifest.json", {
|
||||
"status": "REVIEW_ONLY_NOT_CALIBRATION_PASS",
|
||||
"publication_allowed": False,
|
||||
"policy": SPATIAL_ZERO_POLICY,
|
||||
"urdf": correction.path.name,
|
||||
"source_urdf": str(Path(source_urdf).resolve()),
|
||||
"source_urdf_sha256": sha256_file(source_urdf),
|
||||
"candidate_urdf_sha256": sha256_file(correction.path),
|
||||
"protected_inputs": dict(protected_inputs),
|
||||
"static_origin_offsets_rad": dict(error.review_fit.zero_offsets_rad),
|
||||
"spatial_validation": error.diagnostics,
|
||||
"limitations": ["Not approved for hardware/control",
|
||||
"Passive URDF mimic is a linear approximation",
|
||||
"Fingertip contact accuracy not verified"],
|
||||
})
|
||||
error.diagnostics = {**error.diagnostics, "review_urdf": str(correction.path)}
|
||||
error.args = (str(error) + "; 仅供复核、未发布的 URDF:" + str(correction.path),)
|
||||
except Exception as review_error:
|
||||
# Preserve the original failure even if diagnostic export fails.
|
||||
error.diagnostics = {**error.diagnostics, "review_export_error": str(review_error)}
|
||||
atomic_write_json(directory / "spatial_zero_diagnostics.json", error.diagnostics)
|
||||
raise
|
||||
atomic_write_json(directory / "spatial_zero_diagnostics.json", {
|
||||
"passed": True, "policy": SPATIAL_ZERO_POLICY,
|
||||
"result": asdict(result.full_hand_zero_result),
|
||||
"static_zero_exclusions": {
|
||||
name: "immutable_source_cad"
|
||||
for name in sorted(STATIC_ZERO_EXCLUDED_JOINTS)
|
||||
},
|
||||
})
|
||||
CalibrationEngine(build_typed_profile()).result_from_fit(
|
||||
result,
|
||||
transfers=TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
)
|
||||
payload = build_o12_runtime_payload(
|
||||
serial_number=serial_number,
|
||||
source_urdf=source_urdf,
|
||||
@@ -105,6 +233,26 @@ def finalize_o12_session(
|
||||
protected_inputs=protected_inputs,
|
||||
passed=True,
|
||||
)
|
||||
resume_rows = [
|
||||
row for row in records
|
||||
if row.get("kind") == "o12_resume_checkpoint_import"
|
||||
]
|
||||
resume = (
|
||||
{
|
||||
"used": True,
|
||||
"source_session": str(resume_rows[-1]["source_session"]),
|
||||
"compatibility": str(resume_rows[-1]["compatibility"]),
|
||||
"completed_unit_count": int(
|
||||
resume_rows[-1]["completed_unit_count"]
|
||||
),
|
||||
"completed_task_keys": list(
|
||||
resume_rows[-1].get("completed_task_keys", ())
|
||||
),
|
||||
}
|
||||
if resume_rows
|
||||
else {"used": False}
|
||||
)
|
||||
payload["quality"]["resume"] = resume
|
||||
correction = write_o12_corrected_urdf(
|
||||
source_urdf=source_urdf,
|
||||
output_directory=directory,
|
||||
@@ -112,6 +260,9 @@ def finalize_o12_session(
|
||||
result=result,
|
||||
timestamp=timestamp or datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
)
|
||||
validate_o12_runtime_payload_against_urdf(
|
||||
payload, correction.path, source_urdf=source_urdf
|
||||
)
|
||||
json_path = directory / f"o12_right_{serial_number}_calibration.json"
|
||||
atomic_write_json(json_path, payload)
|
||||
summary = {
|
||||
@@ -119,13 +270,52 @@ def finalize_o12_session(
|
||||
"profile_id": "O12/right/o12_right_16/v1",
|
||||
"serial_number": str(serial_number),
|
||||
"result": "PASS",
|
||||
"calibration_scope": payload["calibration_scope"],
|
||||
"spatial_zero_policy": SPATIAL_ZERO_POLICY,
|
||||
"static_origin_offsets_rad": dict(result.zero_offsets_rad),
|
||||
"static_zero_methods": dict(result.zero_method_by_joint),
|
||||
"static_zero_exclusions": {
|
||||
name: {
|
||||
"static_origin_offset_rad": result.zero_offsets_rad[name],
|
||||
"source": "immutable_source_cad",
|
||||
"dynamic_curve_and_holdout_passed": True,
|
||||
}
|
||||
for name in sorted(STATIC_ZERO_EXCLUDED_JOINTS)
|
||||
},
|
||||
"measured_active_joints": sorted(CALIBRATED_ACTIVE_JOINTS),
|
||||
"measured_passive_joints": sorted(MEASURED_PASSIVE_JOINTS),
|
||||
"thumb_static_origin_offsets_rad": {
|
||||
name: correction.origin_offsets_rad[name]
|
||||
for name in (
|
||||
"thumb_cmc_roll",
|
||||
"thumb_cmc_yaw",
|
||||
"thumb_cmc_pitch",
|
||||
"thumb_mcp",
|
||||
)
|
||||
},
|
||||
"thumb_static_zero_methods": {
|
||||
name: result.zero_method_by_joint[name]
|
||||
for name in (
|
||||
"thumb_cmc_roll",
|
||||
"thumb_cmc_yaw",
|
||||
"thumb_cmc_pitch",
|
||||
"thumb_mcp",
|
||||
)
|
||||
},
|
||||
"mechanical_endpoint_origin_offsets_rad": {
|
||||
name: correction.origin_offsets_rad[name]
|
||||
for name in sorted(build_typed_profile().zero.endpoint_anchor_by_joint)
|
||||
},
|
||||
"corrected_limits_rad": {
|
||||
name: list(values)
|
||||
for name, values in sorted(correction.corrected_limits_rad.items())
|
||||
},
|
||||
"ring_transfer": {
|
||||
"source": "pinky_mcp_pitch",
|
||||
"target": "ring_mcp_pitch",
|
||||
"preserved_fields": list(correction.preserved_ring_fields),
|
||||
},
|
||||
"resume": resume,
|
||||
"artifacts": {
|
||||
"json": json_path.name,
|
||||
"urdf": correction.path.name,
|
||||
@@ -140,6 +330,7 @@ def finalize_o12_session(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MEASURED_JOINTS", "accepted_records_by_joint", "finalize_o12_session",
|
||||
"MEASURED_JOINTS", "accepted_records_by_joint",
|
||||
"accepted_roll_cross_view_records", "finalize_o12_session",
|
||||
"load_o12_raw_samples",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""O12 thumb-chain branch selection; never alters a measured pose or angle."""
|
||||
import math
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from ...pnp import SquareTagGroupPoseTracker
|
||||
|
||||
|
||||
THUMB_ROLES = ('thumb_cmc', 'thumb_mcp', 'thumb_dip')
|
||||
|
||||
|
||||
class O12ThumbPoseTracker(SquareTagGroupPoseTracker):
|
||||
"""Use parallel hinge directions, not a CAD zero or passive angle ratio.
|
||||
|
||||
Both relative rotations live in different marker frames. Transport the
|
||||
follower's rotation vector through the *reference* parent rotation before
|
||||
comparing axes. Fixed arbitrary marker mounting rotations cancel out.
|
||||
This is a soft ambiguity discriminator, not a new capture/stop gate.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
roles=THUMB_ROLES,
|
||||
adjacent_pairs=(THUMB_ROLES[:2], THUMB_ROLES[1:]),
|
||||
maximum_pose_jump_rad=math.pi,
|
||||
maximum_translation_jump_m=0.04,
|
||||
relative_rotation_scale_rad=math.radians(5),
|
||||
relative_translation_scale_m=0.01,
|
||||
reprojection_scale_px=0.1,
|
||||
reprojection_weight=0.05,
|
||||
reset_after_seconds=5.0,
|
||||
initialization_frames=8,
|
||||
)
|
||||
self.reference = None
|
||||
|
||||
def reset(self, *, preserve_task_reference=False):
|
||||
super().reset(preserve_task_reference=preserve_task_reference)
|
||||
self.reference = None
|
||||
|
||||
@staticmethod
|
||||
def relative(poses):
|
||||
rotations = [Rotation.from_quat(poses[r].quaternion_xyzw) for r in THUMB_ROLES]
|
||||
return rotations[0].inv()*rotations[1], rotations[1].inv()*rotations[2]
|
||||
|
||||
def _informative_coupled_rotation_costs(self, combinations):
|
||||
# Reuse the shared group selector's soft-cost extension point without
|
||||
# installing coupled_rotation_pairs (there is NO angle-ratio prior).
|
||||
if self.reference is None:
|
||||
return tuple(0.0 for _ in combinations)
|
||||
driver_ref, follower_ref = self.reference
|
||||
residuals = []
|
||||
for poses in combinations:
|
||||
driver, follower = self.relative(poses)
|
||||
a = (driver*driver_ref.inv()).as_rotvec()
|
||||
b = driver_ref.apply((follower*follower_ref.inv()).as_rotvec())
|
||||
if min(np.linalg.norm(a), np.linalg.norm(b)) < math.radians(5):
|
||||
residuals.append(None)
|
||||
continue
|
||||
axis = a/np.linalg.norm(a)
|
||||
residuals.append(float(np.linalg.norm(b-axis*np.dot(axis,b))))
|
||||
# Insufficient excitation or no geometrically credible candidate:
|
||||
# keep ordinary visual continuity, rather than inventing a pose or
|
||||
# rejecting all observations because a prior did not match.
|
||||
if not any(r is not None and r < math.radians(10) for r in residuals):
|
||||
return tuple(0.0 for _ in combinations)
|
||||
return tuple(0.0 if r is None else r/math.radians(3) for r in residuals)
|
||||
|
||||
def select(self, candidates_by_role, *, stamp_ns, **kwargs):
|
||||
# O12 radians never enter the shared tracker's u8 return-path cache.
|
||||
if (self._previous_stamp_ns is not None and
|
||||
not 0 <= stamp_ns-self._previous_stamp_ns <= self.reset_after_ns):
|
||||
self.reset()
|
||||
selected, reason = super().select(candidates_by_role, stamp_ns=stamp_ns)
|
||||
if selected is not None and self.reference is None:
|
||||
self.reference = self.relative(selected)
|
||||
return selected, reason
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
from ...core import (
|
||||
AcquisitionPolicy,
|
||||
ArtifactPolicy,
|
||||
CalibrationProfile,
|
||||
CommandLayout,
|
||||
@@ -58,15 +60,24 @@ SDK_UPPER_RAD = (
|
||||
1.53588974175501, 1.53588974175501,
|
||||
)
|
||||
|
||||
# Source-URDF-safe intersections. The SDK exposes a larger range on several
|
||||
# axes, but calibration must never command outside the immutable CAD model.
|
||||
SAFE_LOWER_RAD = (
|
||||
0.0, -0.94, -0.8272860654453121, -1.29,
|
||||
-0.26, 0.0, 0.0, -0.26, 0.0, 0.0, 0.0, 0.0,
|
||||
# The calibration domain is the vendor's physical O12 range. Restricting a
|
||||
# scan to the source URDF would be circular: that URDF is precisely the model
|
||||
# being corrected, and its smaller thumb/outer-finger limits previously hid
|
||||
# the real hardware endpoints. "SAFE" is retained as the public profile name
|
||||
# but now means the reviewed SDK hard range, not a CAD intersection.
|
||||
SAFE_LOWER_RAD = SDK_LOWER_RAD
|
||||
SAFE_UPPER_RAD = SDK_UPPER_RAD
|
||||
|
||||
# Joint-state feedback can cross a commanded endpoint slightly because it is
|
||||
# measured after servo tracking and encoder conversion. The envelope remains
|
||||
# anchored to the reviewed SDK hard range, with an explicit two-degree
|
||||
# observation allowance that is never used to generate a command.
|
||||
FEEDBACK_DOMAIN_MARGIN_RAD = math.radians(2.0)
|
||||
FEEDBACK_LOWER_RAD = tuple(
|
||||
value - FEEDBACK_DOMAIN_MARGIN_RAD for value in SAFE_LOWER_RAD
|
||||
)
|
||||
SAFE_UPPER_RAD = (
|
||||
0.73, 0.0, 0.0, 0.0,
|
||||
0.26, 1.33, 1.48, 0.26, 1.33, 1.71, 1.38, 1.38,
|
||||
FEEDBACK_UPPER_RAD = tuple(
|
||||
value + FEEDBACK_DOMAIN_MARGIN_RAD for value in SAFE_UPPER_RAD
|
||||
)
|
||||
|
||||
SDK_TO_URDF_JOINT: tuple[str, ...] = (
|
||||
@@ -84,6 +95,16 @@ SDK_TO_URDF_JOINT: tuple[str, ...] = (
|
||||
"pinky_mcp_pitch",
|
||||
)
|
||||
|
||||
# Fixed direction contract between vendor channels and the target CAD axes.
|
||||
# The magnitude remains visually calibrated because O12's 12 active SDK
|
||||
# coordinates include tendon/solver coordinates that are not one-to-one with
|
||||
# all 19 physical URDF joints.
|
||||
SDK_TO_URDF_SIGN: tuple[float, ...] = (
|
||||
1.0, -1.0, -1.0, -1.0,
|
||||
-1.0, 1.0, 1.0, -1.0,
|
||||
1.0, 1.0, 1.0, 1.0,
|
||||
)
|
||||
|
||||
ACTIVE_JOINTS = SDK_TO_URDF_JOINT
|
||||
PASSIVE_JOINTS: tuple[str, ...] = (
|
||||
"thumb_dip",
|
||||
@@ -110,14 +131,125 @@ MIMIC_SOURCE_BY_JOINT = {
|
||||
"pinky_pip": "pinky_mcp_pitch",
|
||||
"pinky_dip": "pinky_pip",
|
||||
}
|
||||
|
||||
# SDK endpoints measure travel; they have not been surveyed as CAD contact
|
||||
# datums. In particular CAD.upper - travel is NOT an encoder zero. The root
|
||||
# thumb axes observe roll/yaw phase; parallel flexion zeros additionally need
|
||||
# adjacent axis-line observations. The one declared exclusion below remains
|
||||
# CAD-owned; all other observable active zeros are required for publication.
|
||||
ROOT_GEOMETRIC_ZERO_JOINTS = frozenset({"thumb_cmc_roll", "thumb_cmc_yaw"})
|
||||
# ID2/ID3 are still used to fit and independently validate thumb MCP/DIP
|
||||
# motion, but the curved-shell ID3 observation is not a reliable absolute
|
||||
# axis-line datum for the thumb MCP assembly phase. Keep that one static
|
||||
# origin at immutable source CAD until a rigid downstream fiducial is
|
||||
# available. This is an explicit O12 profile contract, not an exception-path
|
||||
# quality bypass; every other observable active zero remains mandatory.
|
||||
STATIC_ZERO_EXCLUDED_JOINTS = frozenset({"thumb_mcp"})
|
||||
GEOMETRIC_ZERO_JOINTS = frozenset(
|
||||
CALIBRATED_ACTIVE_JOINTS - STATIC_ZERO_EXCLUDED_JOINTS
|
||||
)
|
||||
ENDPOINT_ANCHOR_BY_JOINT: dict[str, str] = {}
|
||||
COMMAND_INDEX_BY_JOINT = {
|
||||
joint: index for index, joint in enumerate(SDK_TO_URDF_JOINT)
|
||||
}
|
||||
|
||||
PARK_FINGER_RAD = 0.65 * 1.38
|
||||
PARK_MIDDLE_MCP_RAD = 0.65 * 1.33
|
||||
PARK_MIDDLE_PIP_RAD = 0.65 * 1.71
|
||||
# The outer pair is used only as collision clearance after the pinky's own
|
||||
# full-range scan has already exercised the same safe endpoint. O12 has only
|
||||
# one active coordinate on ring/pinky, so partial MCP flexion leaves both long
|
||||
# passive chains inside the middle/index camera corridor. Park them at the
|
||||
# reviewed CAD/SDK intersection instead of the former 65% pose.
|
||||
OUTER_CLEARANCE_FRACTION = 1.0
|
||||
PARK_RING_MCP_RAD = OUTER_CLEARANCE_FRACTION * SAFE_UPPER_RAD[10]
|
||||
PARK_PINKY_MCP_RAD = OUTER_CLEARANCE_FRACTION * SAFE_UPPER_RAD[11]
|
||||
# Compatibility name for downstream diagnostics; motion code uses the two
|
||||
# channel-specific values above and never takes their minimum.
|
||||
PARK_FINGER_RAD = PARK_PINKY_MCP_RAD
|
||||
PARK_MIDDLE_MCP_RAD = SAFE_UPPER_RAD[8]
|
||||
PARK_MIDDLE_PIP_RAD = SAFE_UPPER_RAD[9]
|
||||
# Feedback radians are the quantity being calibrated and need not numerically
|
||||
# equal the command endpoint. Once a finger has been scanned, clearance uses
|
||||
# that measured feedback travel as its endpoint reference. Simultaneous MCP
|
||||
# and PIP flexion may shorten the vendor solver coordinate slightly, so 90%
|
||||
# of the isolated measured stroke plus a settled hold is the accepted physical
|
||||
# maximum for the coupled clearance pose.
|
||||
CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION = 0.90
|
||||
CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD = 0.06
|
||||
CLEARANCE_SPLAY_ENDPOINT_TOLERANCE_RAD = 0.03
|
||||
INDEX_CLEARANCE_RAD = -math.radians(10.0)
|
||||
# O12's index ABAD/MCP and middle ABAD/MCP are coupled tendon coordinates.
|
||||
# The vendor solver clamps MCP upward when a large ABAD command is paired
|
||||
# with MCP=0. These reviewed values keep the requested pose inside the
|
||||
# solver's feasible set instead of relying on an invisible SDK correction:
|
||||
# - index ABAD -10 deg requires MCP >= 0.109846 rad;
|
||||
# - either ABAD roll endpoint (+/-0.26 rad) requires MCP >= 0.163636 rad.
|
||||
INDEX_CLEARANCE_MCP_RAD = 0.11
|
||||
ROLL_CLEARANCE_MCP_RAD = 0.17
|
||||
# O12 feedback is continuous radians. Use 64 possible normalized cells and
|
||||
# require at least 32 of them; using exactly 32 possible cells accidentally
|
||||
# required both mechanical endpoints despite the separate 90% span gate.
|
||||
NORMALIZED_SWEEP_BIN_COUNT = 64
|
||||
# A feedback curve is the quantity being identified, so its unknown endpoint
|
||||
# scale must never be judged against the command endpoint as if they were the
|
||||
# same coordinate. Cycle 0 is normalized over its own measured endpoints and
|
||||
# therefore establishes a unit-span physical reference. Later cycles must
|
||||
# reproduce at least 90% of that measured full-stroke reference.
|
||||
INITIAL_FEEDBACK_SPAN_FRACTION = 0.99
|
||||
EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION = 0.90
|
||||
# Both FRONT/TOP and FRONT/SIDE are close to orthogonal on the reviewed O12
|
||||
# rig. Their common planar checkerboard must be oblique in at least one view,
|
||||
# so the G20 1.2 px (and O6 1.5 px) batch gates reject highly repeatable O12
|
||||
# solutions for image-geometry reasons. At the measured 3700--3800 px focal
|
||||
# length, 2.0 px is about 0.030 degrees. Treat RMS as a gross-error gate and
|
||||
# keep the independent 0.3 degree and 1.5 mm pose-repeatability gates as the
|
||||
# final geometric consistency evidence.
|
||||
MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RollCrossViewObserver:
|
||||
"""O12-only side-camera contract for one front-camera roll sweep."""
|
||||
|
||||
task_name: str
|
||||
view: str
|
||||
parent_role: str
|
||||
child_role: str
|
||||
source_joint: str
|
||||
model_joint: str
|
||||
coupled_channel_index: int
|
||||
maximum_coupled_motion_rad: float
|
||||
|
||||
|
||||
# The downstream PIP Tag is rigidly carried by MCP roll, so it supplies an
|
||||
# independent side-view roll curve while the front Tag supplies the published
|
||||
# curve. O12's tendon solver also has a small, deterministic PIP back-drive
|
||||
# around centred ABAD: joint->actuator->joint reaches about 0.0124 rad even
|
||||
# when PIP is commanded to zero. The reviewed 0.020 rad envelope includes
|
||||
# encoder quantisation and applies only to roll-task cross-view integrity.
|
||||
ROLL_CROSS_VIEW_OBSERVERS: tuple[RollCrossViewObserver, ...] = (
|
||||
RollCrossViewObserver(
|
||||
"middle_roll_front", "side", "side_base", "middle_pip",
|
||||
"middle_mcp_roll_side", "middle_mcp_roll", 9, 0.020,
|
||||
),
|
||||
RollCrossViewObserver(
|
||||
"index_roll_front", "side", "side_base", "index_pip",
|
||||
"index_mcp_roll_side", "index_mcp_roll", 6, 0.020,
|
||||
),
|
||||
)
|
||||
ROLL_CROSS_VIEW_BY_TASK = {
|
||||
observer.task_name: observer for observer in ROLL_CROSS_VIEW_OBSERVERS
|
||||
}
|
||||
|
||||
# Fast-calibration caps by fixed SDK channel. The thumb CMC pitch cap keeps
|
||||
# explicit margin below its unusually low 0.11 rad/s source-URDF limit; the
|
||||
# remaining caps are deliberately far below their corresponding CAD limits
|
||||
# while keeping one visual sweep long enough for dense 30 Hz observations.
|
||||
FORMAL_SPEED_CAP_RAD_S: tuple[float, ...] = (
|
||||
0.16, 0.16, 0.10, 0.32,
|
||||
# Index/middle ABAD at 0.16 rad/s repeatedly returned only 89.2--89.5%
|
||||
# feedback coverage; 0.12 rad/s measured 91.2% on the physical hand.
|
||||
0.12, 0.32, 0.32, 0.12,
|
||||
0.32, 0.32, 0.32, 0.32,
|
||||
)
|
||||
|
||||
|
||||
def _task(
|
||||
@@ -147,15 +279,24 @@ def _task(
|
||||
def build_typed_profile() -> CalibrationProfile:
|
||||
middle_clearance = (
|
||||
(4, INDEX_CLEARANCE_RAD),
|
||||
(10, PARK_FINGER_RAD),
|
||||
(11, PARK_FINGER_RAD),
|
||||
(5, INDEX_CLEARANCE_MCP_RAD),
|
||||
(10, PARK_RING_MCP_RAD),
|
||||
(11, PARK_PINKY_MCP_RAD),
|
||||
)
|
||||
middle_roll_clearance = (
|
||||
*middle_clearance,
|
||||
(8, ROLL_CLEARANCE_MCP_RAD),
|
||||
)
|
||||
index_clearance = (
|
||||
(7, 0.0),
|
||||
(8, PARK_MIDDLE_MCP_RAD),
|
||||
(9, PARK_MIDDLE_PIP_RAD),
|
||||
(10, PARK_FINGER_RAD),
|
||||
(11, PARK_FINGER_RAD),
|
||||
(10, PARK_RING_MCP_RAD),
|
||||
(11, PARK_PINKY_MCP_RAD),
|
||||
)
|
||||
index_roll_clearance = (
|
||||
*index_clearance,
|
||||
(5, ROLL_CLEARANCE_MCP_RAD),
|
||||
)
|
||||
tasks = (
|
||||
_task("thumb_pitch_front", "front", 2, ("thumb_cmc_pitch",), start=0.0, end=SAFE_LOWER_RAD[2], speed=0.03),
|
||||
@@ -163,10 +304,10 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
_task("thumb_mcp_dip_front", "front", 3, ("thumb_mcp", "thumb_dip"), start=0.0, end=SAFE_LOWER_RAD[3], speed=0.08),
|
||||
_task("thumb_yaw_top", "top", 1, ("thumb_cmc_yaw",), start=0.0, end=SAFE_LOWER_RAD[1], speed=0.04),
|
||||
_task("pinky_chain_side", "side", 11, ("pinky_mcp_pitch", "pinky_pip", "pinky_dip"), start=0.0, end=SAFE_UPPER_RAD[11], speed=0.08),
|
||||
_task("middle_roll_front", "front", 7, ("middle_mcp_roll",), start=SAFE_UPPER_RAD[7], end=SAFE_LOWER_RAD[7], speed=0.04, auxiliary=middle_clearance),
|
||||
_task("middle_roll_front", "front", 7, ("middle_mcp_roll",), start=SAFE_UPPER_RAD[7], end=SAFE_LOWER_RAD[7], speed=0.04, auxiliary=middle_roll_clearance),
|
||||
_task("middle_mcp_side", "side", 8, ("middle_mcp_pitch",), start=0.0, end=SAFE_UPPER_RAD[8], speed=0.08, auxiliary=middle_clearance),
|
||||
_task("middle_pip_dip_side", "side", 9, ("middle_pip", "middle_dip"), start=0.0, end=SAFE_UPPER_RAD[9], speed=0.08, auxiliary=middle_clearance),
|
||||
_task("index_roll_front", "front", 4, ("index_mcp_roll",), start=SAFE_UPPER_RAD[4], end=SAFE_LOWER_RAD[4], speed=0.04, auxiliary=index_clearance),
|
||||
_task("index_roll_front", "front", 4, ("index_mcp_roll",), start=SAFE_UPPER_RAD[4], end=SAFE_LOWER_RAD[4], speed=0.04, auxiliary=index_roll_clearance),
|
||||
_task("index_mcp_side", "side", 5, ("index_mcp_pitch",), start=0.0, end=SAFE_UPPER_RAD[5], speed=0.08, auxiliary=index_clearance),
|
||||
_task("index_pip_dip_side", "side", 6, ("index_pip", "index_dip"), start=0.0, end=SAFE_UPPER_RAD[6], speed=0.08, auxiliary=index_clearance),
|
||||
)
|
||||
@@ -184,7 +325,7 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
"middle_pip": MeasurementSpec("middle_pip", "relative_rotation", "side", "side_base", "middle_pip"),
|
||||
"middle_dip": MeasurementSpec("middle_dip", "relative_rotation", "side", "middle_pip", "middle_dip", pose_axis_line_required=False),
|
||||
"index_mcp_roll": MeasurementSpec("index_mcp_roll", "relative_rotation", "front", "front_base", "index_roll"),
|
||||
"index_mcp_pitch": MeasurementSpec("index_mcp_pitch", "relative_rotation", "side", "side_base", "index_pip"),
|
||||
"index_mcp_pitch": MeasurementSpec("index_mcp_pitch", "relative_rotation", "side", "side_base", "index_dip"),
|
||||
"index_pip": MeasurementSpec("index_pip", "relative_rotation", "side", "side_base", "index_pip"),
|
||||
"index_dip": MeasurementSpec("index_dip", "relative_rotation", "side", "index_pip", "index_dip", pose_axis_line_required=False),
|
||||
}
|
||||
@@ -196,6 +337,8 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
"transferred_static_dynamic"
|
||||
if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
|
||||
else "measured_static_dynamic"
|
||||
if name in GEOMETRIC_ZERO_JOINTS
|
||||
else "measured_dynamic_cad_static"
|
||||
)
|
||||
for name in active
|
||||
},
|
||||
@@ -218,6 +361,8 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
# The public command domain is the reviewed SDK/CAD intersection.
|
||||
lower_bounds=SAFE_LOWER_RAD,
|
||||
upper_bounds=SAFE_UPPER_RAD,
|
||||
feedback_lower_bounds=FEEDBACK_LOWER_RAD,
|
||||
feedback_upper_bounds=FEEDBACK_UPPER_RAD,
|
||||
unit="rad",
|
||||
feedback_by_index=True,
|
||||
command_index_by_joint=COMMAND_INDEX_BY_JOINT,
|
||||
@@ -243,7 +388,7 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
common_frame="calibration_common",
|
||||
extrinsic_reference_view="front",
|
||||
extrinsics_quality_limits={
|
||||
"reprojection_rms_px": 1.2,
|
||||
"reprojection_rms_px": MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX,
|
||||
"maximum_rotation_repeatability_deg": 0.3,
|
||||
"maximum_translation_repeatability_m": 0.0015,
|
||||
},
|
||||
@@ -251,10 +396,12 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
),
|
||||
motion=MotionPolicy(
|
||||
tasks=tasks,
|
||||
precheck_sweeps=True,
|
||||
steady_command_checkpoints=True,
|
||||
# O12 uses AcquisitionPolicy.mapping_probe_maximum_rad for one
|
||||
# channel-mapping jog; it does not inherit legacy sweep prechecks.
|
||||
precheck_sweeps=False,
|
||||
steady_command_checkpoints=False,
|
||||
speed_parameters={
|
||||
"command_rate_hz": 20.0,
|
||||
"command_rate_hz": 50.0,
|
||||
"clearance_flex_rad_s": 0.10,
|
||||
"clearance_splay_rad_s": 0.04,
|
||||
"probe_travel_rad": math.radians(3.0),
|
||||
@@ -267,22 +414,31 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
zero=ZeroSolvePolicy(
|
||||
active_joints=active,
|
||||
passive_joints=passive,
|
||||
direct_zero_joints=tuple(sorted(CALIBRATED_ACTIVE_JOINTS)),
|
||||
axis_joints=tuple(sorted(CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS)),
|
||||
mechanical_endpoint_joints=frozenset(CALIBRATED_ACTIVE_JOINTS),
|
||||
direct_zero_joints=tuple(sorted(GEOMETRIC_ZERO_JOINTS)),
|
||||
axis_joints=tuple(sorted(
|
||||
CALIBRATED_ACTIVE_JOINTS
|
||||
| (MEASURED_PASSIVE_JOINTS - {"thumb_dip"})
|
||||
)),
|
||||
mechanical_endpoint_joints=frozenset(ENDPOINT_ANCHOR_BY_JOINT),
|
||||
post_solve_endpoint_joints=frozenset(),
|
||||
mimic_source_by_joint=MIMIC_SOURCE_BY_JOINT,
|
||||
cad_frozen_joints=passive,
|
||||
endpoint_anchor_by_joint={name: "lower_at_start" for name in CALIBRATED_ACTIVE_JOINTS},
|
||||
fitted_mimic_joints=MEASURED_PASSIVE_JOINTS,
|
||||
coupling_model_by_joint={name: "quadratic_runtime" for name in MEASURED_PASSIVE_JOINTS},
|
||||
cad_frozen_joints=passive | (active - GEOMETRIC_ZERO_JOINTS - set(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT)),
|
||||
endpoint_anchor_by_joint=ENDPOINT_ANCHOR_BY_JOINT,
|
||||
# AprilTags validate passive motion, but the deployed passive
|
||||
# mapping is owned by the O12 SDK solver. A planar-PnP branch must
|
||||
# never replace that product kinematic contract in the URDF.
|
||||
fitted_mimic_joints=frozenset(),
|
||||
coupling_model_by_joint={
|
||||
name: "vendor_o12_polynomial"
|
||||
for name in MEASURED_PASSIVE_JOINTS
|
||||
},
|
||||
),
|
||||
quality=QualityPolicy(
|
||||
training_cycles=(0, 1, 2),
|
||||
holdout_cycle=3,
|
||||
hard_threshold_keys=frozenset({
|
||||
"minimum_detection_rate", "maximum_state_image_skew_ms",
|
||||
"maximum_hysteresis_rad", "maximum_validation_error_rad",
|
||||
"maximum_validation_error_rad",
|
||||
"maximum_mimic_residual_rad",
|
||||
}),
|
||||
isolated_holdout=True,
|
||||
@@ -301,9 +457,16 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
"calibration_config_sha256", "tag_config_sha256", "sdk_config_sha256",
|
||||
}),
|
||||
publication_pointer="latest_passed",
|
||||
session_compatibility_tokens=frozenset({"o12_right_16_v1", "feedback_rad_v1"}),
|
||||
session_compatibility_tokens=frozenset({
|
||||
"o12_right_16_v1", "feedback_rad_v1", "full_sdk_range_v2"
|
||||
}),
|
||||
publish_corrected_urdf=True,
|
||||
),
|
||||
acquisition=AcquisitionPolicy(
|
||||
mapping_probe_maximum_rad=math.radians(3.0),
|
||||
physical_first_cycle_minimum_span_01=INITIAL_FEEDBACK_SPAN_FRACTION,
|
||||
physical_repeat_minimum_fraction=EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION,
|
||||
),
|
||||
joint_coverage=coverage,
|
||||
)
|
||||
|
||||
@@ -336,9 +499,19 @@ def build_profile() -> RegisteredProfile:
|
||||
|
||||
__all__ = [
|
||||
"ACTIVE_JOINTS", "CALIBRATED_ACTIVE_JOINTS", "COMMAND_INDEX_BY_JOINT",
|
||||
"COMMAND_NAMES", "INDEX_CLEARANCE_RAD", "KEY", "MEASURED_PASSIVE_JOINTS",
|
||||
"MIMIC_SOURCE_BY_JOINT", "PARK_FINGER_RAD", "PARK_MIDDLE_MCP_RAD",
|
||||
"CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD",
|
||||
"CLEARANCE_SPLAY_ENDPOINT_TOLERANCE_RAD", "COMMAND_NAMES",
|
||||
"GEOMETRIC_ZERO_JOINTS", "INDEX_CLEARANCE_RAD", "KEY",
|
||||
"MEASURED_PASSIVE_JOINTS",
|
||||
"EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION",
|
||||
"INITIAL_FEEDBACK_SPAN_FRACTION", "MIMIC_SOURCE_BY_JOINT",
|
||||
"NORMALIZED_SWEEP_BIN_COUNT", "OUTER_CLEARANCE_FRACTION",
|
||||
"PARK_FINGER_RAD", "PARK_MIDDLE_MCP_RAD", "PARK_PINKY_MCP_RAD",
|
||||
"PARK_RING_MCP_RAD", "CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION",
|
||||
"PARK_MIDDLE_PIP_RAD", "PASSIVE_JOINTS", "SAFE_LOWER_RAD", "SAFE_UPPER_RAD",
|
||||
"ROLL_CROSS_VIEW_BY_TASK", "ROLL_CROSS_VIEW_OBSERVERS",
|
||||
"RollCrossViewObserver",
|
||||
"SDK_LOWER_RAD", "SDK_TO_URDF_JOINT", "SDK_UPPER_RAD",
|
||||
"STATIC_ZERO_EXCLUDED_JOINTS",
|
||||
"TRANSFERRED_ACTIVE_SOURCE_BY_JOINT", "build_profile", "build_typed_profile",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""O12-only sweep observability policy.
|
||||
|
||||
The O12 records continuous radian feedback into a fixed normalized grid. A
|
||||
raw per-frame Tag rate is useful for camera diagnostics, but it is not by
|
||||
itself evidence that a fitted curve is unobservable: a long sweep may retain
|
||||
hundreds of synchronized samples and dense travel coverage after a short Tag
|
||||
occlusion. This module makes the actual fitting information the acceptance
|
||||
contract and keeps the stricter camera targets as warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
QUALITY_POLICY_VERSION = 5
|
||||
# G20 permits roughly one sixteenth of its 256-bin domain to be unobserved
|
||||
# contiguously. Express that invariant as a fraction so O12's 64-bin grid is
|
||||
# judged at the same physical scale instead of copying a raw bin count.
|
||||
MAXIMUM_UNOBSERVED_BIN_FRACTION = 1.0 / 16.0
|
||||
|
||||
|
||||
def evaluate_o12_observation_quality(
|
||||
normalized_feedback: Sequence[float],
|
||||
observation: Mapping[str, Any],
|
||||
*,
|
||||
normalized_bin_count: int,
|
||||
required_feedback_span: float,
|
||||
minimum_sweep_frames: int,
|
||||
minimum_sweep_bins: int,
|
||||
minimum_joint_frame_rate: float,
|
||||
minimum_feedback_hz: float,
|
||||
feedback_hz: float,
|
||||
target_detection_rate: float,
|
||||
target_maximum_bin_gap: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Return one authoritative O12 acceptance decision and diagnostics."""
|
||||
count = int(normalized_bin_count)
|
||||
if count < 32:
|
||||
raise ValueError("normalized sweep bin count must be at least 32")
|
||||
values = np.asarray(normalized_feedback, dtype=float)
|
||||
values = values[np.isfinite(values)]
|
||||
clipped = np.clip(values, 0.0, 1.0)
|
||||
bins = sorted(set(
|
||||
min(count - 1, max(0, int(value * count)))
|
||||
for value in clipped
|
||||
))
|
||||
span = float(np.ptp(clipped)) if clipped.size else 0.0
|
||||
maximum_gap = max(
|
||||
(right - left - 1 for left, right in zip(bins, bins[1:])),
|
||||
default=0 if bins else count,
|
||||
)
|
||||
maximum_unobserved_bins = max(
|
||||
1, int(math.floor(count * MAXIMUM_UNOBSERVED_BIN_FRACTION))
|
||||
)
|
||||
allowed_maximum_gap = maximum_unobserved_bins
|
||||
detection_rate = float(observation.get("tag_detection_rate", 0.0))
|
||||
joint_frame_rate = float(observation.get("joint_frame_rate", 0.0))
|
||||
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
if clipped.size < int(minimum_sweep_frames):
|
||||
failures.append(f"frames={clipped.size}")
|
||||
if clipped.size == 0 or span < float(required_feedback_span):
|
||||
failures.append("feedback_span")
|
||||
if len(bins) < int(minimum_sweep_bins):
|
||||
failures.append(f"bins={len(bins)}")
|
||||
if maximum_gap > allowed_maximum_gap:
|
||||
failures.append(f"maximum_gap={maximum_gap}")
|
||||
if joint_frame_rate < float(minimum_joint_frame_rate):
|
||||
warnings.append(f"joint_frame_rate={joint_frame_rate:.3f}")
|
||||
if float(feedback_hz) < float(minimum_feedback_hz):
|
||||
warnings.append(f"feedback_hz={float(feedback_hz):.2f}")
|
||||
|
||||
# These remain explicit operator diagnostics. They do not duplicate the
|
||||
# observability gates above or force a complete rescan of otherwise dense
|
||||
# data after a short, localized occlusion.
|
||||
if detection_rate < float(target_detection_rate):
|
||||
role_rates = observation.get("tag_detection_rate_by_role", {})
|
||||
worst_role = min(role_rates, key=role_rates.get, default="unknown")
|
||||
warnings.append(
|
||||
f"tag_rate_target[{worst_role}]={detection_rate:.3f}"
|
||||
)
|
||||
if maximum_gap > int(target_maximum_bin_gap):
|
||||
warnings.append(f"maximum_gap_target={maximum_gap}")
|
||||
|
||||
return {
|
||||
"quality_policy_version": QUALITY_POLICY_VERSION,
|
||||
"decision_basis": "normalized_fit_observability",
|
||||
"valid_frames": int(clipped.size),
|
||||
"feedback_bins": len(bins),
|
||||
"feedback_span": round(span, 9),
|
||||
"required_feedback_span": round(float(required_feedback_span), 9),
|
||||
"maximum_bin_gap": int(maximum_gap),
|
||||
"allowed_maximum_bin_gap": int(allowed_maximum_gap),
|
||||
"gap_scope": "observed_feedback_span",
|
||||
"maximum_unobserved_bin_fraction": (
|
||||
MAXIMUM_UNOBSERVED_BIN_FRACTION
|
||||
),
|
||||
"target_detection_rate": float(target_detection_rate),
|
||||
"target_maximum_bin_gap": int(target_maximum_bin_gap),
|
||||
"warnings": warnings,
|
||||
"failures": failures,
|
||||
"passed": not failures,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAXIMUM_UNOBSERVED_BIN_FRACTION",
|
||||
"QUALITY_POLICY_VERSION",
|
||||
"evaluate_o12_observation_quality",
|
||||
]
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Durable, safety-checked O12 scan checkpoint recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from ...product import ProductConfig
|
||||
from ...runtime import ACQUISITION_POLICY_VERSION
|
||||
from .pipeline import load_o12_raw_samples
|
||||
from .profile import ROLL_CROSS_VIEW_BY_TASK
|
||||
|
||||
|
||||
PROTECTED_INPUT_KEYS = (
|
||||
"source_urdf_sha256",
|
||||
"camera_extrinsics_sha256",
|
||||
"calibration_config_sha256",
|
||||
"tag_config_sha256",
|
||||
"sdk_config_sha256",
|
||||
)
|
||||
|
||||
ResumeUnit = tuple[str, int, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class O12ResumeCheckpoint:
|
||||
source_session: Path
|
||||
compatibility: str
|
||||
completed_units: tuple[ResumeUnit, ...]
|
||||
completed_tasks: tuple[str, ...]
|
||||
imported_records: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
def ordered_resume_units(profile) -> tuple[ResumeUnit, ...]:
|
||||
return tuple(
|
||||
(task.key, cycle, direction)
|
||||
for task in profile.motion.tasks
|
||||
for cycle in (0, 1, 2, 3)
|
||||
for direction in ("decreasing", "increasing")
|
||||
)
|
||||
|
||||
|
||||
def _quality_evidence_accepted(
|
||||
row: Mapping[str, Any], valid: int, total: int
|
||||
) -> bool:
|
||||
"""Use the decision recorded by the matching O12 quality-policy version."""
|
||||
if int(row.get("quality_policy_version", 0)) >= 2:
|
||||
return bool(
|
||||
row.get("passed")
|
||||
and not list(row.get("failures", ()))
|
||||
and valid >= 40
|
||||
and total > 0
|
||||
)
|
||||
# Preserve the exact contract used by sessions written before the O12
|
||||
# observability policy existed. This branch is compatibility only; it
|
||||
# does not reinterpret a previously rejected sweep as passing.
|
||||
return bool(
|
||||
not list(row.get("failures", ()))
|
||||
and valid >= 40
|
||||
and total > 0
|
||||
# Older sessions can contain one final synchronized callback after
|
||||
# the total counter reset (for example 259/258); cap the ratio at one.
|
||||
and valid / max(total, valid) >= 0.85
|
||||
and float(row.get("tag_detection_rate", 0.0)) >= 0.95
|
||||
and int(row.get("feedback_bins", 0)) >= 32
|
||||
and int(row.get("maximum_bin_gap", 999)) <= 2
|
||||
)
|
||||
|
||||
|
||||
def _session_start(rows: Sequence[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||
starts = [row for row in rows if row.get("kind") == "session_start"]
|
||||
if len(starts) != 1:
|
||||
raise ValueError("resume raw must contain exactly one session_start")
|
||||
return starts[0]
|
||||
|
||||
|
||||
def _protected_values(config: ProductConfig) -> dict[str, str]:
|
||||
return {
|
||||
"source_urdf_sha256": config.source_urdf_sha256,
|
||||
"camera_extrinsics_sha256": config.camera_extrinsics_sha256,
|
||||
"calibration_config_sha256": config.calibration_config_sha256,
|
||||
"tag_config_sha256": config.tag_config_sha256,
|
||||
"sdk_config_sha256": config.sdk_config_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_checkpoint_is_attested(
|
||||
config: ProductConfig,
|
||||
session: Path,
|
||||
rows: Sequence[Mapping[str, Any]],
|
||||
) -> bool:
|
||||
"""Accept pre-checkpoint O12 data only when its local inputs are unchanged."""
|
||||
raw_path = session / "raw_samples.jsonl"
|
||||
try:
|
||||
raw_time = raw_path.stat().st_mtime
|
||||
protected_paths = (
|
||||
config.source_urdf,
|
||||
config.camera_extrinsics,
|
||||
config.calibration_config,
|
||||
config.tag_config,
|
||||
config.sdk_config,
|
||||
)
|
||||
if any(
|
||||
path is None or path.stat().st_mtime > raw_time
|
||||
for path in protected_paths
|
||||
):
|
||||
return False
|
||||
log = (session / "calibration.log").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
if str(config.source_urdf) not in log:
|
||||
return False
|
||||
return any(
|
||||
row.get("kind") == "o12_temperature_capability_fallback"
|
||||
and row.get("sdk_config_sha256") == config.sdk_config_sha256
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def validate_resume_source(
|
||||
config: ProductConfig,
|
||||
session: str | Path,
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
candidate = Path(session).expanduser().resolve(strict=True)
|
||||
root = config.session_root.expanduser().resolve(strict=True)
|
||||
if candidate.parent != root or not candidate.is_dir():
|
||||
raise ValueError("resume session must be a direct child of the serial root")
|
||||
summary_path = candidate / "calibration_summary_zh.json"
|
||||
if summary_path.is_file():
|
||||
try:
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError("resume session has an invalid summary") from error
|
||||
if isinstance(summary, Mapping) and summary.get("result") == "PASS":
|
||||
raise ValueError("a passed O12 session cannot be used as a checkpoint")
|
||||
rows = load_o12_raw_samples(candidate / "raw_samples.jsonl")
|
||||
start = _session_start(rows)
|
||||
if (
|
||||
start.get("profile_id") != config.profile_key.profile_id
|
||||
or start.get("serial_number") != config.serial_number
|
||||
or int(start.get("sample_schema_version", -1))
|
||||
!= config.calibration_contract.typed_profile.artifacts.output_schema_version
|
||||
):
|
||||
raise ValueError("resume profile, serial number, or schema differs")
|
||||
if start.get("acquisition_policy_version") != ACQUISITION_POLICY_VERSION:
|
||||
raise ValueError(
|
||||
"resume acquisition policy differs; unified_engine_v1 requires "
|
||||
"a new full capture"
|
||||
)
|
||||
expected = _protected_values(config)
|
||||
recorded = {key: str(start.get(key, "")) for key in PROTECTED_INPUT_KEYS}
|
||||
if all(recorded.values()):
|
||||
if recorded != expected:
|
||||
raise ValueError("resume protected input hashes differ")
|
||||
compatibility = "protected_hashes_v1"
|
||||
else:
|
||||
raise ValueError("resume checkpoint has no protected input hashes")
|
||||
return rows, compatibility
|
||||
|
||||
|
||||
def build_resume_checkpoint(
|
||||
config: ProductConfig,
|
||||
session: str | Path,
|
||||
) -> O12ResumeCheckpoint:
|
||||
rows, compatibility = validate_resume_source(config, session)
|
||||
return build_resume_checkpoint_from_rows(
|
||||
config.calibration_contract.typed_profile,
|
||||
session,
|
||||
rows,
|
||||
compatibility=compatibility,
|
||||
)
|
||||
|
||||
|
||||
def build_resume_checkpoint_from_rows(
|
||||
profile,
|
||||
session: str | Path,
|
||||
rows: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
compatibility: str,
|
||||
) -> O12ResumeCheckpoint:
|
||||
task_by_key = {task.key: task for task in profile.motion.tasks}
|
||||
cross_view_passing: set[tuple[ResumeUnit, int]] = set()
|
||||
for row in rows:
|
||||
if row.get("kind") != "o12_roll_cross_view_quality":
|
||||
continue
|
||||
try:
|
||||
unit = (
|
||||
str(row["task_name"]),
|
||||
int(row["cycle"]),
|
||||
str(row["direction"]),
|
||||
)
|
||||
attempt = int(row.get("attempt", 1))
|
||||
valid = int(row.get("valid_frames", 0))
|
||||
total = int(row.get("total_frames", 0))
|
||||
accepted = (
|
||||
unit[0] in ROLL_CROSS_VIEW_BY_TASK
|
||||
and unit[1] in (0, 1, 2, 3)
|
||||
and unit[2] in {"decreasing", "increasing"}
|
||||
and _quality_evidence_accepted(row, valid, total)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, ZeroDivisionError):
|
||||
continue
|
||||
if accepted:
|
||||
cross_view_passing.add((unit, attempt))
|
||||
|
||||
passing: dict[ResumeUnit, tuple[int, Mapping[str, Any]]] = {}
|
||||
for row in rows:
|
||||
if row.get("kind") != "o12_sweep_observation_quality":
|
||||
continue
|
||||
try:
|
||||
unit = (
|
||||
str(row["task_name"]),
|
||||
int(row["cycle"]),
|
||||
str(row["direction"]),
|
||||
)
|
||||
attempt = int(row.get("attempt", 1))
|
||||
valid = int(row.get("valid_frames", 0))
|
||||
total = int(row.get("total_frames", 0))
|
||||
accepted = (
|
||||
unit[0] in task_by_key
|
||||
and unit[1] in (0, 1, 2, 3)
|
||||
and unit[2] in {"decreasing", "increasing"}
|
||||
and _quality_evidence_accepted(row, valid, total)
|
||||
and (
|
||||
unit[0] not in ROLL_CROSS_VIEW_BY_TASK
|
||||
or (unit, attempt) in cross_view_passing
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, ZeroDivisionError):
|
||||
continue
|
||||
if accepted and attempt >= passing.get(unit, (0, {}))[0]:
|
||||
passing[unit] = (attempt, row)
|
||||
|
||||
ordered = ordered_resume_units(profile)
|
||||
completed: list[ResumeUnit] = []
|
||||
for unit in ordered:
|
||||
if unit not in passing:
|
||||
break
|
||||
completed.append(unit)
|
||||
if not completed:
|
||||
raise ValueError("resume session has no contiguous passed scan unit")
|
||||
# A session created while resuming declares how many source units it
|
||||
# intended to import. If startup was interrupted during persistence, its
|
||||
# JSONL can contain that audit header but only a prefix of the associated
|
||||
# samples. Do not let such a newer, truncated session shadow the older
|
||||
# complete checkpoint during automatic selection.
|
||||
declared_import_counts = [
|
||||
int(row.get("completed_unit_count", -1))
|
||||
for row in rows
|
||||
if row.get("kind") == "o12_resume_checkpoint_import"
|
||||
]
|
||||
if any(count < 0 or count > len(completed) for count in declared_import_counts):
|
||||
raise ValueError("resume checkpoint import is incomplete or truncated")
|
||||
|
||||
selected_attempt = {
|
||||
unit: passing[unit][0] for unit in completed
|
||||
}
|
||||
imported: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
kind = row.get("kind")
|
||||
if kind not in {
|
||||
"o12_joint_sample",
|
||||
"o12_pnp_candidate_frame",
|
||||
"o12_sweep_observation_quality",
|
||||
"o12_roll_cross_view_sample",
|
||||
"o12_roll_cross_view_quality",
|
||||
}:
|
||||
continue
|
||||
try:
|
||||
unit = (
|
||||
str(row["task_name"]),
|
||||
int(row["cycle"]),
|
||||
str(row["direction"]),
|
||||
)
|
||||
attempt = int(row.get("attempt", 1))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if unit not in selected_attempt or attempt != selected_attempt[unit]:
|
||||
continue
|
||||
copied = dict(row)
|
||||
copied["resume_imported"] = True
|
||||
copied["resume_source_session"] = Path(session).resolve().name
|
||||
imported.append(copied)
|
||||
|
||||
completed_set = set(completed)
|
||||
completed_tasks = tuple(
|
||||
task.key
|
||||
for task in profile.motion.tasks
|
||||
if all(
|
||||
(task.key, cycle, direction) in completed_set
|
||||
for cycle in (0, 1, 2, 3)
|
||||
for direction in ("decreasing", "increasing")
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
if (
|
||||
row.get("kind") == "o12_fixed_mapping_preflight"
|
||||
and str(row.get("task_name", "")) in completed_tasks
|
||||
):
|
||||
copied = dict(row)
|
||||
copied["resume_imported"] = True
|
||||
copied["resume_source_session"] = Path(session).resolve().name
|
||||
imported.append(copied)
|
||||
|
||||
primary_by_unit = {
|
||||
unit: 0 for unit in completed
|
||||
}
|
||||
for row in imported:
|
||||
if row.get("kind") != "o12_joint_sample":
|
||||
continue
|
||||
unit = (
|
||||
str(row["task_name"]), int(row["cycle"]), str(row["direction"])
|
||||
)
|
||||
task = task_by_key[unit[0]]
|
||||
if row.get("joint") == task.joints[0]:
|
||||
primary_by_unit[unit] += 1
|
||||
if any(count < 40 for count in primary_by_unit.values()):
|
||||
raise ValueError("resume quality record lacks its primary joint samples")
|
||||
|
||||
cross_samples_by_unit = {
|
||||
unit: 0 for unit in completed if unit[0] in ROLL_CROSS_VIEW_BY_TASK
|
||||
}
|
||||
for row in imported:
|
||||
if row.get("kind") != "o12_roll_cross_view_sample":
|
||||
continue
|
||||
unit = (
|
||||
str(row["task_name"]), int(row["cycle"]), str(row["direction"])
|
||||
)
|
||||
if unit in cross_samples_by_unit:
|
||||
cross_samples_by_unit[unit] += 1
|
||||
if any(count < 40 for count in cross_samples_by_unit.values()):
|
||||
raise ValueError("resume roll quality lacks its side-view samples")
|
||||
|
||||
return O12ResumeCheckpoint(
|
||||
source_session=Path(session).expanduser().resolve(),
|
||||
compatibility=compatibility,
|
||||
completed_units=tuple(completed),
|
||||
completed_tasks=completed_tasks,
|
||||
imported_records=tuple(imported),
|
||||
)
|
||||
|
||||
|
||||
def automatic_resume_candidate(config: ProductConfig) -> O12ResumeCheckpoint | None:
|
||||
root = config.session_root
|
||||
try:
|
||||
candidates = sorted(
|
||||
(
|
||||
path for path in root.resolve(strict=True).iterdir()
|
||||
if path.is_dir() and not path.name.startswith("latest_")
|
||||
),
|
||||
key=lambda path: path.name,
|
||||
reverse=True,
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
passed: Path | None = None
|
||||
pointer = root / "latest_passed"
|
||||
if pointer.exists():
|
||||
try:
|
||||
passed = pointer.resolve(strict=True)
|
||||
except OSError:
|
||||
pass
|
||||
for candidate in candidates:
|
||||
if passed is not None and candidate.name <= passed.name:
|
||||
continue
|
||||
try:
|
||||
return build_resume_checkpoint(config, candidate)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"O12ResumeCheckpoint",
|
||||
"automatic_resume_candidate",
|
||||
"build_resume_checkpoint",
|
||||
"build_resume_checkpoint_from_rows",
|
||||
"ordered_resume_units",
|
||||
"validate_resume_source",
|
||||
]
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -22,10 +23,10 @@ from ..l6.runner import (
|
||||
_l6_reason_zh,
|
||||
_launch_command,
|
||||
_stop_stack,
|
||||
_wait_until,
|
||||
render_six_channel_progress_zh,
|
||||
)
|
||||
from .pipeline import finalize_o12_session, load_o12_raw_samples
|
||||
from .resume import automatic_resume_candidate, ordered_resume_units
|
||||
|
||||
|
||||
_TASK_LABELS = {
|
||||
@@ -34,17 +35,30 @@ _TASK_LABELS = {
|
||||
"thumb_mcp_dip_front": "拇指 MCP / 被动 DIP(正面 ID1→ID2→ID3)",
|
||||
"thumb_yaw_top": "拇指 CMC yaw(顶部 ID14→ID15)",
|
||||
"pinky_chain_side": "小指 MCP / 被动 PIP/DIP(侧面 ID4→ID5→ID6→ID7)",
|
||||
"middle_roll_front": "中指 MCP roll(正面 ID0→ID12)",
|
||||
"middle_roll_front": "中指 MCP roll(正面 ID0→ID12 + 侧面 ID4→ID8)",
|
||||
"middle_mcp_side": "中指 MCP pitch(侧面 ID4→ID8)",
|
||||
"middle_pip_dip_side": "中指 PIP / 被动 DIP(侧面 ID8→ID9)",
|
||||
"index_roll_front": "食指 MCP roll(正面 ID0→ID13)",
|
||||
"index_mcp_side": "食指 MCP pitch(侧面 ID4→ID10)",
|
||||
"index_roll_front": "食指 MCP roll(正面 ID0→ID13 + 侧面 ID4→ID10)",
|
||||
"index_mcp_side": "食指 MCP pitch(侧面 ID4→ID11)",
|
||||
"index_pip_dip_side": "食指 PIP / 被动 DIP(侧面 ID10→ID11)",
|
||||
}
|
||||
|
||||
|
||||
def _o12_reason_zh(status):
|
||||
reason = str(status.get("reason", ""))
|
||||
if reason.startswith("sweep_quality_failed:"):
|
||||
details = reason.split(":", 2)[-1]
|
||||
if any(
|
||||
item in details
|
||||
for item in ("side_frames=", "side_bins=", "side_maximum_gap=")
|
||||
):
|
||||
return (
|
||||
"O12-CROSS-VIEW-SAMPLING-104",
|
||||
"侧摆任务的主视角数据已采到,但辅助侧面视角在本段轨迹内"
|
||||
f"保留的时序覆盖不足:{details}。这不表示 Tag 被遮挡。",
|
||||
"程序会保留已通过断点;重新启动后只重做该扫描单元。"
|
||||
"若 Tag 持续可见,优先检查侧面 AprilTag 节点的发布频率和 CPU 调度。",
|
||||
)
|
||||
if reason.startswith("mapping_preflight_no_target_motion:"):
|
||||
task = reason.split(":", 1)[1]
|
||||
return (
|
||||
@@ -52,11 +66,42 @@ def _o12_reason_zh(status):
|
||||
f"固定 SDK 映射点动时没有观察到目标关节运动:{task}。",
|
||||
"检查对应 Tag、通道映射和机械连接;不要交换通道后强行继续。",
|
||||
)
|
||||
if reason.startswith("o12_error_report_nonzero"):
|
||||
if reason.startswith("mapping_preflight_wrong_feedback_direction:"):
|
||||
task = reason.split(":", 1)[1]
|
||||
return (
|
||||
"O12-MAPPING-302",
|
||||
f"固定 SDK 映射点动的目标反馈方向不正确:{task}。",
|
||||
"检查对应 SDK 通道和机械连接;不要交换通道后强行继续。",
|
||||
)
|
||||
if reason.startswith("o12_active_motor_fault"):
|
||||
return (
|
||||
"O12-HEALTH-201",
|
||||
f"O12 返回非零错误码:{status.get('latest_error_codes', [])}。",
|
||||
"检查堵转、过流、过热或通信异常,排除后重新启动。",
|
||||
f"O12 返回活动电机故障:{status.get('error_faults', [])}。",
|
||||
"检查对应电机的堵转、过流、过热或电机异常;排除后从断点启动。",
|
||||
)
|
||||
if reason.startswith("o12_new_communication_fault"):
|
||||
return (
|
||||
"O12-HEALTH-202",
|
||||
f"运行中出现了新的 O12 通信异常:{status.get('error_faults', [])}。",
|
||||
"检查 HCAN 线缆、供电和对应电机通信;程序已保持当前位置。",
|
||||
)
|
||||
if reason.startswith("o12_feedback_stream_timeout"):
|
||||
return (
|
||||
"O12-HEALTH-203",
|
||||
"O12 命令触发反馈流中断超过 1 秒。",
|
||||
"检查 HCAN 连接、SDK 节点和供电;恢复后从断点启动。",
|
||||
)
|
||||
if reason.startswith("feedback_outside_registered_feedback_domain:"):
|
||||
return (
|
||||
"O12-FEEDBACK-202",
|
||||
"O12 反馈超出了已登记的物理反馈范围。",
|
||||
"保持当前位置,检查诊断中的通道、反馈值和机械端点。",
|
||||
)
|
||||
if reason == "calibration_node_status_timeout":
|
||||
return (
|
||||
"O12-NODE-500",
|
||||
"O12 标定节点已停止发布状态,运行栈将自动退出。",
|
||||
"查看本次 calibration.log 中的 Python traceback,并从断点重启。",
|
||||
)
|
||||
return _l6_reason_zh(status, model_name="O12")
|
||||
|
||||
@@ -76,12 +121,107 @@ def render_o12_progress_zh(status, estimator=None) -> str:
|
||||
if status.get("temperature_fallback_active")
|
||||
else "等待温度能力确认"
|
||||
)
|
||||
return text + (
|
||||
resume = status.get("resume", {})
|
||||
resume_text = ""
|
||||
if isinstance(resume, dict) and resume.get("used"):
|
||||
resume_text = (
|
||||
"\n断点恢复:已复用 "
|
||||
f"{int(resume.get('completed_unit_count', 0))} 个扫描单元;"
|
||||
f"来源 {resume.get('source_session', '-')}"
|
||||
)
|
||||
cross_view = status.get("roll_cross_view", {})
|
||||
cross_view_text = ""
|
||||
if isinstance(cross_view, dict) and cross_view.get("active"):
|
||||
recognized = "/".join(
|
||||
f"ID{int(value)}"
|
||||
for value in cross_view.get("recognized_tag_ids", ())
|
||||
) or "无"
|
||||
missing = "/".join(
|
||||
f"ID{int(value)}"
|
||||
for value in cross_view.get("unrecognized_tag_ids", ())
|
||||
) or "无"
|
||||
cross_view_text = (
|
||||
"\n侧摆双机位:侧面已识别 "
|
||||
f"{recognized};未识别/不合格 {missing};联合 "
|
||||
f"{int(cross_view.get('valid_frames', 0))}/"
|
||||
f"{int(cross_view.get('total_frames', 0))} 帧("
|
||||
f"{float(cross_view.get('joint_frame_rate', 0.0)):.1%})"
|
||||
)
|
||||
coupling = status.get("feedback_coupling", {})
|
||||
coupling_text = ""
|
||||
if isinstance(coupling, dict) and coupling.get("active"):
|
||||
stage = {
|
||||
"training_observation": "训练采集",
|
||||
"holdout_pending_full_sweep_validation": "第四轮整段验证",
|
||||
"vendor_solver_motion_prior": "运动准备",
|
||||
}.get(str(coupling.get("reference_kind", "")), "耦合观测")
|
||||
coupling_text = (
|
||||
"\nO12耦合:"
|
||||
f"{coupling.get('coupled_channel', '-')} 位移 "
|
||||
f"{float(coupling.get('displacement_rad', 0.0)):.3f}/"
|
||||
f"{float(coupling.get('hard_displacement_limit_rad', 0.0)):.3f} rad;"
|
||||
f"{stage};vendor偏差 "
|
||||
f"{float(coupling.get('residual_rad', 0.0)):.3f} rad(仅诊断)"
|
||||
)
|
||||
auxiliary = status.get("auxiliary_tracking", {})
|
||||
auxiliary_text = ""
|
||||
if isinstance(auxiliary, dict) and auxiliary.get("active"):
|
||||
channels = list(auxiliary.get("channels", ()))
|
||||
worst = max(
|
||||
channels,
|
||||
key=lambda item: float(item.get("error_rad", 0.0)),
|
||||
default={},
|
||||
)
|
||||
stage = (
|
||||
"平滑过渡中"
|
||||
if auxiliary.get("mode") == "transitioning"
|
||||
else "已稳定保持"
|
||||
if auxiliary.get("ready")
|
||||
else "等待稳定"
|
||||
)
|
||||
auxiliary_text = (
|
||||
"\nO12避让轴:"
|
||||
f"{stage};最大偏差 {worst.get('channel', '-')}="
|
||||
f"{float(worst.get('error_rad', 0.0)):.3f} rad"
|
||||
)
|
||||
locked_ids = list(status.get("locked_reference_tag_ids", ()))
|
||||
locked_reference_text = ""
|
||||
if locked_ids:
|
||||
locked_reference_text = (
|
||||
"\n固定基准:"
|
||||
+ "/".join(f"ID{int(value)}" for value in locked_ids)
|
||||
+ " 已锁定,避让遮挡期间复用"
|
||||
)
|
||||
error_health = str(
|
||||
status.get("error_health_classification", "awaiting_error_report")
|
||||
)
|
||||
if error_health == "historical_communication_latch":
|
||||
channels = "/".join(
|
||||
str(value) for value in status.get(
|
||||
"confirmed_historical_communication_channels", ()
|
||||
)
|
||||
) or "未知通道"
|
||||
error_health_text = f"历史通信位已核验({channels})"
|
||||
elif error_health == "confirming_historical_communication":
|
||||
error_health_text = (
|
||||
"确认历史通信位 "
|
||||
f"{int(status.get('error_report_matching_count', 0))}/3"
|
||||
)
|
||||
elif error_health == "clear":
|
||||
error_health_text = "正常"
|
||||
else:
|
||||
error_health_text = error_health
|
||||
return (
|
||||
text + cross_view_text + coupling_text + auxiliary_text
|
||||
+ locked_reference_text + resume_text + (
|
||||
"\nO12安全:POSITION="
|
||||
f"{bool(status.get('position_mode_verified'))};"
|
||||
f"错误码通道={bool(status.get('error_report_verified'))};"
|
||||
f"错误码通道={bool(status.get('error_report_verified'))}"
|
||||
f"({error_health_text});"
|
||||
f"温度策略={temperature};速度倍率="
|
||||
f"{float(status.get('motion_speed_scale', 1.0)):.1f}x"
|
||||
f"{float(status.get('motion_speed_scale', 1.0)):.1f}x;"
|
||||
f"控制发布={float(status.get('command_publish_hz', 0.0)):.1f} Hz"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -89,6 +229,7 @@ class _Monitor(Node):
|
||||
def __init__(self, progress: _ProgressConsole) -> None:
|
||||
super().__init__("o12_calibration_runner")
|
||||
self.status: dict[str, Any] = {}
|
||||
self.last_status_at = 0.0
|
||||
self.progress = progress
|
||||
self.create_subscription(String, "/o12_calibration/status", self._status, 10)
|
||||
self.start_client = self.create_client(Trigger, "/o12_calibration/start")
|
||||
@@ -101,9 +242,76 @@ class _Monitor(Node):
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
self.status = value
|
||||
self.last_status_at = time.monotonic()
|
||||
self.progress.update(value)
|
||||
|
||||
|
||||
def _wait_until_o12(
|
||||
monitor: _Monitor,
|
||||
process: subprocess.Popen[Any],
|
||||
predicate,
|
||||
*,
|
||||
timeout: float | None,
|
||||
status_stale_after: float = 3.0,
|
||||
) -> bool:
|
||||
"""Wait for O12 while also detecting a dead calibration child node."""
|
||||
started = time.monotonic()
|
||||
while rclpy.ok():
|
||||
if process.poll() is not None:
|
||||
return False
|
||||
rclpy.spin_once(monitor, timeout_sec=0.2)
|
||||
if predicate(monitor.status):
|
||||
return True
|
||||
now = time.monotonic()
|
||||
stale_limit = (
|
||||
180.0
|
||||
if monitor.status.get("state") == "FINALIZING"
|
||||
else float(status_stale_after)
|
||||
)
|
||||
if (
|
||||
monitor.last_status_at > 0.0
|
||||
and now - monitor.last_status_at > stale_limit
|
||||
):
|
||||
status_publishers = monitor.count_publishers(
|
||||
"/o12_calibration/status"
|
||||
)
|
||||
monitor.status = {
|
||||
**monitor.status,
|
||||
"state": "ABORTED",
|
||||
"reason": (
|
||||
"calibration_node_process_exited"
|
||||
if status_publishers == 0
|
||||
else "calibration_node_status_timeout"
|
||||
),
|
||||
}
|
||||
return False
|
||||
if timeout is not None and now - started > timeout:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _log_exception_summary(log_path: Path) -> str:
|
||||
"""Return the final child exception instead of hiding it as status loss."""
|
||||
try:
|
||||
lines = log_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
).splitlines()
|
||||
except OSError:
|
||||
return ""
|
||||
prefixes = (
|
||||
"AttributeError:", "AssertionError:", "ImportError:",
|
||||
"IndexError:", "KeyError:", "ModuleNotFoundError:",
|
||||
"OSError:", "RuntimeError:", "TypeError:", "ValueError:",
|
||||
)
|
||||
ansi = re.compile(r"\x1b\[[0-9;]*m")
|
||||
for raw in reversed(lines[-300:]):
|
||||
line = ansi.sub("", raw).strip()
|
||||
payload = line.rsplit("] ", 1)[-1].strip()
|
||||
if payload.startswith(prefixes):
|
||||
return payload
|
||||
return ""
|
||||
|
||||
|
||||
def _overlay_environment(setup: Path) -> dict[str, str]:
|
||||
completed = subprocess.run(
|
||||
["bash", "-c", 'source "$1" >/dev/null 2>&1; env -0', "bash", str(setup)],
|
||||
@@ -118,39 +326,140 @@ def _overlay_environment(setup: Path) -> dict[str, str]:
|
||||
return environment
|
||||
|
||||
|
||||
def _protected_inputs(config: ProductConfig) -> dict[str, str]:
|
||||
return {
|
||||
"source_urdf_sha256": config.source_urdf_sha256,
|
||||
"camera_extrinsics_sha256": config.camera_extrinsics_sha256,
|
||||
"calibration_config_sha256": config.calibration_config_sha256,
|
||||
"tag_config_sha256": config.tag_config_sha256,
|
||||
"sdk_config_sha256": config.sdk_config_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _finalize_completed_resume(config, resume, session: Path) -> int:
|
||||
"""Publish a complete checkpoint without touching calibration hardware."""
|
||||
expected = ordered_resume_units(
|
||||
config.calibration_contract.typed_profile
|
||||
)
|
||||
if tuple(resume.completed_units) != tuple(expected):
|
||||
raise ValueError("completed-resume finalization requires every scan unit")
|
||||
print(
|
||||
"O12 完整断点已包含全部 "
|
||||
f"{len(expected)} 个扫描单元;不启动 SDK、相机或避让运动,"
|
||||
"直接拟合、验证并生成 URDF(约需 1 分钟)。",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
payload, _fit, correction = finalize_o12_session(
|
||||
session_dir=session,
|
||||
serial_number=config.serial_number,
|
||||
source_urdf=config.source_urdf,
|
||||
protected_inputs=_protected_inputs(config),
|
||||
# The checkpoint builder imports only matching, passed scan
|
||||
# transactions plus their PnP provenance. This also avoids
|
||||
# recursively replaying prior session/audit wrapper records.
|
||||
records=list(resume.imported_records),
|
||||
publish=True,
|
||||
)
|
||||
except BaseException as error:
|
||||
print(
|
||||
"O12 完整断点的拟合、验证或 URDF 写回失败:"
|
||||
f"{error}",
|
||||
flush=True,
|
||||
)
|
||||
return 3
|
||||
print("\n".join((
|
||||
"PASS:O12 完整断点已通过拟合和独立 holdout;"
|
||||
"thumb_mcp 静态零位保留原始 CAD。",
|
||||
f"发布结果:{config.session_root / 'latest_passed'}",
|
||||
f"JSON:{session / config.calibration_contract.typed_profile.artifacts.calibration_filename.format(serial_number=config.serial_number)}",
|
||||
f"URDF:{correction.path}",
|
||||
)), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_online(
|
||||
config: ProductConfig, *, record_bag: bool, commands_enabled: bool
|
||||
config: ProductConfig, *, record_bag: bool, commands_enabled: bool,
|
||||
allow_resume: bool,
|
||||
) -> int:
|
||||
if config.sdk_setup is None or config.sdk_config is None:
|
||||
raise ValueError("O12 vendor SDK overlay/config are required")
|
||||
resume = automatic_resume_candidate(config) if allow_resume else None
|
||||
session = config.session_root / datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
while session.exists():
|
||||
time.sleep(1.0)
|
||||
session = config.session_root / datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
session.mkdir(parents=True)
|
||||
expected_resume_units = ordered_resume_units(
|
||||
config.calibration_contract.typed_profile
|
||||
)
|
||||
if (
|
||||
resume is not None
|
||||
and tuple(resume.completed_units) == tuple(expected_resume_units)
|
||||
):
|
||||
return _finalize_completed_resume(config, resume, session)
|
||||
log_path = session / "calibration.log"
|
||||
log_stream = log_path.open("a", encoding="utf-8", buffering=1)
|
||||
print(f"O12 标定环境正在启动;日志:{log_path}", flush=True)
|
||||
process = subprocess.Popen(
|
||||
_launch_command(
|
||||
config, session, record_bag=record_bag,
|
||||
commands_enabled=commands_enabled,
|
||||
),
|
||||
cwd=config.workspace,
|
||||
env=_overlay_environment(config.sdk_setup),
|
||||
stdout=log_stream,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
rclpy.init()
|
||||
monitor = _Monitor(_ProgressConsole(renderer=render_o12_progress_zh))
|
||||
process: subprocess.Popen[str] | None = None
|
||||
try:
|
||||
ready = _wait_until(
|
||||
# Discover already-running SDK/GUI/calibration publishers before this
|
||||
# command starts its own vendor node. Otherwise a vendor open failure
|
||||
# could be masked by feedback from the stale process holding HCAN.
|
||||
discovery_deadline = time.monotonic() + 1.5
|
||||
while rclpy.ok() and time.monotonic() < discovery_deadline:
|
||||
rclpy.spin_once(monitor, timeout_sec=0.1)
|
||||
existing_state = monitor.count_publishers("/o12/right/joint_states")
|
||||
existing_command = monitor.count_publishers("/o12/right/joint_cmd")
|
||||
if existing_state or existing_command:
|
||||
print(
|
||||
"O12 启动前独占检查失败:检测到既有 SDK/GUI/标定进程"
|
||||
f"(状态发布者={existing_state},命令发布者={existing_command})。"
|
||||
"请先关闭它们,再只运行本标定命令。",
|
||||
flush=True,
|
||||
)
|
||||
return 2
|
||||
print(f"O12 标定环境正在启动;日志:{log_path}", flush=True)
|
||||
if resume is not None:
|
||||
print(
|
||||
"O12 断点恢复:来源 "
|
||||
f"{resume.source_session},复用 "
|
||||
f"{len(resume.completed_units)} 个已通过扫描单元,"
|
||||
"启动后先恢复安全姿态。",
|
||||
flush=True,
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
_launch_command(
|
||||
config, session, record_bag=record_bag,
|
||||
commands_enabled=commands_enabled,
|
||||
resume_from=(
|
||||
None if resume is None else resume.source_session
|
||||
),
|
||||
),
|
||||
cwd=config.workspace,
|
||||
env=_overlay_environment(config.sdk_setup),
|
||||
stdout=log_stream,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
ready = _wait_until_o12(
|
||||
monitor, process,
|
||||
lambda status: status.get("state") in {"READY", "PAUSED", "ABORTED"},
|
||||
timeout=120.0,
|
||||
)
|
||||
if not ready and not monitor.status:
|
||||
exception = _log_exception_summary(log_path)
|
||||
print(
|
||||
"O12 标定节点初始化超时:设备进程已启动,但标定节点未发布状态;"
|
||||
"请查看日志中的节点构造或断点导入阶段。"
|
||||
f"日志:{log_path}",
|
||||
flush=True,
|
||||
)
|
||||
if exception:
|
||||
print(f"标定节点异常:{exception}", flush=True)
|
||||
return 2
|
||||
if not ready or monitor.status.get("state") != "READY":
|
||||
print(
|
||||
"O12 启动预检失败:请检查 HCAN、POSITION 模式、错误/温度回读、"
|
||||
@@ -167,21 +476,29 @@ def _run_online(
|
||||
if response is None or not response.success:
|
||||
print(f"O12 标定未启动:{getattr(response, 'message', '')}", flush=True)
|
||||
return 2
|
||||
print("O12 标定已自动开始:POSITION,20 Hz,弧度余弦轨迹。", flush=True)
|
||||
finished = _wait_until(
|
||||
print(
|
||||
"O12 标定已自动开始:POSITION,50 Hz,平滑限速轨迹。",
|
||||
flush=True,
|
||||
)
|
||||
finished = _wait_until_o12(
|
||||
monitor, process,
|
||||
lambda status: status.get("state") in {"PASSED", "PAUSED", "ABORTED"},
|
||||
timeout=None,
|
||||
)
|
||||
if not finished or monitor.status.get("state") != "PASSED":
|
||||
exception = _log_exception_summary(log_path)
|
||||
print(
|
||||
"O12 标定已安全停止并保持当前位置:"
|
||||
+ str(monitor.status.get("reason", "process_exit")),
|
||||
flush=True,
|
||||
)
|
||||
if exception:
|
||||
print(f"标定节点异常:{exception}", flush=True)
|
||||
print(f"诊断日志:{log_path}", flush=True)
|
||||
return 3
|
||||
print("\n".join((
|
||||
"PASS:O12 右手 11 个实测任务和独立 holdout 已通过。",
|
||||
"PASS:O12 右手 11 个实测任务和独立 holdout 已通过;"
|
||||
"thumb_mcp 静态零位保留原始 CAD。",
|
||||
f"发布结果:{config.session_root / 'latest_passed'}",
|
||||
f"JSON:{monitor.status.get('final_json')}",
|
||||
f"URDF:{monitor.status.get('final_urdf')}",
|
||||
@@ -196,7 +513,8 @@ def _run_online(
|
||||
monitor.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
_stop_stack(process)
|
||||
if process is not None:
|
||||
_stop_stack(process)
|
||||
log_stream.flush()
|
||||
os.fsync(log_stream.fileno())
|
||||
log_stream.close()
|
||||
@@ -212,6 +530,10 @@ def main(args: list[str] | None = None) -> None:
|
||||
parser.add_argument("--offline-raw", default="")
|
||||
parser.add_argument("--offline-output", default="")
|
||||
parser.add_argument("--publish-offline", action="store_true")
|
||||
parser.add_argument(
|
||||
"--no-resume", action="store_true",
|
||||
help="忽略兼容的失败会话,从头开始采集",
|
||||
)
|
||||
selected = parser.parse_args(args)
|
||||
config = load_product_config(
|
||||
selected.config,
|
||||
@@ -234,22 +556,20 @@ def main(args: list[str] | None = None) -> None:
|
||||
session_dir=output,
|
||||
serial_number=config.serial_number,
|
||||
source_urdf=config.source_urdf,
|
||||
protected_inputs={
|
||||
"source_urdf_sha256": config.source_urdf_sha256,
|
||||
"camera_extrinsics_sha256": config.camera_extrinsics_sha256,
|
||||
"calibration_config_sha256": config.calibration_config_sha256,
|
||||
"tag_config_sha256": config.tag_config_sha256,
|
||||
"sdk_config_sha256": config.sdk_config_sha256,
|
||||
},
|
||||
protected_inputs=_protected_inputs(config),
|
||||
records=load_o12_raw_samples(selected.offline_raw),
|
||||
publish=selected.publish_offline,
|
||||
)
|
||||
print(f"离线回放PASS:schema {payload['schema_version']},URDF {correction.path}")
|
||||
print(
|
||||
f"离线回放PASS:schema {payload['schema_version']},"
|
||||
f"thumb_mcp 静态零位保留原始 CAD,URDF {correction.path}"
|
||||
)
|
||||
return
|
||||
raise SystemExit(_run_online(
|
||||
config,
|
||||
record_bag=selected.record_bag,
|
||||
commands_enabled=not selected.commands_disabled,
|
||||
allow_resume=not selected.no_resume,
|
||||
))
|
||||
|
||||
|
||||
|
||||
@@ -10,23 +10,144 @@ import re
|
||||
from typing import Mapping
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from ...core.urdf import UrdfJointPatch, UrdfPatchSet, write_urdf_patches
|
||||
from .fitting import O12FitResult
|
||||
import numpy as np
|
||||
|
||||
from ...core.urdf import (
|
||||
UrdfJointPatch,
|
||||
UrdfPatchSet,
|
||||
validate_urdf_mimic_ranges,
|
||||
write_urdf_patches,
|
||||
)
|
||||
from .fitting import O12FitResult, measured_curve_bounds
|
||||
from .kinematics import PASSIVE_SDK_SOURCE_BY_JOINT, vendor_passive_curve
|
||||
from ..l6.urdf import _corrected_origin_rpy
|
||||
from .profile import (
|
||||
ACTIVE_JOINTS,
|
||||
CALIBRATED_ACTIVE_JOINTS,
|
||||
COMMAND_INDEX_BY_JOINT,
|
||||
MEASURED_PASSIVE_JOINTS,
|
||||
MIMIC_SOURCE_BY_JOINT,
|
||||
SDK_TO_URDF_SIGN,
|
||||
STATIC_ZERO_EXCLUDED_JOINTS,
|
||||
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
)
|
||||
|
||||
|
||||
SPLAY_JOINTS = frozenset(
|
||||
{"index_mcp_roll", "middle_mcp_roll"}
|
||||
)
|
||||
SPLAY_JOINTS = frozenset({"index_mcp_roll", "middle_mcp_roll"})
|
||||
|
||||
|
||||
def _measured_curve_bounds(
|
||||
name: str, result: O12FitResult, *, sign: float
|
||||
) -> tuple[float, float]:
|
||||
donor = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name)
|
||||
return measured_curve_bounds(
|
||||
donor,
|
||||
result.curves[donor],
|
||||
result.feedback_domains_rad[donor],
|
||||
sign=sign,
|
||||
)
|
||||
|
||||
|
||||
def _source_reachable_upper(
|
||||
name: str,
|
||||
joints: Mapping[str, ET.Element],
|
||||
cache: dict[str, float],
|
||||
) -> float:
|
||||
if name in cache:
|
||||
return cache[name]
|
||||
node = joints[name]
|
||||
limit = node.find("limit")
|
||||
if limit is None or limit.get("upper") is None:
|
||||
raise ValueError(f"source URDF joint has no upper limit: {name}")
|
||||
mimic = node.find("mimic")
|
||||
if mimic is None:
|
||||
value = float(limit.get("upper"))
|
||||
else:
|
||||
source_name = str(mimic.get("joint", ""))
|
||||
if source_name not in joints:
|
||||
raise ValueError(f"source URDF mimic source is missing: {source_name}")
|
||||
value = (
|
||||
float(mimic.get("offset", "0"))
|
||||
+ float(mimic.get("multiplier", "1"))
|
||||
* _source_reachable_upper(source_name, joints, cache)
|
||||
)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"source URDF reachable endpoint is invalid: {name}")
|
||||
cache[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def o12_active_ranges(result: O12FitResult) -> dict[str, tuple[float, float]]:
|
||||
"""Return the exact active ranges used by O12 JSON and URDF publication."""
|
||||
ranges: dict[str, tuple[float, float]] = {}
|
||||
for name in sorted(
|
||||
CALIBRATED_ACTIVE_JOINTS | set(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT)
|
||||
):
|
||||
if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT:
|
||||
continue
|
||||
measured_lower, measured_upper = _measured_curve_bounds(
|
||||
name,
|
||||
result,
|
||||
sign=SDK_TO_URDF_SIGN[COMMAND_INDEX_BY_JOINT[name]],
|
||||
)
|
||||
ranges[name] = (
|
||||
(measured_lower, measured_upper)
|
||||
if name in SPLAY_JOINTS
|
||||
else (min(0.0, measured_lower), max(0.0, measured_upper))
|
||||
)
|
||||
return ranges
|
||||
|
||||
|
||||
def o12_endpoint_mimic_contract(
|
||||
source_urdf: str | Path,
|
||||
result: O12FitResult,
|
||||
) -> dict[str, float]:
|
||||
"""Linear URDF mimics that preserve the source-CAD closed chain pose.
|
||||
|
||||
O12 runtime uses the nonlinear vendor solver. URDF ``mimic`` is only a
|
||||
linear visualization fallback, so choose its coefficient to reproduce the
|
||||
reviewed CAD mechanical endpoint rather than fitting an arbitrary planar
|
||||
Tag phase. Chained pinky DIP is resolved after pinky PIP.
|
||||
"""
|
||||
root = ET.parse(Path(source_urdf).expanduser().resolve()).getroot()
|
||||
joints = {
|
||||
str(node.get("name")): node
|
||||
for node in root.findall("joint")
|
||||
if node.get("type") == "revolute"
|
||||
}
|
||||
reachable_source: dict[str, float] = {}
|
||||
desired = {
|
||||
name: _source_reachable_upper(name, joints, reachable_source)
|
||||
for name in MEASURED_PASSIVE_JOINTS
|
||||
}
|
||||
corrected_source_upper = {
|
||||
name: values[1] for name, values in o12_active_ranges(result).items()
|
||||
}
|
||||
multipliers: dict[str, float] = {}
|
||||
for name in (
|
||||
"thumb_dip", "index_dip", "middle_dip", "pinky_pip", "pinky_dip"
|
||||
):
|
||||
node = joints[name]
|
||||
mimic = node.find("mimic")
|
||||
if mimic is None:
|
||||
raise ValueError(f"O12 passive joint has no mimic element: {name}")
|
||||
source_name = MIMIC_SOURCE_BY_JOINT[name]
|
||||
source_endpoint = corrected_source_upper.get(source_name, desired.get(source_name))
|
||||
if source_endpoint is None or abs(source_endpoint) <= 1.0e-12:
|
||||
raise ValueError(f"O12 mimic source endpoint is invalid: {source_name}")
|
||||
offset = float(mimic.get("offset", "0"))
|
||||
multiplier = (desired[name] - offset) / source_endpoint
|
||||
if not math.isfinite(multiplier) or multiplier <= 0.0:
|
||||
raise ValueError(f"O12 endpoint mimic is invalid: {name}")
|
||||
multipliers[name] = multiplier
|
||||
corrected_source_upper[name] = desired[name]
|
||||
return multipliers
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class O12UrdfCorrection:
|
||||
path: Path
|
||||
origin_offsets_rad: Mapping[str, float]
|
||||
corrected_limits_rad: Mapping[str, tuple[float, float]]
|
||||
mimic_multipliers: Mapping[str, float]
|
||||
preserved_ring_fields: tuple[str, ...]
|
||||
@@ -40,11 +161,13 @@ def write_o12_corrected_urdf(
|
||||
result: O12FitResult,
|
||||
timestamp: str | None = None,
|
||||
) -> O12UrdfCorrection:
|
||||
"""Patch only measured travel and dynamic mimic terms.
|
||||
"""Write full-range visual joint limits and equivalent endpoint mimics.
|
||||
|
||||
Tag mounting angle cannot be separated from a static passive-joint zero,
|
||||
so origins and passive limits stay byte-for-byte CAD. The unobserved ring
|
||||
keeps its own origin, limits and both CAD mimic ratios.
|
||||
O12 SDK feedback is the continuous input coordinate, while the 16-Tag
|
||||
trajectories identify the corresponding 19-joint URDF motion. The source
|
||||
CAD is never allowed to truncate acquisition. Ring geometry and mimic
|
||||
ratios remain CAD-owned; its active range remains constrained by that
|
||||
preserved chain.
|
||||
"""
|
||||
source = Path(source_urdf).expanduser().resolve()
|
||||
if not source.is_file():
|
||||
@@ -57,6 +180,23 @@ def write_o12_corrected_urdf(
|
||||
raise ValueError("O12 travel result has the wrong active joint set")
|
||||
if set(result.mimic_fits) != MEASURED_PASSIVE_JOINTS:
|
||||
raise ValueError("O12 mimic result has the wrong passive joint set")
|
||||
for name in STATIC_ZERO_EXCLUDED_JOINTS:
|
||||
if (
|
||||
not math.isclose(
|
||||
float(result.zero_offsets_rad.get(name, math.nan)),
|
||||
0.0,
|
||||
rel_tol=0.0,
|
||||
abs_tol=1.0e-12,
|
||||
)
|
||||
or result.zero_method_by_joint.get(name)
|
||||
!= "source_cad_zero_profile_excluded"
|
||||
):
|
||||
raise ValueError(
|
||||
f"O12 {name} must retain immutable source-CAD static zero"
|
||||
)
|
||||
for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items():
|
||||
if not math.isclose(result.zero_offsets_rad[target], result.zero_offsets_rad[donor], abs_tol=1.0e-9, rel_tol=0.0):
|
||||
raise ValueError(f"{target} static zero differs from transfer donor {donor}")
|
||||
|
||||
root = ET.parse(source).getroot()
|
||||
joints = {
|
||||
@@ -72,40 +212,95 @@ def write_o12_corrected_urdf(
|
||||
|
||||
corrected_limits: dict[str, tuple[float, float]] = {}
|
||||
patches: dict[str, UrdfJointPatch] = {}
|
||||
for name in sorted(CALIBRATED_ACTIVE_JOINTS):
|
||||
active_targets = CALIBRATED_ACTIVE_JOINTS | set(
|
||||
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
|
||||
)
|
||||
measured_active_ranges = o12_active_ranges(result)
|
||||
active_ranges: dict[str, tuple[float, float]] = {}
|
||||
for name in sorted(active_targets):
|
||||
limit = joints[name].find("limit")
|
||||
if limit is None:
|
||||
raise ValueError(f"O12 source active joint has no limit: {name}")
|
||||
cad_lower = float(limit.get("lower", "nan"))
|
||||
cad_upper = float(limit.get("upper", "nan"))
|
||||
lower_text = limit.get("lower")
|
||||
upper_text = limit.get("upper")
|
||||
if lower_text is None or upper_text is None:
|
||||
raise ValueError(f"O12 source active joint has incomplete limits: {name}")
|
||||
cad_lower = float(lower_text)
|
||||
cad_upper = float(upper_text)
|
||||
travel = abs(float(result.travels_rad[name]))
|
||||
if not math.isfinite(travel) or travel <= math.radians(2.0):
|
||||
raise ValueError(f"invalid measured O12 travel: {name}")
|
||||
if name in SPLAY_JOINTS:
|
||||
half = min(0.5 * travel, abs(cad_lower), abs(cad_upper))
|
||||
lower, upper = -half, half
|
||||
else:
|
||||
lower, upper = max(0.0, cad_lower), min(cad_upper, travel)
|
||||
if not lower < upper:
|
||||
if not cad_lower < cad_upper:
|
||||
raise ValueError(f"invalid corrected O12 limits: {name}")
|
||||
if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT:
|
||||
# The ring has no Tag. Preserve its own reviewed CAD range and
|
||||
# passive-chain contract rather than transplanting pinky geometry.
|
||||
lower, upper = cad_lower, cad_upper
|
||||
else:
|
||||
lower, upper = measured_active_ranges[name]
|
||||
if not math.isfinite(lower) or not math.isfinite(upper) or lower >= upper:
|
||||
raise ValueError(f"invalid measured O12 limits: {name}")
|
||||
corrected_limits[name] = (lower, upper)
|
||||
active_ranges[name] = (lower, upper)
|
||||
origin_offset = float(result.zero_offsets_rad.get(name, 0.0))
|
||||
patches[name] = UrdfJointPatch(
|
||||
origin_rpy=(
|
||||
_corrected_origin_rpy(joints[name], origin_offset)
|
||||
if abs(origin_offset) > 1.0e-12 else None
|
||||
),
|
||||
limit_lower=f"{lower:.15g}", limit_upper=f"{upper:.15g}"
|
||||
)
|
||||
|
||||
mimic_multipliers: dict[str, float] = {}
|
||||
mimic_multipliers = o12_endpoint_mimic_contract(source, result)
|
||||
for name in sorted(MEASURED_PASSIVE_JOINTS):
|
||||
mimic = joints[name].find("mimic")
|
||||
if mimic is None:
|
||||
raise ValueError(f"O12 passive joint has no mimic element: {name}")
|
||||
multiplier = float(result.mimic_fits[name].urdf_mimic_multiplier)
|
||||
multiplier = float(mimic_multipliers[name])
|
||||
if not math.isfinite(multiplier) or not 0.5 <= multiplier <= 2.2:
|
||||
raise ValueError(f"invalid O12 mimic multiplier: {name}")
|
||||
mimic_multipliers[name] = multiplier
|
||||
patches[name] = UrdfJointPatch(
|
||||
mimic_multiplier=f"{multiplier:.15g}"
|
||||
)
|
||||
|
||||
# Passive limits remain CAD-owned unless the reviewed vendor polynomial
|
||||
# itself has a small interior extremum outside that range (index DIP does).
|
||||
# Never expand a physical limit from the noisier Tag-derived curve.
|
||||
for name in (
|
||||
"thumb_dip", "index_dip", "middle_dip", "pinky_pip", "pinky_dip"
|
||||
):
|
||||
node = joints[name]
|
||||
limit = node.find("limit")
|
||||
assert limit is not None
|
||||
cad_lower = float(limit.get("lower", "nan"))
|
||||
cad_upper = float(limit.get("upper", "nan"))
|
||||
sdk_source = PASSIVE_SDK_SOURCE_BY_JOINT[name]
|
||||
feedback_lower, feedback_upper = result.feedback_domains_rad[name]
|
||||
inputs = np.linspace(
|
||||
min(0.0, feedback_lower), max(0.0, feedback_upper), 257
|
||||
)
|
||||
motor = COMMAND_INDEX_BY_JOINT[sdk_source]
|
||||
vendor_values = vendor_passive_curve(
|
||||
name,
|
||||
inputs,
|
||||
sdk_to_urdf_sign=SDK_TO_URDF_SIGN[motor],
|
||||
)
|
||||
lower = min(cad_lower, *vendor_values)
|
||||
upper = max(cad_upper, *vendor_values)
|
||||
if not lower < upper:
|
||||
raise ValueError(f"invalid corrected O12 passive limits: {name}")
|
||||
corrected_limits[name] = (lower, upper)
|
||||
existing = patches[name]
|
||||
patches[name] = UrdfJointPatch(
|
||||
limit_lower=(
|
||||
f"{lower:.15g}" if lower < cad_lower - 1.0e-12 else None
|
||||
),
|
||||
limit_upper=(
|
||||
f"{upper:.15g}" if upper > cad_upper + 1.0e-12 else None
|
||||
),
|
||||
mimic_multiplier=existing.mimic_multiplier,
|
||||
)
|
||||
|
||||
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if re.fullmatch(r"\d{8}_\d{6}", stamp) is None:
|
||||
raise ValueError("O12 URDF timestamp must use YYYYMMDD_HHMMSS")
|
||||
@@ -125,16 +320,32 @@ def write_o12_corrected_urdf(
|
||||
forbidden_source_stem_patterns=(r"calibrated",),
|
||||
copy_complete_mesh_directory=True,
|
||||
)
|
||||
try:
|
||||
validate_urdf_mimic_ranges(destination, reference_urdf=source)
|
||||
except Exception:
|
||||
# Publication is transactional: a generated file that failed the
|
||||
# physical mimic-chain gate must never remain as a plausible result.
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
return O12UrdfCorrection(
|
||||
path=destination,
|
||||
origin_offsets_rad={
|
||||
name: float(result.zero_offsets_rad[name])
|
||||
for name in ACTIVE_JOINTS
|
||||
},
|
||||
corrected_limits_rad=corrected_limits,
|
||||
mimic_multipliers=mimic_multipliers,
|
||||
preserved_ring_fields=(
|
||||
"ring_mcp_pitch.origin", "ring_mcp_pitch.limit",
|
||||
"ring_mcp_pitch.origin.xyz", "ring_mcp_pitch.limit",
|
||||
"ring_pip.origin", "ring_pip.limit", "ring_pip.mimic",
|
||||
"ring_dip.origin", "ring_dip.limit", "ring_dip.mimic",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["O12UrdfCorrection", "write_o12_corrected_urdf"]
|
||||
__all__ = [
|
||||
"O12UrdfCorrection",
|
||||
"o12_active_ranges",
|
||||
"o12_endpoint_mimic_contract",
|
||||
"write_o12_corrected_urdf",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""O12 observation graph for the shared G20 spatial-zero solver.
|
||||
|
||||
Parallel flexion axes observe phase through their line positions, not their
|
||||
directions. SDK travel endpoints are deliberately not treated as CAD datums.
|
||||
Passive joints supply observations but never independently fitted static zeros.
|
||||
"""
|
||||
|
||||
from dataclasses import asdict, replace
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from ..g20.profile import JointCurveFit, JointSpec
|
||||
from ..g20.zero_solver import (
|
||||
ZeroCalibrationProfile, ZeroSolveResult,
|
||||
fit_joint_axis_measurement, solve_urdf_zero_offsets,
|
||||
with_depth_free_axis_projection,
|
||||
)
|
||||
from .profile import (
|
||||
CALIBRATED_ACTIVE_JOINTS,
|
||||
COMMAND_INDEX_BY_JOINT,
|
||||
)
|
||||
from .kinematics import PASSIVE_SDK_SOURCE_BY_JOINT
|
||||
|
||||
SPATIAL_ZERO_POLICY = (
|
||||
"o12_full_hand_spatial_v5_thumb_mcp_cad_static_stable_pnp_bias"
|
||||
)
|
||||
|
||||
AXIS_PARENT = {
|
||||
"thumb_cmc_yaw": "thumb_cmc_roll",
|
||||
"thumb_cmc_pitch": "thumb_cmc_yaw",
|
||||
"index_mcp_pitch": "index_mcp_roll",
|
||||
"middle_mcp_pitch": "middle_mcp_roll",
|
||||
}
|
||||
PHASE_PARENT = {
|
||||
"thumb_mcp": "thumb_cmc_pitch",
|
||||
"index_pip": "index_mcp_pitch", "index_dip": "index_pip",
|
||||
"middle_pip": "middle_mcp_pitch", "middle_dip": "middle_pip",
|
||||
"pinky_pip": "pinky_mcp_pitch",
|
||||
}
|
||||
ZERO_OBSERVER = {
|
||||
"thumb_cmc_roll": "thumb_cmc_yaw",
|
||||
"thumb_cmc_yaw": "thumb_cmc_pitch",
|
||||
"thumb_cmc_pitch": "thumb_mcp",
|
||||
"index_mcp_roll": "index_mcp_pitch",
|
||||
"index_mcp_pitch": "index_pip", "index_pip": "index_dip",
|
||||
"middle_mcp_roll": "middle_mcp_pitch",
|
||||
"middle_mcp_pitch": "middle_pip", "middle_pip": "middle_dip",
|
||||
"pinky_mcp_pitch": "pinky_pip",
|
||||
}
|
||||
# Order also ensures an upstream axis exists before its passive observer.
|
||||
AXIS_JOINTS = (
|
||||
"thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch", "thumb_mcp",
|
||||
"pinky_mcp_pitch", "pinky_pip",
|
||||
"middle_mcp_roll", "middle_mcp_pitch", "middle_pip", "middle_dip",
|
||||
"index_mcp_roll", "index_mcp_pitch", "index_pip", "index_dip",
|
||||
)
|
||||
|
||||
|
||||
def motor_index(joint: str) -> int:
|
||||
return COMMAND_INDEX_BY_JOINT[PASSIVE_SDK_SOURCE_BY_JOINT.get(joint, joint)]
|
||||
|
||||
|
||||
def full_hand_zero_profile() -> ZeroCalibrationProfile:
|
||||
# Reuse the existing root orientation datum; adding flexion phases must
|
||||
# not silently change the palm frame or the fixed SDK channel contract.
|
||||
from .fitting import _thumb_root_zero_profile
|
||||
root = _thumb_root_zero_profile()
|
||||
specs = {
|
||||
name: JointSpec(name, motor_index(name), name in CALIBRATED_ACTIVE_JOINTS,
|
||||
None, None, None, pose_axis_line_required=name in PHASE_PARENT)
|
||||
for name in AXIS_JOINTS
|
||||
}
|
||||
return replace(
|
||||
root, hand=replace(root.hand, joint_specs=specs),
|
||||
direct_zero_joints=tuple(ZERO_OBSERVER), axis_joints=AXIS_JOINTS,
|
||||
constrained_circle_joints=frozenset(AXIS_JOINTS),
|
||||
axis_parent_joint=AXIS_PARENT, phase_parent_joint=PHASE_PARENT,
|
||||
offset_observer_joint=ZERO_OBSERVER,
|
||||
accept_validated_zero_in_confidence_interval=True,
|
||||
project_axis_gauge_before_image=True,
|
||||
)
|
||||
|
||||
|
||||
class O12SpatialZeroError(ValueError):
|
||||
"""Unpublishable geometry, with machine-readable per-joint evidence."""
|
||||
|
||||
def __init__(self, message: str, diagnostics: Mapping[str, Any]) -> None:
|
||||
super().__init__(message)
|
||||
self.diagnostics = diagnostics
|
||||
# Populated only after the full dynamic fit and an identifiable
|
||||
# spatial estimate exist. Never a substitute for a passed result.
|
||||
self.review_fit = None
|
||||
|
||||
|
||||
def solve_full_hand_zero(
|
||||
source_urdf: str | Path,
|
||||
virtual_records: Mapping[str, Sequence[Mapping[str, Any]]],
|
||||
solver_curves: Mapping[str, JointCurveFit],
|
||||
) -> ZeroSolveResult:
|
||||
profile = full_hand_zero_profile()
|
||||
measurements = []
|
||||
for cycle in range(4):
|
||||
by_joint = {}
|
||||
for name in AXIS_JOINTS:
|
||||
rows = virtual_records[name]
|
||||
# Relative passive motion is measured in its moving parent Tag
|
||||
# frame. Its common-frame line is evaluated at the same baseline
|
||||
# condition, just as in G20; it is not a circle of the entire chain.
|
||||
parent = PHASE_PARENT.get(name)
|
||||
# PHASE_PARENT declares parallel physical flexion axes, including
|
||||
# ACTIVE PIP/MCP axes. Independent planar-PnP direction errors must
|
||||
# not turn those into skew lines and then appear as encoder zeros.
|
||||
# The task profile holds nonparallel upstream axes at the same
|
||||
# pose for these paired captures. Preserve each child's measured
|
||||
# scalar rotation; constrain only the shared physical direction.
|
||||
constraint = by_joint[parent].axis_common_xyz if parent in by_joint else None
|
||||
try:
|
||||
item = fit_joint_axis_measurement(
|
||||
name, rows, cycle=cycle, zero_command_u8=255,
|
||||
constrained_circle_joints=profile.constrained_circle_joints,
|
||||
view_normal_common_xyz=rows[0]["view_normal_common_xyz"],
|
||||
canonical_zero_direction="decreasing",
|
||||
axis_common_constraint=constraint,
|
||||
separate_axial_residual=True,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise O12SpatialZeroError(
|
||||
f"O12 spatial zero observation failed:{name}:cycle={cycle}:{error}",
|
||||
{"passed": False, "stage": "axis_observation", "joint": name,
|
||||
"cycle": cycle, "reason": str(error)},
|
||||
) from error
|
||||
item = with_depth_free_axis_projection(item, rows[0]["camera_center_common_xyz_m"])
|
||||
measurements.append(item)
|
||||
by_joint[name] = item
|
||||
try:
|
||||
result = solve_urdf_zero_offsets(
|
||||
source_urdf=source_urdf, measurements=measurements, curves=solver_curves,
|
||||
motor_by_joint={name: motor_index(name) for name in AXIS_JOINTS},
|
||||
training_cycles=(0, 1, 2), validation_cycle=3,
|
||||
maximum_offset_rad=math.radians(20), finger_maximum_offset_rad=math.radians(20),
|
||||
maximum_cycle_difference_rad=math.radians(.75),
|
||||
minimum_applied_offset_rad=math.radians(.1),
|
||||
maximum_validation_mae_rad=math.radians(1),
|
||||
maximum_validation_p95_rad=math.radians(2),
|
||||
maximum_validation_error_rad=math.radians(3),
|
||||
maximum_confidence_half_width_rad=math.radians(1),
|
||||
# Match G20's established treatment of a repeatable planar-Tag
|
||||
# direction bias: 5 deg remains the normal cone target, while a
|
||||
# stable (<=0.75 deg cycle spread) bias up to 15 deg is retained
|
||||
# as an explicit diagnostic instead of corrupting/rejecting an
|
||||
# otherwise observable encoder zero.
|
||||
maximum_systematic_axis_cone_bias_rad=math.radians(15),
|
||||
maximum_pose_axis_line_rms_m=.0015,
|
||||
hand_type="right", tag_layout="o12_right_16", zero_profile=profile,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise O12SpatialZeroError(
|
||||
"O12 full-hand spatial zero geometry is invalid:" + str(error),
|
||||
{"passed": False, "stage": "spatial_solve", "reason": str(error),
|
||||
"axis_measurements": [asdict(item) for item in measurements]},
|
||||
) from error
|
||||
result = replace(result, axis_residual_diagnostics={
|
||||
f"{item.joint}:cycle{item.cycle}": {
|
||||
"axial_component_separated": item.axis_point_axial_component_separated,
|
||||
"fit_rms_m": item.pose_axis_line_rms_m,
|
||||
"raw_rms_m": item.pose_axis_line_raw_rms_m,
|
||||
"axial_rms_m": item.pose_axis_line_axial_rms_m,
|
||||
"transverse_rms_m": item.pose_axis_line_transverse_rms_m,
|
||||
} for item in measurements
|
||||
})
|
||||
if not result.passed:
|
||||
details = ",".join(f"{n}={r}" for n, r in sorted(result.failure_reasons.items()))
|
||||
raise O12SpatialZeroError(
|
||||
"O12 full-hand spatial zero solve failed:" + details,
|
||||
{"passed": False, "stage": "spatial_solve", "policy": SPATIAL_ZERO_POLICY,
|
||||
"result": asdict(result),
|
||||
"axis_measurements": [asdict(item) for item in measurements]},
|
||||
)
|
||||
return result
|
||||
@@ -272,11 +272,6 @@ def fit_o6_session(
|
||||
)
|
||||
if fit.maximum_monotonic_correction_rad > correction_limit:
|
||||
raise ValueError(f"{name} monotonic correction exceeds limit")
|
||||
if fit.maximum_hysteresis_rad > math.radians(MAXIMUM_HYSTERESIS_DEG):
|
||||
raise ValueError(
|
||||
f"{name} hysteresis {math.degrees(fit.maximum_hysteresis_rad):.3f} "
|
||||
f"degrees exceeds O6 limit {MAXIMUM_HYSTERESIS_DEG:.3f} degrees"
|
||||
)
|
||||
curves[name] = fit
|
||||
holdout[name] = errors
|
||||
cycle_curves[name] = {
|
||||
|
||||
@@ -8,6 +8,8 @@ import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from ...runtime.engine import CalibrationEngine
|
||||
|
||||
from .artifacts import (
|
||||
artifact_hashes,
|
||||
atomic_write_json,
|
||||
@@ -24,6 +26,7 @@ from .profile import (
|
||||
MEASURED_PASSIVE_JOINTS,
|
||||
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
|
||||
build_typed_profile,
|
||||
)
|
||||
from .urdf import O6UrdfCorrection, write_o6_corrected_urdf
|
||||
|
||||
@@ -111,6 +114,13 @@ def finalize_o6_session(
|
||||
result = fit_o6_session(
|
||||
source_urdf, accepted_records_by_joint(records), require_thumb_axis_zero=True
|
||||
)
|
||||
CalibrationEngine(build_typed_profile()).result_from_fit(
|
||||
result,
|
||||
transfers={
|
||||
**TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||
**TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
|
||||
},
|
||||
)
|
||||
payload = build_o6_runtime_payload(
|
||||
serial_number=serial_number,
|
||||
source_urdf=source_urdf,
|
||||
|
||||
@@ -217,8 +217,8 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
),
|
||||
motion=MotionPolicy(
|
||||
tasks=tasks,
|
||||
precheck_sweeps=True,
|
||||
steady_command_checkpoints=True,
|
||||
precheck_sweeps=False,
|
||||
steady_command_checkpoints=False,
|
||||
speed_parameters={
|
||||
"baseline_u8": BASELINE_SPEED_U8,
|
||||
"preflight_u8": PREFLIGHT_SPEED_U8,
|
||||
@@ -253,7 +253,6 @@ def build_typed_profile() -> CalibrationProfile:
|
||||
hard_threshold_keys=frozenset({
|
||||
"minimum_detection_rate",
|
||||
"maximum_state_image_skew_ms",
|
||||
"maximum_hysteresis_rad",
|
||||
"maximum_validation_error_rad",
|
||||
"maximum_mimic_residual_rad",
|
||||
}),
|
||||
|
||||
@@ -6,11 +6,13 @@ from dataclasses import dataclass
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
from ..core import CalibrationProfile, ProfileKey, validate_profile
|
||||
from ..runtime.engine import CalibrationEngine
|
||||
from ..runtime.adapters import ProfileSdkAdapter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineBindings:
|
||||
"""Temporary bridge from typed policies to the proven engine objects."""
|
||||
"""Small model plugin: SDK I/O hooks plus compatible CLI serializers."""
|
||||
|
||||
hand_profile: Any
|
||||
zero_profile: Any
|
||||
@@ -19,6 +21,7 @@ class EngineBindings:
|
||||
return_waypoints: Callable[..., tuple[tuple[int, ...], ...]]
|
||||
cli_main: Callable[[list[str] | None], None]
|
||||
node_main: Callable[[list[str] | None], None]
|
||||
sdk_adapter_factory: Callable[[Any], ProfileSdkAdapter] = ProfileSdkAdapter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -26,6 +29,12 @@ class RegisteredProfile:
|
||||
profile: CalibrationProfile
|
||||
engine: EngineBindings
|
||||
|
||||
def build_engine(self) -> CalibrationEngine:
|
||||
return CalibrationEngine(self.profile)
|
||||
|
||||
def build_sdk_adapter(self) -> ProfileSdkAdapter:
|
||||
return self.engine.sdk_adapter_factory(self.profile.command)
|
||||
|
||||
|
||||
class ProfileRegistry:
|
||||
"""An in-package registry; no discovery plugins or string evaluation."""
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"""Common orchestration; ROS integration is isolated below ``nodes``."""
|
||||
"""Common calibration engine; model registry imports remain lazy."""
|
||||
|
||||
from .controller import ControllerSnapshot, SessionController, SessionState
|
||||
from .runner import build_session_controller
|
||||
from .engine import (
|
||||
ACQUISITION_POLICY_VERSION,
|
||||
CalibrationEngine,
|
||||
CalibrationResult,
|
||||
ScanUnit,
|
||||
SweepQuality,
|
||||
)
|
||||
|
||||
|
||||
def build_session_controller(*args, **kwargs):
|
||||
# Avoid a registry/runtime import cycle while preserving the public API.
|
||||
from .runner import build_session_controller as build
|
||||
return build(*args, **kwargs)
|
||||
|
||||
__all__ = [
|
||||
"ControllerSnapshot",
|
||||
"SessionController",
|
||||
"SessionState",
|
||||
"build_session_controller",
|
||||
"ACQUISITION_POLICY_VERSION", "CalibrationEngine", "CalibrationResult",
|
||||
"ControllerSnapshot", "ScanUnit", "SessionController", "SessionState",
|
||||
"SweepQuality", "build_session_controller",
|
||||
]
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
"""Camera, detector, and hand-SDK runtime adapters."""
|
||||
|
||||
from .base import HardwareHealth, ProfileSdkAdapter, SdkAdapter
|
||||
|
||||
__all__ = ["HardwareHealth", "ProfileSdkAdapter", "SdkAdapter"]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Hardware-facing contract used by the model-independent calibration engine.
|
||||
|
||||
Adapters deliberately stop at the SDK boundary. Tag geometry, task ordering,
|
||||
fitting, and URDF edits belong to :class:`CalibrationProfile`, so adding a hand
|
||||
does not add another acquisition state machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Protocol, Sequence, runtime_checkable
|
||||
|
||||
from ...core import CommandLayout
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HardwareHealth:
|
||||
connected: bool
|
||||
position_mode: bool
|
||||
active_faults: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def safe(self) -> bool:
|
||||
return self.connected and self.position_mode and not self.active_faults
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SdkAdapter(Protocol):
|
||||
"""Minimum SDK surface required by ``CalibrationEngine``.
|
||||
|
||||
ROS publishers/subscribers or vendor API objects can implement this
|
||||
protocol. The engine never assumes byte commands, radians, CAN names, or
|
||||
a particular ``JointState.name`` ordering.
|
||||
"""
|
||||
|
||||
@property
|
||||
def command_layout(self) -> CommandLayout: ...
|
||||
|
||||
def parse_feedback(
|
||||
self, names: Sequence[str], values: Sequence[float]
|
||||
) -> tuple[float, ...] | None: ...
|
||||
|
||||
def validate_command(self, values: Sequence[float]) -> tuple[float, ...]: ...
|
||||
|
||||
def health(self) -> HardwareHealth: ...
|
||||
|
||||
def publish_position(self, values: Sequence[float]) -> None: ...
|
||||
|
||||
def set_speed(self, command_index: int, speed: float) -> None: ...
|
||||
|
||||
|
||||
class ProfileSdkAdapter:
|
||||
"""Shared profile-driven parsing and physical-domain validation.
|
||||
|
||||
Live model adapters subclass this object only to implement SDK I/O and
|
||||
health. The parsing rules are shared by legacy-byte and physical-angle
|
||||
model nodes.
|
||||
"""
|
||||
|
||||
def __init__(self, command_layout: CommandLayout) -> None:
|
||||
self.command_layout = command_layout
|
||||
|
||||
def parse_feedback(
|
||||
self, names: Sequence[str], values: Sequence[float]
|
||||
) -> tuple[float, ...] | None:
|
||||
layout = self.command_layout
|
||||
if len(values) != layout.command_count:
|
||||
return None
|
||||
if names and not layout.feedback_by_index:
|
||||
by_name = dict(zip((str(name) for name in names), values))
|
||||
for alias, canonical in layout.feedback_name_aliases.items():
|
||||
if alias in by_name and canonical not in by_name:
|
||||
by_name[canonical] = by_name[alias]
|
||||
if any(name not in by_name for name in layout.names):
|
||||
return None
|
||||
parsed = tuple(float(by_name[name]) for name in layout.names)
|
||||
else:
|
||||
parsed = tuple(float(value) for value in values)
|
||||
return parsed if all(math.isfinite(value) for value in parsed) else None
|
||||
|
||||
def validate_command(self, values: Sequence[float]) -> tuple[float, ...]:
|
||||
layout = self.command_layout
|
||||
command = tuple(float(value) for value in values)
|
||||
if len(command) != layout.command_count:
|
||||
raise ValueError("command channel count differs from profile")
|
||||
for index, value in enumerate(command):
|
||||
if (
|
||||
not math.isfinite(value)
|
||||
or value < layout.minimum_values[index]
|
||||
or value > layout.maximum_values[index]
|
||||
):
|
||||
raise ValueError(
|
||||
f"command outside physical range: channel={index}:value={value}"
|
||||
)
|
||||
return command
|
||||
|
||||
def health(self) -> HardwareHealth: # pragma: no cover - live subclass hook
|
||||
raise NotImplementedError
|
||||
|
||||
def publish_position(self, values: Sequence[float]) -> None: # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
def set_speed(self, command_index: int, speed: float) -> None: # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
__all__ = ["HardwareHealth", "ProfileSdkAdapter", "SdkAdapter"]
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Model-independent calibration schedule and lightweight acceptance policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..core import CalibrationProfile, TaskSpec, validate_profile
|
||||
|
||||
|
||||
ACQUISITION_POLICY_VERSION = "unified_engine_v1"
|
||||
TRAINING_CYCLES = (0, 1, 2)
|
||||
HOLDOUT_CYCLE = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanUnit:
|
||||
task_key: str
|
||||
cycle: int
|
||||
direction: str
|
||||
start: float
|
||||
end: float
|
||||
speed: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SweepQuality:
|
||||
passed: bool
|
||||
failures: tuple[str, ...]
|
||||
warnings: tuple[str, ...]
|
||||
metrics: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationResult:
|
||||
"""Unified internal result; serializers retain each deployed schema."""
|
||||
|
||||
profile_id: str
|
||||
policy_version: str
|
||||
curves: Mapping[str, Any]
|
||||
zero_offsets_rad: Mapping[str, float]
|
||||
travel: Mapping[str, float]
|
||||
coupling: Mapping[str, Any]
|
||||
transfers: Mapping[str, str]
|
||||
quality: Mapping[str, Any]
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class CalibrationEngine:
|
||||
"""One policy kernel shared by all model-specific ROS/SDK wrappers."""
|
||||
|
||||
def __init__(self, profile: CalibrationProfile) -> None:
|
||||
validate_profile(profile)
|
||||
self.profile = profile
|
||||
|
||||
@property
|
||||
def checkpoint_token(self) -> str:
|
||||
return ACQUISITION_POLICY_VERSION
|
||||
|
||||
def scan_units(self) -> tuple[ScanUnit, ...]:
|
||||
units: list[ScanUnit] = []
|
||||
for task in self.profile.motion.tasks:
|
||||
speed = self._formal_speed(task)
|
||||
for cycle in (*TRAINING_CYCLES, HOLDOUT_CYCLE):
|
||||
units.extend((
|
||||
ScanUnit(
|
||||
task.key, cycle, "decreasing", task.start_value,
|
||||
task.end_value, speed,
|
||||
),
|
||||
ScanUnit(
|
||||
task.key, cycle, "increasing", task.end_value,
|
||||
task.start_value, speed,
|
||||
),
|
||||
))
|
||||
return tuple(units)
|
||||
|
||||
def mapping_probe_delta(self, task: TaskSpec) -> float | None:
|
||||
"""Only physical-angle profiles perform a single <=3 degree jog."""
|
||||
if self.profile.command.unit != "rad":
|
||||
return None
|
||||
distance = task.end_value - task.start_value
|
||||
return math.copysign(
|
||||
min(abs(distance), self.profile.acquisition.mapping_probe_maximum_rad),
|
||||
distance,
|
||||
)
|
||||
|
||||
def retry_speed(self, original_speed: float, attempt: int) -> float:
|
||||
if int(attempt) != 2:
|
||||
raise ValueError("unified engine permits exactly one rescan")
|
||||
return float(original_speed)
|
||||
|
||||
@staticmethod
|
||||
def permits_retry(stage: str, completed_retries: int) -> bool:
|
||||
"""Only an under-sampled scan direction gets one same-speed retry."""
|
||||
return str(stage) == "sweep_acquisition" and int(completed_retries) == 0
|
||||
|
||||
def evaluate_sweep(
|
||||
self,
|
||||
feedback_progress_01: Sequence[float],
|
||||
*,
|
||||
minimum_span: float,
|
||||
total_frames: int,
|
||||
joint_frame_rate: float,
|
||||
feedback_hz: float,
|
||||
detection_rate: float,
|
||||
bin_count: int = 256,
|
||||
) -> SweepQuality:
|
||||
"""Judge fitting observability; ideal rates are diagnostics only."""
|
||||
values = np.asarray(feedback_progress_01, dtype=float)
|
||||
values = np.clip(values[np.isfinite(values)], 0.0, 1.0)
|
||||
bins = sorted(set(
|
||||
min(bin_count - 1, max(0, int(value * bin_count)))
|
||||
for value in values
|
||||
))
|
||||
span = float(np.ptp(values)) if values.size else 0.0
|
||||
internal_missing_runs = [
|
||||
right - left - 1 for left, right in zip(bins, bins[1:])
|
||||
]
|
||||
# Byte-command products have a known 0..255 feedback domain, so an
|
||||
# unobserved endpoint is a real acquisition hole. A radian product's
|
||||
# feedback endpoint scale is itself being calibrated: its separate
|
||||
# span/repeatability gate proves effective travel, while this gate must
|
||||
# only reject holes *inside* that observed physical stroke.
|
||||
gap_scope = (
|
||||
"full_command_domain"
|
||||
if self.profile.command.unit == "u8"
|
||||
else "observed_feedback_span"
|
||||
)
|
||||
missing_runs = (
|
||||
[bins[0], bin_count - 1 - bins[-1], *internal_missing_runs]
|
||||
if bins and gap_scope == "full_command_domain"
|
||||
else internal_missing_runs
|
||||
if bins
|
||||
else [bin_count]
|
||||
)
|
||||
maximum_gap = max(missing_runs, default=0)
|
||||
policy = self.profile.acquisition
|
||||
allowed_gap = max(
|
||||
1, int(math.floor(bin_count * policy.maximum_unobserved_fraction))
|
||||
)
|
||||
failures: list[str] = []
|
||||
if values.size < policy.minimum_valid_samples:
|
||||
failures.append(f"frames={values.size}")
|
||||
if span < float(minimum_span):
|
||||
failures.append("feedback_span")
|
||||
if len(bins) < policy.minimum_bins:
|
||||
failures.append(f"bins={len(bins)}")
|
||||
if maximum_gap > allowed_gap:
|
||||
failures.append(f"maximum_gap={maximum_gap}")
|
||||
warnings: list[str] = []
|
||||
if detection_rate < 0.95:
|
||||
warnings.append(f"tag_rate={detection_rate:.3f}")
|
||||
if joint_frame_rate < 0.85:
|
||||
warnings.append(f"joint_frame_rate={joint_frame_rate:.3f}")
|
||||
# A rate target is useful to diagnose latency but valid synchronized
|
||||
# samples, coverage, and gaps are the actual correctness evidence.
|
||||
if feedback_hz <= 0.0:
|
||||
warnings.append("feedback_rate_unavailable")
|
||||
metrics = {
|
||||
"policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"valid_frames": int(values.size),
|
||||
"total_frames": int(total_frames),
|
||||
"feedback_span": span,
|
||||
"feedback_bins": len(bins),
|
||||
"maximum_bin_gap": maximum_gap,
|
||||
"allowed_maximum_bin_gap": allowed_gap,
|
||||
"gap_scope": gap_scope,
|
||||
"joint_frame_rate": float(joint_frame_rate),
|
||||
"feedback_hz": float(feedback_hz),
|
||||
"tag_detection_rate": float(detection_rate),
|
||||
}
|
||||
return SweepQuality(not failures, tuple(failures), tuple(warnings), metrics)
|
||||
|
||||
def resume_compatible(self, session_start: Mapping[str, Any]) -> bool:
|
||||
return (
|
||||
session_start.get("acquisition_policy_version")
|
||||
== ACQUISITION_POLICY_VERSION
|
||||
and session_start.get("profile_id") == self.profile.key.profile_id
|
||||
)
|
||||
|
||||
def result_from_fit(
|
||||
self,
|
||||
fit_result: Any,
|
||||
*,
|
||||
transfers: Mapping[str, str] | None = None,
|
||||
holdout_errors_rad: Mapping[str, Sequence[float]] | None = None,
|
||||
curves: Mapping[str, Any] | None = None,
|
||||
zero_offsets_rad: Mapping[str, float] | None = None,
|
||||
travel: Mapping[str, float] | None = None,
|
||||
coupling: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> CalibrationResult:
|
||||
"""Normalize a model fitter result before its legacy serializer runs.
|
||||
|
||||
Model-specific serializers deliberately remain responsible for the
|
||||
deployed v4/v6/v7 JSON shapes. This object is the common publication
|
||||
gate between fitting and both JSON/URDF writers.
|
||||
"""
|
||||
holdout = (
|
||||
holdout_errors_rad
|
||||
if holdout_errors_rad is not None
|
||||
else getattr(fit_result, "holdout_errors_rad", {})
|
||||
)
|
||||
quality_by_joint: dict[str, Mapping[str, float]] = {}
|
||||
fit_diagnostics_by_joint: dict[str, Mapping[str, float]] = {}
|
||||
all_errors: list[float] = []
|
||||
for name, values in dict(holdout).items():
|
||||
absolute = np.abs(np.asarray(tuple(values), dtype=float))
|
||||
if absolute.size == 0 or not np.all(np.isfinite(absolute)):
|
||||
raise ValueError(f"holdout evidence is missing or invalid: {name}")
|
||||
mae = float(np.mean(absolute))
|
||||
p95 = float(np.percentile(absolute, 95.0))
|
||||
maximum = float(np.max(absolute))
|
||||
if (
|
||||
mae > math.radians(1.0)
|
||||
or p95 > math.radians(2.0)
|
||||
or maximum > math.radians(3.0)
|
||||
):
|
||||
raise ValueError(f"holdout quality failed: {name}")
|
||||
quality_by_joint[str(name)] = {
|
||||
"mae_rad": mae,
|
||||
"p95_rad": p95,
|
||||
"maximum_rad": maximum,
|
||||
}
|
||||
all_errors.extend(float(value) for value in absolute)
|
||||
if not quality_by_joint:
|
||||
raise ValueError("independent holdout evidence is required")
|
||||
normalized_curves = dict(
|
||||
curves if curves is not None else getattr(fit_result, "curves", {})
|
||||
)
|
||||
for name, curve in normalized_curves.items():
|
||||
diagnostics: dict[str, float] = {}
|
||||
for field_name in (
|
||||
"maximum_hysteresis_rad",
|
||||
"maximum_monotonic_correction_rad",
|
||||
):
|
||||
value = getattr(curve, field_name, None)
|
||||
if value is None:
|
||||
continue
|
||||
numeric = float(value)
|
||||
if not math.isfinite(numeric) or numeric < 0.0:
|
||||
raise ValueError(
|
||||
f"fit diagnostic is invalid: {name}.{field_name}"
|
||||
)
|
||||
diagnostics[field_name] = numeric
|
||||
if diagnostics:
|
||||
fit_diagnostics_by_joint[str(name)] = diagnostics
|
||||
offsets = dict(
|
||||
zero_offsets_rad
|
||||
if zero_offsets_rad is not None
|
||||
else getattr(fit_result, "zero_offsets_rad", {})
|
||||
)
|
||||
normalized_travel = dict(
|
||||
travel
|
||||
if travel is not None
|
||||
else getattr(
|
||||
fit_result,
|
||||
"travels_rad",
|
||||
getattr(fit_result, "travel", {}),
|
||||
)
|
||||
)
|
||||
if not normalized_curves or not offsets:
|
||||
raise ValueError("fit result is missing curves or zero offsets")
|
||||
result = CalibrationResult(
|
||||
profile_id=self.profile.key.profile_id,
|
||||
policy_version=ACQUISITION_POLICY_VERSION,
|
||||
curves=normalized_curves,
|
||||
zero_offsets_rad=offsets,
|
||||
travel=normalized_travel,
|
||||
coupling=dict(
|
||||
coupling
|
||||
if coupling is not None
|
||||
else getattr(fit_result, "mimic_fits", {})
|
||||
),
|
||||
transfers=dict(transfers or {}),
|
||||
quality={
|
||||
"passed": True,
|
||||
"training_cycles": TRAINING_CYCLES,
|
||||
"holdout_cycle": HOLDOUT_CYCLE,
|
||||
"holdout_by_joint": quality_by_joint,
|
||||
"holdout_sample_count": len(all_errors),
|
||||
# Direction-aware curves explicitly compensate repeatable
|
||||
# mechanical backlash. Hysteresis is therefore diagnostic
|
||||
# evidence, not a standalone rejection criterion; isolated
|
||||
# holdout accuracy remains the publication gate.
|
||||
"fit_diagnostics_by_joint": fit_diagnostics_by_joint,
|
||||
},
|
||||
metadata=dict(metadata or {}),
|
||||
)
|
||||
self.validate_result(result)
|
||||
return result
|
||||
|
||||
def validate_result(self, result: CalibrationResult) -> None:
|
||||
if result.profile_id != self.profile.key.profile_id:
|
||||
raise ValueError("calibration result belongs to another profile")
|
||||
if result.policy_version != ACQUISITION_POLICY_VERSION:
|
||||
raise ValueError("calibration result uses an obsolete policy")
|
||||
if not bool(result.quality.get("passed")):
|
||||
raise ValueError("calibration result is not publishable")
|
||||
if tuple(result.quality.get("training_cycles", ())) != TRAINING_CYCLES:
|
||||
raise ValueError("calibration result must use three training cycles")
|
||||
if int(result.quality.get("holdout_cycle", -1)) != HOLDOUT_CYCLE:
|
||||
raise ValueError("calibration result must use cycle four as holdout")
|
||||
|
||||
def _formal_speed(self, task: TaskSpec) -> float:
|
||||
if self.profile.command.unit == "rad":
|
||||
assert task.formal_speed is not None
|
||||
return float(task.formal_speed)
|
||||
return float(
|
||||
task.formal_speed_u8
|
||||
if task.formal_speed_u8 is not None
|
||||
else self.profile.motion.speed_parameters.get("formal_u8", 1.0)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACQUISITION_POLICY_VERSION", "CalibrationEngine", "CalibrationResult",
|
||||
"HOLDOUT_CYCLE", "ScanUnit", "SweepQuality", "TRAINING_CYCLES",
|
||||
]
|
||||
@@ -24,6 +24,17 @@ def build_session_controller(
|
||||
return SessionController(registered.profile, evaluator, solver)
|
||||
|
||||
|
||||
def build_registered_runtime(
|
||||
profile_key: ProfileKey,
|
||||
*,
|
||||
registry: ProfileRegistry | None = None,
|
||||
):
|
||||
"""Assemble the same engine/adapter pair for any registered hand."""
|
||||
selected_registry = registry or get_default_registry()
|
||||
registered = selected_registry.get(profile_key)
|
||||
return registered.build_engine(), registered.build_sdk_adapter()
|
||||
|
||||
|
||||
def main(args: list[str] | None = None) -> None:
|
||||
"""Select the profile first, then delegate to its reviewed CLI strategy."""
|
||||
selector = argparse.ArgumentParser(add_help=False)
|
||||
|
||||
@@ -635,6 +635,7 @@ def _fit_joint_curve(
|
||||
*,
|
||||
endpoint_reference: Mapping[str, Sequence[float]] | None = None,
|
||||
preserve_direction_offset: bool = False,
|
||||
require_observed_domain_endpoints: bool = True,
|
||||
) -> tuple[dict[str, Any], float, float]:
|
||||
by_direction: dict[str, list[list[float]]] = {
|
||||
direction: [[] for _ in range(256)] for direction in DIRECTIONS
|
||||
@@ -655,10 +656,12 @@ def _fit_joint_curve(
|
||||
],
|
||||
dtype=int,
|
||||
)
|
||||
if (
|
||||
commands.size < 3
|
||||
or int(commands[0]) != 0
|
||||
or int(commands[-1]) != 255
|
||||
if commands.size < 3:
|
||||
raise ValueError(
|
||||
f"{direction} centre trajectory requires at least 3 commands"
|
||||
)
|
||||
if require_observed_domain_endpoints and (
|
||||
int(commands[0]) != 0 or int(commands[-1]) != 255
|
||||
):
|
||||
raise ValueError(
|
||||
f"{direction} centre trajectory requires commands 0 and 255"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Read-only, model-independent URDF pose comparison; never a release gate.
|
||||
|
||||
Coordinates are URDF joint radians, NOT SDK feedback. Link origins are not
|
||||
fingertip contact points. A matching model is not a physical accuracy proof.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from .storage import atomic_write_json
|
||||
|
||||
|
||||
def _vector(value):
|
||||
a = np.asarray([float(x) for x in value.split()])
|
||||
if a.shape != (3,) or not np.isfinite(a).all():
|
||||
raise ValueError("invalid URDF vector")
|
||||
return a
|
||||
|
||||
|
||||
class _Model:
|
||||
def __init__(self, path):
|
||||
self.path = Path(path).resolve(strict=True)
|
||||
root = ET.parse(self.path).getroot()
|
||||
self.links = {x.get("name"): ET.tostring(x) for x in root.findall("link")}
|
||||
self.joints = {x.get("name"): x for x in root.findall("joint")}
|
||||
if len(self.links) != len(root.findall("link")) or len(self.joints) != len(root.findall("joint")):
|
||||
raise ValueError("duplicate URDF names")
|
||||
self.parents = {}
|
||||
for name, j in self.joints.items():
|
||||
if j.get("type") not in {"fixed", "revolute", "continuous"}:
|
||||
raise ValueError(f"unsupported joint type: {name}")
|
||||
p, c = j.find("parent").get("link"), j.find("child").get("link")
|
||||
if p not in self.links or c not in self.links or c in self.parents:
|
||||
raise ValueError(f"invalid topology: {name}")
|
||||
self.parents[c] = name
|
||||
roots = set(self.links) - set(self.parents)
|
||||
if len(roots) != 1:
|
||||
raise ValueError("URDF must have one root")
|
||||
self.root = roots.pop()
|
||||
self.active = {n for n, j in self.joints.items()
|
||||
if j.get("type") != "fixed" and j.find("mimic") is None}
|
||||
|
||||
def bounds(self, name):
|
||||
j = self.joints[name]
|
||||
if j.get("type") == "continuous":
|
||||
return -math.pi, math.pi
|
||||
lim = j.find("limit")
|
||||
values = float(lim.get("lower")), float(lim.get("upper"))
|
||||
if not all(math.isfinite(v) for v in values) or values[0] > values[1]:
|
||||
raise ValueError(f"invalid limits: {name}")
|
||||
return values
|
||||
|
||||
def fk(self, values):
|
||||
angles, poses = {}, {self.root: np.eye(4)}
|
||||
|
||||
def angle(name, stack=()):
|
||||
if name in angles:
|
||||
return angles[name]
|
||||
if name in stack or name not in self.joints:
|
||||
raise ValueError("invalid mimic dependency")
|
||||
j = self.joints[name]
|
||||
mimic = j.find("mimic")
|
||||
if j.get("type") == "fixed":
|
||||
v = 0.
|
||||
elif mimic is None:
|
||||
v = float(values.get(name, 0.))
|
||||
else:
|
||||
v = (float(mimic.get("multiplier", "1")) *
|
||||
angle(mimic.get("joint"), (*stack, name)) + float(mimic.get("offset", "0")))
|
||||
if not math.isfinite(v):
|
||||
raise ValueError("non-finite joint coordinate")
|
||||
angles[name] = v
|
||||
return v
|
||||
|
||||
def pose(link, stack=()):
|
||||
if link in poses:
|
||||
return poses[link]
|
||||
if link in stack:
|
||||
raise ValueError("cyclic URDF topology")
|
||||
name = self.parents[link]
|
||||
j = self.joints[name]
|
||||
origin = j.find("origin")
|
||||
t = np.eye(4)
|
||||
if origin is not None:
|
||||
t[:3, 3] = _vector(origin.get("xyz", "0 0 0"))
|
||||
t[:3, :3] = Rotation.from_euler("xyz", _vector(origin.get("rpy", "0 0 0"))).as_matrix()
|
||||
q = angle(name)
|
||||
if j.get("type") != "fixed":
|
||||
a = j.find("axis")
|
||||
axis = _vector("1 0 0" if a is None else a.get("xyz", "1 0 0"))
|
||||
if np.linalg.norm(axis) < 1.e-12:
|
||||
raise ValueError("zero joint axis")
|
||||
t[:3, :3] = t[:3, :3] @ Rotation.from_rotvec(axis / np.linalg.norm(axis) * q).as_matrix()
|
||||
poses[link] = pose(j.find("parent").get("link"), (*stack, link)) @ t
|
||||
return poses[link]
|
||||
|
||||
for link in self.links:
|
||||
pose(link)
|
||||
return poses
|
||||
|
||||
|
||||
def compare_urdfs(reference, candidate):
|
||||
a, b = _Model(reference), _Model(candidate)
|
||||
if set(a.links) != set(b.links) or set(a.joints) != set(b.joints) or a.active != b.active:
|
||||
raise ValueError("models have different links/joints/actuators")
|
||||
for name in a.joints:
|
||||
x, y = a.joints[name], b.joints[name]
|
||||
if x.get("type") != y.get("type") or any(x.find(k).attrib != y.find(k).attrib for k in ("parent", "child")):
|
||||
raise ValueError("models have different topology")
|
||||
bounds = {}
|
||||
for name in sorted(a.active):
|
||||
al, au = a.bounds(name)
|
||||
bl, bu = b.bounds(name)
|
||||
bounds[name] = max(al, bl), min(au, bu)
|
||||
if bounds[name][0] > bounds[name][1]:
|
||||
raise ValueError(f"no common joint interval: {name}")
|
||||
neutral = {n: float(np.clip(0., lo, hi)) for n, (lo, hi) in bounds.items()}
|
||||
samples = {"neutral": neutral}
|
||||
if len(bounds) > 1:
|
||||
samples["combined_middle"] = {n: (lo + hi) / 2 for n, (lo, hi) in bounds.items()}
|
||||
samples["combined_upper"] = {n: hi for n, (lo, hi) in bounds.items()}
|
||||
for name, (lo, hi) in bounds.items():
|
||||
for label, q in (("lower", lo), ("middle", (lo + hi) / 2), ("upper", hi)):
|
||||
samples[f"{name}:{label}"] = {**neutral, name: q}
|
||||
errors = {link: {"maximum_origin_distance_mm": 0., "maximum_orientation_difference_deg": 0.}
|
||||
for link in sorted(a.links)}
|
||||
for q in samples.values():
|
||||
left, right = a.fk(q), b.fk(q)
|
||||
for link, item in errors.items():
|
||||
distance = float(np.linalg.norm(left[link][:3, 3] - right[link][:3, 3]) * 1000)
|
||||
rotation = float(np.degrees(Rotation.from_matrix(left[link][:3, :3].T @ right[link][:3, :3]).magnitude()))
|
||||
item["maximum_origin_distance_mm"] = max(item["maximum_origin_distance_mm"], distance)
|
||||
item["maximum_orientation_difference_deg"] = max(item["maximum_orientation_difference_deg"], rotation)
|
||||
return {
|
||||
"comparison_schema": 1,
|
||||
"scope": "same_urdf_joint_angles_not_sdk_feedback",
|
||||
"is_accuracy_certificate": False,
|
||||
"note": "Link origins are not fingertip contact points; never send these offline probe poses to hardware.",
|
||||
"reference": str(a.path), "candidate": str(b.path),
|
||||
"reference_sha256": hashlib.sha256(a.path.read_bytes()).hexdigest(),
|
||||
"candidate_sha256": hashlib.sha256(b.path.read_bytes()).hexdigest(),
|
||||
"link_elements_identical": a.links == b.links,
|
||||
"active_joint_ranges_rad": {n: {"reference": a.bounds(n), "candidate": b.bounds(n),
|
||||
"comparison_interval": bounds[n]} for n in bounds},
|
||||
"pose_count": len(samples), "probe_joint_positions_rad": samples,
|
||||
"link_frame_differences": errors,
|
||||
"maximum_origin_distance_mm": max(x["maximum_origin_distance_mm"] for x in errors.values()),
|
||||
"maximum_orientation_difference_deg": max(x["maximum_orientation_difference_deg"] for x in errors.values()),
|
||||
}
|
||||
|
||||
|
||||
def main(args=None):
|
||||
parser = argparse.ArgumentParser(description="离线 URDF 姿态对比,不驱动硬件、不改变标定结果")
|
||||
parser.add_argument("--reference", required=True)
|
||||
parser.add_argument("--candidate", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
selected = parser.parse_args(args)
|
||||
target = Path(selected.output).resolve()
|
||||
if target.exists():
|
||||
parser.error("output must be a new file")
|
||||
result = compare_urdfs(selected.reference, selected.candidate)
|
||||
atomic_write_json(target, result)
|
||||
print(f"模型对比完成(非精度PASS):最大连杆原点差 {result['maximum_origin_distance_mm']:.6f} mm;"
|
||||
f"最大方向差 {result['maximum_orientation_difference_deg']:.6f}°;报告 {target}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,7 +17,12 @@ def package_data_tree(root: str) -> list[tuple[str, list[str]]]:
|
||||
result = []
|
||||
for directory in directories:
|
||||
files = sorted(
|
||||
str(path) for path in directory.iterdir() if path.is_file()
|
||||
str(path)
|
||||
for path in directory.iterdir()
|
||||
if path.is_file()
|
||||
# Generated calibration products belong under calibration_output,
|
||||
# never in the installed immutable CAD input bundle.
|
||||
and "calibrated" not in path.stem.lower()
|
||||
)
|
||||
if files:
|
||||
result.append(
|
||||
@@ -51,6 +56,7 @@ setup(
|
||||
license="MIT",
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"compare_calibration_urdfs = linkerhand_calibration.urdf_comparison:main",
|
||||
(
|
||||
"hikrobot_camera_node = "
|
||||
"linkerhand_calibration.hikrobot_camera:main"
|
||||
|
||||
@@ -203,15 +203,15 @@ def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
|
||||
assert parameters["pinky_pip_zero_endpoint_tolerance_u8"] == 5.0
|
||||
assert parameters["minimum_sweep_bins"] >= 32
|
||||
assert parameters["maximum_bin_gap"] <= 16
|
||||
assert parameters["automatic_sweep_retry_limit"] == 2
|
||||
assert parameters["automatic_fit_retry_limit"] == 2
|
||||
assert parameters["automatic_sweep_retry_limit"] == 1
|
||||
assert parameters["automatic_fit_retry_limit"] == 0
|
||||
assert parameters["motor_stall_timeout_seconds"] == 2.0
|
||||
assert parameters["motor_stall_startup_grace_seconds"] == 1.0
|
||||
assert parameters["motor_stall_minimum_progress_u8"] == 1.0
|
||||
assert parameters["automatic_motion_retry_limit"] == 2
|
||||
assert parameters["automatic_motion_retry_limit"] == 0
|
||||
assert parameters["provisional_warning_ratio"] == 1.25
|
||||
assert parameters["retry_speed_scales"] == [0.8, 0.6]
|
||||
assert parameters["retry_endpoint_hold_seconds"] == [0.75, 1.0]
|
||||
assert parameters["retry_speed_scales"] == [1.0]
|
||||
assert parameters["retry_endpoint_hold_seconds"] == [0.5]
|
||||
assert parameters["position_timeout_seconds"] >= 20.0
|
||||
assert parameters["maximum_state_image_skew_ms"] <= 50.0
|
||||
assert parameters["axis_maximum_rotation_circle_difference_deg"] <= 1.0
|
||||
|
||||
@@ -77,6 +77,7 @@ from linkerhand_calibration.urdf_zero import (
|
||||
get_zero_calibration_profile,
|
||||
write_zero_corrected_urdf,
|
||||
)
|
||||
from linkerhand_calibration.runtime import ACQUISITION_POLICY_VERSION
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
@@ -963,6 +964,7 @@ def test_automatic_resume_requires_failed_matching_geometry(tmp_path: Path) -> N
|
||||
json.dumps(
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"hand_type": "right",
|
||||
"tag_layout": G20_RIGHT_19_LAYOUT,
|
||||
"source_urdf_sha256": config.source_urdf_sha256,
|
||||
@@ -1180,6 +1182,7 @@ def test_automatic_resume_accepts_ctrl_c_checkpoint_without_summary(
|
||||
json.dumps(
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"hand_type": "right",
|
||||
"tag_layout": G20_RIGHT_19_LAYOUT,
|
||||
"source_urdf_sha256": config.source_urdf_sha256,
|
||||
@@ -1205,6 +1208,7 @@ def test_automatic_resume_skips_newer_attempt_without_start_checkpoint(
|
||||
json.dumps(
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"hand_type": "right",
|
||||
"tag_layout": G20_RIGHT_19_LAYOUT,
|
||||
"source_urdf_sha256": config.source_urdf_sha256,
|
||||
@@ -1251,6 +1255,7 @@ def test_node_restores_complete_prefix_into_new_self_contained_raw(
|
||||
rows = [
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"hand_type": "right",
|
||||
"tag_layout": G20_RIGHT_19_LAYOUT,
|
||||
"view_tags": {
|
||||
@@ -1344,6 +1349,7 @@ def test_full_resume_discards_all_old_tasks_after_start_position_change(
|
||||
rows = [
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"model": "G20",
|
||||
"hand_type": "right",
|
||||
"tag_layout": G20_RIGHT_19_LAYOUT,
|
||||
@@ -1448,6 +1454,7 @@ def test_thumb_recalibration_imports_fingers_but_invalidates_all_thumb_tasks(
|
||||
rows = [
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"model": "G20",
|
||||
"hand_type": "right",
|
||||
"tag_layout": G20_RIGHT_19_LAYOUT,
|
||||
@@ -2725,6 +2732,7 @@ def test_node_drops_imported_task_failing_hard_gates(
|
||||
rows = [
|
||||
{
|
||||
"kind": "session_start",
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
"hand_type": "right",
|
||||
"tag_layout": G20_RIGHT_19_LAYOUT,
|
||||
"view_tags": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -432,11 +433,13 @@ def test_l6_quality_gates_each_tag_separately_from_joined_frames(
|
||||
L6ThreeCameraCalibrationNode._qualify_recording_step(fake, step)
|
||||
|
||||
|
||||
def test_l6_quality_failure_names_the_specific_tag_id(tmp_path: Path) -> None:
|
||||
def test_l6_low_tag_rate_is_diagnostic_when_samples_are_observable(tmp_path: Path) -> None:
|
||||
fake, step = _l6_sweep_quality_fake(tmp_path)
|
||||
fake.step_tag_quality_frames["pinky_dip"] = 190
|
||||
with pytest.raises(ValueError, match=r"tag_rate\[ID5/pinky_dip\]=0\.900"):
|
||||
L6ThreeCameraCalibrationNode._qualify_recording_step(fake, step)
|
||||
L6ThreeCameraCalibrationNode._qualify_recording_step(fake, step)
|
||||
quality = json.loads(fake.raw_path.read_text().splitlines()[-1])
|
||||
assert quality["failures"] == []
|
||||
assert any(value.startswith("tag_rate=") for value in quality["warnings"])
|
||||
|
||||
|
||||
def test_l6_operator_progress_shows_exact_failed_sweep_metric() -> None:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Observable line residuals must not be confused with axial PnP drift."""
|
||||
|
||||
import inspect
|
||||
import math
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from linkerhand_calibration.models.g20.zero_solver import (
|
||||
_fit_axis_point_from_pose_trajectory, fit_joint_axis_measurement,
|
||||
)
|
||||
|
||||
|
||||
def axis_fit(*, axial_drift=0., transverse_drift=0., separate=False):
|
||||
axis = np.array([0., 0., 1.])
|
||||
point = np.array([.012, -.017, 0.])
|
||||
reference = np.array([.047, .009, .02])
|
||||
rows = []
|
||||
for index, angle in enumerate(np.linspace(0., 1.2, 128)):
|
||||
rotation = Rotation.from_rotvec(axis * angle)
|
||||
translation = point + rotation.apply(reference - point)
|
||||
translation += axis * axial_drift * math.sin(angle * 3.)
|
||||
translation[0] += transverse_drift * math.sin(angle * 11.)
|
||||
rows.append({
|
||||
'command_u8': 255 - 2 * index, 'direction': 'decreasing',
|
||||
'relative_translation_xyz_m': translation.tolist(),
|
||||
'relative_quaternion_xyzw': rotation.as_quat().tolist(),
|
||||
})
|
||||
diagnostics = {}
|
||||
fitted, rms, _ = _fit_axis_point_from_pose_trajectory(
|
||||
rows, zero_command_u8=255, axis_parent_xyz=axis,
|
||||
angle_axis_parent_xyz=axis, phase_reference_point_parent_xyz=point,
|
||||
view_normal_common_xyz=None, canonical_zero_direction='decreasing',
|
||||
allow_axial_translation=separate, residual_diagnostics=diagnostics,
|
||||
)
|
||||
return fitted, rms, diagnostics
|
||||
|
||||
|
||||
def test_axial_component_is_reported_but_not_used_as_transverse_fit_error():
|
||||
baseline, baseline_rms, _ = axis_fit(separate=True)
|
||||
observed, rms, diagnostic = axis_fit(axial_drift=.015, separate=True)
|
||||
assert observed == pytest.approx(baseline, abs=1.e-9)
|
||||
assert rms == pytest.approx(baseline_rms, abs=1.e-9)
|
||||
assert diagnostic['axial_rms_m'] > .005
|
||||
assert diagnostic['raw_rms_m'] > rms
|
||||
assert diagnostic['transverse_rms_m'] == pytest.approx(rms, abs=1.e-9)
|
||||
|
||||
|
||||
def test_transverse_model_error_is_not_discarded_by_projection():
|
||||
_, rms, diagnostic = axis_fit(transverse_drift=.01, separate=True)
|
||||
assert rms > .0015
|
||||
assert diagnostic['transverse_rms_m'] > .0015
|
||||
|
||||
|
||||
def test_legacy_default_does_not_silently_enable_projection():
|
||||
assert inspect.signature(fit_joint_axis_measurement).parameters[
|
||||
'separate_axial_residual'].default is False
|
||||
_, rms, diagnostic = axis_fit(axial_drift=.015)
|
||||
assert rms > .0015
|
||||
assert diagnostic['raw_rms_m'] == pytest.approx(rms)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Non-zero recovery through G20 geometry, not zero-only CAD fixtures."""
|
||||
|
||||
from dataclasses import replace
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from linkerhand_calibration.models.g20.profile import JointCurveFit
|
||||
from linkerhand_calibration.models.g20.zero_solver import UrdfKinematicModel
|
||||
from linkerhand_calibration.models.o12.profile import (
|
||||
CALIBRATED_ACTIVE_JOINTS,
|
||||
GEOMETRIC_ZERO_JOINTS,
|
||||
STATIC_ZERO_EXCLUDED_JOINTS,
|
||||
build_typed_profile,
|
||||
)
|
||||
from linkerhand_calibration.models.o12.zero import (
|
||||
AXIS_JOINTS, PHASE_PARENT, ZERO_OBSERVER, O12SpatialZeroError,
|
||||
full_hand_zero_profile, motor_index, solve_full_hand_zero,
|
||||
)
|
||||
from linkerhand_calibration.models.o12.kinematics import PASSIVE_SDK_SOURCE_BY_JOINT
|
||||
from test_o12_right_profile import SOURCE_URDF, _synthetic_records
|
||||
|
||||
|
||||
def pose(t):
|
||||
return {"translation_xyz_m": t[:3, 3].tolist(),
|
||||
"quaternion_xyzw": Rotation.from_matrix(t[:3, :3]).as_quat().tolist()}
|
||||
|
||||
|
||||
def transform(xyz, rpy):
|
||||
t = np.eye(4)
|
||||
t[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix()
|
||||
t[:3, 3] = xyz
|
||||
return t
|
||||
|
||||
|
||||
def observations(truth, *, coupled_passive=False):
|
||||
model = UrdfKinematicModel(SOURCE_URDF)
|
||||
common = transform([.03, -.02, .6], [.15, -.2, .3])
|
||||
angle = tuple(np.linspace(.7, 0., 256))
|
||||
curve = JointCurveFit(angle, angle, angle, {}, 0., 0., {})
|
||||
rows = {}
|
||||
for i, name in enumerate(AXIS_JOINTS):
|
||||
mount = transform([.013, -.011, .024], [.21, -.1 * i, .3])
|
||||
# Passive trajectories live in the parent Tag frame. A constant
|
||||
# arbitrary mount must not be mistaken for an encoder zero.
|
||||
parent = common @ transform([-.01, .015, -.02], [-.2, .11, -.15])
|
||||
rows[name] = []
|
||||
for cycle in range(4):
|
||||
for direction, indices in (("decreasing", range(255, -1, -4)),
|
||||
("increasing", range(3, 256, 4))):
|
||||
for index in indices:
|
||||
state = [255.] * 12
|
||||
# A passive axis is independently rotated here to test
|
||||
# the spatial kernel, not the model's SDK adapter.
|
||||
if name in CALIBRATED_ACTIVE_JOINTS:
|
||||
state[motor_index(name)] = index
|
||||
joint_angles = {name: angle[index]}
|
||||
sample_parent = parent
|
||||
if coupled_passive and name in PASSIVE_SDK_SOURCE_BY_JOINT:
|
||||
state[motor_index(name)] = index
|
||||
joint_angles[PASSIVE_SDK_SOURCE_BY_JOINT[name]] = angle[index]
|
||||
sample_parent = common @ model.link_transform(
|
||||
PHASE_PARENT[name], zero_offsets=truth, joint_angles=joint_angles,
|
||||
independent_mimic_angles=True,
|
||||
) @ transform([.01, .004, .014], [.2, -.1, .3])
|
||||
child = common @ model.link_transform(
|
||||
name, zero_offsets=truth, joint_angles=joint_angles,
|
||||
independent_mimic_angles=True,
|
||||
) @ mount
|
||||
relative = np.linalg.inv(sample_parent) @ child
|
||||
rows[name].append({
|
||||
"cycle": cycle, "direction": direction, "command_u8": index,
|
||||
"state_u8": state,
|
||||
"relative_quaternion_xyzw": pose(relative)["quaternion_xyzw"],
|
||||
"relative_translation_xyz_m": relative[:3, 3].tolist(),
|
||||
"parent_pose_common": pose(sample_parent), "child_pose_common": pose(child),
|
||||
"view_normal_common_xyz": common[:3, 0].tolist(),
|
||||
"camera_center_common_xyz_m": [0., 0., 0.],
|
||||
})
|
||||
return rows, {name: curve for name in AXIS_JOINTS}
|
||||
|
||||
|
||||
def test_profile_authorizes_every_observed_active_zero_without_endpoint_assumptions():
|
||||
zero = build_typed_profile().zero
|
||||
assert set(zero.direct_zero_joints) == GEOMETRIC_ZERO_JOINTS == set(ZERO_OBSERVER)
|
||||
assert CALIBRATED_ACTIVE_JOINTS - GEOMETRIC_ZERO_JOINTS == STATIC_ZERO_EXCLUDED_JOINTS
|
||||
assert STATIC_ZERO_EXCLUDED_JOINTS == {"thumb_mcp"}
|
||||
assert not zero.mechanical_endpoint_joints
|
||||
assert zero.cad_frozen_joints & CALIBRATED_ACTIVE_JOINTS == STATIC_ZERO_EXCLUDED_JOINTS
|
||||
from linkerhand_calibration.models.g20.zero_solver import get_right_19_thumb_zero_profile
|
||||
assert not get_right_19_thumb_zero_profile().accept_validated_zero_in_confidence_interval
|
||||
assert full_hand_zero_profile().accept_validated_zero_in_confidence_interval
|
||||
assert not get_right_19_thumb_zero_profile().project_axis_gauge_before_image
|
||||
assert full_hand_zero_profile().project_axis_gauge_before_image
|
||||
assert full_hand_zero_profile().hand.stable_cross_view_cone_bias
|
||||
model = UrdfKinematicModel(SOURCE_URDF)
|
||||
for child, parent in PHASE_PARENT.items():
|
||||
a, _ = model.axis_line(child, zero_offsets={}, joint_angles={})
|
||||
b, _ = model.axis_line(parent, zero_offsets={}, joint_angles={})
|
||||
assert abs(float(a @ b)) == pytest.approx(1., abs=1.e-8), (parent, child)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def full_solution():
|
||||
truth = {name: (.04 if i % 2 else -.06) for i, name in enumerate(ZERO_OBSERVER)}
|
||||
rows, curves = observations(truth)
|
||||
result = solve_full_hand_zero(SOURCE_URDF, rows, curves)
|
||||
return truth, result
|
||||
|
||||
|
||||
def test_all_ten_observable_nonzero_offsets_recovered_with_arbitrary_mounts(full_solution):
|
||||
truth, result = full_solution
|
||||
assert result.passed
|
||||
assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4)
|
||||
assert len(result.axis_residual_diagnostics) == 4 * len(AXIS_JOINTS)
|
||||
assert all(item["axial_component_separated"]
|
||||
for item in result.axis_residual_diagnostics.values())
|
||||
|
||||
|
||||
def test_coupled_passive_parent_motion_is_not_mistaken_for_a_static_zero():
|
||||
truth = {name: .04 for name in ZERO_OBSERVER}
|
||||
rows, curves = observations(truth, coupled_passive=True)
|
||||
result = solve_full_hand_zero(SOURCE_URDF, rows, curves)
|
||||
assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("move_base,remount", [(False, False), (True, False),
|
||||
(False, True), (True, True)])
|
||||
def test_end_on_phase_is_invariant_to_pre_capture_base_and_tag_changes(move_base, remount):
|
||||
# Previously the synthetic camera faced CAD X, perpendicular to the
|
||||
# finger Y axes; that never exercised the real side-camera image branch.
|
||||
truth = {name: .04 for name in ZERO_OBSERVER}
|
||||
rows, curves = observations(truth, coupled_passive=True)
|
||||
common = transform([.03, -.02, .6], [.15, -.2, .3])
|
||||
normal = common[:3, :3] @ np.array([.1, .99, .1])
|
||||
normal /= np.linalg.norm(normal)
|
||||
body = transform([.03, -.02, .01], [.07, -.08, .05]) if move_base else np.eye(4)
|
||||
parent_mount = transform([-.005, .004, .012], [-.1, .05, -.11]) if remount else np.eye(4)
|
||||
child_mount = transform([.008, -.005, .011], [.1, -.12, .2]) if remount else np.eye(4)
|
||||
def matrix(payload):
|
||||
t = np.eye(4)
|
||||
t[:3, :3] = Rotation.from_quat(payload["quaternion_xyzw"]).as_matrix()
|
||||
t[:3, 3] = payload["translation_xyz_m"]
|
||||
return t
|
||||
for samples in rows.values():
|
||||
for row in samples:
|
||||
parent = body @ matrix(row["parent_pose_common"]) @ parent_mount
|
||||
child = body @ matrix(row["child_pose_common"]) @ child_mount
|
||||
relative = np.linalg.inv(parent) @ child
|
||||
row.update(parent_pose_common=pose(parent), child_pose_common=pose(child),
|
||||
relative_translation_xyz_m=relative[:3, 3].tolist(),
|
||||
relative_quaternion_xyzw=Rotation.from_matrix(relative[:3, :3]).as_quat().tolist(),
|
||||
view_normal_common_xyz=normal.tolist())
|
||||
result = solve_full_hand_zero(SOURCE_URDF, rows, curves)
|
||||
assert result.passed
|
||||
assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4)
|
||||
|
||||
|
||||
def test_active_parallel_axis_direction_bias_does_not_become_a_zero():
|
||||
truth = {name: .04 for name in ZERO_OBSERVER}
|
||||
rows, curves = observations(truth, coupled_passive=True)
|
||||
common = transform([.03, -.02, .6], [.15, -.2, .3])
|
||||
normal = common[:3, :3] @ np.array([.1, .99, .1])
|
||||
normal /= np.linalg.norm(normal)
|
||||
for samples in rows.values():
|
||||
for row in samples:
|
||||
row["view_normal_common_xyz"] = normal.tolist()
|
||||
bias = Rotation.from_euler("xyz", [.06, -.04, .07])
|
||||
for name in ("index_pip", "middle_pip"):
|
||||
ref = Rotation.from_quat(rows[name][0]["relative_quaternion_xyzw"])
|
||||
for row in rows[name]:
|
||||
observed = Rotation.from_quat(row["relative_quaternion_xyzw"])
|
||||
delta = observed * ref.inv()
|
||||
changed = bias * delta * bias.inv() * ref
|
||||
row["relative_quaternion_xyzw"] = changed.as_quat().tolist()
|
||||
parent = Rotation.from_quat(row["parent_pose_common"]["quaternion_xyzw"])
|
||||
row["child_pose_common"]["quaternion_xyzw"] = (parent * changed).as_quat().tolist()
|
||||
result = solve_full_hand_zero(SOURCE_URDF, rows, curves)
|
||||
assert result.passed
|
||||
assert result.direct_offsets_rad == pytest.approx(truth, abs=3.e-4)
|
||||
|
||||
|
||||
def test_rotation_only_data_cannot_be_published_as_full_spatial_calibration():
|
||||
from linkerhand_calibration.models.o12.fitting import fit_o12_session
|
||||
with pytest.raises(O12SpatialZeroError, match="requires pose observations"):
|
||||
fit_o12_session(SOURCE_URDF, _synthetic_records(), require_full_hand_spatial_zero=True)
|
||||
|
||||
|
||||
def test_small_uncertain_zero_is_kept_only_when_frozen_zero_passes_holdout():
|
||||
truth = {name: .04 for name in ZERO_OBSERVER}
|
||||
rows, curves = observations({**truth, "index_mcp_roll": .003})
|
||||
for cycle, value in enumerate((.001, .005, .003, 0.)):
|
||||
changed, _ = observations({**truth, "index_mcp_roll": value})
|
||||
for name in rows:
|
||||
rows[name] = [r for r in rows[name] if r["cycle"] != cycle] + [
|
||||
r for r in changed[name] if r["cycle"] == cycle]
|
||||
result = solve_full_hand_zero(SOURCE_URDF, rows, curves)
|
||||
assert result.passed
|
||||
assert result.direct_offsets_rad["index_mcp_roll"] == 0.
|
||||
assert result.validation_error_by_joint_rad["index_mcp_pitch"] < 1.e-5
|
||||
changed, _ = observations({**truth, "index_mcp_roll": .15})
|
||||
for name in rows:
|
||||
rows[name] = [r for r in rows[name] if r["cycle"] != 3] + [
|
||||
r for r in changed[name] if r["cycle"] == 3]
|
||||
with pytest.raises(O12SpatialZeroError):
|
||||
solve_full_hand_zero(SOURCE_URDF, rows, curves)
|
||||
|
||||
|
||||
def test_independent_fourth_cycle_cannot_define_or_hide_a_bad_zero():
|
||||
truth = {name: .04 for name in ZERO_OBSERVER}
|
||||
rows, curves = observations(truth)
|
||||
moved, _ = observations({**truth, "index_pip": .18})
|
||||
for name in rows:
|
||||
rows[name] = [r for r in rows[name] if r["cycle"] < 3] + [r for r in moved[name] if r["cycle"] == 3]
|
||||
with pytest.raises(O12SpatialZeroError, match="spatial zero solve failed") as error:
|
||||
solve_full_hand_zero(SOURCE_URDF, rows, curves)
|
||||
assert error.value.diagnostics["passed"] is False
|
||||
|
||||
|
||||
def test_full_zero_writeback_and_ring_transfer_preserve_each_cad_frame(full_solution, tmp_path):
|
||||
from linkerhand_calibration.models.o12.fitting import fit_o12_session
|
||||
from linkerhand_calibration.models.o12.urdf import write_o12_corrected_urdf
|
||||
from linkerhand_calibration.models.o12.artifacts import (
|
||||
build_o12_runtime_payload, validate_o12_runtime_payload_against_urdf,
|
||||
)
|
||||
truth, solved = full_solution
|
||||
offsets = {
|
||||
**truth,
|
||||
"thumb_mcp": 0.0,
|
||||
"ring_mcp_pitch": truth["pinky_mcp_pitch"],
|
||||
}
|
||||
fit = replace(fit_o12_session(SOURCE_URDF, _synthetic_records()),
|
||||
zero_offsets_rad=offsets, full_hand_zero_result=solved,
|
||||
zero_method_by_joint={
|
||||
n: (
|
||||
"source_cad_zero_profile_excluded"
|
||||
if n == "thumb_mcp"
|
||||
else "urdf_serial_axis_geometry"
|
||||
)
|
||||
for n in offsets
|
||||
})
|
||||
written = write_o12_corrected_urdf(source_urdf=SOURCE_URDF,
|
||||
output_directory=tmp_path, serial_number="FULL", result=fit)
|
||||
before, after = UrdfKinematicModel(SOURCE_URDF), UrdfKinematicModel(written.path)
|
||||
for q in ({}, {n: .3 for n in offsets}):
|
||||
for name in offsets:
|
||||
assert after.link_transform(name, zero_offsets={}, joint_angles=q) == pytest.approx(
|
||||
before.link_transform(name, zero_offsets=offsets, joint_angles=q), abs=1.e-8)
|
||||
hashes = {k: "a" * 64 for k in build_typed_profile().artifacts.protected_input_fields}
|
||||
payload = build_o12_runtime_payload(serial_number="FULL", source_urdf=SOURCE_URDF,
|
||||
result=fit, protected_inputs=hashes, passed=True)
|
||||
validate_o12_runtime_payload_against_urdf(payload, written.path, source_urdf=SOURCE_URDF)
|
||||
assert payload["calibration_scope"] == "full_dynamic_except_thumb_mcp_static_zero"
|
||||
assert payload["quality"]["cad_static_joints_not_measured"] == ["thumb_mcp"]
|
||||
assert set(payload["quality"]["static_zero_exclusions"]) == {"thumb_mcp"}
|
||||
assert payload["joints"]["thumb_mcp"]["calibration_status"] == "measured_dynamic_cad_static"
|
||||
assert payload["joints"]["thumb_mcp"]["static_urdf_origin_offset_rad"] == 0.0
|
||||
assert payload["quality"]["full_hand_spatial_zero"]["passed"]
|
||||
invalid = replace(
|
||||
fit,
|
||||
zero_offsets_rad={**fit.zero_offsets_rad, "thumb_mcp": 0.01},
|
||||
)
|
||||
with pytest.raises(ValueError, match="retain immutable source-CAD"):
|
||||
write_o12_corrected_urdf(
|
||||
source_urdf=SOURCE_URDF,
|
||||
output_directory=tmp_path / "invalid",
|
||||
serial_number="INVALID",
|
||||
result=invalid,
|
||||
)
|
||||
with pytest.raises(ValueError, match="retain immutable source-CAD"):
|
||||
build_o12_runtime_payload(
|
||||
serial_number="INVALID",
|
||||
source_urdf=SOURCE_URDF,
|
||||
result=invalid,
|
||||
protected_inputs=hashes,
|
||||
passed=True,
|
||||
)
|
||||
|
||||
|
||||
def test_failed_spatial_solve_writes_evidence_without_replacing_published_result(monkeypatch, tmp_path):
|
||||
from linkerhand_calibration.models.o12 import pipeline
|
||||
import json
|
||||
previous = tmp_path / "previous"
|
||||
previous.mkdir()
|
||||
pointer = tmp_path / "latest_passed"
|
||||
pointer.symlink_to(previous, target_is_directory=True)
|
||||
diagnostic = {"passed": False, "stage": "axis_observation", "joint": "thumb_mcp"}
|
||||
def fail(*args, **kwargs):
|
||||
assert kwargs["require_full_hand_spatial_zero"]
|
||||
raise O12SpatialZeroError("unreliable geometry", diagnostic)
|
||||
monkeypatch.setattr(pipeline, "fit_o12_session", fail)
|
||||
session = tmp_path / "new"
|
||||
with pytest.raises(O12SpatialZeroError):
|
||||
pipeline.finalize_o12_session(session_dir=session, serial_number="FULL",
|
||||
source_urdf=SOURCE_URDF, protected_inputs={}, records=[], publish=True)
|
||||
assert pointer.resolve() == previous
|
||||
assert not list(session.glob("*.urdf"))
|
||||
assert json.loads((session / "spatial_zero_diagnostics.json").read_text()) == diagnostic
|
||||
|
||||
|
||||
def test_identifiable_failed_fit_exports_only_review_model(monkeypatch, tmp_path, full_solution):
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from linkerhand_calibration.models.o12 import pipeline
|
||||
from linkerhand_calibration.models.o12.fitting import fit_o12_session
|
||||
from linkerhand_calibration.models.o12.artifacts import build_o12_runtime_payload
|
||||
truth, valid_zero = full_solution
|
||||
invalid_zero = replace(valid_zero, passed=False,
|
||||
failure_reasons={"index_pip": "zero_phase_axis_line_residual_too_large"})
|
||||
fit = fit_o12_session(SOURCE_URDF, _synthetic_records())
|
||||
offsets = {
|
||||
**truth,
|
||||
"thumb_mcp": 0.0,
|
||||
"ring_mcp_pitch": truth["pinky_mcp_pitch"],
|
||||
}
|
||||
fit = replace(fit, zero_offsets_rad=offsets, full_hand_zero_result=invalid_zero)
|
||||
error = O12SpatialZeroError("unreliable geometry", {
|
||||
"passed": False, "stage": "spatial_solve", "result": asdict(invalid_zero)})
|
||||
error.review_fit = fit
|
||||
def fail(*args, **kwargs):
|
||||
raise error
|
||||
monkeypatch.setattr(pipeline, "fit_o12_session", fail)
|
||||
previous = tmp_path / "previous"
|
||||
previous.mkdir()
|
||||
pointer = tmp_path / "latest_passed"
|
||||
pointer.symlink_to(previous, target_is_directory=True)
|
||||
session = tmp_path / "new"
|
||||
with pytest.raises(O12SpatialZeroError, match="仅供复核"):
|
||||
pipeline.finalize_o12_session(session_dir=session, serial_number="FULL",
|
||||
source_urdf=SOURCE_URDF, protected_inputs={}, records=[], publish=True,
|
||||
timestamp="20260909_000000")
|
||||
assert pointer.resolve() == previous
|
||||
assert not list(session.glob("*.urdf"))
|
||||
assert not list(session.rglob("*calibration.json"))
|
||||
manifest = json.loads((session / "review_only/review_manifest.json").read_text())
|
||||
assert not manifest["publication_allowed"]
|
||||
assert not manifest["spatial_validation"]["result"]["passed"]
|
||||
assert "REVIEW_ONLY" in manifest["urdf"]
|
||||
assert (session / "review_only" / manifest["urdf"]).is_file()
|
||||
with pytest.raises(ValueError):
|
||||
build_o12_runtime_payload(serial_number="FULL", source_urdf=SOURCE_URDF,
|
||||
result=fit, protected_inputs={}, passed=True)
|
||||
@@ -0,0 +1,117 @@
|
||||
import copy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation as R
|
||||
|
||||
from linkerhand_calibration.models.o12 import observations as module
|
||||
from linkerhand_calibration.pnp import SquareTagPose
|
||||
|
||||
|
||||
def build_data(monkeypatch, *, wrong_holdout=False):
|
||||
"""Ambiguous initial poses: the wrong distal branch has lower image error."""
|
||||
rows=[]; pose_lookup={}; idx=0
|
||||
camera=R.from_euler('xyz',[.2,-.3,.4])
|
||||
mounts=[R.from_euler('xyz',angles) for angles in ([.1,.3,.2],[-.3,.5,-.4],[.2,.6,-.7])]
|
||||
def payload(rotation,t,error):
|
||||
return dict(quaternion_xyzw=(camera*rotation).as_quat().tolist(),
|
||||
translation_xyz_m=(camera.apply(t)+[0,0,1]).tolist(),reprojection_error_px=error)
|
||||
for cycle in range(4):
|
||||
for theta in np.linspace(0,.9,24):
|
||||
idx+=1
|
||||
driver=R.from_euler('z',theta)
|
||||
good=[payload(mounts[0],[0,0,0],.05),
|
||||
payload(driver*mounts[1],driver.apply([.03,0,0]),.05),
|
||||
payload(R.from_euler('z',2*theta)*mounts[2],driver.apply([.04,0,0])+R.from_euler('z',2*theta).apply([.02,0,0]),.08)]
|
||||
bad=payload(R.from_euler('x',2*theta)*R.from_euler('y',.8)*mounts[2],[0,0,0],.01)
|
||||
bad['translation_xyz_m']=good[2]['translation_xyz_m']
|
||||
# Holdout is allowed to disagree; it may never choose the training branch.
|
||||
if wrong_holdout and cycle==3:
|
||||
good[2]['quaternion_xyzw']=bad['quaternion_xyzw']
|
||||
evidence={'kind':'o12_pnp_candidate_frame','task_name':'thumb_mcp_dip_front','cycle':cycle,'direction':'decreasing','attempt':1,
|
||||
'image_stamp_ns':idx,'camera_matrix':np.eye(3).tolist(),
|
||||
'camera_matrix_source':module.MATRIX_SOURCE,'tag_size_m':.016,'roles':{}}
|
||||
for j,role in enumerate(module.THUMB_ROLES):
|
||||
marker=idx*10+j
|
||||
candidates=[good[j],good[j]] if j<2 else [bad,good[j]]
|
||||
pose_lookup[marker]=[SquareTagPose(tuple(p['quaternion_xyzw']),tuple(p['translation_xyz_m']),p['reprojection_error_px']) for p in candidates]
|
||||
evidence['roles'][role]={'corners_xy':[[marker,0]]*4,'selected':candidates[0],
|
||||
'maximum_reprojection_error_px':1.5}
|
||||
rows.append(evidence)
|
||||
for j,name in enumerate(('thumb_mcp','thumb_dip')):
|
||||
pa=evidence['roles'][module.THUMB_ROLES[j]]['selected']
|
||||
ch=evidence['roles'][module.THUMB_ROLES[j+1]]['selected']
|
||||
q,t=module._relative(pa,ch)
|
||||
rows.append({'kind':'o12_joint_sample','task_name':'thumb_mcp_dip_front',
|
||||
'joint':name,'cycle':cycle,'direction':'decreasing','attempt':1,
|
||||
'image_stamp_ns':idx,'parent_pose_common':pa,'child_pose_common':ch,
|
||||
'relative_quaternion_xyzw':q.tolist(),'relative_translation_xyz_m':t.tolist()})
|
||||
monkeypatch.setattr(module,'solve_square_tag_ippe',lambda xy,**kw:pose_lookup[xy[0][0]])
|
||||
return rows
|
||||
|
||||
|
||||
def test_complete_training_can_reconsider_wrong_initial_branch(monkeypatch):
|
||||
rows=build_data(monkeypatch);original=copy.deepcopy(rows)
|
||||
resolved,report=module.resolve_thumb_observations(rows)
|
||||
assert rows==original
|
||||
assert report['status']=='resolved' and report['selected_branches'][2]==1
|
||||
assert report['training_frames']==72 and report['holdout_frames']==24
|
||||
assert report['changed_joint_rows']>0
|
||||
assert resolved!=rows
|
||||
|
||||
|
||||
def test_holdout_cannot_change_training_branch_or_scores(monkeypatch):
|
||||
_,a=module.resolve_thumb_observations(build_data(monkeypatch))
|
||||
_,b=module.resolve_thumb_observations(build_data(monkeypatch,wrong_holdout=True))
|
||||
assert a['selected_branches']==b['selected_branches']
|
||||
assert a['hypotheses']==b['hypotheses']
|
||||
|
||||
|
||||
def test_legacy_projection_is_not_silently_relabelled(monkeypatch):
|
||||
rows=build_data(monkeypatch)
|
||||
for r in rows:r.pop('camera_matrix_source',None)
|
||||
result,report=module.resolve_thumb_observations(rows)
|
||||
assert report['status']=='legacy_projection_unverified' and result==rows
|
||||
assert module.resolve_thumb_observations([])[1]['status']=='legacy_no_corners'
|
||||
|
||||
|
||||
def test_missing_evidence_cannot_be_silently_dropped(monkeypatch):
|
||||
rows=build_data(monkeypatch)
|
||||
rows=[r for r in rows if not(r['kind']=='o12_pnp_candidate_frame' and r['image_stamp_ns']==20)]
|
||||
with pytest.raises(ValueError,match='lacks evidence'):module.resolve_thumb_observations(rows)
|
||||
|
||||
|
||||
def test_other_task_evidence_is_not_mixed_into_thumb(monkeypatch):
|
||||
rows=build_data(monkeypatch)
|
||||
rows.append({'kind':'o12_pnp_candidate_frame','task_name':'pinky_chain_side','image_stamp_ns':1})
|
||||
assert module.resolve_thumb_observations(rows)[1]['status']=='resolved'
|
||||
|
||||
|
||||
def test_partial_external_projection_cannot_finalize_whole_hand(tmp_path):
|
||||
from linkerhand_calibration.models.o12.pipeline import finalize_o12_session
|
||||
with pytest.raises(ValueError,match='diagnostic-only'):
|
||||
finalize_o12_session(session_dir=tmp_path/'out',serial_number='test',source_urdf='unused',
|
||||
protected_inputs={},records=[{'projection_reprocessing_scope':'thumb_only_external_projection'}],publish=False)
|
||||
|
||||
|
||||
def test_known_unverified_projection_cannot_publish_even_if_fit_passes(monkeypatch,tmp_path):
|
||||
from dataclasses import dataclass,field
|
||||
from linkerhand_calibration.models.o12 import pipeline
|
||||
from linkerhand_calibration.models.o12.zero import O12SpatialZeroError
|
||||
@dataclass
|
||||
class Spatial:
|
||||
passed: bool=True
|
||||
failure_reasons: dict=field(default_factory=dict)
|
||||
@dataclass
|
||||
class Fit:
|
||||
full_hand_zero_result: Spatial=field(default_factory=Spatial)
|
||||
zero_offsets_rad: dict=field(default_factory=dict)
|
||||
monkeypatch.setattr(pipeline,'fit_o12_session',lambda *a,**k:Fit())
|
||||
def no_review(**kwargs):
|
||||
raise RuntimeError('test omits review geometry')
|
||||
monkeypatch.setattr(pipeline,'write_o12_corrected_urdf',no_review)
|
||||
with pytest.raises(O12SpatialZeroError) as error:
|
||||
pipeline.finalize_o12_session(session_dir=tmp_path/'out',serial_number='test',source_urdf='unused',
|
||||
protected_inputs={},records=[{'kind':'o12_pnp_candidate_frame','task_name':'thumb_mcp_dip_front','image_stamp_ns':1}])
|
||||
assert error.value.diagnostics['stage']=='camera_projection'
|
||||
assert not error.value.review_fit.full_hand_zero_result.passed
|
||||
assert not (tmp_path/'latest_passed').exists()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Optional local-data replay: deterministic recovery, NOT physical certification.
|
||||
|
||||
Set O12_REPLAY_RAW and O12_REPLAY_REFERENCE to use a captured session and an
|
||||
independently reviewed model. The test never publishes or launches hardware.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from linkerhand_calibration.models.o12.pipeline import finalize_o12_session, load_o12_raw_samples
|
||||
from linkerhand_calibration.models.o12.zero import O12SpatialZeroError
|
||||
from linkerhand_calibration.product import sha256_file
|
||||
from linkerhand_calibration.storage import atomic_write_json
|
||||
from linkerhand_calibration.urdf_comparison import compare_urdfs
|
||||
from test_o12_right_profile import SOURCE_URDF
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("O12_REPLAY_RAW") or not os.environ.get("O12_REPLAY_REFERENCE"),
|
||||
reason="local raw recording/reference not supplied")
|
||||
def test_recorded_replay_matches_reviewed_geometry_without_overriding_quality(tmp_path):
|
||||
raw = Path(os.environ["O12_REPLAY_RAW"]).resolve(strict=True)
|
||||
reference = Path(os.environ["O12_REPLAY_REFERENCE"]).resolve(strict=True)
|
||||
output = Path(os.environ.get("O12_REPLAY_OUTPUT", str(tmp_path / "replay"))).resolve()
|
||||
assert not output.exists(), "use a new output directory"
|
||||
hashes = {str(p): sha256_file(p) for p in (raw, reference, SOURCE_URDF)}
|
||||
try:
|
||||
_, fit, correction = finalize_o12_session(
|
||||
session_dir=output, serial_number="O12_RIGHT_001", source_urdf=SOURCE_URDF,
|
||||
protected_inputs=hashes, records=load_o12_raw_samples(raw), publish=False,
|
||||
)
|
||||
candidate = correction.path
|
||||
assert fit.full_hand_zero_result.passed
|
||||
except O12SpatialZeroError as error:
|
||||
assert error.review_fit is not None
|
||||
assert not error.review_fit.full_hand_zero_result.passed
|
||||
assert error.review_fit.full_hand_zero_result.failure_reasons
|
||||
candidate = Path(error.diagnostics["review_urdf"])
|
||||
assert "REVIEW_ONLY" in candidate.name
|
||||
assert not list(output.glob("*calibration.json"))
|
||||
report = compare_urdfs(reference, candidate)
|
||||
atomic_write_json(output / "reference_geometry_comparison.json", report)
|
||||
assert report["maximum_origin_distance_mm"] < 1.e-5
|
||||
assert report["maximum_orientation_difference_deg"] < 1.e-5
|
||||
assert report["link_elements_identical"]
|
||||
for ranges in report["active_joint_ranges_rad"].values():
|
||||
assert ranges["reference"] == pytest.approx(ranges["candidate"], abs=1.e-10)
|
||||
assert all(sha256_file(p) == digest for p, digest in hashes.items())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
"""Absolute zero and transfer tests, independent of curve-fit repeatability."""
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from linkerhand_calibration.models.g20.zero_solver import UrdfKinematicModel
|
||||
from linkerhand_calibration.models.o12.fitting import (
|
||||
O12_THUMB_ROOT_AXIS_JOINTS, _fit_thumb_root_zero, fit_o12_session,
|
||||
)
|
||||
from linkerhand_calibration.models.o12.profile import build_typed_profile
|
||||
from linkerhand_calibration.models.o12.artifacts import (
|
||||
build_o12_runtime_payload, validate_o12_runtime_payload,
|
||||
validate_o12_runtime_payload_against_urdf,
|
||||
)
|
||||
from linkerhand_calibration.models.o12.urdf import write_o12_corrected_urdf
|
||||
from test_o12_right_profile import SOURCE_URDF, _synthetic_records
|
||||
|
||||
|
||||
def _transform(xyz, rpy):
|
||||
t = np.eye(4)
|
||||
t[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix()
|
||||
t[:3, 3] = xyz
|
||||
return t
|
||||
|
||||
|
||||
def _pose(t):
|
||||
return {"translation_xyz_m": t[:3, 3].tolist(),
|
||||
"quaternion_xyzw": Rotation.from_matrix(t[:3, :3]).as_quat().tolist()}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rotation_fit():
|
||||
return fit_o12_session(SOURCE_URDF, _synthetic_records())
|
||||
|
||||
|
||||
def _geometry_records(fit, offsets, camera_rpy):
|
||||
model = UrdfKinematicModel(SOURCE_URDF)
|
||||
tasks = {j: t for t in build_typed_profile().motion.tasks for j in t.joints}
|
||||
palm = _transform([.02, -.01, .7], camera_rpy)
|
||||
records = {}
|
||||
for i, name in enumerate(O12_THUMB_ROOT_AXIS_JOINTS):
|
||||
task = tasks[name]
|
||||
mounting = _transform([.012, -.009, .02], [.13 * i, -.24, .31])
|
||||
parent = palm @ _transform([-.01, .007, .003], [-.12, .1 * i, .2])
|
||||
rows = []
|
||||
for cycle in range(4):
|
||||
for direction, phases in (("decreasing", np.linspace(0, 1, 65)),
|
||||
("increasing", np.linspace(1, 0, 65))):
|
||||
for phase in phases:
|
||||
state = [0.] * 12
|
||||
state[task.command_index] = task.start_value + phase * (task.end_value - task.start_value)
|
||||
child = palm @ model.link_transform(
|
||||
name, zero_offsets=offsets,
|
||||
joint_angles={name: float(phase * fit.travels_rad[name])},
|
||||
) @ mounting
|
||||
relative = np.linalg.inv(parent) @ child
|
||||
rows.append({
|
||||
"cycle": cycle, "direction": direction,
|
||||
"feedback_rad": state[task.command_index], "state_rad": state,
|
||||
"relative_quaternion_xyzw": _pose(relative)["quaternion_xyzw"],
|
||||
"relative_translation_xyz_m": relative[:3, 3].tolist(),
|
||||
"parent_pose_common": _pose(parent), "child_pose_common": _pose(child),
|
||||
"view_normal_common_xyz": [0., 0., 1.],
|
||||
"camera_center_common_xyz_m": [0., 0., 0.],
|
||||
})
|
||||
records[name] = rows
|
||||
return records
|
||||
|
||||
|
||||
@pytest.mark.parametrize("camera_rpy", [[.2, -.3, .4], [-.3, .5, -.6]])
|
||||
def test_root_zeros_recover_known_geometry_with_arbitrary_tag_mounts(rotation_fit, camera_rpy, tmp_path):
|
||||
truth = {"thumb_cmc_roll": .08, "thumb_cmc_yaw": -.12}
|
||||
rows = _geometry_records(rotation_fit, truth, camera_rpy)
|
||||
solved = _fit_thumb_root_zero(SOURCE_URDF, rows, rotation_fit.curves,
|
||||
rotation_fit.feedback_domains_rad)
|
||||
assert solved.passed
|
||||
assert set(solved.direct_offsets_rad) == set(truth)
|
||||
for name, value in truth.items():
|
||||
assert solved.direct_offsets_rad[name] == pytest.approx(value, abs=1.e-5)
|
||||
result = replace(rotation_fit, zero_offsets_rad={
|
||||
**rotation_fit.zero_offsets_rad, **solved.direct_offsets_rad,
|
||||
})
|
||||
corrected = write_o12_corrected_urdf(source_urdf=SOURCE_URDF,
|
||||
output_directory=tmp_path, serial_number="ROOT", result=result)
|
||||
actual, original = UrdfKinematicModel(corrected.path), UrdfKinematicModel(SOURCE_URDF)
|
||||
for state in ({}, {"thumb_cmc_roll": .72},
|
||||
{"thumb_cmc_roll": .36, "thumb_cmc_yaw": .4, "thumb_cmc_pitch": .3, "thumb_mcp": .5}):
|
||||
assert actual.link_transform("thumb_mcp", zero_offsets={}, joint_angles=state) == pytest.approx(
|
||||
original.link_transform("thumb_mcp", zero_offsets=truth, joint_angles=state), abs=1.e-5)
|
||||
|
||||
|
||||
def test_root_holdout_rejects_changed_camera_pose(rotation_fit):
|
||||
rows = _geometry_records(rotation_fit, {"thumb_cmc_roll": .08, "thumb_cmc_yaw": -.12}, [.2, -.3, .4])
|
||||
rotation = Rotation.from_rotvec([.15, .05, .1])
|
||||
for row in rows["thumb_cmc_yaw"]:
|
||||
if row["cycle"] == 3:
|
||||
for field in ("parent_pose_common", "child_pose_common"):
|
||||
p = row[field]
|
||||
p["translation_xyz_m"] = rotation.apply(p["translation_xyz_m"]).tolist()
|
||||
p["quaternion_xyzw"] = (rotation * Rotation.from_quat(p["quaternion_xyzw"])).as_quat().tolist()
|
||||
with pytest.raises(ValueError, match="spatial zero solve failed"):
|
||||
_fit_thumb_root_zero(SOURCE_URDF, rows, rotation_fit.curves,
|
||||
rotation_fit.feedback_domains_rad)
|
||||
|
||||
|
||||
def test_rotation_only_records_cannot_pass_production_static_solve():
|
||||
with pytest.raises(ValueError, match="absolute zero requires"):
|
||||
fit_o12_session(SOURCE_URDF, _synthetic_records(), require_thumb_root_spatial_zero=True)
|
||||
|
||||
|
||||
def test_ring_transfers_nonzero_correction_without_copying_geometry(rotation_fit, tmp_path):
|
||||
offsets = {**rotation_fit.zero_offsets_rad, "pinky_mcp_pitch": .06, "ring_mcp_pitch": .06}
|
||||
result = replace(rotation_fit, zero_offsets_rad=offsets)
|
||||
corrected = write_o12_corrected_urdf(source_urdf=SOURCE_URDF,
|
||||
output_directory=tmp_path, serial_number="TRANSFER", result=result)
|
||||
original = ET.parse(SOURCE_URDF).getroot()
|
||||
actual = ET.parse(corrected.path).getroot()
|
||||
for name in ("pinky_mcp_pitch", "ring_mcp_pitch"):
|
||||
before = original.find(f"joint[@name='{name}']/origin")
|
||||
after = actual.find(f"joint[@name='{name}']/origin")
|
||||
assert before.get("xyz") == after.get("xyz")
|
||||
rb = Rotation.from_euler("xyz", [float(x) for x in before.get("rpy").split()])
|
||||
ra = Rotation.from_euler("xyz", [float(x) for x in after.get("rpy").split()])
|
||||
assert (rb.inv() * ra).as_rotvec() == pytest.approx([0., .06, 0.], abs=1.e-10)
|
||||
for name in ("ring_pip", "ring_dip"):
|
||||
assert ET.tostring(original.find(f"joint[@name='{name}']")) == ET.tostring(actual.find(f"joint[@name='{name}']"))
|
||||
hashes = {k: "a" * 64 for k in build_typed_profile().artifacts.protected_input_fields}
|
||||
payload = build_o12_runtime_payload(serial_number="TRANSFER", source_urdf=SOURCE_URDF,
|
||||
result=result, protected_inputs=hashes, passed=True)
|
||||
ring = payload["joints"]["ring_mcp_pitch"]
|
||||
pinky = payload["joints"]["pinky_mcp_pitch"]
|
||||
# Equal feedback must have equal corrections before the ring saturates.
|
||||
for i, value in enumerate(pinky["angle_rad"]):
|
||||
assert ring["angle_rad"][i] == pytest.approx(min(1.38, value))
|
||||
validate_o12_runtime_payload_against_urdf(payload, corrected.path, source_urdf=SOURCE_URDF)
|
||||
payload["joints"]["ring_mcp_pitch"]["static_urdf_origin_offset_rad"] = 0.
|
||||
with pytest.raises(ValueError, match="static zero differs"):
|
||||
validate_o12_runtime_payload(payload)
|
||||
|
||||
|
||||
def test_static_frame_validator_catches_correct_limits_but_wrong_origin(rotation_fit, tmp_path):
|
||||
corrected = write_o12_corrected_urdf(source_urdf=SOURCE_URDF,
|
||||
output_directory=tmp_path, serial_number="FRAME", result=rotation_fit)
|
||||
hashes = {k: "a" * 64 for k in build_typed_profile().artifacts.protected_input_fields}
|
||||
payload = build_o12_runtime_payload(serial_number="FRAME", source_urdf=SOURCE_URDF,
|
||||
result=rotation_fit, protected_inputs=hashes, passed=True)
|
||||
tree = ET.parse(corrected.path)
|
||||
tree.getroot().find("joint[@name='thumb_cmc_pitch']/origin").set("rpy", "0 0.9259 -1.5708")
|
||||
tree.write(corrected.path)
|
||||
with pytest.raises(ValueError, match="thumb_cmc_pitch static origin disagrees"):
|
||||
validate_o12_runtime_payload_against_urdf(payload, corrected.path, source_urdf=SOURCE_URDF)
|
||||
@@ -0,0 +1,124 @@
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation as R
|
||||
|
||||
from linkerhand_calibration.models.o12.pnp import O12ThumbPoseTracker, THUMB_ROLES
|
||||
from linkerhand_calibration.pnp import SquareTagPose
|
||||
|
||||
|
||||
def pose(rotation, error=0.05):
|
||||
return SquareTagPose(tuple(rotation.as_quat()), (0., 0., 1.), error)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('remount', [False, True])
|
||||
@pytest.mark.parametrize('passive_travel', [12, 28, 45])
|
||||
def test_parallel_branch_disambiguation_without_angle_ratio(remount, passive_travel):
|
||||
tracker=O12ThumbPoseTracker()
|
||||
mounts=[R.identity()]*3 if not remount else [
|
||||
R.from_euler('xyz', angles) for angles in
|
||||
([.3,-.7,.2],[-.5,.4,.8],[.6,.2,-.4])]
|
||||
camera=R.from_euler('xyz',[.4,-.8,.7])
|
||||
baseline={role:(pose(camera*mount),) for role,mount in zip(THUMB_ROLES,mounts)}
|
||||
for i in range(8):
|
||||
selected,reason=tracker.select(baseline,stamp_ns=1_000_000_000+i*30_000_000)
|
||||
assert selected is not None
|
||||
driver=R.from_euler('z',20,degrees=True)
|
||||
follower=R.from_euler('z',passive_travel,degrees=True)
|
||||
wrong=R.from_euler('x',passive_travel,degrees=True)
|
||||
good_pose=pose(camera*driver*follower*mounts[2],.08)
|
||||
candidates={THUMB_ROLES[0]:baseline[THUMB_ROLES[0]],
|
||||
THUMB_ROLES[1]:(pose(camera*driver*mounts[1]),),
|
||||
THUMB_ROLES[2]:(pose(camera*driver*wrong*mounts[2]),good_pose)}
|
||||
selected,reason=tracker.select(candidates,stamp_ns=1_250_000_000)
|
||||
assert reason == ''
|
||||
assert selected[THUMB_ROLES[2]] == good_pose
|
||||
# Angles are observed candidates, not replaced with driver*mimic.
|
||||
assert tracker.coupled_rotation_pairs == ()
|
||||
|
||||
|
||||
def test_no_credible_parallel_candidate_does_not_stop_or_invent_pose():
|
||||
tracker=O12ThumbPoseTracker()
|
||||
baseline={role:(pose(R.identity()),) for role in THUMB_ROLES}
|
||||
for i in range(8):
|
||||
tracker.select(baseline,stamp_ns=i*30_000_000)
|
||||
bad=pose(R.from_euler('zx',[20,45],degrees=True))
|
||||
selected,reason=tracker.select({THUMB_ROLES[0]:baseline[THUMB_ROLES[0]],
|
||||
THUMB_ROLES[1]:(pose(R.from_euler('z',20,degrees=True)),),
|
||||
THUMB_ROLES[2]:(bad,)},stamp_ns=250_000_000)
|
||||
assert reason == ''
|
||||
assert selected[THUMB_ROLES[2]] == bad
|
||||
tracker.reset()
|
||||
assert tracker.reference is None
|
||||
|
||||
|
||||
def test_missing_tag_discards_observation_and_gap_reinitializes():
|
||||
tracker=O12ThumbPoseTracker()
|
||||
baseline={role:(pose(R.identity()),) for role in THUMB_ROLES}
|
||||
for i in range(8):
|
||||
tracker.select(baseline,stamp_ns=i*30_000_000)
|
||||
selected,reason=tracker.select({},stamp_ns=250_000_000)
|
||||
assert selected is None and reason == 'group_missing_pose_candidates'
|
||||
selected,_=tracker.select(baseline,stamp_ns=10_000_000_000)
|
||||
assert selected is None and tracker.reference is None
|
||||
|
||||
|
||||
def test_hook_only_applies_to_o12_thumb_task():
|
||||
from linkerhand_calibration.models.o12.node import O12ThreeCameraCalibrationNode
|
||||
from linkerhand_calibration.models.l6.node import L6ThreeCameraCalibrationNode
|
||||
from linkerhand_calibration.models.o6.node import O6ThreeCameraCalibrationNode
|
||||
assert not hasattr(L6ThreeCameraCalibrationNode,'_select_articulated_capture_poses')
|
||||
assert not hasattr(O6ThreeCameraCalibrationNode,'_select_articulated_capture_poses')
|
||||
hook=O12ThreeCameraCalibrationNode._select_articulated_capture_poses
|
||||
assert hook(SimpleNamespace(),'side',None,(),{},0) is None
|
||||
assert hook(SimpleNamespace(),'front',SimpleNamespace(task_key='thumb_pitch_front'),(),{},0) is None
|
||||
|
||||
|
||||
def test_hook_records_corners_all_candidates_and_rejected_frames(monkeypatch, tmp_path):
|
||||
import json
|
||||
from linkerhand_calibration.models.o12 import node as module
|
||||
from linkerhand_calibration.models.o12.profile import build_typed_profile
|
||||
profile=build_typed_profile()
|
||||
front=next(v for v in profile.vision.views if v.name == 'front')
|
||||
good=pose(R.identity())
|
||||
high_error=pose(R.from_euler('y',.5),3.)
|
||||
monkeypatch.setattr(module,'solve_square_tag_ippe',lambda *a,**k:[good,high_error])
|
||||
raw=tmp_path/'raw.jsonl'
|
||||
node=SimpleNamespace(trackers={'front':SimpleNamespace(maximum_reprojection_error_px=1.5)},
|
||||
camera_matrices={'front':np.diag([500.,500.,1.])},tag_size_m=.016,
|
||||
raw_path=raw,_view=lambda view:front)
|
||||
step=SimpleNamespace(task_key='thumb_mcp_dip_front',cycle=0,direction='decreasing',attempt=1,phase='sweep')
|
||||
corners={role:np.array([[1.,1.],[2.,1.],[2.,2.],[1.,2.]]) for role in THUMB_ROLES}
|
||||
hook=module.O12ThreeCameraCalibrationNode._select_articulated_capture_poses
|
||||
for i in range(8):
|
||||
selected,reason=hook(node,'front',step,THUMB_ROLES,corners,i*30_000_000)
|
||||
assert selected is not None
|
||||
records=[json.loads(line) for line in raw.read_text().splitlines()]
|
||||
assert len(records)==8
|
||||
assert records[0]['roles']['thumb_dip']['selected'] is None
|
||||
assert len(records[-1]['roles']['thumb_dip']['candidates'])==2
|
||||
assert records[-1]['roles']['thumb_dip']['eligible_candidate_count']==1
|
||||
assert records[-1]['roles']['thumb_dip']['tag_id']==3
|
||||
assert records[-1]['camera_matrix']==node.camera_matrices['front'].tolist()
|
||||
assert records[-1]['roles']['thumb_mcp']['corners_xy']==corners['thumb_mcp'].tolist()
|
||||
|
||||
|
||||
def test_other_task_evidence_distinguishes_locked_reference_from_pixels(tmp_path):
|
||||
import json
|
||||
from linkerhand_calibration.models.o12.node import O12ThreeCameraCalibrationNode
|
||||
roles=('front_base','moving')
|
||||
poses={r:pose(R.identity()) for r in roles}
|
||||
node=SimpleNamespace(raw_path=tmp_path/'raw.jsonl',tag_size_m=.016,
|
||||
camera_matrices={'front':np.eye(3)},
|
||||
trackers={'front':SimpleNamespace(last_candidates_by_role={r:(p,) for r,p in poses.items()},maximum_reprojection_error_px=1.5)},
|
||||
_view=lambda v:SimpleNamespace(tags=[SimpleNamespace(role=r,tag_id=i) for i,r in enumerate(roles)]))
|
||||
step=SimpleNamespace(task_key='index_roll_front',cycle=0,direction='decreasing',attempt=1,phase='sweep')
|
||||
corners={r:np.zeros((4,2)) for r in roles}
|
||||
O12ThreeCameraCalibrationNode._record_capture_pose_evidence(node,'front',step,roles,corners,poses,1,locked_roles=('front_base',))
|
||||
record=json.loads(node.raw_path.read_text())
|
||||
assert record['camera_matrix_source']=='CameraInfo.P[:3,:3]'
|
||||
assert record['roles']['front_base']['observation_source']=='locked_reference'
|
||||
assert record['roles']['front_base']['candidates']==[]
|
||||
assert record['roles']['moving']['observation_source']=='image'
|
||||
@@ -208,7 +208,7 @@ def test_o6_motion_plan_uses_o6_specific_speed_tiers() -> None:
|
||||
steps = O6ThreeCameraCalibrationNode._build_steps(fake)
|
||||
assert steps[0].phase == "baseline"
|
||||
assert steps[0].speed_u8 == 80
|
||||
assert {step.speed_u8 for step in steps if step.phase == "preflight"} == {60}
|
||||
assert not [step for step in steps if step.phase == "preflight"]
|
||||
assert {
|
||||
step.speed_u8
|
||||
for step in steps
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from types import SimpleNamespace
|
||||
import numpy as np
|
||||
import pytest
|
||||
import cv2
|
||||
from scipy.spatial.transform import Rotation as R
|
||||
|
||||
from linkerhand_calibration.core.geometry.camera import rectified_camera_matrix
|
||||
from linkerhand_calibration.pnp import solve_square_tag_ippe, square_object_points
|
||||
|
||||
|
||||
def test_rectified_corners_require_projection_not_raw_intrinsics():
|
||||
k=np.array([[3816.108989,0,620.129591],[0,3796.2534,570.26288],[0,0,1.]])
|
||||
p=np.array([[3792.732422,0,606.014017,0],[0,3825.369629,568.524418,0],[0,0,1,0.]])
|
||||
expected=R.from_euler('xyz',[.2,-.5,.3])
|
||||
t=np.array([.12,.05,1.])
|
||||
corners=cv2.projectPoints(square_object_points(.016),expected.as_rotvec(),t,p[:,:3],np.zeros(4))[0].reshape(4,2)
|
||||
candidates=solve_square_tag_ippe(corners,tag_size_m=.016,camera_matrix=rectified_camera_matrix(p))
|
||||
errors=[(expected.inv()*R.from_quat(c.quaternion_xyzw)).magnitude() for c in candidates]
|
||||
assert min(errors)<1e-6
|
||||
wrong=solve_square_tag_ippe(corners,tag_size_m=.016,camera_matrix=k)
|
||||
assert min((expected.inv()*R.from_quat(c.quaternion_xyzw)).magnitude() for c in wrong)>.001
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad',[[],[0.]*12,[float('nan')]*12])
|
||||
def test_invalid_p_cannot_silently_fall_back_to_k(bad):
|
||||
with pytest.raises(ValueError):rectified_camera_matrix(bad)
|
||||
|
||||
|
||||
def test_shared_camera_callback_uses_p_saves_provenance_and_invalidates(tmp_path):
|
||||
import json
|
||||
from linkerhand_calibration.models.l6.node import L6ThreeCameraCalibrationNode
|
||||
node=SimpleNamespace(camera_matrices={},image_sizes={},raw_path=tmp_path/'raw.jsonl')
|
||||
p=[500.,0,320,0,0,510,240,0,0,0,1,0]
|
||||
msg=SimpleNamespace(p=p,k=[600.,0,300,0,620,220,0,0,1],d=[.1]*5,r=np.eye(3).ravel().tolist(),width=640,height=480)
|
||||
callback=L6ThreeCameraCalibrationNode._camera_info_callback
|
||||
callback(node,'front',msg); callback(node,'front',msg)
|
||||
np.testing.assert_allclose(node.camera_matrices['front'],np.array(p).reshape(3,4)[:,:3])
|
||||
records=[json.loads(l) for l in node.raw_path.read_text().splitlines()]
|
||||
assert len(records)==1 and records[0]['raw_k']==msg.k
|
||||
assert records[0]['matrix_source']=='CameraInfo.P[:3,:3]'
|
||||
msg.p=[0.]*12
|
||||
callback(node,'front',msg)
|
||||
assert 'front' not in node.camera_matrices
|
||||
@@ -235,16 +235,14 @@ def test_refit_invalidates_all_derived_calibration_artifacts() -> None:
|
||||
def test_right_19_plan_is_one_deterministic_transaction_per_task() -> None:
|
||||
plan = _build_sweep_plan(RIGHT_19_HAND_PROFILE, repetitions=4)
|
||||
|
||||
assert len(plan) == len(RIGHT_19_HAND_PROFILE.sweep_specs) * 10
|
||||
assert len(plan) == len(RIGHT_19_HAND_PROFILE.sweep_specs) * 8
|
||||
for spec_index, spec in enumerate(RIGHT_19_HAND_PROFILE.sweep_specs):
|
||||
task = plan[spec_index * 10 : (spec_index + 1) * 10]
|
||||
task = plan[spec_index * 8 : (spec_index + 1) * 8]
|
||||
assert all(item.spec == spec for item in task)
|
||||
assert [
|
||||
(item.precheck, item.cycle, item.direction)
|
||||
for item in task
|
||||
] == [
|
||||
(True, -1, DIRECTION_DECREASING),
|
||||
(True, -1, DIRECTION_INCREASING),
|
||||
*[
|
||||
(False, cycle, direction)
|
||||
for cycle in range(4)
|
||||
@@ -259,8 +257,6 @@ def test_right_19_plan_is_one_deterministic_transaction_per_task() -> None:
|
||||
for left, right in zip(task, task[1:])
|
||||
]
|
||||
assert transitions == [
|
||||
"immediate_reverse",
|
||||
"immediate_reverse",
|
||||
"immediate_reverse",
|
||||
"cycle_reset",
|
||||
"immediate_reverse",
|
||||
@@ -271,7 +267,7 @@ def test_right_19_plan_is_one_deterministic_transaction_per_task() -> None:
|
||||
]
|
||||
task_boundaries = [
|
||||
(plan[index], plan[index + 1])
|
||||
for index in range(9, len(plan) - 1, 10)
|
||||
for index in range(7, len(plan) - 1, 8)
|
||||
]
|
||||
assert all(
|
||||
_sweep_plan_transition(RIGHT_19_HAND_PROFILE, left, right)
|
||||
@@ -337,8 +333,8 @@ def test_right_19_complete_plan_has_no_unobserved_immediate_handoff() -> None:
|
||||
minimum_per_joint=1,
|
||||
)
|
||||
|
||||
# 16 tasks × (precheck out/back, precheck->formal, four formal reversals).
|
||||
assert immediate_boundaries == 16 * 6
|
||||
# Four formal decreasing/increasing pairs per task; no full-range precheck.
|
||||
assert immediate_boundaries == 16 * 4
|
||||
assert cycle_resets == 16 * 3
|
||||
assert task_changes == 15
|
||||
|
||||
@@ -356,8 +352,7 @@ def test_right_19_initializes_pnp_once_per_normal_task() -> None:
|
||||
]
|
||||
|
||||
assert len(reset_items) == len(RIGHT_19_HAND_PROFILE.sweep_specs)
|
||||
assert all(item.precheck for item in reset_items)
|
||||
assert all(item.cycle == -1 for item in reset_items)
|
||||
assert all(not item.precheck and item.cycle == 0 for item in reset_items)
|
||||
assert all(
|
||||
item.direction == DIRECTION_DECREASING for item in reset_items
|
||||
)
|
||||
@@ -2960,7 +2955,7 @@ def test_multiview_retry_discards_only_failed_side_measurement(tmp_path) -> None
|
||||
assert event["cycles"] == [1]
|
||||
|
||||
|
||||
def test_recoverable_sweep_failure_retries_three_times_before_pause(tmp_path) -> None:
|
||||
def test_recoverable_sweep_failure_retries_once_at_same_speed(tmp_path) -> None:
|
||||
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
|
||||
item = SweepItem(spec, 0, DIRECTION_INCREASING)
|
||||
transitions: list[str] = []
|
||||
@@ -2975,8 +2970,8 @@ def test_recoverable_sweep_failure_retries_three_times_before_pause(tmp_path) ->
|
||||
_publish_hold_current=lambda: holds.append(True),
|
||||
_begin_return_baseline=lambda after: transitions.append(after),
|
||||
_pause=lambda reason: pauses.append(reason),
|
||||
retry_speed_scales=(0.8, 0.6, 0.5),
|
||||
retry_endpoint_hold_seconds=(0.75, 1.0, 1.25),
|
||||
retry_speed_scales=(1.0,),
|
||||
retry_endpoint_hold_seconds=(0.5,),
|
||||
)
|
||||
|
||||
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
|
||||
@@ -2985,22 +2980,15 @@ def test_recoverable_sweep_failure_retries_three_times_before_pause(tmp_path) ->
|
||||
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
|
||||
node, "sweep_bin_gap_too_large"
|
||||
)
|
||||
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
|
||||
node, "sweep_bin_gap_too_large"
|
||||
)
|
||||
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
|
||||
node, "sweep_bin_gap_too_large"
|
||||
)
|
||||
|
||||
assert transitions == ["retry_sweep", "retry_sweep", "retry_sweep"]
|
||||
assert transitions == ["retry_sweep"]
|
||||
assert pauses == ["sweep_bin_gap_too_large"]
|
||||
assert holds == []
|
||||
assert node.sweep_retry_counts[(6, 0, DIRECTION_INCREASING)] == 3
|
||||
assert node.sweep_retry_counts[(6, 0, DIRECTION_INCREASING)] == 1
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in (tmp_path / "raw_samples.jsonl").read_text().splitlines()
|
||||
]
|
||||
assert [event["retry"] for event in events] == [1, 2, 3]
|
||||
assert [event["retry"] for event in events] == [1]
|
||||
|
||||
|
||||
def test_visibility_precheck_does_not_require_dense_feedback_bins(tmp_path) -> None:
|
||||
@@ -3209,7 +3197,7 @@ def test_formal_sweep_still_rejects_17_u8_feedback_gap(tmp_path) -> None:
|
||||
assert retries == ["sweep_bin_gap_too_large"]
|
||||
|
||||
|
||||
def test_first_provisional_fit_failure_retries_automatically(tmp_path) -> None:
|
||||
def test_first_provisional_fit_failure_stops_without_more_motion(tmp_path) -> None:
|
||||
spec = next(item for item in SWEEP_SPECS if item.motor_index == 15)
|
||||
transitions: list[str] = []
|
||||
pauses: list[str] = []
|
||||
@@ -3240,10 +3228,9 @@ def test_first_provisional_fit_failure_retries_automatically(tmp_path) -> None:
|
||||
[{"joint": "thumb_ip", "metric": "rotation_orthogonal_rms_deg"}],
|
||||
)
|
||||
|
||||
assert prepared == [True]
|
||||
assert transitions == ["resume_sweep"]
|
||||
assert pauses == []
|
||||
assert node.paused_reason == "joint_fit_check_failed"
|
||||
assert prepared == []
|
||||
assert transitions == []
|
||||
assert pauses == ["joint_fit_check_failed"]
|
||||
|
||||
|
||||
def test_repeatable_all_cycle_model_conflict_does_not_waste_full_retry(
|
||||
@@ -3290,10 +3277,7 @@ def test_repeatable_all_cycle_model_conflict_does_not_waste_full_retry(
|
||||
assert node.fit_failure["directions_to_rescan"] == 0
|
||||
|
||||
|
||||
def test_near_threshold_provisional_failure_rescans_in_place(tmp_path) -> None:
|
||||
# In-band values are rejected by the final fit on the same records, so
|
||||
# the first in-band result reschedules the task here instead of warning
|
||||
# and deferring the rejection to the end of the session.
|
||||
def test_near_threshold_provisional_failure_stops_without_rescan(tmp_path) -> None:
|
||||
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
|
||||
node = SimpleNamespace(
|
||||
raw_path=tmp_path / "raw_samples.jsonl",
|
||||
@@ -3333,11 +3317,11 @@ def test_near_threshold_provisional_failure_rescans_in_place(tmp_path) -> None:
|
||||
for line in (tmp_path / "raw_samples.jsonl").read_text().splitlines()
|
||||
]
|
||||
kinds = [event["kind"] for event in events]
|
||||
assert "provisional_fit_warning_rescan" in kinds
|
||||
assert "provisional_fit_warning_retry_exhausted" in kinds
|
||||
assert "fit_failure" in kinds
|
||||
|
||||
|
||||
def test_motion_timeout_republishes_twice_before_pause(tmp_path) -> None:
|
||||
def test_motion_timeout_pauses_without_republishing(tmp_path) -> None:
|
||||
commands: list[list[int]] = []
|
||||
pauses: list[str] = []
|
||||
holds: list[bool] = []
|
||||
@@ -3363,12 +3347,12 @@ def test_motion_timeout_republishes_twice_before_pause(tmp_path) -> None:
|
||||
node, "return_baseline_timeout", now
|
||||
)
|
||||
|
||||
assert len(commands) == 2
|
||||
assert commands == []
|
||||
assert holds == []
|
||||
assert pauses == ["return_baseline_timeout"]
|
||||
assert pauses == ["return_baseline_timeout"] * 3
|
||||
|
||||
|
||||
def test_sweep_start_tag_timeout_resets_pnp_before_retry(tmp_path) -> None:
|
||||
def test_sweep_start_tag_timeout_does_not_block_motion(tmp_path) -> None:
|
||||
spec = next(
|
||||
item
|
||||
for item in RIGHT_19_HAND_PROFILE.sweep_specs
|
||||
@@ -3379,6 +3363,8 @@ def test_sweep_start_tag_timeout_resets_pnp_before_retry(tmp_path) -> None:
|
||||
resets: list[tuple[object, bool]] = []
|
||||
diagnostic_resets: list[object] = []
|
||||
commands: list[list[int]] = []
|
||||
starts: list[float] = []
|
||||
pauses: list[str] = []
|
||||
start_frames = [object()]
|
||||
node = SimpleNamespace(
|
||||
profile=RIGHT_19_HAND_PROFILE,
|
||||
@@ -3406,22 +3392,18 @@ def test_sweep_start_tag_timeout_resets_pnp_before_retry(tmp_path) -> None:
|
||||
_publish_speed_profile=lambda profile: None,
|
||||
_publish_command=lambda command: commands.append(command),
|
||||
_reset_motion_progress=lambda now, error: None,
|
||||
_pause=lambda reason: pytest.fail(f"unexpected pause: {reason}"),
|
||||
_pause=lambda reason: pauses.append(reason),
|
||||
_begin_active_sweep=lambda now: starts.append(now),
|
||||
)
|
||||
|
||||
G20ThreeCameraCalibrationNode._retry_motion_or_pause(
|
||||
node, "sweep_start_tag_timeout", 20.0
|
||||
G20ThreeCameraCalibrationNode._handle_sweep_start_timeout(
|
||||
node, 20.0, True
|
||||
)
|
||||
|
||||
assert resets == [(runtime, True)]
|
||||
assert diagnostic_resets == [runtime]
|
||||
assert runtime.pnp_invalid_since is None
|
||||
assert runtime.pnp_reset_count == 1
|
||||
assert start_frames == []
|
||||
assert len(commands) == 1
|
||||
assert starts == [20.0]
|
||||
assert pauses == []
|
||||
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
|
||||
assert event["pnp_trackers_reset"] == ["side"]
|
||||
assert event["task_reference_preserved"] is True
|
||||
assert event["kind"] == "sweep_start_vision_timeout_warning"
|
||||
|
||||
|
||||
def test_motion_stall_pauses_without_consuming_sweep_retries(tmp_path) -> None:
|
||||
@@ -3453,7 +3435,7 @@ def test_motion_stall_pauses_without_consuming_sweep_retries(tmp_path) -> None:
|
||||
assert event["context"] == "sweep_motor_0"
|
||||
|
||||
|
||||
def test_slower_sweep_retry_extends_timeout_inversely() -> None:
|
||||
def test_same_speed_sweep_retry_keeps_timeout() -> None:
|
||||
spec = next(item for item in SWEEP_SPECS if item.motor_index == 0)
|
||||
item = SweepItem(spec, 0, DIRECTION_DECREASING)
|
||||
key = (spec.motor_index, 0, DIRECTION_DECREASING)
|
||||
@@ -3466,11 +3448,11 @@ def test_slower_sweep_retry_extends_timeout_inversely() -> None:
|
||||
|
||||
assert G20ThreeCameraCalibrationNode._active_sweep_timeout_seconds(
|
||||
node
|
||||
) == 112.5
|
||||
) == 90.0
|
||||
node.sweep_retry_counts[key] = 3
|
||||
assert G20ThreeCameraCalibrationNode._active_sweep_timeout_seconds(
|
||||
node
|
||||
) == 180.0
|
||||
) == 90.0
|
||||
|
||||
|
||||
def test_provisional_fit_rejects_inconsistent_cycle_travel() -> None:
|
||||
@@ -3726,7 +3708,7 @@ def test_thumb_yaw_zero_outlier_retries_only_localized_source_cycle(
|
||||
"thumb_cmc_pitch_front",
|
||||
"thumb_cmc_roll_front",
|
||||
]
|
||||
assert calls == ["prepare", "resume_sweep"]
|
||||
assert calls == ["pause:joint_fit_check_failed"]
|
||||
|
||||
|
||||
def test_previous_passed_joint_zero_offset_reads_formal_pointer(
|
||||
@@ -5327,7 +5309,7 @@ def _warning_band_node(tmp_path, attempts: dict) -> SimpleNamespace:
|
||||
return node
|
||||
|
||||
|
||||
def test_warning_band_failure_rescans_once_in_place(tmp_path) -> None:
|
||||
def test_warning_band_failure_stops_in_place(tmp_path) -> None:
|
||||
node = _warning_band_node(tmp_path, attempts={})
|
||||
failures = [
|
||||
{
|
||||
@@ -5343,22 +5325,19 @@ def test_warning_band_failure_rescans_once_in_place(tmp_path) -> None:
|
||||
node, node._spec, failures, allow_warning=True
|
||||
)
|
||||
|
||||
# The final fit rejects 0.779 against 0.75 on the same records, so the
|
||||
# first in-band result must rescan here instead of deferring the
|
||||
# rejection to the end of the session.
|
||||
assert paused is True
|
||||
assert "prepare_retry" in node._calls
|
||||
assert node._calls == ["pause_joint_fit_check_failed"]
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in node.raw_path.read_text().splitlines()
|
||||
]
|
||||
kinds = {row["kind"] for row in rows}
|
||||
assert "provisional_fit_warning_rescan" in kinds
|
||||
assert "provisional_fit_warning_retry_exhausted" in kinds
|
||||
assert "provisional_fit_warning" not in kinds
|
||||
assert "fit_failure" in kinds
|
||||
|
||||
|
||||
def test_second_warning_band_result_retries_immediately(tmp_path) -> None:
|
||||
def test_second_warning_band_result_also_stops_without_motion(tmp_path) -> None:
|
||||
spec = next(
|
||||
item
|
||||
for item in RIGHT_19_HAND_PROFILE.sweep_specs
|
||||
@@ -5382,17 +5361,17 @@ def test_second_warning_band_result_retries_immediately(tmp_path) -> None:
|
||||
)
|
||||
|
||||
assert paused is True
|
||||
assert node._calls == ["prepare_retry", "return_resume_sweep"]
|
||||
assert node._calls == ["pause_joint_fit_check_failed"]
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in node.raw_path.read_text().splitlines()
|
||||
]
|
||||
assert [row["kind"] for row in rows] == [
|
||||
"provisional_fit_warning_rescan",
|
||||
"provisional_fit_warning_retry_exhausted",
|
||||
"fit_failure",
|
||||
]
|
||||
assert rows[0]["attempt"] == 2
|
||||
assert rows[0]["attempt_limit"] == 3
|
||||
assert rows[0]["attempt_limit"] == 1
|
||||
|
||||
|
||||
def test_third_warning_band_result_stops_at_current_joint(tmp_path) -> None:
|
||||
@@ -5427,7 +5406,7 @@ def test_third_warning_band_result_stops_at_current_joint(tmp_path) -> None:
|
||||
"fit_failure",
|
||||
]
|
||||
assert rows[0]["attempt"] == 3
|
||||
assert rows[0]["attempt_limit"] == 3
|
||||
assert rows[0]["attempt_limit"] == 1
|
||||
|
||||
|
||||
def test_repeated_branch_clusters_stop_before_third_full_rescan(tmp_path) -> None:
|
||||
@@ -5459,7 +5438,7 @@ def test_repeated_branch_clusters_stop_before_third_full_rescan(tmp_path) -> Non
|
||||
allow_warning=True,
|
||||
)
|
||||
assert paused is True
|
||||
assert node._calls == ["prepare_retry", "return_resume_sweep"]
|
||||
assert node._calls == ["pause_joint_fit_check_failed"]
|
||||
|
||||
node._calls.clear()
|
||||
node.sweep_attempts[node._spec.key] = 2
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from linkerhand_calibration.models import get_default_registry
|
||||
from linkerhand_calibration.core import (
|
||||
ArtifactPolicy,
|
||||
CalibrationProfile,
|
||||
CommandLayout,
|
||||
MeasurementPolicy,
|
||||
MeasurementSpec,
|
||||
MotionPolicy,
|
||||
ProfileKey,
|
||||
QualityPolicy,
|
||||
ScopePolicy,
|
||||
TagSpec,
|
||||
TaskSpec,
|
||||
ViewSpec,
|
||||
VisionRigSpec,
|
||||
ZeroSolvePolicy,
|
||||
)
|
||||
from linkerhand_calibration.core.urdf import (
|
||||
UrdfJointPatch,
|
||||
UrdfPatchSet,
|
||||
write_urdf_patches,
|
||||
)
|
||||
from linkerhand_calibration.runtime import (
|
||||
ACQUISITION_POLICY_VERSION,
|
||||
CalibrationEngine,
|
||||
)
|
||||
from linkerhand_calibration.runtime.adapters import ProfileSdkAdapter
|
||||
from linkerhand_calibration.core.geometry.rotation import fit_rotation_axis
|
||||
from linkerhand_calibration.models.g20.profile import JointCurveFit
|
||||
from linkerhand_calibration.models.l6.fitting import fit_coupling_model
|
||||
|
||||
|
||||
def test_all_product_profiles_use_unified_engine_and_sdk_adapter() -> None:
|
||||
registry = get_default_registry()
|
||||
products = {
|
||||
registered.profile.key.model: registered
|
||||
for registered in registry
|
||||
if registered.profile.key.layout
|
||||
in {"g20_right_19", "l6_right_8", "o6_right_8", "o12_right_16"}
|
||||
}
|
||||
assert set(products) == {"G20", "L6", "O6", "O12"}
|
||||
for registered in products.values():
|
||||
engine = registered.build_engine()
|
||||
adapter = registered.build_sdk_adapter()
|
||||
assert engine.checkpoint_token == ACQUISITION_POLICY_VERSION
|
||||
assert adapter.command_layout is registered.profile.command
|
||||
assert len(engine.scan_units()) == len(registered.profile.motion.tasks) * 8
|
||||
|
||||
|
||||
def test_only_o12_has_one_three_degree_mapping_probe() -> None:
|
||||
for registered in get_default_registry():
|
||||
if registered.profile.key.layout not in {
|
||||
"g20_right_19", "l6_right_8", "o6_right_8", "o12_right_16"
|
||||
}:
|
||||
continue
|
||||
engine = registered.build_engine()
|
||||
task = registered.profile.motion.tasks[0]
|
||||
probe = engine.mapping_probe_delta(task)
|
||||
if registered.profile.key.model == "O12":
|
||||
assert probe is not None
|
||||
assert 0.0 < abs(probe) <= math.radians(3.0)
|
||||
else:
|
||||
assert probe is None
|
||||
|
||||
|
||||
def test_short_vision_loss_and_low_ideal_rates_are_warnings() -> None:
|
||||
profile = next(
|
||||
item.profile for item in get_default_registry()
|
||||
if item.profile.key.layout == "o12_right_16"
|
||||
)
|
||||
result = CalibrationEngine(profile).evaluate_sweep(
|
||||
[index / 63.0 for index in range(64)],
|
||||
minimum_span=0.85,
|
||||
total_frames=90,
|
||||
joint_frame_rate=0.71,
|
||||
feedback_hz=12.0,
|
||||
detection_rate=0.72,
|
||||
bin_count=64,
|
||||
)
|
||||
assert result.passed
|
||||
assert any(value.startswith("tag_rate=") for value in result.warnings)
|
||||
assert any(value.startswith("joint_frame_rate=") for value in result.warnings)
|
||||
|
||||
|
||||
def test_observability_failure_gets_only_one_same_speed_rescan() -> None:
|
||||
profile = next(
|
||||
item.profile for item in get_default_registry()
|
||||
if item.profile.key.layout == "l6_right_8"
|
||||
)
|
||||
engine = CalibrationEngine(profile)
|
||||
failed = engine.evaluate_sweep(
|
||||
[index / 255.0 for index in range(20)],
|
||||
minimum_span=240.0 / 255.0,
|
||||
total_frames=200,
|
||||
joint_frame_rate=0.1,
|
||||
feedback_hz=5.0,
|
||||
detection_rate=0.1,
|
||||
)
|
||||
assert not failed.passed
|
||||
assert engine.retry_speed(40.0, 2) == 40.0
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
engine.retry_speed(40.0, 3)
|
||||
|
||||
|
||||
def test_o12_effective_endpoint_scale_is_not_an_internal_blind_spot() -> None:
|
||||
profile = next(
|
||||
item.profile for item in get_default_registry()
|
||||
if item.profile.key.layout == "o12_right_16"
|
||||
)
|
||||
result = CalibrationEngine(profile).evaluate_sweep(
|
||||
[index / 255.0 for index in range(20, 256)],
|
||||
minimum_span=0.85,
|
||||
total_frames=236,
|
||||
joint_frame_rate=1.0,
|
||||
feedback_hz=50.0,
|
||||
detection_rate=1.0,
|
||||
)
|
||||
assert result.passed
|
||||
assert result.metrics["gap_scope"] == "observed_feedback_span"
|
||||
assert result.metrics["maximum_bin_gap"] == 0
|
||||
|
||||
|
||||
def test_rotation_axis_uses_observed_endpoints_for_physical_feedback() -> None:
|
||||
commands = np.arange(25, 254, dtype=int)
|
||||
physical_angle = (253.0 - commands.astype(float)) / 228.0
|
||||
expected_axis = np.asarray([-0.25, 0.1, -0.96], dtype=float)
|
||||
expected_axis /= np.linalg.norm(expected_axis)
|
||||
vectors = physical_angle[:, None] * expected_axis[None, :]
|
||||
|
||||
axis = fit_rotation_axis(vectors, commands)
|
||||
projections = vectors @ axis
|
||||
|
||||
assert float(np.median(projections[commands <= 48])) > float(
|
||||
np.median(projections[commands >= 230])
|
||||
)
|
||||
|
||||
|
||||
def test_repeatable_hysteresis_is_diagnostic_when_holdout_passes() -> None:
|
||||
profile = next(
|
||||
item.profile for item in get_default_registry()
|
||||
if item.profile.key.layout == "o12_right_16"
|
||||
)
|
||||
curve = SimpleNamespace(
|
||||
maximum_hysteresis_rad=math.radians(4.2),
|
||||
maximum_monotonic_correction_rad=math.radians(0.2),
|
||||
)
|
||||
fit = SimpleNamespace(
|
||||
curves={"joint": curve},
|
||||
zero_offsets_rad={"joint": 0.0},
|
||||
travels_rad={"joint": 1.0},
|
||||
mimic_fits={},
|
||||
holdout_errors_rad={"joint": (0.0, math.radians(0.5))},
|
||||
)
|
||||
|
||||
result = CalibrationEngine(profile).result_from_fit(fit)
|
||||
|
||||
assert result.quality["passed"]
|
||||
assert result.quality["fit_diagnostics_by_joint"]["joint"][
|
||||
"maximum_hysteresis_rad"
|
||||
] == pytest.approx(math.radians(4.2))
|
||||
|
||||
|
||||
def test_directional_lookup_does_not_force_passive_curve_into_polynomial() -> None:
|
||||
source = tuple(float(value) for value in np.linspace(1.5, 0.0, 256))
|
||||
target = tuple(float(value) for value in 1.2 * np.asarray(source) ** 3)
|
||||
|
||||
def curve(values: tuple[float, ...]) -> JointCurveFit:
|
||||
return JointCurveFit(
|
||||
angle_rad=values,
|
||||
decreasing_rad=values,
|
||||
increasing_rad=tuple(value + 0.05 for value in values),
|
||||
circle={},
|
||||
maximum_monotonic_correction_rad=0.0,
|
||||
maximum_hysteresis_rad=0.05,
|
||||
quality={},
|
||||
)
|
||||
|
||||
fit = fit_coupling_model(
|
||||
"source", "target", curve(source), curve(target),
|
||||
model="direction_aware_knots",
|
||||
minimum_multiplier=0.1,
|
||||
maximum_multiplier=3.0,
|
||||
)
|
||||
|
||||
assert fit.model == "direction_aware_knots"
|
||||
assert fit.urdf_mimic_policy == "endpoint_linear_fallback"
|
||||
assert fit.urdf_mimic_multiplier == pytest.approx(target[0] / source[0])
|
||||
assert fit.residual_max_rad == 0.0
|
||||
|
||||
|
||||
def test_o12_internal_blind_spot_larger_than_one_sixteenth_is_rejected() -> None:
|
||||
profile = next(
|
||||
item.profile for item in get_default_registry()
|
||||
if item.profile.key.layout == "o12_right_16"
|
||||
)
|
||||
values = [
|
||||
index / 255.0 for index in range(256)
|
||||
if not 100 <= index <= 130
|
||||
]
|
||||
result = CalibrationEngine(profile).evaluate_sweep(
|
||||
values,
|
||||
minimum_span=0.85,
|
||||
total_frames=len(values),
|
||||
joint_frame_rate=1.0,
|
||||
feedback_hz=50.0,
|
||||
detection_rate=1.0,
|
||||
)
|
||||
assert not result.passed
|
||||
assert any(value.startswith("maximum_gap=") for value in result.failures)
|
||||
|
||||
|
||||
def test_old_checkpoint_policy_is_intentionally_incompatible() -> None:
|
||||
profile = next(
|
||||
item.profile for item in get_default_registry()
|
||||
if item.profile.key.layout == "g20_right_19"
|
||||
)
|
||||
engine = CalibrationEngine(profile)
|
||||
assert not engine.resume_compatible({"profile_id": profile.key.profile_id})
|
||||
assert engine.resume_compatible({
|
||||
"profile_id": profile.key.profile_id,
|
||||
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
||||
})
|
||||
|
||||
|
||||
def test_removed_soft_stop_gates_are_not_in_live_nodes() -> None:
|
||||
package = Path(__file__).resolve().parents[1] / "linkerhand_calibration/models"
|
||||
live = "\n".join(
|
||||
(package / relative).read_text(encoding="utf-8")
|
||||
for relative in ("l6/node.py", "o12/node.py")
|
||||
)
|
||||
for removed in (
|
||||
"non_target_motor_moved",
|
||||
"o12_auxiliary_settle_timeout",
|
||||
"o12_sdk_feedback_coupling_hard_limit",
|
||||
"o12_roll_coupling_exceeded",
|
||||
):
|
||||
assert removed not in live
|
||||
|
||||
|
||||
def test_virtual_new_model_needs_only_adapter_profile_and_raw_urdf(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
joint = "finger_joint"
|
||||
profile = CalibrationProfile(
|
||||
key=ProfileKey("VIRTUAL", "right", "one_joint"),
|
||||
namespace="/virtual/right",
|
||||
command=CommandLayout(
|
||||
names=("finger",),
|
||||
baseline_u8=(255,),
|
||||
command_index_by_joint={joint: 0},
|
||||
urdf_joint_by_joint={joint: joint},
|
||||
),
|
||||
vision=VisionRigSpec(
|
||||
views=(ViewSpec("front", (
|
||||
TagSpec("base", 0, True), TagSpec("finger", 1),
|
||||
)),),
|
||||
common_frame="front",
|
||||
extrinsic_reference_view="front",
|
||||
),
|
||||
motion=MotionPolicy(tasks=(
|
||||
TaskSpec(
|
||||
"finger_sweep", "front", 0, (joint,),
|
||||
start_u8=255, end_u8=0, formal_speed_u8=40,
|
||||
),
|
||||
)),
|
||||
measurement=MeasurementPolicy(measurements={
|
||||
joint: MeasurementSpec(
|
||||
joint, "rotation", "front", "base", "finger"
|
||||
),
|
||||
}),
|
||||
zero=ZeroSolvePolicy(
|
||||
active_joints=frozenset({joint}),
|
||||
passive_joints=frozenset(),
|
||||
direct_zero_joints=(joint,),
|
||||
axis_joints=(joint,),
|
||||
mechanical_endpoint_joints=frozenset(),
|
||||
post_solve_endpoint_joints=frozenset(),
|
||||
mimic_source_by_joint={},
|
||||
cad_frozen_joints=frozenset(),
|
||||
),
|
||||
quality=QualityPolicy(
|
||||
(0, 1, 2), 3, frozenset({"holdout"}), isolated_holdout=True
|
||||
),
|
||||
scope=ScopePolicy(
|
||||
calibrate_joints={"full": frozenset({joint})},
|
||||
frozen_joints={"full": frozenset()},
|
||||
),
|
||||
artifacts=ArtifactPolicy(
|
||||
1, "virtual.json", "virtual.urdf", frozenset(),
|
||||
publish_corrected_urdf=True,
|
||||
),
|
||||
)
|
||||
engine = CalibrationEngine(profile)
|
||||
adapter = ProfileSdkAdapter(profile.command)
|
||||
assert len(engine.scan_units()) == 8
|
||||
assert adapter.parse_feedback(("finger",), (127.0,)) == (127.0,)
|
||||
|
||||
source = tmp_path / "virtual_raw.urdf"
|
||||
source.write_text(
|
||||
'<robot name="v"><link name="base"/><link name="tip"/>'
|
||||
'<joint name="finger_joint" type="revolute">'
|
||||
'<parent link="base"/><child link="tip"/>'
|
||||
'<origin xyz="0 0 0" rpy="0 0 0"/><axis xyz="0 0 1"/>'
|
||||
'<limit lower="0" upper="1" effort="1" velocity="1"/>'
|
||||
'</joint></robot>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = write_urdf_patches(
|
||||
source_urdf=source,
|
||||
destination_urdf=tmp_path / "virtual.urdf",
|
||||
patches=UrdfPatchSet({
|
||||
joint: UrdfJointPatch(origin_rpy="0 0 0.01")
|
||||
}),
|
||||
)
|
||||
corrected = output.read_text(encoding="utf-8")
|
||||
assert 'rpy="0 0 0.01"' in corrected
|
||||
assert '<limit lower="0" upper="1" effort="1" velocity="1"/>' in corrected
|
||||
@@ -0,0 +1,59 @@
|
||||
import math
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from linkerhand_calibration.urdf_comparison import compare_urdfs, _Model, main
|
||||
|
||||
|
||||
def model(tmp_path, name, zero=0.):
|
||||
path = tmp_path / name
|
||||
path.write_text(f'''<robot name="test">
|
||||
<link name="base"/><link name="moving"/><link name="tip"/>
|
||||
<joint name="motor" type="revolute"><parent link="base"/><child link="moving"/>
|
||||
<origin xyz="0 0 0" rpy="0 0 {zero}"/><axis xyz="0 0 1"/>
|
||||
<limit lower="0" upper="1" effort="1" velocity="1"/></joint>
|
||||
<joint name="tip_fixed" type="fixed"><parent link="moving"/><child link="tip"/>
|
||||
<origin xyz="0.1 0 0"/></joint></robot>''')
|
||||
return path
|
||||
|
||||
|
||||
def test_identical_geometry_has_zero_pose_difference(tmp_path):
|
||||
a, b = model(tmp_path, "a.urdf"), model(tmp_path, "b.urdf")
|
||||
result = compare_urdfs(a, b)
|
||||
assert result["maximum_origin_distance_mm"] == pytest.approx(0.)
|
||||
assert result["maximum_orientation_difference_deg"] == pytest.approx(0.)
|
||||
assert not result["is_accuracy_certificate"]
|
||||
assert result["pose_count"] == 4
|
||||
assert result["scope"] == "same_urdf_joint_angles_not_sdk_feedback"
|
||||
|
||||
|
||||
def test_reports_actual_link_frame_shift_not_just_parameter_delta(tmp_path):
|
||||
a, b = model(tmp_path, "a.urdf"), model(tmp_path, "b.urdf", .1)
|
||||
result = compare_urdfs(a, b)
|
||||
assert result["maximum_origin_distance_mm"] == pytest.approx(200 * math.sin(.05))
|
||||
assert result["maximum_orientation_difference_deg"] == pytest.approx(math.degrees(.1))
|
||||
|
||||
|
||||
def test_mimic_source_on_other_branch_is_resolved_recursively(tmp_path):
|
||||
path = model(tmp_path, "a.urdf")
|
||||
root = ET.parse(path).getroot()
|
||||
ET.SubElement(root, "link", name="follower_link")
|
||||
j = ET.SubElement(root, "joint", name="follower", type="revolute")
|
||||
ET.SubElement(j, "parent", link="base")
|
||||
ET.SubElement(j, "child", link="follower_link")
|
||||
ET.SubElement(j, "axis", xyz="0 0 1")
|
||||
ET.SubElement(j, "mimic", joint="motor", multiplier="0.5", offset="0.1")
|
||||
ET.ElementTree(root).write(path)
|
||||
poses = _Model(path).fk({"motor": .4})
|
||||
assert poses["follower_link"][0, 0] == pytest.approx(math.cos(.3))
|
||||
assert np.isfinite(poses["follower_link"]).all()
|
||||
|
||||
|
||||
def test_does_not_overwrite_existing_files(tmp_path):
|
||||
a, b = model(tmp_path, "a.urdf"), model(tmp_path, "b.urdf", .1)
|
||||
before = a.read_bytes()
|
||||
with pytest.raises(SystemExit):
|
||||
main(["--reference", str(a), "--candidate", str(b), "--output", str(a)])
|
||||
assert a.read_bytes() == before
|
||||
@@ -10,6 +10,7 @@ from linkerhand_calibration.core.urdf import (
|
||||
UrdfJointPatch,
|
||||
UrdfPatchSet,
|
||||
apply_urdf_patch_text,
|
||||
validate_urdf_mimic_ranges,
|
||||
write_urdf_patches,
|
||||
)
|
||||
|
||||
@@ -162,3 +163,37 @@ def test_patch_engine_copies_referenced_and_complete_mesh_bundle(
|
||||
assert (
|
||||
destination.parent / "meshes/auxiliary.stl"
|
||||
).read_bytes() == b"auxiliary"
|
||||
|
||||
|
||||
def test_mimic_range_validation_resolves_full_chain(tmp_path: Path) -> None:
|
||||
text = """<robot name="chain">
|
||||
<joint name="a" type="revolute"><limit lower="0" upper="1"/></joint>
|
||||
<joint name="b" type="revolute"><limit lower="0" upper="2.1"/><mimic joint="a" multiplier="2" offset="0"/></joint>
|
||||
<joint name="c" type="revolute"><limit lower="0" upper="1.1"/><mimic joint="b" multiplier="0.5" offset="0"/></joint>
|
||||
</robot>"""
|
||||
valid = tmp_path / "valid.urdf"
|
||||
valid.write_text(text, encoding="utf-8")
|
||||
ranges = validate_urdf_mimic_ranges(valid)
|
||||
assert ranges["b"] == pytest.approx((0.0, 2.0))
|
||||
assert ranges["c"] == pytest.approx((0.0, 1.0))
|
||||
|
||||
invalid = tmp_path / "invalid.urdf"
|
||||
invalid.write_text(text.replace('upper="1.1"', 'upper="0.9"'), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="c=0.100000000rad"):
|
||||
validate_urdf_mimic_ranges(invalid)
|
||||
|
||||
|
||||
def test_mimic_range_validation_never_enlarges_cad_excess(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
reference = tmp_path / "reference.urdf"
|
||||
reference.write_text(SOURCE_TEXT, encoding="utf-8")
|
||||
validate_urdf_mimic_ranges(reference, reference_urdf=reference)
|
||||
|
||||
worsened = tmp_path / "worsened.urdf"
|
||||
worsened.write_text(
|
||||
SOURCE_TEXT.replace('multiplier="1"', 'multiplier="1.1"', 1),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match="passive"):
|
||||
validate_urdf_mimic_ranges(worsened, reference_urdf=reference)
|
||||
|
||||
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
+1234
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user