diff --git a/.gitignore b/.gitignore index f6bcdff..5d3adc9 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ Thumbs.db # src/linkerhand_retarget/resource/linkerforce_v2/profiles/. /profiles/ /calibration_output/ +/config/g20_three_camera_extrinsics.yaml *.wear_check.json *.checkpoint.json *.verification.json @@ -69,6 +70,7 @@ Thumbs.db # Device-specific robot descriptions derived from local calibration runs /src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf +/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_calibrated_*.urdf /src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_zero_calibrated_*.urdf # ROS bag / MCAP recordings and CAN captures diff --git a/src/g20_thumb_apriltag_calibration/README.md b/src/g20_thumb_apriltag_calibration/README.md index 9d49964..18ba27b 100644 --- a/src/g20_thumb_apriltag_calibration/README.md +++ b/src/g20_thumb_apriltag_calibration/README.md @@ -1,9 +1,10 @@ # G20 左手 AprilTag 标定 -## 三机位全手一键标定 +## 三机位三维关节轴零位标定(schema v4) -正式全手入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机, -但只有 `/g20_calibration` 一个节点拥有机械手命令发布权。默认机位绑定为: +正式入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机,只有 +`/g20_calibration` 一个节点拥有机械手命令发布权。相机不需要水平,Tag方向也不需要 +贴正;相机和Tag在一次标定中必须固定。默认绑定为: ```text front = DB2163742,Tag 0/1/2/3/10 @@ -31,7 +32,7 @@ ID 4 不贴在侧面相机看不到的掌心正面;ID 8 必须始终固定且 ID 9 必须在拇指横摆的完整行程中持续可见。 贴纸不能跨关节、贴在软胶上或在扫描过程中翘起。 -每台相机必须有独立的内参文件: +每台相机必须有独立内参文件: ```text ~/.ros/camera_info/hikrobot_DB2163742.yaml @@ -39,16 +40,56 @@ ID 9 必须在拇指横摆的完整行程中持续可见。 ~/.ros/camera_info/hikrobot_DB2163739.yaml ``` -先使用禁止运动模式检查三个机位、内参和标签: +### 1. 一次性三相机外参 + +三相机第一次安装、任何相机移动、镜头重新聚焦或内参变化后,必须重标外参。使用 +`8x5` 内角点、实测方格边长 `27 mm`、粘在硬质平板上的棋盘: + +```bash +mkdir -p /home/lxp/projects/linkerhand_retarget_ros2/config +ros2 launch g20_thumb_apriltag_calibration \ + three_camera_extrinsics.launch.py \ + output_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ + checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027 +``` + +启动后默认打开 `G20 Three-Camera Extrinsics` 交互窗口。可切换 +`FRONT + SIDE` 和 `FRONT + TOP`;窗口实时显示棋盘角点、单相机/组合 +RMS、时间差、联合拟合稳定性和候选/内点数量。单张只要棋盘完整、 +同步、RMS和姿态差异合格,`ADD CANDIDATE` 就会变绿;不再用单张 +PnP的最终外参偏差锁死采集。点击 `AUTO ON` 后,棋盘稳定1秒会自动 +采集,移到新姿态后再自动采下一组。 + +外参分两组采集。让棋盘静止且同时出现在正面/侧面画面中,每改变一次位置和倾角添加 +一个候选;随后以相同方法采集正面/上面。程序使用固定内参的 +`stereoCalibrate` 联合优化唯一旋转/平移,自动剔除粗大异常组;两组均得到 +至少15个内点且联合RMS、三折稳定性合格后 `SAVE` 才变绿。 + +```bash +ros2 service call /g20_camera_extrinsics/capture_front_side std_srvs/srv/Trigger {} +ros2 service call /g20_camera_extrinsics/capture_front_top std_srvs/srv/Trigger {} +ros2 service call /g20_camera_extrinsics/save std_srvs/srv/Trigger {} +``` + +采集时可分别查看 `/g20_extrinsics/{front,side,top}/camera/image_rect`。保存门限为: +联合重投影RMS不超过1.2 px、三折重拟外参最大旋转差不超过0.3°、最大平移差不超过 +1.5 mm。文件同时绑定三台相机序列号、分辨率和内参哈希;不满足任一项时不会保存 +通过结果,正式标定也不会运动。 + +### 2. 预检和正式标定 + +先使用禁止运动模式检查三个机位、外参、内参和标签: ```bash ros2 launch g20_thumb_apriltag_calibration \ three_camera_calibration.launch.py \ serial_number:=G20_LEFT_001 \ + camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ + source_urdf_path:=/home/lxp/projects/linkerhand_retarget_ros2/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf \ commands_enabled:=false ``` -分别查看三个相机画面: +分别查看正式流程的三个画面: ```bash ros2 run image_view image_view --ros-args \ @@ -59,39 +100,14 @@ ros2 run image_view image_view --ros-args \ --remap image:=/g20_calibration/top/camera/image_rect ``` -安装相机时可以按机位启动红/蓝线对准辅助节点。红线是画面理想水平线, -蓝线是在画面下部检测到的桌边、底座边或临时刚性直尺;两线夹角不超过 -`±0.5°` 且上下构图偏差不超过 `±12 px` 时显示 `ALIGNED`。Tag只画绿色 -识别框,其角点方向完全不参与红/蓝线角度计算,因此Tag无需为了相机对准而贴正。 - -下面以正面机位为例,先启动辅助节点: - -```bash -ros2 run g20_thumb_apriltag_calibration camera_alignment_view \ - --ros-args -p view:=front -``` - -再打开它发布的叠加画面: - -```bash -ros2 run image_view image_view --ros-args \ - --remap image:=/g20_camera_alignment_view/image -``` - -侧面和上面分别把 `view:=front` 改为 `view:=side`、`view:=top`。建议一次只开 -一个机位完成调整;侧面或上面没有合适长边时,临时放置与目标机械轴平行的刚性 -直尺。调整完成后退出辅助节点和 -`image_view`,再进行正式标定,以免额外的200万像素图像订阅影响采集帧率。 -这组红/蓝线只检查图像平面滚转角,不检查相机距离、俯仰、偏航,也不会阻止 -`/g20_calibration/start`。 - -确认所有目标关节的 `0~255` 行程安全、MVS 客户端已关闭且没有其他命令发布者后, -重新启动正式流程: +确认全行程安全、MVS客户端已关闭且没有其他命令发布者后,重启正式流程: ```bash ros2 launch g20_thumb_apriltag_calibration \ three_camera_calibration.launch.py \ serial_number:=G20_LEFT_001 \ + camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ + source_urdf_path:=/home/lxp/projects/linkerhand_retarget_ros2/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf \ can_interface:=can0 ``` @@ -110,28 +126,51 @@ ros2 service call /g20_calibration/start std_srvs/srv/Trigger {} 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] ``` -程序依次完成正面电机 `0/5/15/6`、侧面电机 `1/16`、上面电机 `10` 的三轮 -往返扫描。中指、无名指、小指复制食指模板。四指侧摆先以命令255 -为原始0角测出总行程,再减去总行程的一半;最终满足命令0为正、命令255为负, -零位命令是实测曲线上最接近角度中点的整数命令。 +程序依次扫描正面电机 `0/5/15/6`、侧面 `1/16`、上面 `10`,每项三轮 +`255→0→255`。轨迹角由父/子Tag完整相对四元数的旋转向量投影到三维拟合轴得到。 +对正面/侧面近图像平面的浅圆弧,使用姿态旋转轴约束Tag中心三维圆,只由圆轨迹 +拟合轴线位置和半径,避免单目平面Tag深度噪声改变轴方向;对运动平面充分可观测的 +斜视轨迹,仍保留姿态轴和独立三维圆轴的交叉检查。每条主动曲线在其baseline命令 +严格归零:普通通道255,四指侧摆127。中指、无名指、小指继续继承食指曲线;四指 +`mcp_roll/mcp_pitch/pip` 的静态URDF偏移统一固定为0,不再把单根食指的安装残差复制 +到四根手指。`thumb_mcp` 的静态URDF偏移同样固定为0,避免IP轴线深度偏差在命令255 +处产生额外向外伸展;其256项实测轨迹保持不变。 + +零位模式锁死原始CAD几何。URDF中确定平行的 `thumb_cmc_pitch→thumb_mcp→thumb_ip` +和 `index_mcp_pitch→index_pip→index_dip` 使用同轮上游实测方向,同时由各自Tag中心 +轨迹独立拟合轴线位置;被动角度曲线仍由对应父/子Tag完整相对姿态生成。这样保留 +下游轴线对上游零位的约束,同时避免小平面Tag的稳定PnP方向偏差被误认为URDF轴误差。 + +前两轮拟合,第三轮强制留出验证;第三轮轨迹MAE必须≤1°、P95≤2°,三轮真实 +姿态轴方向极差≤0.75°、轴向/径向RMS≤3 mm。零位留出验收只统计能观测对应上游 +零位的下游轴分量:方向MAE≤1°、P95≤2°,轴线RMS≤3 mm;根轴自身及与当前零位 +无关的固定CAD/PnP残差不会再把零位推向±20°。独立三维运动平面充分可观测的关节 +额外要求姿态轴与圆轴夹角≤1°。任一指标失败会暂停并指出失败关节;调用 `resume` +只重扫对应电机的6个方向。 + +单轮姿态相对理想固定轴的轴外RMS与跨轮重复性分别判定:主动关节上限2.5°,被动 +耦合关节上限7.5°。较宽的被动模型门限只容纳可重复的机构耦合和双Tag PnP系统误差, +不会替代三轮轴方向≤0.75°和第三轮MAE/P95留出验证。 + +零位求解联合优化公共手掌六维位姿和3个拇指CMC直接主动关节的 +`urdf_zero_offset_rad`。依赖链为 yaw轴约束拇指roll、pitch轴约束拇指yaw、MCP轴线 +约束拇指pitch;`thumb_mcp` 固定为0,不再使用IP轴线修改它的静态零位。食指扫描仍 +用于生成四指轨迹,但不再用于修改四指静态URDF零位。 +原始URDF的 `origin.xyz`、`axis.xyz`、连杆长度、mesh和被动结构固定。yaw扫描时电机5 +保持145,求解器使用实测 `angle_rad[145]` 还原该条件,不会把145误当成baseline。 +偏移超过±20°时整次失败。 + +生成修正URDF时,四指主动 `mcp_roll/mcp_pitch/pip` 的关节限位会检查对应256项实测 +轨迹;仅当实测端点越过原始CAD限位时才向外扩展,并额外保留0.5°数值余量。不会 +收窄原限位,也不会修改拇指或四指被动DIP的限位。 -每个直接测量任务完成 `3轮×2方向=6个扫描方向` 后,程序立即试拟合 -该电机对应的所有主动/被动关节。`thumb_cmc_pitch`、`thumb_cmc_roll`、 -`thumb_mcp`、`thumb_ip`、`index_mcp_roll`、`index_mcp_pitch` 和 `index_pip` 使用图像平面的 -连杆相对中心圆相位,避免小尺寸平面 Tag 的PnP深度双解把稳定的二维圆轨迹扭曲成 -错误三维轨迹。上面斜视的 `thumb_cmc_yaw` 和侧面的被动 `index_dip` 继续使用 -父Tag坐标系下的三维相对圆。`thumb_ip` 理论上也适合相对三维,但当前正面小Tag的 -PnP深度在三轮间不稳定,实测会让三维行程漂移,因此继续采用可重复的二维投影轨迹。 -程序分别检查二维圆残差/半径或三维平面RMS/圆残差/半径,并统一检查最小圆弧、 -单调修正量、正反程回差、三轮行程一致性和直接零位拟合。主动关节三轮行程最大差 -默认不超过3°;被动耦合关节允许不超过10°,但仍必须通过其余质量门限。 任一指标失败时会立即暂停,中文状态显示关节名、实测值和阈值,不再等到42个方向 全部结束。修正现场问题后调用 `resume`, 程序只清除该电机任务的内存样本并重扫它的6个方向;前面已通过的关节保留。 失败样本不从 `raw_samples.jsonl` 删除,而是使用 `attempt` 和 `retry` 记录区分, 便于调试;最终拟合只使用当前通过尝试的内存数据。 -标定食指 `index_mcp_roll`(电机6)及其随机复测时,为避免中指遮挡ID 10, +标定食指 `index_mcp_roll`(电机6)时,为避免中指遮挡ID 10, 程序将中指、无名指和小指的侧摆电机7/8/9固定为0;开始采样前会同时确认 电机6到达扫描起点且电机7/8/9均已到达0。离开该标定项后恢复统一基准姿态。 该项目还会通过SDK设置接口把五指速度临时设为 `[15,5,15,15,15]`,即只把 @@ -140,31 +179,34 @@ PnP深度在三轮间不稳定,实测会让三维行程漂移,因此继续 五指速度设为 `[15,10,15,15,15]`,即食指屈伸使用第三档速度10;其余直接 测量关节保持普通速度15。 -标定 `thumb_cmc_yaw`(电机10)及其随机复测时,程序将 +标定 `thumb_cmc_yaw`(电机10)时,程序将 `thumb_cmc_roll`(电机5)固定为145,并在它到位后才开始采样,以保持运动Tag ID 9的可见性和PnP稳定性。离开该标定项后,电机5恢复基准值255; 最终JSON的 `baseline_command_u8` 不变。 -三机位流程默认设置 `validation_enabled:=false`,因此拟合完成后会直接 -恢复基准姿态并生成JSON,不再进入 `VALIDATION_MOVE/VALIDATION_CAPTURE`。 -此时 `quality.passed` 只由轨迹与零位拟合质量决定,`validation_mae_rad` 和 -`validation_p95_rad` 为 `null`。需要恢复随机复测时,启动参数加 -`validation_enabled:=true`。 +三机位流程默认 `validation_enabled:=false`,即不增加随机机械动作,但第三轮留出验证 +始终启用且不能关闭;最终 `quality.validation_mae_rad/p95_rad` 正是第三轮轨迹误差。 上面机位在Tag二维质量合格但PnP连续无效达到1秒时,会自动重置该机位的单Tag 和双Tag连续性跟踪器,并从下一帧重新建链,早于3秒采集超时。暂停恢复时只要求 当前活动机位就绪;首次调用 `start` 仍要求三个机位全部通过预检。 -对外只生成一个精简运行时结果: +### 3. 输出 + +通过后生成精简JSON和一个新URDF: ```text calibration_output/G20_LEFT_001/<时间戳>/ g20_left_G20_LEFT_001_calibration.json +src/.../g20_left/ + linkerhand_g20_left_zero_calibrated_G20_LEFT_001_<时间戳>.urdf ``` 文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`、 -5个被动标记、模板来源和总体质量。相机、Tag、正反程及每轮质量只进入状态、日志和 -`raw_samples.jsonl`,不写入最终运行时 JSON。 +`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从 +指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或 +覆盖原文件;除16个主动关节的 `origin.rpy` 外,原URDF文本保持不变。每帧Tag SE(3)、 +图像时间戳、20通道状态、同步误差和PnP误差只进入 `raw_samples.jsonl`。 下面保留原有正面拇指独立标定说明和兼容入口。 @@ -205,7 +247,8 @@ PnP 双分支跟踪和整段刚体复核仍保留,用于选出稳定的三维 `T5` 固定在最末节。四张 Tag 必须与所在刚性件完全固定,不能跨关节或贴在软胶上。 - 当前实物使用 `tag36h11` 的 ID `0/1/2/3`,依次对应 T0/T3/T4/T5。如果实物 ID 改变,同时修改 `config/front_tags.yaml` 里检测节点和标定节点的两组数组。 -- `tag.sizes`/`tag_sizes_m` 必须填写每张 Tag 的实测有效边长(米),当前配置为 `0.010`。 +- `tag.sizes`/`tag_sizes_m` 必须填写每张 Tag 的实测有效边长(米),当前实物黑色正方形实测为 + `16 mm`,因此配置为 `0.016`。 测量检测角点所围成的正方形边长,不包含外围白色留边。 - 当前试标定允许四张 Tag 的有效边长至少 30 px(实测静态约 32~38 px),最终仍由 静止角度 RMS 和随机复测误差决定是否合格。四张 Tag 必须在全行程内均可见。需要短时检查标记时, diff --git a/src/g20_thumb_apriltag_calibration/config/front_tags.yaml b/src/g20_thumb_apriltag_calibration/config/front_tags.yaml index a469078..7a33f4d 100644 --- a/src/g20_thumb_apriltag_calibration/config/front_tags.yaml +++ b/src/g20_thumb_apriltag_calibration/config/front_tags.yaml @@ -6,7 +6,7 @@ # from back-pressuring image_proc's reliable image publisher. qos_profile: sensor_data family: 36h11 - size: 0.01 + size: 0.016 profile: false max_hamming: 0 detector: @@ -20,11 +20,11 @@ tag: ids: [0, 1, 2, 3] frames: [tag_t0, tag_t3, tag_t4, tag_t5] - sizes: [0.010, 0.010, 0.010, 0.010] + sizes: [0.016, 0.016, 0.016, 0.016] g20_thumb_calibration: ros__parameters: tag_roles: [t0, t3, t4, t5] tag_ids: [0, 1, 2, 3] tag_frames: [tag_t0, tag_t3, tag_t4, tag_t5] - tag_sizes_m: [0.010, 0.010, 0.010, 0.010] + tag_sizes_m: [0.016, 0.016, 0.016, 0.016] diff --git a/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml b/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml index ea727e4..339d411 100644 --- a/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml +++ b/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml @@ -18,7 +18,8 @@ g20_calibration: index_flex_calibration_speed: 10 speed_setting_settle_seconds: 0.25 - tag_size_m: 0.010 + # tag36h11检测角点围成的黑色正方形实测为16 mm;不包含外围白边。 + tag_size_m: 0.016 repetitions: 3 preflight_frames: 60 minimum_detection_rate: 0.95 @@ -34,9 +35,27 @@ g20_calibration: pnp_maximum_tag_tilt_deg: 75.0 pnp_tracker_reset_seconds: 5.0 top_pnp_invalid_reset_seconds: 1.0 - maximum_state_image_skew_ms: 150.0 + # 三维位姿必须与实测20通道状态严格按时间戳配对。 + maximum_state_image_skew_ms: 50.0 + + axis_maximum_plane_rms_m: 0.003 + # 被动耦合轴只用轨迹确定轴线位置,允许更大的轴向深度噪声;径向和跨轮 + # 轴线一致性仍沿用严格检查。 + passive_axis_maximum_plane_rms_m: 0.004 + axis_maximum_radial_rms_m: 0.003 + # 仅用于运动平面在三维中可观测的斜视关节;近图像平面关节使用姿态轴 + # 约束三维圆,不让单目平面Tag的深度噪声自由决定转轴方向。 + axis_maximum_rotation_circle_difference_deg: 1.0 + # 单轴模型残差与跨轮重复误差分开判定:主动刚性关节要求更严;被动耦合 + # 关节允许可重复的非理想单轴分量,但仍须通过0.75°跨轮轴差及第三轮留出。 + active_maximum_rotation_orthogonal_rms_deg: 2.5 + passive_maximum_rotation_orthogonal_rms_deg: 7.5 + zero_maximum_axis_cycle_difference_deg: 0.75 + zero_maximum_offset_deg: 20.0 endpoint_tolerance_u8: 2.0 + # 电机10在命令0时实测会稳定反馈为3;仅其0端使用±3,255端及其他电机仍为±2。 + thumb_yaw_zero_endpoint_tolerance_u8: 3.0 endpoint_hold_seconds: 0.5 baseline_hold_seconds: 0.5 position_timeout_seconds: 30.0 @@ -51,26 +70,22 @@ g20_calibration: trajectory_maximum_radial_rms_m: 0.004 trajectory_minimum_radius_m: 0.003 trajectory_minimum_arc_deg: 15.0 - # 正面拇指pitch/MCP/IP使用二维圆相位,避免平面Tag的PnP深度歧义。 + # 以下二维参数只供旧轨迹工具兼容,三机位v4零位不使用二维投影。 image_trajectory_maximum_radial_rms_px: 2.0 image_trajectory_maximum_radial_p95_px: 3.5 image_trajectory_minimum_radius_px: 20.0 trajectory_maximum_cycle_travel_difference_deg: 3.0 passive_maximum_cycle_travel_difference_deg: 10.0 - zero_minimum_radius_px: 20.0 - zero_maximum_radial_rms_px: 2.0 - zero_maximum_radial_p95_px: 3.5 - zero_maximum_round_difference_deg: 1.0 maximum_monotonic_correction_deg: 2.0 maximum_hysteresis_deg: 5.0 passive_maximum_monotonic_correction_deg: 3.0 passive_maximum_hysteresis_deg: 7.5 - # 默认跳过耗时的随机复测;需要验收精度时可在launch中设为true。 + # 默认无额外随机动作;第三轮扫描始终作为不可关闭的留出验证。 validation_enabled: false validation_command_count: 3 validation_frames: 10 validation_seed: 20260804 validation_timeout_seconds: 20.0 - maximum_validation_mae_deg: 2.0 - maximum_validation_p95_deg: 3.0 + maximum_validation_mae_deg: 1.0 + maximum_validation_p95_deg: 2.0 diff --git a/src/g20_thumb_apriltag_calibration/config/three_camera_tags.yaml b/src/g20_thumb_apriltag_calibration/config/three_camera_tags.yaml index 5f24344..b7afb31 100644 --- a/src/g20_thumb_apriltag_calibration/config/three_camera_tags.yaml +++ b/src/g20_thumb_apriltag_calibration/config/three_camera_tags.yaml @@ -3,7 +3,7 @@ image_transport: raw qos_profile: sensor_data family: 36h11 - size: 0.010 + size: 0.016 profile: false max_hamming: 0 detector: @@ -17,14 +17,14 @@ tag: ids: [0, 1, 2, 3, 10] frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll] - sizes: [0.010, 0.010, 0.010, 0.010, 0.010] + sizes: [0.016, 0.016, 0.016, 0.016, 0.016] /g20_calibration/side/apriltag/apriltag: ros__parameters: image_transport: raw qos_profile: sensor_data family: 36h11 - size: 0.010 + size: 0.016 profile: false max_hamming: 0 detector: @@ -38,14 +38,14 @@ tag: ids: [4, 5, 6, 7] frames: [side_base, index_mcp, index_pip, index_dip] - sizes: [0.010, 0.010, 0.010, 0.010] + sizes: [0.016, 0.016, 0.016, 0.016] /g20_calibration/top/apriltag/apriltag: ros__parameters: image_transport: raw qos_profile: sensor_data family: 36h11 - size: 0.010 + size: 0.016 profile: false max_hamming: 0 detector: @@ -59,4 +59,4 @@ tag: ids: [8, 9] frames: [top_base, thumb_yaw] - sizes: [0.010, 0.010] + sizes: [0.016, 0.016] diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/extrinsics.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/extrinsics.py new file mode 100644 index 0000000..3cea37b --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/extrinsics.py @@ -0,0 +1,227 @@ +"""Camera-extrinsic data model shared by calibration and runtime nodes.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +import yaml +from scipy.spatial.transform import Rotation + + +VIEWS: tuple[str, ...] = ("front", "side", "top") + + +def camera_info_fingerprint( + *, + width: int, + height: int, + camera_matrix: Sequence[Sequence[float]] | Sequence[float], + distortion: Sequence[float] = (), + rectification: Sequence[float] = (), + projection: Sequence[float] = (), +) -> str: + """Return a stable fingerprint for rectified image geometry.""" + matrix = np.asarray(camera_matrix, dtype=float).reshape(3, 3) + payload = { + "width": int(width), + "height": int(height), + "camera_matrix": [round(float(value), 12) for value in matrix.flat], + "distortion": [round(float(value), 12) for value in distortion], + "rectification": [round(float(value), 12) for value in rectification], + "projection": [round(float(value), 12) for value in projection], + } + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def transform_matrix( + translation_xyz_m: Sequence[float], + quaternion_xyzw: Sequence[float], +) -> np.ndarray: + translation = np.asarray(translation_xyz_m, dtype=float) + quaternion = np.asarray(quaternion_xyzw, dtype=float) + if translation.shape != (3,) or not np.all(np.isfinite(translation)): + raise ValueError("translation_xyz_m must contain three finite values") + if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)): + raise ValueError("quaternion_xyzw must contain four finite values") + norm = float(np.linalg.norm(quaternion)) + if norm < 1.0e-12: + raise ValueError("quaternion_xyzw has zero norm") + result = np.eye(4, dtype=float) + result[:3, :3] = Rotation.from_quat(quaternion / norm).as_matrix() + result[:3, 3] = translation + return result + + +def matrix_payload(matrix: Sequence[Sequence[float]]) -> dict[str, list[float]]: + value = np.asarray(matrix, dtype=float) + if value.shape != (4, 4) or not np.all(np.isfinite(value)): + raise ValueError("transform must be a finite 4x4 matrix") + return { + "translation_xyz_m": [float(item) for item in value[:3, 3]], + "quaternion_xyzw": [ + float(item) for item in Rotation.from_matrix(value[:3, :3]).as_quat() + ], + } + + +@dataclass(frozen=True) +class CameraCalibrationIdentity: + serial_number: str + width: int + height: int + intrinsics_sha256: str + + +@dataclass(frozen=True) +class ThreeCameraExtrinsics: + """Transforms points from each camera optical frame into front optical.""" + + cameras: Mapping[str, CameraCalibrationIdentity] + front_from_view: Mapping[str, np.ndarray] + quality: Mapping[str, float] + + def transform(self, view: str) -> np.ndarray: + if view not in self.front_from_view: + raise KeyError(f"extrinsics do not contain view {view}") + return np.asarray(self.front_from_view[view], dtype=float).copy() + + def camera_matches( + self, + view: str, + *, + serial_number: str, + width: int, + height: int, + intrinsics_sha256: str, + ) -> bool: + expected = self.cameras.get(view) + return bool( + expected is not None + and expected.serial_number == str(serial_number) + and expected.width == int(width) + and expected.height == int(height) + and expected.intrinsics_sha256 == str(intrinsics_sha256) + ) + + +def validate_extrinsics_payload(payload: Mapping[str, Any]) -> None: + if int(payload.get("schema_version", -1)) != 1: + raise ValueError("camera extrinsics schema_version must be 1") + if payload.get("reference_view") != "front": + raise ValueError("camera extrinsics reference_view must be front") + cameras = payload.get("cameras") + transforms = payload.get("front_from_view") + quality = payload.get("quality") + if not isinstance(cameras, Mapping) or set(cameras) != set(VIEWS): + raise ValueError("camera extrinsics must contain front/side/top cameras") + if not isinstance(transforms, Mapping) or set(transforms) != set(VIEWS): + raise ValueError("camera extrinsics must contain all three transforms") + if not isinstance(quality, Mapping) or not bool(quality.get("passed")): + raise ValueError("camera extrinsics quality is not passed") + quality_limits = { + "reprojection_rms_px": 1.2, + "maximum_rotation_repeatability_deg": 0.3, + "maximum_translation_repeatability_m": 0.0015, + } + for key, limit in quality_limits.items(): + value = float(quality.get(key, float("inf"))) + if not np.isfinite(value) or value > limit: + raise ValueError( + f"camera extrinsics {key}={value} exceeds {limit}" + ) + for key in ("front_side_captures", "front_top_captures"): + if int(quality.get(key, 0)) < 15: + raise ValueError(f"camera extrinsics {key} must be at least 15") + for view in VIEWS: + identity = cameras[view] + if not isinstance(identity, Mapping): + raise ValueError(f"{view} camera identity must be an object") + if not str(identity.get("serial_number", "")): + raise ValueError(f"{view} camera serial_number is missing") + if int(identity.get("width", 0)) <= 0 or int(identity.get("height", 0)) <= 0: + raise ValueError(f"{view} camera image dimensions are invalid") + fingerprint = str(identity.get("intrinsics_sha256", "")) + if len(fingerprint) != 64: + raise ValueError(f"{view} camera intrinsics fingerprint is invalid") + transform = transforms[view] + if not isinstance(transform, Mapping): + raise ValueError(f"{view} transform must be an object") + matrix = transform_matrix( + transform.get("translation_xyz_m", ()), + transform.get("quaternion_xyzw", ()), + ) + if view == "front" and not np.allclose(matrix, np.eye(4), atol=1.0e-9): + raise ValueError("front_from_view.front must be identity") + serials = [str(cameras[view]["serial_number"]) for view in VIEWS] + if len(set(serials)) != len(VIEWS): + raise ValueError("camera extrinsics serial numbers must be unique") + + +def load_three_camera_extrinsics(path: str | Path) -> ThreeCameraExtrinsics: + source = Path(path).expanduser().resolve() + if not source.is_file(): + raise ValueError(f"camera extrinsics file does not exist: {source}") + with source.open("r", encoding="utf-8") as stream: + payload = yaml.safe_load(stream) + if not isinstance(payload, Mapping): + raise ValueError("camera extrinsics file must contain an object") + validate_extrinsics_payload(payload) + cameras = { + view: CameraCalibrationIdentity( + serial_number=str(payload["cameras"][view]["serial_number"]), + width=int(payload["cameras"][view]["width"]), + height=int(payload["cameras"][view]["height"]), + intrinsics_sha256=str( + payload["cameras"][view]["intrinsics_sha256"] + ), + ) + for view in VIEWS + } + transforms = { + view: transform_matrix( + payload["front_from_view"][view]["translation_xyz_m"], + payload["front_from_view"][view]["quaternion_xyzw"], + ) + for view in VIEWS + } + return ThreeCameraExtrinsics( + cameras=cameras, + front_from_view=transforms, + quality={ + str(key): float(value) if isinstance(value, (int, float)) else value + for key, value in payload["quality"].items() + }, + ) + + +def dump_three_camera_extrinsics( + path: str | Path, + *, + cameras: Mapping[str, Mapping[str, Any]], + front_from_view: Mapping[str, Sequence[Sequence[float]]], + quality: Mapping[str, Any], +) -> None: + payload = { + "schema_version": 1, + "reference_view": "front", + "cameras": {view: dict(cameras[view]) for view in VIEWS}, + "front_from_view": { + view: matrix_payload(front_from_view[view]) for view in VIEWS + }, + "quality": dict(quality), + } + validate_extrinsics_payload(payload) + destination = Path(path).expanduser().resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix(destination.suffix + ".tmp") + with temporary.open("w", encoding="utf-8") as stream: + yaml.safe_dump(payload, stream, allow_unicode=True, sort_keys=False) + temporary.replace(destination) diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/extrinsics_node.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/extrinsics_node.py new file mode 100644 index 0000000..1fb899d --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/extrinsics_node.py @@ -0,0 +1,1411 @@ +"""Interactive one-time three-camera checkerboard extrinsic calibration.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +import math +import os +from pathlib import Path +import time +from typing import Any + +import cv2 +from cv_bridge import CvBridge +import numpy as np +import rclpy +from rclpy.node import Node +from rclpy.qos import qos_profile_sensor_data +from scipy.spatial.transform import Rotation +from sensor_msgs.msg import CameraInfo, Image +from std_srvs.srv import Trigger + +from .core import robust_rotation_summary +from .extrinsics import ( + VIEWS, + camera_info_fingerprint, + dump_three_camera_extrinsics, +) +from .hikrobot_camera import configure_fastdds_large_image_transport + + +@dataclass(frozen=True) +class BoardPose: + stamp_ns: int + camera_from_board_candidates: tuple[np.ndarray, ...] + reprojection_rms_px: float + image_points: np.ndarray | None = None + + +@dataclass(frozen=True) +class ImageFrame: + stamp_ns: int + message: Image + + +@dataclass(frozen=True) +class PairAssessment: + other: str + ready: bool + reason_code: str + message: str + front_frame: ImageFrame | None = None + selected_frame: ImageFrame | None = None + front_pose: BoardPose | None = None + selected_pose: BoardPose | None = None + skew_ns: int | None = None + pair_reprojection_rms_px: float | None = None + transform_candidates: tuple[np.ndarray, ...] = () + provisional_rotation_error_rad: float | None = None + provisional_translation_error_m: float | None = None + + +@dataclass(frozen=True) +class StereoCapture: + front_points_px: np.ndarray + other_points_px: np.ndarray + transform_candidates: tuple[np.ndarray, ...] + pair_reprojection_rms_px: float + skew_ns: int + + +@dataclass(frozen=True) +class StereoFitResult: + front_from_other: np.ndarray + stereo_rms_px: float + per_view_rms_px: tuple[float, ...] + inlier_indices: tuple[int, ...] + rejected_indices: tuple[int, ...] + rotation_stability_rad: float + translation_stability_m: float + passed: bool + + +def _select_latest_synchronised_pair( + front_history: list[Any] | deque[Any], + other_history: list[Any] | deque[Any], + maximum_skew_ns: int, +) -> tuple[Any, Any, int] | None: + """Select the newest available pair whose image stamps are close enough. + + Keep image callbacks cheap and pair their timestamps before running the + expensive checkerboard detector. This prevents image-processing latency + from turning into a false camera synchronization failure. + """ + candidates: list[tuple[int, int, Any, Any]] = [] + for front in front_history: + for other in other_history: + skew = abs(int(front.stamp_ns) - int(other.stamp_ns)) + if skew <= int(maximum_skew_ns): + common_stamp = min(int(front.stamp_ns), int(other.stamp_ns)) + candidates.append((common_stamp, -skew, front, other)) + if not candidates: + return None + _, negative_skew, front, other = max( + candidates, key=lambda item: (item[0], item[1]) + ) + return front, other, -negative_skew + + +def _minimum_history_skew_ns( + front_history: list[Any] | deque[Any], + other_history: list[Any] | deque[Any], +) -> int | None: + if not front_history or not other_history: + return None + return min( + abs(int(front.stamp_ns) - int(other.stamp_ns)) + for front in front_history + for other in other_history + ) + + +def _stamp_ns(message: Image) -> int: + return ( + int(message.header.stamp.sec) * 1_000_000_000 + + int(message.header.stamp.nanosec) + ) + + +def _inverse(transform: np.ndarray) -> np.ndarray: + result = np.eye(4) + result[:3, :3] = transform[:3, :3].T + result[:3, 3] = -result[:3, :3] @ transform[:3, 3] + return result + + +def _mean_transform(transforms: list[np.ndarray]) -> np.ndarray: + quaternion = robust_rotation_summary( + [Rotation.from_matrix(value[:3, :3]).as_quat() for value in transforms] + )[0] + result = np.eye(4) + result[:3, :3] = Rotation.from_quat(quaternion).as_matrix() + result[:3, 3] = np.median( + np.asarray([value[:3, 3] for value in transforms]), axis=0 + ) + return result + + +def _transform_residual( + reference: np.ndarray, observed: np.ndarray +) -> tuple[float, float]: + rotation = Rotation.from_matrix(reference[:3, :3]).inv() * Rotation.from_matrix( + observed[:3, :3] + ) + translation = observed[:3, 3] - reference[:3, 3] + return float(rotation.magnitude()), float(np.linalg.norm(translation)) + + +def _select_consistent_transforms( + candidates_by_capture: list[list[np.ndarray]], +) -> tuple[np.ndarray, list[np.ndarray]]: + """Resolve the checkerboard's 180-degree corner-order ambiguity.""" + if not candidates_by_capture or not candidates_by_capture[0]: + raise ValueError("no checkerboard transform candidates") + best_score = float("inf") + best_reference = np.eye(4) + best_selected: list[np.ndarray] = [] + for seed in candidates_by_capture[0]: + reference = seed + selected: list[np.ndarray] = [] + for _ in range(5): + selected = [] + for candidates in candidates_by_capture: + selected.append( + min( + candidates, + key=lambda candidate: ( + _transform_residual(reference, candidate)[0] + / math.radians(0.3) + + _transform_residual(reference, candidate)[1] + / 0.0015 + ), + ) + ) + updated = _mean_transform(selected) + if _transform_residual(reference, updated)[0] < 1.0e-10: + reference = updated + break + reference = updated + score = sum( + (rotation / math.radians(0.3)) ** 2 + + (translation / 0.0015) ** 2 + for rotation, translation in ( + _transform_residual(reference, observed) + for observed in selected + ) + ) + if score < best_score: + best_score = score + best_reference = reference + best_selected = selected + return best_reference, best_selected + + +def _summarize_transform_repeatability( + candidates_by_capture: list[list[np.ndarray]], +) -> tuple[np.ndarray, list[np.ndarray], float, float]: + """Fit one transform and return worst rotation/translation residuals.""" + reference, selected = _select_consistent_transforms(candidates_by_capture) + residuals = [ + _transform_residual(reference, observed) for observed in selected + ] + return ( + reference, + selected, + max(rotation for rotation, _ in residuals), + max(translation for _, translation in residuals), + ) + + +def _stereo_fit_once( + captures: list[StereoCapture], + indices: list[int], + object_points: np.ndarray, + front_matrix: np.ndarray, + other_matrix: np.ndarray, + image_size: tuple[int, int], +) -> tuple[float, np.ndarray, np.ndarray]: + if len(indices) < 3: + raise ValueError("stereo calibration requires at least three views") + objects = [ + np.asarray(object_points, dtype=np.float32).copy() for _ in indices + ] + candidate_sets = [ + list(captures[index].transform_candidates) for index in indices + ] + _, selected_transforms = _select_consistent_transforms(candidate_sets) + front_points: list[np.ndarray] = [] + other_points: list[np.ndarray] = [] + for capture_index, selected_transform in zip( + indices, selected_transforms + ): + capture = captures[capture_index] + candidate_index = min( + range(len(capture.transform_candidates)), + key=lambda index: sum( + _transform_residual( + selected_transform, + capture.transform_candidates[index], + ) + ), + ) + candidates_per_camera = int( + round(math.sqrt(len(capture.transform_candidates))) + ) + if candidates_per_camera * candidates_per_camera != len( + capture.transform_candidates + ): + raise ValueError("checkerboard pose candidate layout is invalid") + front_reversed = candidate_index // candidates_per_camera != 0 + other_reversed = candidate_index % candidates_per_camera != 0 + front = np.asarray(capture.front_points_px, dtype=np.float32) + other = np.asarray(capture.other_points_px, dtype=np.float32) + front_points.append(front[::-1].copy() if front_reversed else front) + other_points.append(other[::-1].copy() if other_reversed else other) + result = cv2.stereoCalibrateExtended( + objects, + front_points, + other_points, + np.asarray(front_matrix, dtype=float).copy(), + np.zeros(5), + np.asarray(other_matrix, dtype=float).copy(), + np.zeros(5), + tuple(int(value) for value in image_size), + np.eye(3), + np.zeros((3, 1)), + flags=cv2.CALIB_FIX_INTRINSIC, + criteria=( + cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, + 100, + 1.0e-7, + ), + ) + rms = float(result[0]) + other_from_front = np.eye(4) + other_from_front[:3, :3] = np.asarray(result[5], dtype=float) + other_from_front[:3, 3] = np.asarray(result[6], dtype=float).reshape(3) + per_camera_errors = np.asarray(result[9], dtype=float).reshape( + len(indices), -1 + ) + per_view_errors = np.sqrt( + np.mean(np.square(per_camera_errors), axis=1) + ) + return rms, _inverse(other_from_front), per_view_errors + + +def _fit_stereo_robust( + captures: list[StereoCapture], + object_points: np.ndarray, + front_matrix: np.ndarray, + other_matrix: np.ndarray, + image_size: tuple[int, int], + *, + minimum_inliers: int, + maximum_rms_px: float, + maximum_rotation_stability_rad: float, + maximum_translation_stability_m: float, +) -> StereoFitResult: + """Jointly fit one stereo transform and remove gross pose outliers.""" + if len(captures) < 3: + raise ValueError("at least three stereo captures are required") + active = list(range(len(captures))) + fit_rms = float("inf") + transform = np.eye(4) + per_view = np.full(len(active), float("inf")) + while len(active) >= 3: + fit_rms, transform, per_view = _stereo_fit_once( + captures, + active, + object_points, + front_matrix, + other_matrix, + image_size, + ) + median = float(np.median(per_view)) + mad = float(np.median(np.abs(per_view - median))) + reprojection_limit = max( + maximum_rms_px * 1.5, + median + 3.0 * 1.4826 * max(mad, 1.0e-6), + ) + rotation_limit = max( + math.radians(1.2), 4.0 * maximum_rotation_stability_rad + ) + translation_limit = max( + 0.008, 4.0 * maximum_translation_stability_m + ) + scores: list[float] = [] + for local_index, capture_index in enumerate(active): + residuals = [ + _transform_residual(transform, candidate) + for candidate in captures[capture_index].transform_candidates + ] + rotation_error, translation_error = min( + residuals, + key=lambda value: ( + value[0] / rotation_limit + + value[1] / translation_limit + ), + ) + scores.append( + max( + float(per_view[local_index]) / reprojection_limit, + rotation_error / rotation_limit, + translation_error / translation_limit, + ) + ) + worst_local = int(np.argmax(scores)) + if scores[worst_local] <= 1.0 or len(active) <= 3: + break + active.pop(worst_local) + + rotation_stability = float("inf") + translation_stability = float("inf") + if len(active) >= 6: + rotation_values: list[float] = [] + translation_values: list[float] = [] + for held_out_fold in range(3): + subset = [ + index + for position, index in enumerate(active) + if position % 3 != held_out_fold + ] + _, subset_transform, _ = _stereo_fit_once( + captures, + subset, + object_points, + front_matrix, + other_matrix, + image_size, + ) + rotation_error, translation_error = _transform_residual( + transform, subset_transform + ) + rotation_values.append(rotation_error) + translation_values.append(translation_error) + rotation_stability = max(rotation_values) + translation_stability = max(translation_values) + + rejected = tuple( + index for index in range(len(captures)) if index not in set(active) + ) + passed = bool( + len(active) >= minimum_inliers + and fit_rms <= maximum_rms_px + and rotation_stability <= maximum_rotation_stability_rad + and translation_stability <= maximum_translation_stability_m + ) + return StereoFitResult( + front_from_other=transform, + stereo_rms_px=fit_rms, + per_view_rms_px=tuple(float(value) for value in per_view), + inlier_indices=tuple(active), + rejected_indices=rejected, + rotation_stability_rad=rotation_stability, + translation_stability_m=translation_stability, + passed=passed, + ) + + +class ThreeCameraExtrinsicsNode(Node): + GUI_WINDOW = "G20 Three-Camera Extrinsics" + + def __init__(self) -> None: + super().__init__("g20_camera_extrinsics") + self._declare_parameters() + self._load_parameters() + self.bridge = CvBridge() + self.camera_matrices: dict[str, np.ndarray] = {} + self.camera_identities: dict[str, dict[str, Any]] = {} + # Do not run expensive 2 MP checkerboard detection in the image + # callbacks. That made a single-threaded executor compare frames that + # were processed at different times. Cache messages cheaply, select a + # synchronized pair first, then detect only those two images on demand. + self.image_history: dict[str, deque[ImageFrame]] = { + view: deque(maxlen=12) for view in VIEWS + } + self.captures: dict[str, list[list[np.ndarray]]] = { + "side": [], + "top": [], + } + self.capture_reprojection: dict[str, list[float]] = { + "side": [], + "top": [], + } + self.stereo_captures: dict[str, list[StereoCapture]] = { + "side": [], + "top": [], + } + self.fit_results: dict[str, StereoFitResult | None] = { + "side": None, + "top": None, + } + self.captured_front_board: dict[str, list[np.ndarray]] = { + "side": [], + "top": [], + } + self.gui_active_other = "side" + self.gui_last_assessment: PairAssessment | None = None + self.gui_feedback = "Move the checkerboard into both views" + self.gui_action: str | None = None + self.gui_auto_capture = self.auto_capture_default + self.gui_auto_pose: np.ndarray | None = None + self.gui_auto_stable_since: float | None = None + self.gui_enabled = bool(self.enable_gui and os.environ.get("DISPLAY")) + for view in VIEWS: + self.create_subscription( + CameraInfo, + self.info_topics[view], + lambda message, selected=view: self._info_callback( + selected, message + ), + qos_profile_sensor_data, + ) + self.create_subscription( + Image, + self.image_topics[view], + lambda message, selected=view: self._image_callback( + selected, message + ), + qos_profile_sensor_data, + ) + self.create_service( + Trigger, "~/capture_front_side", self._capture_front_side + ) + self.create_service( + Trigger, "~/capture_front_top", self._capture_front_top + ) + self.create_service(Trigger, "~/save", self._save) + self.create_service(Trigger, "~/reset", self._reset) + if self.enable_gui and not self.gui_enabled: + self.get_logger().warning( + "Extrinsics GUI requested but DISPLAY is unavailable; GUI disabled" + ) + if self.gui_enabled: + try: + cv2.namedWindow(self.GUI_WINDOW, cv2.WINDOW_NORMAL) + cv2.resizeWindow(self.GUI_WINDOW, 1400, 760) + cv2.setMouseCallback( + self.GUI_WINDOW, self._gui_mouse_callback + ) + self.gui_timer = self.create_timer( + 1.0 / self.gui_refresh_hz, self._gui_timer_callback + ) + except cv2.error as error: + self.gui_enabled = False + self.get_logger().error( + f"Failed to create extrinsics GUI; GUI disabled: {error}" + ) + self.get_logger().info( + "Checkerboard extrinsic calibration ready: " + f"inner corners={self.columns}x{self.rows}, " + f"square={self.square_size_m * 1000.0:.3f} mm" + ) + + def _declare_parameters(self) -> None: + self.declare_parameter("output_file", "camera_extrinsics.yaml") + self.declare_parameter("checkerboard_columns", 8) + self.declare_parameter("checkerboard_rows", 5) + self.declare_parameter("square_size_m", 0.027) + self.declare_parameter("minimum_captures_per_pair", 15) + self.declare_parameter("maximum_pair_skew_ms", 100.0) + self.declare_parameter("maximum_reprojection_rms_px", 1.2) + self.declare_parameter("maximum_rotation_repeatability_deg", 0.3) + self.declare_parameter("maximum_translation_repeatability_m", 0.0015) + self.declare_parameter("enable_gui", False) + self.declare_parameter("gui_refresh_hz", 2.0) + self.declare_parameter("auto_capture_default", False) + self.declare_parameter("auto_capture_stable_seconds", 1.0) + for view in VIEWS: + self.declare_parameter(f"{view}_camera_serial", "") + self.declare_parameter( + f"{view}_image_topic", + f"/g20_extrinsics/{view}/camera/image_rect", + ) + self.declare_parameter( + f"{view}_camera_info_topic", + f"/g20_extrinsics/{view}/camera/camera_info", + ) + + def _load_parameters(self) -> None: + def value(name: str) -> Any: + return self.get_parameter(name).value + + self.output_file = Path(str(value("output_file"))).expanduser().resolve() + self.columns = int(value("checkerboard_columns")) + self.rows = int(value("checkerboard_rows")) + self.square_size_m = float(value("square_size_m")) + self.minimum_captures = int(value("minimum_captures_per_pair")) + self.maximum_pair_skew_ns = int( + float(value("maximum_pair_skew_ms")) * 1_000_000.0 + ) + self.maximum_reprojection_rms_px = float( + value("maximum_reprojection_rms_px") + ) + self.maximum_rotation_repeatability_rad = math.radians( + float(value("maximum_rotation_repeatability_deg")) + ) + self.maximum_translation_repeatability_m = float( + value("maximum_translation_repeatability_m") + ) + self.enable_gui = bool(value("enable_gui")) + self.gui_refresh_hz = float(value("gui_refresh_hz")) + self.auto_capture_default = bool(value("auto_capture_default")) + self.auto_capture_stable_seconds = float( + value("auto_capture_stable_seconds") + ) + self.serials = { + view: str(value(f"{view}_camera_serial")) for view in VIEWS + } + self.image_topics = { + view: str(value(f"{view}_image_topic")) for view in VIEWS + } + self.info_topics = { + view: str(value(f"{view}_camera_info_topic")) for view in VIEWS + } + if self.columns < 3 or self.rows < 3 or self.square_size_m <= 0.0: + raise ValueError("checkerboard geometry is invalid") + if self.minimum_captures < 15: + raise ValueError("minimum_captures_per_pair must be at least 15") + if self.gui_refresh_hz <= 0.0 or self.gui_refresh_hz > 10.0: + raise ValueError("gui_refresh_hz must be in (0, 10]") + if self.auto_capture_stable_seconds < 0.5: + raise ValueError("auto_capture_stable_seconds must be at least 0.5") + if any(not serial for serial in self.serials.values()): + raise ValueError("all three camera serial numbers are required") + + @property + def object_points(self) -> np.ndarray: + points = np.zeros((self.columns * self.rows, 3), dtype=np.float32) + points[:, :2] = np.mgrid[0:self.columns, 0:self.rows].T.reshape(-1, 2) + points[:, :2] *= self.square_size_m + return points + + def _info_callback(self, view: str, message: CameraInfo) -> None: + matrix = np.asarray(message.p, dtype=float).reshape(3, 4)[:, :3] + if ( + int(message.width) <= 0 + or int(message.height) <= 0 + or not np.all(np.isfinite(matrix)) + or matrix[0, 0] <= 0.0 + or matrix[1, 1] <= 0.0 + ): + self.camera_matrices.pop(view, None) + self.camera_identities.pop(view, None) + return + self.camera_matrices[view] = matrix + self.camera_identities[view] = { + "serial_number": self.serials[view], + "width": int(message.width), + "height": int(message.height), + "intrinsics_sha256": camera_info_fingerprint( + width=message.width, + height=message.height, + camera_matrix=message.k, + distortion=message.d, + rectification=message.r, + projection=message.p, + ), + } + + def _image_callback(self, view: str, message: Image) -> None: + stamp_ns = _stamp_ns(message) + if stamp_ns <= 0: + return + self.image_history[view].append( + ImageFrame(stamp_ns=stamp_ns, message=message) + ) + + def _detect_board_pose( + self, view: str, message: Image + ) -> BoardPose | None: + matrix = self.camera_matrices.get(view) + if matrix is None: + return None + image = self.bridge.imgmsg_to_cv2(message, desired_encoding="mono8") + found, corners = cv2.findChessboardCornersSB( + image, + (self.columns, self.rows), + flags=cv2.CALIB_CB_NORMALIZE_IMAGE, + ) + if not found or corners is None: + return None + image_points = np.asarray(corners, dtype=np.float32).reshape(-1, 2) + pose_candidates: list[np.ndarray] = [] + reprojection_values: list[float] = [] + for ordered_points in (image_points, image_points[::-1].copy()): + solved, rotation_vector, translation = cv2.solvePnP( + self.object_points, + ordered_points, + matrix, + np.zeros(5), + flags=cv2.SOLVEPNP_IPPE, + ) + if not solved: + continue + # IPPE supplies a stable planar initial pose. LM refinement then + # minimizes the exact reprojection residual used by the quality + # gate, avoiding an avoidable analytical-solver error floor. + rotation_vector, translation = cv2.solvePnPRefineLM( + self.object_points, + ordered_points, + matrix, + np.zeros(5), + rotation_vector, + translation, + ) + projected, _ = cv2.projectPoints( + self.object_points, + rotation_vector, + translation, + matrix, + np.zeros(5), + ) + residual = projected.reshape(-1, 2) - ordered_points + reprojection_values.append( + float(np.sqrt(np.mean(np.sum(np.square(residual), axis=1)))) + ) + rotation_matrix, _ = cv2.Rodrigues(rotation_vector) + transform = np.eye(4) + transform[:3, :3] = rotation_matrix + transform[:3, 3] = np.asarray(translation).reshape(3) + pose_candidates.append(transform) + if not pose_candidates: + return None + return BoardPose( + stamp_ns=_stamp_ns(message), + camera_from_board_candidates=tuple(pose_candidates), + reprojection_rms_px=min(reprojection_values), + image_points=image_points, + ) + + def _assess_pair(self, other: str) -> PairAssessment: + """Evaluate the newest synchronized pair without recording it.""" + if not self.image_history["front"] or not self.image_history[other]: + return PairAssessment( + other, False, "waiting_for_images", f"front/{other}尚未收到图像" + ) + frame_pair = _select_latest_synchronised_pair( + self.image_history["front"], + self.image_history[other], + self.maximum_pair_skew_ns, + ) + if frame_pair is None: + minimum_skew = _minimum_history_skew_ns( + self.image_history["front"], self.image_history[other] + ) + message = ( + f"front/{other}没有可配对的图像" + if minimum_skew is None + else ( + f"front/{other}最近图像历史的最小时间差" + f"{minimum_skew / 1e6:.1f}ms,超过" + f"{self.maximum_pair_skew_ns / 1e6:.1f}ms门限" + ) + ) + return PairAssessment( + other, False, "image_skew_too_large", message + ) + + front_frame, selected_frame, skew = frame_pair + front = self._detect_board_pose("front", front_frame.message) + selected = self._detect_board_pose(other, selected_frame.message) + missing = [ + view + for view, pose in (("front", front), (other, selected)) + if pose is None + ] + if missing: + return PairAssessment( + other=other, + ready=False, + reason_code="checkerboard_missing", + message=( + f"同步图像已找到(时间差{skew / 1e6:.1f}ms)," + f"但{'/'.join(missing)}没有检测到完整棋盘" + ), + front_frame=front_frame, + selected_frame=selected_frame, + front_pose=front, + selected_pose=selected, + skew_ns=skew, + ) + + assert front is not None and selected is not None + pair_rms = float( + np.sqrt( + np.mean( + np.square( + [ + front.reprojection_rms_px, + selected.reprojection_rms_px, + ] + ) + ) + ) + ) + common = { + "other": other, + "front_frame": front_frame, + "selected_frame": selected_frame, + "front_pose": front, + "selected_pose": selected, + "skew_ns": skew, + "pair_reprojection_rms_px": pair_rms, + } + if pair_rms > self.maximum_reprojection_rms_px: + return PairAssessment( + ready=False, + reason_code="reprojection_rms_too_large", + message=( + f"当前组重投影RMS={pair_rms:.3f}px超过" + f"{self.maximum_reprojection_rms_px:.3f}px门限,未计入。" + f"front={front.reprojection_rms_px:.3f}px," + f"{other}={selected.reprojection_rms_px:.3f}px;" + f"已保留{len(self.captures[other])}/{self.minimum_captures}组" + ), + **common, + ) + + transform_candidates = tuple( + front_pose @ _inverse(other_pose) + for front_pose in front.camera_from_board_candidates + for other_pose in selected.camera_from_board_candidates + ) + previous = self.captures[other] + board_poses = self.captured_front_board[other] + if board_poses: + board_change_rotation = ( + Rotation.from_matrix(board_poses[-1][:3, :3]).inv() + * Rotation.from_matrix( + front.camera_from_board_candidates[0][:3, :3] + ) + ).magnitude() + board_change_translation = np.linalg.norm( + board_poses[-1][:3, 3] + - front.camera_from_board_candidates[0][:3, 3] + ) + if ( + board_change_rotation < math.radians(2.0) + and board_change_translation < 0.015 + ): + return PairAssessment( + ready=False, + reason_code="board_pose_too_close", + message=( + "棋盘姿态与上次过近," + "请改变位置或倾角后再采集" + ), + transform_candidates=transform_candidates, + **common, + ) + + current_fit = self.fit_results[other] + rotation_error = ( + None + if current_fit is None + else current_fit.rotation_stability_rad + ) + translation_error = ( + None + if current_fit is None + else current_fit.translation_stability_m + ) + metrics = { + "transform_candidates": transform_candidates, + "provisional_rotation_error_rad": rotation_error, + "provisional_translation_error_m": translation_error, + } + return PairAssessment( + ready=True, + reason_code="ready", + message=( + f"当前姿态合格,可加入候选;" + f"已采集{len(previous)}组" + ), + **common, + **metrics, + ) + + def _update_stereo_fit(self, other: str) -> StereoFitResult | None: + captures = self.stereo_captures[other] + if len(captures) < 3: + self.fit_results[other] = None + return None + identity = self.camera_identities.get("front") + front_matrix = self.camera_matrices.get("front") + other_matrix = self.camera_matrices.get(other) + if identity is None or front_matrix is None or other_matrix is None: + self.fit_results[other] = None + return None + try: + result = _fit_stereo_robust( + captures, + self.object_points, + front_matrix, + other_matrix, + (int(identity["width"]), int(identity["height"])), + minimum_inliers=self.minimum_captures, + maximum_rms_px=self.maximum_reprojection_rms_px, + maximum_rotation_stability_rad=( + self.maximum_rotation_repeatability_rad + ), + maximum_translation_stability_m=( + self.maximum_translation_repeatability_m + ), + ) + except (ValueError, cv2.error) as error: + self.fit_results[other] = None + self.get_logger().warning( + f"front/{other} joint stereo fit unavailable: {error}" + ) + return None + self.fit_results[other] = result + return result + + def _capture_pair( + self, other: str, response: Trigger.Response + ) -> Trigger.Response: + assessment = self._assess_pair(other) + if other == self.gui_active_other: + self.gui_last_assessment = assessment + if not assessment.ready: + response.success = False + response.message = assessment.message + return response + + assert assessment.front_pose is not None + assert assessment.selected_pose is not None + assert assessment.skew_ns is not None + assert assessment.pair_reprojection_rms_px is not None + assert assessment.front_pose.image_points is not None + assert assessment.selected_pose.image_points is not None + previous = self.captures[other] + previous.append(list(assessment.transform_candidates)) + self.stereo_captures[other].append( + StereoCapture( + front_points_px=assessment.front_pose.image_points.copy(), + other_points_px=assessment.selected_pose.image_points.copy(), + transform_candidates=assessment.transform_candidates, + pair_reprojection_rms_px=( + assessment.pair_reprojection_rms_px + ), + skew_ns=assessment.skew_ns, + ) + ) + self.captured_front_board[other].append( + assessment.front_pose.camera_from_board_candidates[0].copy() + ) + self.capture_reprojection[other].extend( + [ + assessment.front_pose.reprojection_rms_px, + assessment.selected_pose.reprojection_rms_px, + ] + ) + fit = self._update_stereo_fit(other) + fit_text = "联合拟合需要至少3组" + if fit is not None: + fit_text = ( + f"联合RMS={fit.stereo_rms_px:.3f}px," + f"内点={len(fit.inlier_indices)}/{len(previous)}," + f"稳定性={math.degrees(fit.rotation_stability_rad):.3f}°/" + f"{fit.translation_stability_m * 1000.0:.3f}mm" + ) + response.success = True + response.message = ( + f"front/{other}已加入候选 {len(previous)}组," + f"时间差{assessment.skew_ns / 1e6:.1f}ms," + f"重投影RMS={assessment.pair_reprojection_rms_px:.3f}px," + f"{fit_text}" + ) + return response + + @staticmethod + def _fit_gui_image(image: np.ndarray, width: int, height: int) -> np.ndarray: + canvas = np.full((height, width, 3), 24, dtype=np.uint8) + scale = min(width / image.shape[1], height / image.shape[0]) + resized = cv2.resize( + image, + ( + max(1, int(round(image.shape[1] * scale))), + max(1, int(round(image.shape[0] * scale))), + ), + interpolation=cv2.INTER_AREA, + ) + x = (width - resized.shape[1]) // 2 + y = (height - resized.shape[0]) // 2 + canvas[y : y + resized.shape[0], x : x + resized.shape[1]] = resized + return canvas + + def _annotated_gui_frame( + self, + frame: ImageFrame | None, + pose: BoardPose | None, + label: str, + ) -> np.ndarray: + if frame is None: + image = np.full((480, 640, 3), 24, dtype=np.uint8) + cv2.putText( + image, + f"{label}: waiting for image", + (30, 60), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (0, 180, 255), + 2, + cv2.LINE_AA, + ) + return image + mono = self.bridge.imgmsg_to_cv2( + frame.message, desired_encoding="mono8" + ) + image = cv2.cvtColor(mono, cv2.COLOR_GRAY2BGR) + if pose is not None and pose.image_points is not None: + cv2.drawChessboardCorners( + image, + (self.columns, self.rows), + pose.image_points.reshape(-1, 1, 2), + True, + ) + rms = "--" if pose is None else f"{pose.reprojection_rms_px:.3f}px" + color = ( + (40, 220, 40) + if pose is not None + and pose.reprojection_rms_px <= self.maximum_reprojection_rms_px + else (0, 180, 255) + ) + cv2.putText( + image, + f"{label.upper()} RMS {rms}", + (30, 55), + cv2.FONT_HERSHEY_SIMPLEX, + 1.1, + color, + 3, + cv2.LINE_AA, + ) + return image + + @staticmethod + def _draw_gui_button( + canvas: np.ndarray, + rectangle: tuple[int, int, int, int], + label: str, + color: tuple[int, int, int], + ) -> None: + x1, y1, x2, y2 = rectangle + cv2.rectangle(canvas, (x1, y1), (x2, y2), color, -1) + cv2.rectangle(canvas, (x1, y1), (x2, y2), (220, 220, 220), 1) + size, _ = cv2.getTextSize( + label, cv2.FONT_HERSHEY_SIMPLEX, 0.65, 2 + ) + x = x1 + max(8, (x2 - x1 - size[0]) // 2) + y = y1 + (y2 - y1 + size[1]) // 2 + cv2.putText( + canvas, + label, + (x, y), + cv2.FONT_HERSHEY_SIMPLEX, + 0.65, + (255, 255, 255), + 2, + cv2.LINE_AA, + ) + + def _gui_reason_text(self, assessment: PairAssessment) -> str: + return { + "waiting_for_images": "WAITING FOR IMAGES", + "image_skew_too_large": "IMAGE TIME DIFFERENCE TOO LARGE", + "checkerboard_missing": "FULL CHECKERBOARD NOT FOUND", + "reprojection_rms_too_large": "REPROJECTION RMS TOO HIGH", + "board_pose_too_close": "MOVE BOARD TO A NEW POSE", + "ready": "READY - ADD CANDIDATE", + }.get(assessment.reason_code, assessment.reason_code.upper()) + + def _gui_mouse_callback(self, event, x, y, flags, parameter) -> None: + del flags, parameter + if event != cv2.EVENT_LBUTTONUP: + return + buttons = getattr(self, "gui_buttons", {}) + for name, rectangle in buttons.items(): + x1, y1, x2, y2 = rectangle + if not (x1 <= x <= x2 and y1 <= y <= y2): + continue + if name in ("side", "top"): + self.gui_active_other = name + self.gui_last_assessment = None + self.gui_auto_pose = None + self.gui_auto_stable_since = None + self.gui_feedback = f"Selected FRONT + {name.upper()}" + elif name == "auto": + self.gui_auto_capture = not self.gui_auto_capture + self.gui_auto_pose = None + self.gui_auto_stable_since = None + self.gui_feedback = ( + "Auto capture enabled" + if self.gui_auto_capture + else "Auto capture disabled" + ) + elif name == "capture": + if ( + self.gui_last_assessment is not None + and self.gui_last_assessment.ready + ): + self.gui_action = "capture" + elif name == "save": + if all( + self.fit_results[view] is not None + and bool(self.fit_results[view].passed) + for view in ("side", "top") + ): + self.gui_action = "save" + break + + def _gui_timer_callback(self) -> None: + if not self.gui_enabled: + return + try: + action = self.gui_action + self.gui_action = None + if action == "capture": + response = self._capture_pair( + self.gui_active_other, Trigger.Response() + ) + if response.success: + self.gui_feedback = ( + f"Captured FRONT + {self.gui_active_other.upper()} " + f"{len(self.captures[self.gui_active_other])}/" + f"{self.minimum_captures}" + ) + self.get_logger().info(response.message) + else: + self.gui_feedback = "Capture rejected; see quality status" + self.get_logger().warning(response.message) + elif action == "save": + response = self._save(None, Trigger.Response()) + if response.success: + self.gui_feedback = f"Saved: {self.output_file}" + self.get_logger().info(response.message) + else: + self.gui_feedback = "Save failed; see terminal" + self.get_logger().warning(response.message) + + assessment = self._assess_pair(self.gui_active_other) + self.gui_last_assessment = assessment + active_fit = self.fit_results[self.gui_active_other] + if ( + self.gui_auto_capture + and active_fit is not None + and active_fit.passed + ): + self.gui_auto_capture = False + self.gui_feedback = ( + f"FRONT + {self.gui_active_other.upper()} PASSED; " + "switch pair" + ) + if self.gui_auto_capture and assessment.ready: + assert assessment.front_pose is not None + current_pose = ( + assessment.front_pose.camera_from_board_candidates[0] + ) + now = time.monotonic() + stable = False + if self.gui_auto_pose is not None: + rotation_change, translation_change = _transform_residual( + self.gui_auto_pose, current_pose + ) + stable = bool( + rotation_change <= math.radians(0.5) + and translation_change <= 0.002 + ) + if stable: + if self.gui_auto_stable_since is None: + self.gui_auto_stable_since = now + elif ( + now - self.gui_auto_stable_since + >= self.auto_capture_stable_seconds + ): + response = self._capture_pair( + self.gui_active_other, Trigger.Response() + ) + if response.success: + self.gui_feedback = ( + f"Auto captured FRONT + " + f"{self.gui_active_other.upper()} " + f"{len(self.captures[self.gui_active_other])}" + ) + self.get_logger().info(response.message) + else: + self.gui_feedback = "Auto capture rejected" + self.get_logger().warning(response.message) + self.gui_auto_pose = None + self.gui_auto_stable_since = None + assessment = self._assess_pair( + self.gui_active_other + ) + self.gui_last_assessment = assessment + else: + self.gui_auto_pose = current_pose.copy() + self.gui_auto_stable_since = now + else: + self.gui_auto_pose = None + self.gui_auto_stable_since = None + front_frame = assessment.front_frame + selected_frame = assessment.selected_frame + if front_frame is None and self.image_history["front"]: + front_frame = self.image_history["front"][-1] + if selected_frame is None and self.image_history[self.gui_active_other]: + selected_frame = self.image_history[self.gui_active_other][-1] + front_image = self._annotated_gui_frame( + front_frame, assessment.front_pose, "front" + ) + selected_image = self._annotated_gui_frame( + selected_frame, + assessment.selected_pose, + self.gui_active_other, + ) + view_width, view_height = 640, 488 + left = self._fit_gui_image(front_image, view_width, view_height) + right = self._fit_gui_image( + selected_image, view_width, view_height + ) + canvas = np.full((700, 1300, 3), 18, dtype=np.uint8) + canvas[10 : 10 + view_height, 5 : 5 + view_width] = left + canvas[10 : 10 + view_height, 655 : 655 + view_width] = right + + ready_color = (40, 220, 40) if assessment.ready else (0, 80, 230) + batch_status = ( + " | BATCH PASSED" + if active_fit is not None and active_fit.passed + else "" + ) + cv2.putText( + canvas, + self._gui_reason_text(assessment) + batch_status, + (20, 530), + cv2.FONT_HERSHEY_SIMPLEX, + 0.85, + ready_color, + 2, + cv2.LINE_AA, + ) + pair_rms = ( + "--" + if assessment.pair_reprojection_rms_px is None + else f"{assessment.pair_reprojection_rms_px:.3f}" + ) + skew = ( + "--" + if assessment.skew_ns is None + else f"{assessment.skew_ns / 1e6:.1f}" + ) + rotation = ( + "--" + if assessment.provisional_rotation_error_rad is None + else f"{math.degrees(assessment.provisional_rotation_error_rad):.3f}" + ) + translation = ( + "--" + if assessment.provisional_translation_error_m is None + else f"{assessment.provisional_translation_error_m * 1000.0:.3f}" + ) + cv2.putText( + canvas, + f"Pair RMS {pair_rms}/{self.maximum_reprojection_rms_px:.3f}px " + f"Skew {skew}/{self.maximum_pair_skew_ns / 1e6:.1f}ms " + f"Batch stability {rotation}/" + f"{math.degrees(self.maximum_rotation_repeatability_rad):.3f}deg " + f"{translation}/" + f"{self.maximum_translation_repeatability_m * 1000.0:.3f}mm", + (20, 562), + cv2.FONT_HERSHEY_SIMPLEX, + 0.58, + (220, 220, 220), + 1, + cv2.LINE_AA, + ) + cv2.putText( + canvas, + f"Candidates SIDE {len(self.stereo_captures['side'])} " + f"(inliers " + f"{0 if self.fit_results['side'] is None else len(self.fit_results['side'].inlier_indices)}) " + f"TOP {len(self.stereo_captures['top'])} (inliers " + f"{0 if self.fit_results['top'] is None else len(self.fit_results['top'].inlier_indices)}) | " + f"{self.gui_feedback}", + (20, 590), + cv2.FONT_HERSHEY_SIMPLEX, + 0.58, + (200, 200, 200), + 1, + cv2.LINE_AA, + ) + + self.gui_buttons = { + "side": (20, 615, 220, 680), + "top": (235, 615, 435, 680), + "auto": (450, 615, 635, 680), + "capture": (650, 615, 970, 680), + "save": (985, 615, 1280, 680), + } + self._draw_gui_button( + canvas, + self.gui_buttons["side"], + "FRONT + SIDE", + (170, 105, 20) + if self.gui_active_other == "side" + else (75, 75, 75), + ) + self._draw_gui_button( + canvas, + self.gui_buttons["top"], + "FRONT + TOP", + (170, 105, 20) + if self.gui_active_other == "top" + else (75, 75, 75), + ) + self._draw_gui_button( + canvas, + self.gui_buttons["auto"], + "AUTO ON" if self.gui_auto_capture else "AUTO OFF", + (30, 155, 30) if self.gui_auto_capture else (75, 75, 75), + ) + self._draw_gui_button( + canvas, + self.gui_buttons["capture"], + "ADD CANDIDATE" if assessment.ready else "ADD (DISABLED)", + (30, 155, 30) if assessment.ready else (65, 65, 65), + ) + save_ready = all( + self.fit_results[view] is not None + and bool(self.fit_results[view].passed) + for view in ("side", "top") + ) + self._draw_gui_button( + canvas, + self.gui_buttons["save"], + "SAVE" if save_ready else "SAVE (DISABLED)", + (30, 155, 30) if save_ready else (65, 65, 65), + ) + cv2.imshow(self.GUI_WINDOW, canvas) + cv2.waitKey(1) + except cv2.error as error: + self.gui_enabled = False + self.get_logger().error(f"Extrinsics GUI disabled: {error}") + + def _capture_front_side(self, request, response): + del request + return self._capture_pair("side", response) + + def _capture_front_top(self, request, response): + del request + return self._capture_pair("top", response) + + def _save(self, request, response): + del request + if set(self.camera_identities) != set(VIEWS): + response.success = False + response.message = "三台相机内参尚未全部收到" + return response + if any( + len(self.stereo_captures[view]) < self.minimum_captures + for view in ("side", "top") + ): + response.success = False + response.message = ( + f"候选采集不足:side={len(self.stereo_captures['side'])}," + f"top={len(self.stereo_captures['top'])}" + ) + return response + results: dict[str, StereoFitResult] = {} + for view in ("side", "top"): + result = self._update_stereo_fit(view) + if result is None: + response.success = False + response.message = f"front/{view}联合外参拟合失败" + return response + results[view] = result + if any(not result.passed for result in results.values()): + response.success = False + response.message = "联合外参质量不合格:" + "; ".join( + f"{view}: RMS={result.stereo_rms_px:.3f}px, " + f"内点={len(result.inlier_indices)}/" + f"{len(self.stereo_captures[view])}, " + f"旋转稳定性=" + f"{math.degrees(result.rotation_stability_rad):.3f}°, " + f"平移稳定性=" + f"{result.translation_stability_m * 1000.0:.3f}mm" + for view, result in results.items() + ) + return response + transforms = { + "front": np.eye(4), + "side": results["side"].front_from_other, + "top": results["top"].front_from_other, + } + quality = { + "passed": True, + "reprojection_rms_px": max( + result.stereo_rms_px for result in results.values() + ), + "maximum_rotation_repeatability_deg": math.degrees( + max( + result.rotation_stability_rad + for result in results.values() + ) + ), + "maximum_translation_repeatability_m": max( + result.translation_stability_m for result in results.values() + ), + "front_side_captures": len(results["side"].inlier_indices), + "front_top_captures": len(results["top"].inlier_indices), + "front_side_candidates": len(self.stereo_captures["side"]), + "front_top_candidates": len(self.stereo_captures["top"]), + "front_side_rejected": len(results["side"].rejected_indices), + "front_top_rejected": len(results["top"].rejected_indices), + } + dump_three_camera_extrinsics( + self.output_file, + cameras=self.camera_identities, + front_from_view=transforms, + quality=quality, + ) + response.success = True + response.message = f"外参已保存:{self.output_file};{quality}" + return response + + def _reset(self, request, response): + del request + for values in self.captures.values(): + values.clear() + for values in self.capture_reprojection.values(): + values.clear() + for values in self.stereo_captures.values(): + values.clear() + for values in self.captured_front_board.values(): + values.clear() + self.fit_results = {"side": None, "top": None} + self.gui_auto_pose = None + self.gui_auto_stable_since = None + response.success = True + response.message = "已清空外参采样" + return response + + +def main(args: list[str] | None = None) -> None: + configure_fastdds_large_image_transport() + rclpy.init(args=args) + node = ThreeCameraExtrinsicsNode() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + if node.gui_enabled: + cv2.destroyAllWindows() + node.destroy_node() + if rclpy.ok(): + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py index b21d431..04435c2 100644 --- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py @@ -2,7 +2,7 @@ The hardware node records one parent/child AprilTag trajectory for each directly observable joint. This module deliberately contains no ROS imports: -curve fitting, four-finger splay centring, inheritance, and schema validation +curve fitting, baseline centring, inheritance, and schema validation remain deterministic and unit-testable without connected cameras or a hand. """ @@ -14,7 +14,6 @@ from typing import Any, Mapping, Sequence import numpy as np -from .core import BASELINE_COMMAND from .trajectory import ( _angle_for_circle, _fit_circle_with_axis, @@ -31,6 +30,13 @@ from .zero_calibration import ( ) +THREE_CAMERA_BASELINE_COMMAND: tuple[int, ...] = ( + 255, 255, 255, 255, 255, 255, + 127, 127, 127, 127, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, +) + + @dataclass(frozen=True) class JointSpec: name: str @@ -90,11 +96,11 @@ VIEW_TAGS: dict[str, dict[str, int]] = { JOINT_SPECS: dict[str, JointSpec] = { "thumb_cmc_pitch": JointSpec( "thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_cmc", - zero_kind="projected", + zero_kind="urdf_axis_chain", ), "index_mcp_pitch": JointSpec( "index_mcp_pitch", 1, True, "side", "side_base", "index_mcp", - zero_kind="projected", + zero_kind="urdf_axis_chain", ), "middle_mcp_pitch": JointSpec( "middle_mcp_pitch", 2, True, None, None, None, @@ -110,11 +116,11 @@ JOINT_SPECS: dict[str, JointSpec] = { ), "thumb_cmc_roll": JointSpec( "thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc", - zero_kind="projected", + zero_kind="urdf_axis_chain", ), "index_mcp_roll": JointSpec( "index_mcp_roll", 6, True, "front", "front_base", "index_roll", - zero_kind="travel_midpoint", + zero_kind="urdf_axis_chain", ), "middle_mcp_roll": JointSpec( "middle_mcp_roll", 7, True, None, None, None, @@ -130,15 +136,15 @@ JOINT_SPECS: dict[str, JointSpec] = { ), "thumb_cmc_yaw": JointSpec( "thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw", - zero_kind="projected", + zero_kind="urdf_axis_chain", ), "thumb_mcp": JointSpec( "thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp", - zero_kind="projected", + zero_kind="urdf_axis_chain", ), "index_pip": JointSpec( "index_pip", 16, True, "side", "index_mcp", "index_pip", - zero_kind="projected", + zero_kind="urdf_axis_chain", ), "middle_pip": JointSpec( "middle_pip", 17, True, None, None, None, @@ -203,8 +209,10 @@ SPLAY_JOINTS: tuple[str, ...] = ( # and a motion plane that is close to the active camera's image plane. Their # projected circles are substantially more repeatable than the difference of # two independently estimated planar-Tag PnP depths. thumb_ip remains projected -# because its front-view PnP depth is not repeatable enough for a 3-D fit; the -# side-view index_dip and oblique top-view yaw remain parent-relative 3-D. +# because its front-view PnP depth is not repeatable enough for a 3-D fit. The +# side-view index_dip and oblique top-view yaw remain parent-relative full +# SE(3) measurements; their circle directions are constrained by the more +# repeatable relative-orientation screw axis in urdf_zero.py. IMAGE_TRAJECTORY_JOINTS: frozenset[str] = frozenset( { "thumb_cmc_pitch", @@ -244,7 +252,7 @@ def calibration_auxiliary_commands(spec: SweepSpec) -> dict[int, int]: def build_full_hand_command( motor_index: int, command_u8: int, - baseline: Sequence[int] = BASELINE_COMMAND, + baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND, ) -> list[int]: if len(baseline) != 20: raise ValueError("baseline must contain exactly 20 values") @@ -264,7 +272,7 @@ def build_full_hand_command( def build_calibration_motion_command( spec: SweepSpec, command_u8: int, - baseline: Sequence[int] = BASELINE_COMMAND, + baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND, ) -> list[int]: """Build a sweep command, including any required clearance pose.""" result = build_full_hand_command( @@ -612,23 +620,20 @@ def build_compact_payload( *, serial_number: str, measured_fits: Mapping[str, JointCurveFit], - projected_zeros_rad: Mapping[str, float], - splay_zero_command_u8: int, - splay_midpoint_rad: float, + urdf_zero_offsets_rad: Mapping[str, float], validation_errors_rad: Sequence[float], passed: bool, - baseline: Sequence[int] = BASELINE_COMMAND, + baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND, ) -> dict[str, Any]: if set(measured_fits) != set(MEASURED_JOINTS): raise ValueError("measured_fits must contain all directly measured joints") - expected_projected = { - name for name, spec in JOINT_SPECS.items() - if spec.zero_kind == "projected" + expected_active = { + name for name, spec in JOINT_SPECS.items() if spec.active } - if set(projected_zeros_rad) != expected_projected: - raise ValueError("projected_zeros_rad has the wrong joint set") - if not 0 <= int(splay_zero_command_u8) <= 255: - raise ValueError("splay zero command must be in [0, 255]") + if set(urdf_zero_offsets_rad) != expected_active: + raise ValueError("urdf_zero_offsets_rad has the wrong active-joint set") + if len(baseline) != 20: + raise ValueError("baseline must contain exactly 20 commands") joints: dict[str, dict[str, Any]] = {} for name, spec in JOINT_SPECS.items(): @@ -639,32 +644,23 @@ def build_compact_payload( "angle_rad": [round(float(value), 8) for value in fit.angle_rad], } if spec.active: - joint["zero_command_u8"] = ( - int(splay_zero_command_u8) - if name in SPLAY_JOINTS - else 255 - ) + joint["zero_command_u8"] = int(baseline[spec.motor_index]) + joint["zero_angles"] = { + "urdf_zero_offset_rad": round( + float(urdf_zero_offsets_rad[name]), 8 + ) + } else: joint["passive"] = True if spec.source_joint is not None: joint["source_joint"] = spec.source_joint - elif spec.zero_kind == "projected": - joint["zero_angles"] = { - "table_projected_zero_rad": round( - float(projected_zeros_rad[name]), 8 - ) - } - elif spec.zero_kind == "travel_midpoint": - joint["zero_angles"] = { - "travel_midpoint_rad": round(float(splay_midpoint_rad), 8) - } joints[name] = joint errors = np.abs(np.asarray(validation_errors_rad, dtype=float)) mae = float(np.mean(errors)) if errors.size else float("nan") p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan") payload = { - "schema_version": 3, + "schema_version": 4, "model": "G20", "side": "left", "serial_number": str(serial_number), @@ -693,8 +689,8 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None: } if set(payload) != expected_top: raise ValueError("compact calibration has unexpected top-level fields") - if payload["schema_version"] != 3: - raise ValueError("schema_version must be 3") + if payload["schema_version"] != 4: + raise ValueError("schema_version must be 4") if payload["model"] != "G20" or payload["side"] != "left": raise ValueError("payload must describe a left G20") if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]: @@ -710,10 +706,10 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None: joint = joints[name] allowed = {"motor_index", "angle_rad"} allowed.add("zero_command_u8" if spec.active else "passive") + if spec.active: + allowed.add("zero_angles") if spec.source_joint is not None: allowed.add("source_joint") - elif spec.zero_kind in {"projected", "travel_midpoint"}: - allowed.add("zero_angles") if set(joint) != allowed: raise ValueError(f"{name} has unexpected fields") if int(joint["motor_index"]) != spec.motor_index: @@ -727,6 +723,20 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None: zero = joint["zero_command_u8"] if not isinstance(zero, int) or not 0 <= zero <= 255: raise ValueError(f"{name}.zero_command_u8 is invalid") + zero_angles = joint.get("zero_angles") + if not isinstance(zero_angles, Mapping) or set(zero_angles) != { + "urdf_zero_offset_rad" + }: + raise ValueError( + f"{name}.zero_angles must contain urdf_zero_offset_rad" + ) + offset = float(zero_angles["urdf_zero_offset_rad"]) + if not math.isfinite(offset): + raise ValueError(f"{name}.urdf_zero_offset_rad is invalid") + if abs(float(curve[zero])) > 1.0e-6: + raise ValueError( + f"{name}.angle_rad must be zero at zero_command_u8" + ) elif joint.get("passive") is not True: raise ValueError(f"{name} must be marked passive") if spec.source_joint is not None: @@ -737,17 +747,8 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None: raise ValueError(f"{name} must copy its source curve exactly") if spec.active and joint["zero_command_u8"] != source["zero_command_u8"]: raise ValueError(f"{name} must copy its source zero command") - - index_roll = np.asarray(joints["index_mcp_roll"]["angle_rad"], dtype=float) - if not index_roll[0] > 0.0 or not index_roll[255] < 0.0: - raise ValueError("index_mcp_roll endpoints must be positive then negative") - if abs(float(index_roll[0] + index_roll[255])) > 1.0e-7: - raise ValueError("index_mcp_roll endpoints must be symmetric") - for name, spec in JOINT_SPECS.items(): - if name not in SPLAY_JOINTS and spec.source_joint != "index_mcp_roll": - curve = np.asarray(joints[name]["angle_rad"], dtype=float) - if abs(float(curve[255])) > 1.0e-6: - raise ValueError(f"{name}.angle_rad[255] must be zero") + if spec.active and joint["zero_angles"] != source["zero_angles"]: + raise ValueError(f"{name} must copy its source zero offset") quality = payload["quality"] if set(quality) != { diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/storage.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/storage.py index 6b575df..13f9a36 100644 --- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/storage.py +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/storage.py @@ -30,6 +30,24 @@ def append_jsonl(path: str | Path, payload: Mapping[str, Any]) -> None: os.fsync(stream.fileno()) +def append_jsonl_many( + path: str | Path, payloads: Iterable[Mapping[str, Any]] +) -> None: + """Durably append a batch while paying the fsync cost only once.""" + destination = Path(path) + destination.parent.mkdir(parents=True, exist_ok=True) + lines = [ + json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + for payload in payloads + ] + if not lines: + return + with destination.open("a", encoding="utf-8") as stream: + stream.write("\n".join(lines) + "\n") + stream.flush() + os.fsync(stream.fileno()) + + def load_jsonl(path: str | Path) -> list[dict[str, Any]]: source = Path(path) if not source.exists(): diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py index 2869978..77a7b40 100644 --- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import Any, Mapping @@ -92,6 +93,27 @@ def three_camera_reason_zh( ) tolerance = sample.get("endpoint_tolerance_u8", "?") + if "URDF zero offset reached the configured" in reason: + bound_match = re.search( + r"configured\s+([0-9.]+)\s+degree bound", reason + ) + bound = bound_match.group(1) if bound_match else "配置的" + hit_text = "" + if "bound:" in reason: + hit_text = reason.split("bound:", 1)[1].split( + "; all_offsets:", 1 + )[0] + for name, label in JOINT_NAMES_ZH.items(): + hit_text = hit_text.replace(name, label) + hit_suffix = f";触边关节:{hit_text}" if hit_text else "" + return ( + f"联合URDF零位求解触及±{bound}°安全边界{hit_suffix}。这不是可靠的" + "零位结果,而是三机位米制位姿或固定关节轴链无法由纯零位旋转共同解释。", + "不要调用resume,也不要增大零位边界。先确认Tag有效黑框边长、三相机" + "内外参和原始CAD URDF;Tag尺寸修正后必须调用start重新采集,旧尺度" + "产生的轨迹不能直接生成修正URDF。", + ) + if reason == "sweep_missing_endpoint_bin": missing_text = "、".join(str(value) for value in missing) or "0或255" return ( @@ -159,6 +181,16 @@ def three_camera_reason_zh( "monotonic_correction_deg": "最大单调修正", "hysteresis_deg": "最大正反程差", "cycle_travel_range_deg": "三轮行程差", + "rotation_orthogonal_rms_deg": "三维旋转轴外残差RMS", + "axis_plane_rms_mm": "三维圆轴向RMS", + "axis_radial_rms_mm": "三维圆半径RMS", + "rotation_circle_axis_difference_deg": "姿态轴与圆轨迹轴夹角", + "axis_cycle_difference_deg": "三轮转轴方向极差", + "third_cycle_axis_holdout_deg": "第三轮留出零位可观测轴向误差", + "third_cycle_axis_line_rms_mm": "第三轮留出轴线RMS", + "third_cycle_trajectory_p95_deg": "第三轮留出轨迹误差P95", + "state_image_sync_p95_ms": "图像与电机状态同步误差P95", + "tag_valid_rate_percent": "所需Tag同时有效率", } metric_units = { "plane_rms_mm": "mm", @@ -171,6 +203,16 @@ def three_camera_reason_zh( "monotonic_correction_deg": "°", "hysteresis_deg": "°", "cycle_travel_range_deg": "°", + "rotation_orthogonal_rms_deg": "°", + "axis_plane_rms_mm": "mm", + "axis_radial_rms_mm": "mm", + "rotation_circle_axis_difference_deg": "°", + "axis_cycle_difference_deg": "°", + "third_cycle_axis_holdout_deg": "°", + "third_cycle_axis_line_rms_mm": "mm", + "third_cycle_trajectory_p95_deg": "°", + "state_image_sync_p95_ms": "ms", + "tag_valid_rate_percent": "%", } details: list[str] = [] for failure in active.get("failures", []): @@ -209,7 +251,7 @@ def three_camera_reason_zh( ) if reason in {"waiting_for_three_cameras_tags_and_sdk", "preflight_lost"}: return ( - "正在等待三台相机内参、帧率、全部必需Tag以及机械手SDK同时就绪。", + "正在等待三台相机内参、外参身份匹配、帧率、全部必需Tag以及机械手SDK同时就绪。", "根据下面每个机位的缺失Tag和有效率排查;全部就绪后程序会进入等待开始状态。", ) if reason == "call_start": @@ -226,7 +268,7 @@ def three_camera_reason_zh( if reason == "capturing_random_validation_pose": return "正在当前随机命令位置采集复测数据。", "无需操作,保持设备不动。" if reason in {"calibration_passed", "calibration_complete"}: - return "轨迹、零位和随机复测已经完成。", "检查结果路径和quality.passed。" + return "三维轨迹、关节轴零位和第三轮留出验证已经完成。", "检查JSON、修正URDF路径和quality.passed。" if reason == "quality_failed": return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。" if reason.startswith("prepare_") or state == "PREPARE_SWEEP": @@ -234,7 +276,7 @@ def three_camera_reason_zh( if state == "RETURN_BASELINE": return "正在把已使用的标定电机恢复到统一基准命令。", "无需操作。" if state == "FITTING": - return "所有扫描已经完成,正在拟合21个关节的轨迹和零位。", "无需操作。" + return "所有扫描已经完成,正在联合拟合三维机械轴和URDF零位偏移。", "无需操作。" return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。" @@ -298,9 +340,16 @@ def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str: lines.append( f"- {VIEW_NAMES_ZH.get(str(name), str(name))}:" f"{'就绪' if view.get('ready') else '等待'}," + f"外参{'匹配' if view.get('camera_extrinsics_valid') else '不匹配'}," f"{float(view.get('detection_hz', 0.0)):.1f}Hz," f"全部必需Tag同时有效率{float(view.get('valid_rate', 0.0)):.1%}," f"当前缺失Tag={missing_text}" ) - lines.append(f"结果:{payload.get('result_path') or '尚未生成'}") + extrinsics_error = payload.get("camera_extrinsics_error") + if extrinsics_error: + lines.append(f"外参文件:{extrinsics_error}") + lines.append(f"JSON结果:{payload.get('result_path') or '尚未生成'}") + lines.append( + f"修正URDF:{payload.get('corrected_urdf_path') or '尚未生成'}" + ) return "\n".join(lines) diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py index 6e8acee..d11400b 100644 --- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py @@ -10,7 +10,7 @@ from pathlib import Path import random import re import time -from typing import Any, Mapping +from typing import Any, Mapping, Sequence import numpy as np import rclpy @@ -31,16 +31,23 @@ from .acquisition import ( update_pnp_reset_watchdog, ) from .core import ( - BASELINE_COMMAND, COMMAND_NAMES, DIRECTION_DECREASING, DIRECTION_INCREASING, + robust_rotation_summary, +) +from .extrinsics import ( + ThreeCameraExtrinsics, + camera_info_fingerprint, + load_three_camera_extrinsics, + matrix_payload, + transform_matrix, ) from .full_hand import ( - IMAGE_TRAJECTORY_JOINTS, JOINT_SPECS, MEASURED_JOINTS, SWEEP_SPECS, + THREE_CAMERA_BASELINE_COMMAND, VIEW_TAGS, JointCurveFit, SweepSpec, @@ -48,10 +55,6 @@ from .full_hand import ( build_calibration_speed_profile, build_compact_payload, calibration_auxiliary_commands, - center_splay_curve, - fit_measured_joint_curve, - fit_projected_zero, - measure_joint_observation, ) from .hikrobot_camera import configure_fastdds_large_image_transport from .pnp import ( @@ -59,8 +62,23 @@ from .pnp import ( SquareTagPose, SquareTagPoseTracker, ) -from .storage import append_jsonl, atomic_write_json +from .storage import append_jsonl, append_jsonl_many, atomic_write_json from .three_camera_diagnostics import render_three_camera_status_text_zh +from .urdf_zero import ( + AXIS_JOINTS, + CONSTRAINED_CIRCLE_JOINTS, + ZERO_AXIS_OBSERVATION_JOINTS, + ZERO_LINE_OBSERVATION_JOINTS, + ZERO_ROOT_ANCHOR_JOINTS, + JointAxisMeasurement, + ZeroSolveResult, + fit_joint_axis_measurement, + fit_rotation_joint_curve, + measure_rotation_joint_observation, + rotation_curve_holdout_errors, + solve_urdf_zero_offsets, + write_zero_corrected_urdf, +) STATE_PREFLIGHT = "PREFLIGHT" @@ -85,6 +103,24 @@ def _safe_name(value: str) -> str: return safe or "UNSET" +def _robust_pose_payload( + poses: Sequence[Mapping[str, Sequence[float]]], +) -> dict[str, list[float]]: + if not poses: + raise ValueError("at least one pose is required") + translation = np.median( + np.asarray([pose["translation_xyz_m"] for pose in poses], dtype=float), + axis=0, + ) + quaternion = robust_rotation_summary( + [pose["quaternion_xyzw"] for pose in poses] + )[0] + return { + "translation_xyz_m": [float(value) for value in translation], + "quaternion_xyzw": [float(value) for value in quaternion], + } + + def _view_pairs(view: str) -> tuple[tuple[str, str], ...]: if view == "front": return ( @@ -113,6 +149,10 @@ class FrameObservation: state_sync_error_ns: int joint_vectors_xyz_m: Mapping[str, tuple[float, float, float]] image_vectors_xy_px: Mapping[str, tuple[float, float]] + joint_quaternions_xyzw: Mapping[str, tuple[float, float, float, float]] + parent_poses_common: Mapping[str, Mapping[str, list[float]]] + child_poses_common: Mapping[str, Mapping[str, list[float]]] + joint_reprojection_error_px: Mapping[str, float] @dataclass(frozen=True) @@ -147,6 +187,10 @@ class ViewRuntime: camera_matrix: np.ndarray | None = None camera_info_valid: bool = False camera_frame: str = "" + image_width: int = 0 + image_height: int = 0 + intrinsics_sha256: str = "" + extrinsics_valid: bool = False valid_flags: deque[bool] = field(default_factory=deque) detection_times: deque[float] = field(default_factory=deque) latest_tag_quality: dict[str, TagQuality] = field(default_factory=dict) @@ -201,6 +245,7 @@ class G20ThreeCameraCalibrationNode(Node): for name in ("front", "side", "top") } self.latest_state_u8: tuple[float, ...] = () + self.last_state_at = 0.0 self.state_history: deque[StateSample] = deque(maxlen=1200) self.latest_hand_info: dict[str, Any] = {} self.commanded_speed_profile: tuple[int, ...] = () @@ -218,10 +263,16 @@ class G20ThreeCameraCalibrationNode(Node): self.sweep_index = 0 self.active_sweep: SweepItem | None = None self.sweep_frames: list[FrameObservation] = [] + # Keep synchronised endpoint observations acquired while the motor is + # held at the sweep start. If these frames are discarded and the + # target is commanded immediately, a fast motor can leave the endpoint + # before the next camera/state pair arrives. + self.sweep_start_frames: list[FrameObservation] = [] self.sweep_started_at = 0.0 self.sweep_last_valid_at = 0.0 self.sweep_endpoint_since: float | None = None self.retry_sweep_spec: SweepSpec | None = None + self.retry_resume_index: int | None = None self.fit_failure: dict[str, Any] = {} self.sweep_attempts: dict[int, int] = { spec.motor_index: 1 for spec in SWEEP_SPECS @@ -231,9 +282,9 @@ class G20ThreeCameraCalibrationNode(Node): } self.measured_fits: dict[str, JointCurveFit] = {} - self.projected_zeros_rad: dict[str, float] = {} - self.splay_zero_command_u8 = 0 - self.splay_midpoint_rad = 0.0 + self.axis_measurements: list[JointAxisMeasurement] = [] + self.zero_result: ZeroSolveResult | None = None + self.corrected_urdf_path: Path | None = None self.fit_quality_passed = False self.validation_items: list[ValidationItem] = [] @@ -292,6 +343,9 @@ class G20ThreeCameraCalibrationNode(Node): def _declare_parameters(self) -> None: self.declare_parameter("serial_number", "UNSET") self.declare_parameter("session_dir", "calibration_output/session") + self.declare_parameter("camera_extrinsics_file", "") + self.declare_parameter("source_urdf_path", "") + self.declare_parameter("corrected_urdf_output_dir", "") self.declare_parameter("commands_enabled", True) self.declare_parameter("command_topic", "/g20/cb_left_hand_control_cmd") self.declare_parameter("state_topic", "/g20/cb_left_hand_state") @@ -300,6 +354,7 @@ class G20ThreeCameraCalibrationNode(Node): "setting_topic", "/g20/cb_hand_setting_cmd" ) for view in ("front", "side", "top"): + self.declare_parameter(f"{view}_camera_serial", "") self.declare_parameter( f"{view}_camera_info_topic", f"/g20_calibration/{view}/camera/camera_info", @@ -308,8 +363,10 @@ class G20ThreeCameraCalibrationNode(Node): f"{view}_detections_topic", f"/g20_calibration/{view}/apriltag/detections", ) - self.declare_parameter("tag_size_m", 0.010) - self.declare_parameter("baseline_command_u8", list(BASELINE_COMMAND)) + self.declare_parameter("tag_size_m", 0.016) + self.declare_parameter( + "baseline_command_u8", list(THREE_CAMERA_BASELINE_COMMAND) + ) self.declare_parameter("normal_calibration_speed", 15) self.declare_parameter("index_roll_calibration_speed", 5) self.declare_parameter("index_flex_calibration_speed", 10) @@ -328,8 +385,21 @@ class G20ThreeCameraCalibrationNode(Node): self.declare_parameter("pnp_maximum_tag_tilt_deg", 75.0) self.declare_parameter("pnp_tracker_reset_seconds", 5.0) self.declare_parameter("top_pnp_invalid_reset_seconds", 1.0) - self.declare_parameter("maximum_state_image_skew_ms", 150.0) + self.declare_parameter("maximum_state_image_skew_ms", 50.0) + self.declare_parameter("axis_maximum_plane_rms_m", 0.003) + self.declare_parameter("passive_axis_maximum_plane_rms_m", 0.004) + self.declare_parameter("axis_maximum_radial_rms_m", 0.003) + self.declare_parameter("axis_maximum_rotation_circle_difference_deg", 1.0) + self.declare_parameter( + "active_maximum_rotation_orthogonal_rms_deg", 2.5 + ) + self.declare_parameter( + "passive_maximum_rotation_orthogonal_rms_deg", 7.5 + ) + self.declare_parameter("zero_maximum_axis_cycle_difference_deg", 0.75) + self.declare_parameter("zero_maximum_offset_deg", 20.0) self.declare_parameter("endpoint_tolerance_u8", 2.0) + self.declare_parameter("thumb_yaw_zero_endpoint_tolerance_u8", 3.0) self.declare_parameter("endpoint_hold_seconds", 0.5) self.declare_parameter("baseline_hold_seconds", 0.5) self.declare_parameter("position_timeout_seconds", 30.0) @@ -352,10 +422,6 @@ class G20ThreeCameraCalibrationNode(Node): self.declare_parameter( "passive_maximum_cycle_travel_difference_deg", 10.0 ) - self.declare_parameter("zero_minimum_radius_px", 20.0) - self.declare_parameter("zero_maximum_radial_rms_px", 2.0) - self.declare_parameter("zero_maximum_radial_p95_px", 3.5) - self.declare_parameter("zero_maximum_round_difference_deg", 1.0) self.declare_parameter("maximum_monotonic_correction_deg", 2.0) self.declare_parameter("maximum_hysteresis_deg", 5.0) self.declare_parameter("passive_maximum_monotonic_correction_deg", 3.0) @@ -365,8 +431,8 @@ class G20ThreeCameraCalibrationNode(Node): self.declare_parameter("validation_frames", 10) self.declare_parameter("validation_seed", 20260804) self.declare_parameter("validation_timeout_seconds", 20.0) - self.declare_parameter("maximum_validation_mae_deg", 2.0) - self.declare_parameter("maximum_validation_p95_deg", 3.0) + self.declare_parameter("maximum_validation_mae_deg", 1.0) + self.declare_parameter("maximum_validation_p95_deg", 2.0) def _load_parameters(self) -> None: def value(name: str) -> Any: @@ -376,6 +442,30 @@ class G20ThreeCameraCalibrationNode(Node): if self.serial_number == "UNSET": raise ValueError("serial_number is required") self.session_dir = Path(str(value("session_dir"))).expanduser().resolve() + self.camera_extrinsics_file = Path( + str(value("camera_extrinsics_file")) + ).expanduser().resolve() + self.source_urdf_path = Path( + str(value("source_urdf_path")) + ).expanduser().resolve() + output_value = str(value("corrected_urdf_output_dir")) + self.corrected_urdf_output_dir = ( + Path(output_value).expanduser().resolve() + if output_value + else self.source_urdf_path.parent + ) + self.camera_serials = { + view: str(value(f"{view}_camera_serial")) + for view in ("front", "side", "top") + } + self.extrinsics: ThreeCameraExtrinsics | None = None + self.extrinsics_error = "" + try: + self.extrinsics = load_three_camera_extrinsics( + self.camera_extrinsics_file + ) + except Exception as error: + self.extrinsics_error = str(error) self.commands_enabled = bool(value("commands_enabled")) self.command_topic = str(value("command_topic")) self.state_topic = str(value("state_topic")) @@ -430,7 +520,34 @@ class G20ThreeCameraCalibrationNode(Node): self.maximum_state_image_skew_ns = int( float(value("maximum_state_image_skew_ms")) * 1_000_000.0 ) + self.axis_maximum_plane_rms_m = float( + value("axis_maximum_plane_rms_m") + ) + self.passive_axis_maximum_plane_rms_m = float( + value("passive_axis_maximum_plane_rms_m") + ) + self.axis_maximum_radial_rms_m = float( + value("axis_maximum_radial_rms_m") + ) + self.axis_maximum_rotation_circle_difference_rad = math.radians( + float(value("axis_maximum_rotation_circle_difference_deg")) + ) + self.active_maximum_rotation_orthogonal_rms_rad = math.radians( + float(value("active_maximum_rotation_orthogonal_rms_deg")) + ) + self.passive_maximum_rotation_orthogonal_rms_rad = math.radians( + float(value("passive_maximum_rotation_orthogonal_rms_deg")) + ) + self.zero_maximum_axis_cycle_difference_rad = math.radians( + float(value("zero_maximum_axis_cycle_difference_deg")) + ) + self.zero_maximum_offset_rad = math.radians( + float(value("zero_maximum_offset_deg")) + ) self.endpoint_tolerance_u8 = float(value("endpoint_tolerance_u8")) + self.thumb_yaw_zero_endpoint_tolerance_u8 = float( + value("thumb_yaw_zero_endpoint_tolerance_u8") + ) self.endpoint_hold_seconds = float(value("endpoint_hold_seconds")) self.baseline_hold_seconds = float(value("baseline_hold_seconds")) self.position_timeout_seconds = float(value("position_timeout_seconds")) @@ -467,16 +584,6 @@ class G20ThreeCameraCalibrationNode(Node): self.passive_maximum_cycle_travel_difference_rad = math.radians( float(value("passive_maximum_cycle_travel_difference_deg")) ) - self.zero_minimum_radius_px = float(value("zero_minimum_radius_px")) - self.zero_maximum_radial_rms_px = float( - value("zero_maximum_radial_rms_px") - ) - self.zero_maximum_radial_p95_px = float( - value("zero_maximum_radial_p95_px") - ) - self.zero_maximum_round_difference_rad = math.radians( - float(value("zero_maximum_round_difference_deg")) - ) self.maximum_monotonic_correction_rad = math.radians( float(value("maximum_monotonic_correction_deg")) ) @@ -506,6 +613,26 @@ class G20ThreeCameraCalibrationNode(Node): raise ValueError("baseline_command_u8 must contain exactly 20 values") if any(value < 0 or value > 255 for value in self.baseline_command): raise ValueError("baseline_command_u8 values must be in [0, 255]") + if self.baseline_command != THREE_CAMERA_BASELINE_COMMAND: + raise ValueError( + "schema v4 requires the fixed G20 three-camera baseline" + ) + if any(not serial for serial in self.camera_serials.values()): + raise ValueError("front/side/top camera serial parameters are required") + if not self.source_urdf_path.is_file(): + raise ValueError( + f"source_urdf_path does not exist: {self.source_urdf_path}" + ) + if ( + "zero_calibrated" in self.source_urdf_path.stem.lower() + or re.search( + r"calibrated_20\d{6}", + self.source_urdf_path.stem.lower(), + ) + ): + raise ValueError( + "source_urdf_path must be the original CAD URDF" + ) if not 0 <= self.normal_calibration_speed <= 255: raise ValueError("normal_calibration_speed must be in [0, 255]") if not 0 <= self.index_roll_calibration_speed <= 255: @@ -514,6 +641,17 @@ class G20ThreeCameraCalibrationNode(Node): raise ValueError("index_flex_calibration_speed must be in [0, 255]") if self.speed_setting_settle_seconds < 0.0: raise ValueError("speed_setting_settle_seconds must be non-negative") + if not 0.0 <= self.endpoint_tolerance_u8 <= 10.0: + raise ValueError("endpoint_tolerance_u8 must be in [0, 10]") + if not ( + self.endpoint_tolerance_u8 + <= self.thumb_yaw_zero_endpoint_tolerance_u8 + <= 10.0 + ): + raise ValueError( + "thumb_yaw_zero_endpoint_tolerance_u8 must be between the " + "default endpoint tolerance and 10" + ) if self.top_pnp_invalid_reset_seconds <= 0.0: raise ValueError("top_pnp_invalid_reset_seconds must be positive") if not all( @@ -522,6 +660,10 @@ class G20ThreeCameraCalibrationNode(Node): self.image_trajectory_maximum_radial_rms_px, self.image_trajectory_maximum_radial_p95_px, self.image_trajectory_minimum_radius_px, + self.axis_maximum_plane_rms_m, + self.passive_axis_maximum_plane_rms_m, + self.active_maximum_rotation_orthogonal_rms_rad, + self.passive_maximum_rotation_orthogonal_rms_rad, self.trajectory_maximum_cycle_travel_difference_rad, self.passive_maximum_cycle_travel_difference_rad, self.passive_maximum_monotonic_correction_rad, @@ -593,6 +735,29 @@ class G20ThreeCameraCalibrationNode(Node): runtime.camera_info_valid = valid runtime.camera_matrix = matrix if valid else None runtime.camera_frame = str(message.header.frame_id) + runtime.image_width = int(message.width) + runtime.image_height = int(message.height) + runtime.intrinsics_sha256 = ( + "" + if matrix is None + else camera_info_fingerprint( + width=message.width, + height=message.height, + camera_matrix=message.k, + distortion=message.d, + rectification=message.r, + projection=message.p, + ) + ) + runtime.extrinsics_valid = False + if valid and self.extrinsics is not None: + runtime.extrinsics_valid = self.extrinsics.camera_matches( + view, + serial_number=self.camera_serials[view], + width=runtime.image_width, + height=runtime.image_height, + intrinsics_sha256=runtime.intrinsics_sha256, + ) def _state_callback(self, message: JointState) -> None: if len(message.position) != 20: @@ -606,6 +771,7 @@ class G20ThreeCameraCalibrationNode(Node): if stamp <= 0: stamp = int(self.get_clock().now().nanoseconds) self.latest_state_u8 = state + self.last_state_at = time.monotonic() if not self.state_history or stamp > self.state_history[-1].stamp_ns: self.state_history.append(StateSample(stamp, state)) @@ -734,6 +900,13 @@ class G20ThreeCameraCalibrationNode(Node): state_u8, sync_error_ns = matched joint_vectors: dict[str, tuple[float, float, float]] = {} image_vectors: dict[str, tuple[float, float]] = {} + joint_quaternions: dict[str, tuple[float, float, float, float]] = {} + parent_poses_common: dict[str, Mapping[str, list[float]]] = {} + child_poses_common: dict[str, Mapping[str, list[float]]] = {} + joint_reprojection: dict[str, float] = {} + if self.extrinsics is None or not runtime.extrinsics_valid: + return + front_from_view = self.extrinsics.transform(view) for name, spec in JOINT_SPECS.items(): if not spec.measured or spec.view != view: continue @@ -749,8 +922,26 @@ class G20ThreeCameraCalibrationNode(Node): centres_by_role[spec.child_role] - centres_by_role[spec.parent_role] ) + parent_matrix = front_from_view @ transform_matrix( + parent.translation_xyz_m, parent.quaternion_xyzw + ) + child_matrix = front_from_view @ transform_matrix( + child.translation_xyz_m, child.quaternion_xyzw + ) + relative_rotation = Rotation.from_matrix( + parent_matrix[:3, :3] + ).inv() * Rotation.from_matrix(child_matrix[:3, :3]) joint_vectors[name] = tuple(float(value) for value in relative) image_vectors[name] = tuple(float(value) for value in image_relative) + joint_quaternions[name] = tuple( + float(value) for value in relative_rotation.as_quat() + ) + parent_poses_common[name] = matrix_payload(parent_matrix) + child_poses_common[name] = matrix_payload(child_matrix) + joint_reprojection[name] = max( + float(parent.reprojection_error_px), + float(child.reprojection_error_px), + ) observation = FrameObservation( stamp_ns=stamp, received_at=now, @@ -759,10 +950,30 @@ class G20ThreeCameraCalibrationNode(Node): state_sync_error_ns=int(sync_error_ns), joint_vectors_xyz_m=joint_vectors, image_vectors_xy_px=image_vectors, + joint_quaternions_xyzw=joint_quaternions, + parent_poses_common=parent_poses_common, + child_poses_common=child_poses_common, + joint_reprojection_error_px=joint_reprojection, ) self._accept_frame(observation) def _accept_frame(self, observation: FrameObservation) -> None: + if self.state == STATE_PREPARE_SWEEP and self.active_sweep is not None: + if observation.view != self.active_sweep.spec.view: + return + if not self._motion_command_reached( + self.active_sweep.spec, + self.active_sweep.start_u8, + observation.state_u8, + ): + return + self.sweep_start_frames.append(observation) + maximum_start_frames = max( + 1, int(getattr(self, "preflight_frames", 30)) + ) + if len(self.sweep_start_frames) > maximum_start_frames: + del self.sweep_start_frames[:-maximum_start_frames] + return if self.state == STATE_SWEEP and self.active_sweep is not None: if observation.view != self.active_sweep.spec.view: return @@ -795,6 +1006,7 @@ class G20ThreeCameraCalibrationNode(Node): minimum_frames = min(30, self.preflight_frames) return bool( runtime.camera_info_valid + and runtime.extrinsics_valid and len(runtime.valid_flags) >= minimum_frames and runtime.valid_rate >= self.minimum_detection_rate and runtime.detection_hz >= self.minimum_detection_hz @@ -803,7 +1015,9 @@ class G20ThreeCameraCalibrationNode(Node): def _all_preflight_ready(self, now: float) -> bool: return bool( - len(self.latest_state_u8) == 20 + self.extrinsics is not None + and len(self.latest_state_u8) == 20 + and now - self.last_state_at <= 1.0 and all(self._view_ready(runtime, now) for runtime in self.views.values()) ) @@ -817,7 +1031,10 @@ class G20ThreeCameraCalibrationNode(Node): return None def _resume_preflight_ready(self, now: float) -> bool: - if len(self.latest_state_u8) != 20: + if ( + len(self.latest_state_u8) != 20 + or now - self.last_state_at > 1.0 + ): return False active_view = self._active_view_for_resume() return all( @@ -846,7 +1063,6 @@ class G20ThreeCameraCalibrationNode(Node): "reason": self.paused_reason, }, ) - self.retry_sweep_spec = None self.fit_failure = {} return spec @@ -883,11 +1099,32 @@ class G20ThreeCameraCalibrationNode(Node): ] self.sweep_index = 0 self.retry_sweep_spec = None + self.retry_resume_index = None self.fit_failure = {} self.sweep_attempts = { spec.motor_index: 1 for spec in SWEEP_SPECS } self.records_by_joint = {name: [] for name in MEASURED_JOINTS} + self.axis_measurements.clear() + self.zero_result = None + self.corrected_urdf_path = None + self.validation_errors_rad.clear() + self.completed_payload = None + self.sweep_frames.clear() + self.sweep_start_frames.clear() + append_jsonl( + self.raw_path, + { + "kind": "session_start", + "tag_family": "36h11", + "tag_size_m": float(self.tag_size_m), + "baseline_command_u8": [ + int(value) for value in self.baseline_command + ], + "camera_extrinsics_file": str(self.camera_extrinsics_file), + "source_urdf_path": str(self.source_urdf_path), + }, + ) self._begin_return_baseline("next_sweep") response.success = True response.message = "three-camera calibration started" @@ -929,6 +1166,7 @@ class G20ThreeCameraCalibrationNode(Node): runtime.group_tracker.reset() runtime.pnp_invalid_since = None self.sweep_frames.clear() + self.sweep_start_frames.clear() self.validation_frames_buffer.clear() if self.retry_sweep_spec is not None: self._prepare_failed_sweep_retry() @@ -1048,10 +1286,28 @@ class G20ThreeCameraCalibrationNode(Node): indices.update(calibration_auxiliary_commands(spec)) return all( abs(float(state[index]) - expected[index]) - <= self.endpoint_tolerance_u8 + <= ( + self._endpoint_tolerance_for_spec(spec, command_u8) + if index == spec.motor_index + else self.endpoint_tolerance_u8 + ) for index in indices ) + def _endpoint_tolerance_for_spec( + self, spec: SweepSpec, endpoint_u8: int + ) -> float: + """Return the measured feedback deadband for one sweep endpoint.""" + if spec.motor_index == 10 and int(endpoint_u8) == 0: + return float( + getattr( + self, + "thumb_yaw_zero_endpoint_tolerance_u8", + self.endpoint_tolerance_u8, + ) + ) + return float(self.endpoint_tolerance_u8) + def _start_next_sweep(self) -> None: if self.sweep_index >= len(self.sweep_items): self.active_sweep = None @@ -1059,6 +1315,8 @@ class G20ThreeCameraCalibrationNode(Node): return self.active_sweep = self.sweep_items[self.sweep_index] item = self.active_sweep + self.sweep_frames.clear() + self.sweep_start_frames.clear() self.position_hold_since = None self.motion_stage_started_at = time.monotonic() self.state = STATE_PREPARE_SWEEP @@ -1078,6 +1336,8 @@ class G20ThreeCameraCalibrationNode(Node): def _begin_active_sweep(self, now: float) -> None: assert self.active_sweep is not None self.sweep_frames.clear() + self.sweep_frames.extend(self.sweep_start_frames) + self.sweep_start_frames.clear() self.sweep_started_at = now self.sweep_last_valid_at = now self.sweep_endpoint_since = None @@ -1105,52 +1365,96 @@ class G20ThreeCameraCalibrationNode(Node): *, relaxed: bool = False, ) -> JointCurveFit: - return fit_measured_joint_curve( + del relaxed + zero_command = int( + self.baseline_command[JOINT_SPECS[joint_name].motor_index] + ) + return fit_rotation_joint_curve( + records, zero_command_u8=zero_command + ) + + def _fit_axis_measurement( + self, joint_name: str, cycle: int + ) -> JointAxisMeasurement: + constraint: Sequence[float] | None = None + upstream_joint = { + # These neighbouring axes are parallel in the fixed source URDF. + # A small planar Tag's monocular PnP orientation can have a stable + # field-dependent bias, so use the well-observed upstream axis + # direction and let each Tag-centre trajectory independently fit + # its physical axis line. This changes no URDF geometry and is + # valid only because the source-URDF axes are deliberately locked. + "thumb_mcp": "thumb_cmc_pitch", + "thumb_ip": "thumb_mcp", + "index_pip": "index_mcp_pitch", + "index_dip": "index_pip", + }.get(joint_name) + if upstream_joint is not None: + # Resolve recursively so thumb_ip receives the already constrained + # thumb_mcp direction (and index_dip the constrained index_pip + # direction), rather than reintroducing the raw PnP orientation at + # the last passive joint. + upstream = self._fit_axis_measurement(upstream_joint, cycle) + constraint = upstream.axis_common_xyz + spec = JOINT_SPECS[joint_name] + return fit_joint_axis_measurement( joint_name, - records, - maximum_plane_rms_m=( - float("inf") - if relaxed - else self.trajectory_maximum_plane_rms_m - ), - maximum_radial_rms_m=( - float("inf") - if relaxed - else self.trajectory_maximum_radial_rms_m - ), - minimum_radius_m=( - 0.0 if relaxed else self.trajectory_minimum_radius_m - ), - minimum_arc_rad=( - 0.0 if relaxed else self.trajectory_minimum_arc_rad - ), - image_maximum_radial_rms_px=( - float("inf") - if relaxed - else self.image_trajectory_maximum_radial_rms_px - ), - image_maximum_radial_p95_px=( - float("inf") - if relaxed - else self.image_trajectory_maximum_radial_p95_px - ), - image_minimum_radius_px=( - 0.0 if relaxed else self.image_trajectory_minimum_radius_px + self.records_by_joint[joint_name], + cycle=cycle, + zero_command_u8=int( + self.baseline_command[spec.motor_index] ), + axis_common_constraint=constraint, ) def _provisional_fit_failures( self, spec: SweepSpec ) -> list[dict[str, Any]]: - """Check geometry, repeatability and zero after one six-way task.""" + """Check full-pose curve and 3-D axis after one six-way task.""" failures: list[dict[str, Any]] = [] - fits: dict[str, JointCurveFit] = {} + if hasattr(self, "views"): + valid_rate = float(self.views[spec.view].valid_rate) + if valid_rate < self.minimum_detection_rate: + failures.append( + { + "joint": spec.joints[0], + "metric": "tag_valid_rate_percent", + "actual": round(100.0 * valid_rate, 3), + "limit": round( + 100.0 * self.minimum_detection_rate, 3 + ), + "comparison": "minimum", + } + ) for joint_name in spec.joints: + records = self.records_by_joint[joint_name] + sync_p95 = float( + np.percentile( + [ + float(record.get("state_image_sync_error_ms", 0.0)) + for record in records + ], + 95.0, + ) + ) + sync_limit_ms = ( + getattr(self, "maximum_state_image_skew_ns", 50_000_000) + / 1_000_000.0 + ) + if sync_p95 > sync_limit_ms: + failures.append( + { + "joint": joint_name, + "metric": "state_image_sync_p95_ms", + "actual": round(sync_p95, 6), + "limit": round(sync_limit_ms, 6), + "comparison": "maximum", + } + ) try: fit = self._fit_joint_records( joint_name, - self.records_by_joint[joint_name], - relaxed=True, + records, ) except Exception as error: failures.append( @@ -1161,62 +1465,26 @@ class G20ThreeCameraCalibrationNode(Node): } ) continue - fits[joint_name] = fit - quality = fit.quality - if joint_name in IMAGE_TRAJECTORY_JOINTS: - checks = ( - ( - "image_radial_rms_px", - float(quality["radial_rms_px"]), - self.image_trajectory_maximum_radial_rms_px, - "maximum", + checks = ( + ( + "rotation_orthogonal_rms_deg", + math.degrees( + float(fit.quality["rotation_orthogonal_rms_rad"]) ), - ( - "image_radial_p95_px", - float(quality["radial_p95_px"]), - self.image_trajectory_maximum_radial_p95_px, - "maximum", + math.degrees( + self.active_maximum_rotation_orthogonal_rms_rad + if JOINT_SPECS[joint_name].active + else self.passive_maximum_rotation_orthogonal_rms_rad ), - ( - "image_radius_px", - float(quality["radius_px"]), - self.image_trajectory_minimum_radius_px, - "minimum", - ), - ( - "arc_deg", - math.degrees(float(quality["arc_rad"])), - math.degrees(self.trajectory_minimum_arc_rad), - "minimum", - ), - ) - else: - checks = ( - ( - "plane_rms_mm", - 1000.0 * float(quality["plane_rms_m"]), - 1000.0 * self.trajectory_maximum_plane_rms_m, - "maximum", - ), - ( - "radial_rms_mm", - 1000.0 * float(quality["radial_rms_m"]), - 1000.0 * self.trajectory_maximum_radial_rms_m, - "maximum", - ), - ( - "radius_mm", - 1000.0 * float(quality["radius_m"]), - 1000.0 * self.trajectory_minimum_radius_m, - "minimum", - ), - ( - "arc_deg", - math.degrees(float(quality["arc_rad"])), - math.degrees(self.trajectory_minimum_arc_rad), - "minimum", - ), - ) + "maximum", + ), + ( + "arc_deg", + math.degrees(float(fit.quality["arc_rad"])), + math.degrees(self.trajectory_minimum_arc_rad), + "minimum", + ), + ) joint_spec = JOINT_SPECS[joint_name] monotonic_limit = ( self.maximum_monotonic_correction_rad @@ -1228,7 +1496,7 @@ class G20ThreeCameraCalibrationNode(Node): if joint_spec.active else self.passive_maximum_hysteresis_rad ) - checks += ( + checks = checks + ( ( "monotonic_correction_deg", math.degrees(fit.maximum_monotonic_correction_rad), @@ -1260,6 +1528,7 @@ class G20ThreeCameraCalibrationNode(Node): ) cycle_travels: list[float] = [] + cycle_axes: list[np.ndarray] = [] for cycle in range(self.repetitions): cycle_records = [ record @@ -1286,6 +1555,66 @@ class G20ThreeCameraCalibrationNode(Node): - float(cycle_fit.angle_rad[255]) ) ) + try: + axis = self._fit_axis_measurement(joint_name, cycle) + except Exception as error: + failures.append( + { + "joint": joint_name, + "metric": "axis_fit", + "cycle": cycle + 1, + "reason": str(error), + } + ) + continue + cycle_axes.append(np.asarray(axis.axis_common_xyz, dtype=float)) + plane_limit = ( + self.axis_maximum_plane_rms_m + if joint_spec.active + else self.passive_axis_maximum_plane_rms_m + ) + axis_checks = [ + ( + "axis_radial_rms_mm", + 1000.0 * axis.radial_rms_m, + 1000.0 * self.axis_maximum_radial_rms_m, + ), + ] + if joint_name not in CONSTRAINED_CIRCLE_JOINTS: + axis_checks.extend( + [ + ( + "axis_plane_rms_mm", + 1000.0 * axis.plane_rms_m, + 1000.0 * plane_limit, + ), + ( + "rotation_circle_axis_difference_deg", + math.degrees( + axis.rotation_circle_axis_difference_rad + ), + math.degrees( + self.axis_maximum_rotation_circle_difference_rad + ), + ), + ] + ) + # With a trusted orientation-constrained axis, plane_rms is + # scatter *along* that infinite axis. It cannot change the + # axis line or joint zero, so only the perpendicular/radial + # error is an admissibility check for constrained circles. + for metric, actual, limit in axis_checks: + if actual > limit: + failures.append( + { + "joint": joint_name, + "metric": metric, + "cycle": cycle + 1, + "actual": round(float(actual), 6), + "limit": round(float(limit), 6), + "comparison": "maximum", + } + ) if len(cycle_travels) == self.repetitions: travel_range = max(cycle_travels) - min(cycle_travels) cycle_limit = ( @@ -1307,41 +1636,29 @@ class G20ThreeCameraCalibrationNode(Node): ], } ) - - failed_joints = {str(item["joint"]) for item in failures} - for joint_name in spec.joints: - if joint_name in failed_joints or joint_name not in fits: - continue - joint_spec = JOINT_SPECS[joint_name] - if joint_spec.zero_kind == "projected": - try: - fit_projected_zero( - self.records_by_joint[joint_name], - minimum_radius_px=self.zero_minimum_radius_px, - minimum_arc_rad=self.trajectory_minimum_arc_rad, - maximum_radial_rms_px=self.zero_maximum_radial_rms_px, - maximum_radial_p95_px=self.zero_maximum_radial_p95_px, - maximum_round_difference_rad=( - self.zero_maximum_round_difference_rad - ), - ) - except Exception as error: + if len(cycle_axes) == self.repetitions: + maximum_difference = 0.0 + for left in cycle_axes: + for right in cycle_axes: + maximum_difference = max( + maximum_difference, + math.acos( + abs(float(np.clip(left @ right, -1.0, 1.0))) + ), + ) + if maximum_difference > self.zero_maximum_axis_cycle_difference_rad: failures.append( { "joint": joint_name, - "metric": "projected_zero", - "reason": str(error), - } - ) - elif joint_spec.zero_kind == "travel_midpoint": - try: - center_splay_curve(fits[joint_name]) - except Exception as error: - failures.append( - { - "joint": joint_name, - "metric": "travel_midpoint", - "reason": str(error), + "metric": "axis_cycle_difference_deg", + "actual": round(math.degrees(maximum_difference), 6), + "limit": round( + math.degrees( + self.zero_maximum_axis_cycle_difference_rad + ), + 6, + ), + "comparison": "maximum", } ) return failures @@ -1351,6 +1668,7 @@ class G20ThreeCameraCalibrationNode(Node): ) -> None: start_index = self._sweep_spec_start_index(spec) attempt = self.sweep_attempts.get(spec.motor_index, 1) + self.retry_resume_index = self.sweep_index self.retry_sweep_spec = spec self.sweep_index = start_index self.fit_failure = { @@ -1378,11 +1696,17 @@ class G20ThreeCameraCalibrationNode(Node): states = np.asarray( [float(frame.state_u8[motor]) for frame in self.sweep_frames] ) + start_tolerance = self._endpoint_tolerance_for_spec( + item.spec, item.start_u8 + ) + target_tolerance = self._endpoint_tolerance_for_spec( + item.spec, item.target_u8 + ) bins: dict[int, list[FrameObservation]] = {} for frame, state in zip(self.sweep_frames, states): - if abs(state - item.start_u8) <= self.endpoint_tolerance_u8: + if abs(state - item.start_u8) <= start_tolerance: command = item.start_u8 - elif abs(state - item.target_u8) <= self.endpoint_tolerance_u8: + elif abs(state - item.target_u8) <= target_tolerance: command = item.target_u8 else: command = int(np.clip(np.rint(state), 0, 255)) @@ -1398,6 +1722,45 @@ class G20ThreeCameraCalibrationNode(Node): self._pause("sweep_bin_gap_too_large") return + append_jsonl_many( + self.raw_path, + ( + { + "kind": "synchronised_frame", + "attempt": self.sweep_attempts.get(motor, 1), + "view": item.spec.view, + "motor_index": motor, + "joints": list(item.spec.joints), + "cycle": item.cycle, + "direction": item.direction, + "image_stamp_ns": int(frame.stamp_ns), + "actual_state_u8": [ + float(value) for value in frame.state_u8 + ], + "state_image_sync_error_ms": round( + abs(frame.state_sync_error_ns) / 1_000_000.0, 6 + ), + "relative_quaternion_xyzw": { + name: list(frame.joint_quaternions_xyzw[name]) + for name in item.spec.joints + }, + "parent_pose_common": { + name: frame.parent_poses_common[name] + for name in item.spec.joints + }, + "child_pose_common": { + name: frame.child_poses_common[name] + for name in item.spec.joints + }, + "pnp_reprojection_error_px": { + name: frame.joint_reprojection_error_px[name] + for name in item.spec.joints + }, + } + for frame in self.sweep_frames + ), + ) + for joint_name in item.spec.joints: for command in commands: frames = bins[command] @@ -1413,6 +1776,28 @@ class G20ThreeCameraCalibrationNode(Node): ), axis=0, ) + relative_quaternion = robust_rotation_summary( + [ + frame.joint_quaternions_xyzw[joint_name] + for frame in frames + ] + )[0] + parent_pose = _robust_pose_payload( + [ + frame.parent_poses_common[joint_name] + for frame in frames + ] + ) + child_pose = _robust_pose_payload( + [ + frame.child_poses_common[joint_name] + for frame in frames + ] + ) + state = np.median( + np.asarray([frame.state_u8 for frame in frames], dtype=float), + axis=0, + ) record = { "kind": "sample", "attempt": self.sweep_attempts.get(motor, 1), @@ -1428,6 +1813,37 @@ class G20ThreeCameraCalibrationNode(Node): "image_relative_xy_px": [ float(value) for value in image_vector ], + "relative_quaternion_xyzw": [ + float(value) for value in relative_quaternion + ], + "parent_pose_common": parent_pose, + "child_pose_common": child_pose, + "state_u8": [float(value) for value in state], + "state_image_sync_error_ms": round( + float( + np.percentile( + [ + abs(frame.state_sync_error_ns) + for frame in frames + ], + 95.0, + ) + / 1_000_000.0 + ), + 6, + ), + "pnp_reprojection_error_px": round( + float( + np.percentile( + [ + frame.joint_reprojection_error_px[joint_name] + for frame in frames + ], + 95.0, + ) + ), + 6, + ), "valid_frames": len(frames), } self.records_by_joint[joint_name].append(record) @@ -1448,7 +1864,11 @@ class G20ThreeCameraCalibrationNode(Node): ) return if self.retry_sweep_spec == previous_spec: + resume_index = self.retry_resume_index self.retry_sweep_spec = None + self.retry_resume_index = None + if resume_index is not None: + self.sweep_index = resume_index self.fit_failure = {} if self.sweep_index >= len(self.sweep_items): self._begin_return_baseline("fit") @@ -1459,34 +1879,69 @@ class G20ThreeCameraCalibrationNode(Node): def _fit_all_curves(self) -> None: self.state = STATE_FITTING - self.reason = "fitting_21_joint_compact_calibration" + self.reason = "fitting_3d_axes_and_urdf_zero_offsets" + training_fits: dict[str, JointCurveFit] = {} + holdout_by_joint: dict[str, tuple[float, ...]] = {} measured: dict[str, JointCurveFit] = {} for name in MEASURED_JOINTS: + training_records = [ + record + for record in self.records_by_joint[name] + if int(record["cycle"]) in {0, 1} + ] + holdout_records = [ + record + for record in self.records_by_joint[name] + if int(record["cycle"]) == 2 + ] + zero_command = int( + self.baseline_command[JOINT_SPECS[name].motor_index] + ) + training_fits[name] = self._fit_joint_records( + name, training_records + ) + holdout_by_joint[name] = rotation_curve_holdout_errors( + training_fits[name], + holdout_records, + zero_command_u8=zero_command, + ) measured[name] = self._fit_joint_records( name, self.records_by_joint[name] ) - centred, zero_command, midpoint = center_splay_curve( - measured["index_mcp_roll"] + axes: list[JointAxisMeasurement] = [] + for name in AXIS_JOINTS: + for cycle in range(self.repetitions): + axes.append(self._fit_axis_measurement(name, cycle)) + + motor_by_joint = { + name: int(spec.motor_index) for name, spec in JOINT_SPECS.items() + } + holdout_zero_result = solve_urdf_zero_offsets( + source_urdf=self.source_urdf_path, + measurements=axes, + curves=training_fits, + motor_by_joint=motor_by_joint, + maximum_offset_rad=self.zero_maximum_offset_rad, + maximum_validation_mae_rad=self.maximum_validation_mae_rad, + maximum_validation_p95_rad=self.maximum_validation_p95_rad, ) - measured["index_mcp_roll"] = centred - projected: dict[str, float] = {} - for name, spec in JOINT_SPECS.items(): - if spec.zero_kind != "projected": - continue - projected[name] = fit_projected_zero( - self.records_by_joint[name], - minimum_radius_px=self.zero_minimum_radius_px, - minimum_arc_rad=self.trajectory_minimum_arc_rad, - maximum_radial_rms_px=self.zero_maximum_radial_rms_px, - maximum_radial_p95_px=self.zero_maximum_radial_p95_px, - maximum_round_difference_rad=( - self.zero_maximum_round_difference_rad - ), + holdout_errors = np.abs( + np.asarray( + [ + value + for values in holdout_by_joint.values() + for value in values + ], + dtype=float, ) - self.measured_fits = measured - self.projected_zeros_rad = projected - self.splay_zero_command_u8 = zero_command - self.splay_midpoint_rad = midpoint + ) + trajectory_holdout_passed = bool( + holdout_errors.size + and float(np.mean(holdout_errors)) + <= self.maximum_validation_mae_rad + and float(np.percentile(holdout_errors, 95.0)) + <= self.maximum_validation_p95_rad + ) self.fit_quality_passed = all( fit.maximum_monotonic_correction_rad <= ( @@ -1502,13 +1957,107 @@ class G20ThreeCameraCalibrationNode(Node): ) for name, fit in measured.items() ) + if not trajectory_holdout_passed or not holdout_zero_result.passed: + trajectory_score = { + name: float(np.percentile(np.abs(values), 95.0)) + for name, values in holdout_by_joint.items() + } + zero_axis_errors = { + name: error + for name, error in ( + holdout_zero_result.validation_error_by_joint_rad.items() + ) + if name in ZERO_AXIS_OBSERVATION_JOINTS + and name not in ZERO_ROOT_ANCHOR_JOINTS + } + zero_line_errors = { + name: error + for name, error in ( + holdout_zero_result.validation_line_error_by_joint_m.items() + ) + if name in ZERO_LINE_OBSERVATION_JOINTS + and name not in ZERO_ROOT_ANCHOR_JOINTS + } + if not trajectory_holdout_passed: + worst_joint = max(trajectory_score, key=trajectory_score.get) + elif zero_axis_errors and max(zero_axis_errors.values()) > ( + self.maximum_validation_p95_rad + ): + worst_joint = max( + zero_axis_errors, key=zero_axis_errors.get + ) + elif zero_line_errors: + worst_joint = max(zero_line_errors, key=zero_line_errors.get) + else: + worst_joint = "thumb_cmc_yaw" + failed_spec = next( + spec for spec in SWEEP_SPECS if worst_joint in spec.joints + ) + failures: list[dict[str, Any]] = [ + { + "joint": name, + "metric": "third_cycle_trajectory_p95_deg", + "actual": round(math.degrees(error), 6), + "limit": math.degrees( + self.maximum_validation_p95_rad + ), + "comparison": "maximum", + } + for name, error in trajectory_score.items() + if error > self.maximum_validation_p95_rad + ] + failures.extend( + { + "joint": name, + "metric": "third_cycle_axis_holdout_deg", + "actual": round(math.degrees(error), 6), + "limit": math.degrees( + self.maximum_validation_p95_rad + ), + "comparison": "maximum", + } + for name, error in zero_axis_errors.items() + if error > self.maximum_validation_p95_rad + ) + if holdout_zero_result.axis_line_rms_m > 0.003: + failures.append( + { + "joint": worst_joint, + "metric": "third_cycle_axis_line_rms_mm", + "actual": round( + 1000.0 * holdout_zero_result.axis_line_rms_m, 6 + ), + "limit": 3.0, + "comparison": "maximum", + } + ) + self._pause_for_provisional_fit_failure(failed_spec, failures) + return + zero_result = solve_urdf_zero_offsets( + source_urdf=self.source_urdf_path, + measurements=axes, + curves=measured, + motor_by_joint=motor_by_joint, + maximum_offset_rad=self.zero_maximum_offset_rad, + maximum_validation_mae_rad=self.maximum_validation_mae_rad, + maximum_validation_p95_rad=self.maximum_validation_p95_rad, + ) + if not zero_result.passed: + raise RuntimeError("final_all_cycle_zero_refit_failed") + self.measured_fits = measured + self.axis_measurements = axes + self.zero_result = zero_result + self.validation_errors_rad = [ + float(value) + for values in holdout_by_joint.values() + for value in values + ] if self.validation_enabled: self._build_validation_items() self._start_next_validation() else: self.validation_items.clear() self.validation_index = 0 - self.validation_errors_rad.clear() self._begin_return_baseline("finalize") def _build_validation_items(self) -> None: @@ -1522,7 +2071,6 @@ class G20ThreeCameraCalibrationNode(Node): ValidationItem(spec, command) for command in commands ) self.validation_index = 0 - self.validation_errors_rad.clear() def _start_next_validation(self) -> None: if self.validation_index >= len(self.validation_items): @@ -1555,19 +2103,15 @@ class G20ThreeCameraCalibrationNode(Node): item = self.active_validation command = item.command_u8 for name in item.spec.joints: - vectors = np.asarray( - [frame.joint_vectors_xyz_m[name] for frame in self.validation_frames_buffer] - ) - image_vectors = np.asarray( + quaternion = robust_rotation_summary( [ - frame.image_vectors_xy_px[name] + frame.joint_quaternions_xyzw[name] for frame in self.validation_frames_buffer ] - ) - observed = measure_joint_observation( + )[0] + observed = measure_rotation_joint_observation( self.measured_fits[name], - vector_xyz_m=np.median(vectors, axis=0), - image_vector_xy_px=np.median(image_vectors, axis=0), + quaternion, ) expected = float(self.measured_fits[name].angle_rad[command]) self.validation_errors_rad.append(observed - expected) @@ -1582,23 +2126,60 @@ class G20ThreeCameraCalibrationNode(Node): self._start_next_validation() def _finalize(self) -> None: + if self.zero_result is None: + raise RuntimeError("URDF zero solution is missing") errors = np.abs(np.asarray(self.validation_errors_rad, dtype=float)) validation_passed = bool( - not self.validation_enabled - or ( - errors.size > 0 - and float(np.mean(errors)) <= self.maximum_validation_mae_rad - and float(np.percentile(errors, 95.0)) - <= self.maximum_validation_p95_rad + self.zero_result.passed + and ( + not self.validation_enabled + or ( + errors.size > 0 + and float(np.mean(errors)) + <= self.maximum_validation_mae_rad + and float(np.percentile(errors, 95.0)) + <= self.maximum_validation_p95_rad + ) ) ) passed = bool(self.fit_quality_passed and validation_passed) + if not passed: + raise RuntimeError("final_calibration_quality_failed") + stamp = ( + self.session_dir.name + if re.fullmatch(r"\d{8}_\d{6}", self.session_dir.name) + else None + ) + # The four-finger active trajectories are intentionally kept as + # measured (the untagged fingers inherit the index curves). Ensure + # the generated URDF cannot clip a measured endpoint. This is kept + # separate from the thumb so thumb limits and calibration data remain + # byte-for-byte governed by the existing solution. + four_finger_angle_ranges_rad: dict[str, tuple[float, ...]] = {} + for name, joint_spec in JOINT_SPECS.items(): + if not joint_spec.active: + continue + if not name.startswith(("index_", "middle_", "ring_", "pinky_")): + continue + if not name.endswith(("_mcp_roll", "_mcp_pitch", "_pip")): + continue + source_name = joint_spec.source_joint or name + four_finger_angle_ranges_rad[name] = tuple( + float(value) for value in self.measured_fits[source_name].angle_rad + ) + self.corrected_urdf_path = write_zero_corrected_urdf( + source_urdf=self.source_urdf_path, + output_directory=self.corrected_urdf_output_dir, + serial_number=self.serial_number, + offsets_rad=self.zero_result.all_active_offsets_rad, + timestamp=stamp, + joint_angle_ranges_rad=four_finger_angle_ranges_rad, + joint_limit_margin_rad=math.radians(0.5), + ) payload = build_compact_payload( serial_number=self.serial_number, measured_fits=self.measured_fits, - projected_zeros_rad=self.projected_zeros_rad, - splay_zero_command_u8=self.splay_zero_command_u8, - splay_midpoint_rad=self.splay_midpoint_rad, + urdf_zero_offsets_rad=self.zero_result.all_active_offsets_rad, validation_errors_rad=self.validation_errors_rad, passed=passed, baseline=self.baseline_command, @@ -1674,7 +2255,10 @@ class G20ThreeCameraCalibrationNode(Node): now - self.speed_commanded_at >= self.speed_setting_settle_seconds ) - if reached and speed_ready: + # Do not leave the start endpoint until at least one complete, + # timestamp-synchronised Tag/state observation has been retained. + # The retained frame becomes the strict 0/255 endpoint bin. + if reached and speed_ready and self.sweep_start_frames: if self.position_hold_since is None: self.position_hold_since = now elif now - self.position_hold_since >= self.endpoint_hold_seconds: @@ -1748,6 +2332,7 @@ class G20ThreeCameraCalibrationNode(Node): name: { "ready": self._view_ready(runtime, now), "camera_info_valid": runtime.camera_info_valid, + "camera_extrinsics_valid": runtime.extrinsics_valid, "detection_hz": round(runtime.detection_hz, 2), "valid_rate": round(runtime.valid_rate, 4), "required_tag_ids": sorted(runtime.role_by_id), @@ -1775,6 +2360,12 @@ class G20ThreeCameraCalibrationNode(Node): active: dict[str, Any] = {} if self.active_sweep is not None: motor = self.active_sweep.spec.motor_index + start_tolerance = self._endpoint_tolerance_for_spec( + self.active_sweep.spec, self.active_sweep.start_u8 + ) + target_tolerance = self._endpoint_tolerance_for_spec( + self.active_sweep.spec, self.active_sweep.target_u8 + ) values = [ float(frame.state_u8[motor]) for frame in self.sweep_frames ] @@ -1783,10 +2374,10 @@ class G20ThreeCameraCalibrationNode(Node): ( self.active_sweep.start_u8 if abs(value - self.active_sweep.start_u8) - <= self.endpoint_tolerance_u8 + <= start_tolerance else self.active_sweep.target_u8 if abs(value - self.active_sweep.target_u8) - <= self.endpoint_tolerance_u8 + <= target_tolerance else int(np.clip(np.rint(value), 0, 255)) ) for value in values @@ -1831,7 +2422,11 @@ class G20ThreeCameraCalibrationNode(Node): "maximum_bin_gap": int(max(np.diff(bins), default=0)), "allowed_maximum_bin_gap": self.maximum_bin_gap, "missing_endpoint_u8": missing_endpoints, - "endpoint_tolerance_u8": self.endpoint_tolerance_u8, + "endpoint_tolerance_u8": max( + start_tolerance, target_tolerance + ), + "start_endpoint_tolerance_u8": start_tolerance, + "target_endpoint_tolerance_u8": target_tolerance, }, "auxiliary_motors": [ { @@ -1907,11 +2502,21 @@ class G20ThreeCameraCalibrationNode(Node): "completed_sweeps": self.sweep_index, "total_sweeps": len(self.sweep_items), "baseline_command_u8": list(self.baseline_command), + "hand_state_fresh": bool( + len(self.latest_state_u8) == 20 + and now - self.last_state_at <= 1.0 + ), "views": views, "active": active, "result_path": ( str(self.final_path) if self.completed_payload is not None else "" ), + "corrected_urdf_path": ( + "" + if self.corrected_urdf_path is None + else str(self.corrected_urdf_path) + ), + "camera_extrinsics_error": self.extrinsics_error, "quality": ( {} if self.completed_payload is None else self.completed_payload["quality"] ), diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/urdf_zero.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/urdf_zero.py new file mode 100644 index 0000000..f5f26b8 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/urdf_zero.py @@ -0,0 +1,1133 @@ +"""Three-dimensional joint-axis fitting and URDF zero correction.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import math +import os +from pathlib import Path +import re +from typing import Any, Mapping, Sequence +import xml.etree.ElementTree as ET + +import numpy as np +from scipy.optimize import least_squares +from scipy.spatial.transform import Rotation + +from .core import fit_rotation_axis, robust_rotation_summary +from .full_hand import IMAGE_TRAJECTORY_JOINTS, JointCurveFit +from .trajectory import _fit_circle_with_axis, _fit_joint_curve, _fit_plane_axis + + +CONSTRAINED_CIRCLE_JOINTS: frozenset[str] = ( + IMAGE_TRAJECTORY_JOINTS | {"index_dip", "thumb_cmc_yaw"} +) + + +DIRECT_ZERO_JOINTS: tuple[str, ...] = ( + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + "index_mcp_roll", + "index_mcp_pitch", + "index_pip", +) + +# The four-finger splay, root flexion and PIP curves already use mechanically +# defined baseline commands (127 for splay, 255 for flexion). With only the +# index finger tagged, fitting an absolute palm-to-URDF static correction and +# inheriting it across four fingers introduced a common lean and incorrect +# root/PIP flexion. The thumb MCP axis-line observation likewise produced a +# -6.86 degree extension at command 255 although its measured trajectory is +# already zero there. Keep these joints in the public 7-joint result, but lock +# their static URDF corrections to zero. Their measured angle curves are +# deliberately left unchanged. +FIXED_DIRECT_ZERO_OFFSETS_RAD: dict[str, float] = { + "thumb_mcp": 0.0, + "index_mcp_roll": 0.0, + "index_mcp_pitch": 0.0, + "index_pip": 0.0, +} +OPTIMIZED_ZERO_JOINTS: tuple[str, ...] = tuple( + name for name in DIRECT_ZERO_JOINTS + if name not in FIXED_DIRECT_ZERO_OFFSETS_RAD +) + +AXIS_JOINTS: tuple[str, ...] = ( + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + "thumb_ip", + "index_mcp_roll", + "index_mcp_pitch", + "index_pip", + "index_dip", +) + +INHERITED_ZERO_JOINTS: dict[str, str] = { + "middle_mcp_roll": "index_mcp_roll", + "ring_mcp_roll": "index_mcp_roll", + "pinky_mcp_roll": "index_mcp_roll", + "middle_mcp_pitch": "index_mcp_pitch", + "ring_mcp_pitch": "index_mcp_pitch", + "pinky_mcp_pitch": "index_mcp_pitch", + "middle_pip": "index_pip", + "ring_pip": "index_pip", + "pinky_pip": "index_pip", +} + +ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 = 16 + +# A joint's own screw axis is invariant to its own encoder-zero offset. Only +# the downstream axis selected by the kinematic chain can observe that zero. +# Feeding every axis-direction and axis-line residual into the optimizer lets +# fixed CAD/PnP geometry residuals push unrelated zero offsets to their bounds. +# Keep the two root lines as the palm-pose anchor, then use only the component +# that actually observes the preceding zero in each serial chain. +ZERO_AXIS_OBSERVATION_JOINTS: frozenset[str] = frozenset( + { + "thumb_cmc_roll", + "index_mcp_roll", + "thumb_cmc_yaw", # observes thumb_cmc_roll + "thumb_cmc_pitch", # observes thumb_cmc_yaw + } +) +ZERO_LINE_OBSERVATION_JOINTS: frozenset[str] = frozenset( + { + "thumb_cmc_roll", + "index_mcp_roll", + "thumb_mcp", # observes thumb_cmc_pitch + } +) +ZERO_ROOT_ANCHOR_JOINTS: frozenset[str] = frozenset( + {"thumb_cmc_roll", "index_mcp_roll"} +) +ZERO_AXIS_PARENT_JOINT: dict[str, str] = { + "thumb_cmc_yaw": "thumb_cmc_roll", + "thumb_cmc_pitch": "thumb_cmc_yaw", +} + + +def _zero_sensitive_axis_error_rad( + predicted_axis: Sequence[float], + observed_axis: Sequence[float], + parent_axis: Sequence[float], +) -> float: + """Return only the axis error that a parent-joint zero can change. + + Rotating a downstream axis about its parent preserves their mutual cone + angle. The component normal to their plane is therefore the observable + encoder-zero error; cone-angle mismatch belongs to fixed geometry/PnP and + must not push a zero offset or fail its holdout validation. + """ + predicted = _vector(predicted_axis, 3, name="predicted axis") + predicted /= np.linalg.norm(predicted) + observed = _vector(observed_axis, 3, name="observed axis") + observed /= np.linalg.norm(observed) + parent = _vector(parent_axis, 3, name="parent axis") + parent /= np.linalg.norm(parent) + if float(predicted @ observed) < 0.0: + observed = -observed + sensitivity = np.cross(parent, predicted) + magnitude = float(np.linalg.norm(sensitivity)) + if magnitude < 1.0e-9: + raise ValueError("parent and downstream axes are parallel") + sensitivity /= magnitude + return math.asin(float(np.clip(observed @ sensitivity, -1.0, 1.0))) +def _vector(value: Sequence[float], size: int, *, name: str) -> np.ndarray: + result = np.asarray(value, dtype=float) + if result.shape != (size,) or not np.all(np.isfinite(result)): + raise ValueError(f"{name} must contain {size} finite values") + return result + + +def _pose_matrix(payload: Mapping[str, Any]) -> np.ndarray: + translation = _vector(payload["translation_xyz_m"], 3, name="translation") + quaternion = _vector(payload["quaternion_xyzw"], 4, name="quaternion") + quaternion /= np.linalg.norm(quaternion) + result = np.eye(4) + result[:3, :3] = Rotation.from_quat(quaternion).as_matrix() + result[:3, 3] = translation + return result + + +def _relative_rotation(record: Mapping[str, Any]) -> np.ndarray: + quaternion = _vector( + record["relative_quaternion_xyzw"], 4, name="relative quaternion" + ) + return quaternion / np.linalg.norm(quaternion) + + +def _reference_group_key(record: Mapping[str, Any]) -> tuple[Any, Any]: + return record.get("cycle"), record.get("direction") + + +def _interpolate_reference_rotation( + records: Sequence[Mapping[str, Any]], zero_command_u8: int +) -> Rotation | None: + by_command: dict[int, list[np.ndarray]] = {} + for record in records: + command = int(record["command_u8"]) + by_command.setdefault(command, []).append(_relative_rotation(record)) + if not by_command: + return None + + rotations = { + command: Rotation.from_quat(robust_rotation_summary(values)[0]) + for command, values in by_command.items() + } + zero = int(zero_command_u8) + if zero in rotations: + return rotations[zero] + + lower = [command for command in rotations if command < zero] + upper = [command for command in rotations if command > zero] + lower_command = max(lower) if lower else None + upper_command = min(upper) if upper else None + 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: + lower_rotation = rotations[lower_command] + upper_rotation = rotations[upper_command] + fraction = lower_distance / (upper_command - lower_command) + delta = (lower_rotation.inv() * upper_rotation).as_rotvec() + 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: + return rotations[nearest_command] + return None + + +def _near_zero_records( + records: Sequence[Mapping[str, Any]], zero_command_u8: int +) -> list[Mapping[str, Any]]: + groups: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {} + for record in records: + groups.setdefault(_reference_group_key(record), []).append(record) + selected: list[Mapping[str, Any]] = [] + zero = int(zero_command_u8) + for group in groups.values(): + distance = min(abs(int(record["command_u8"]) - zero) for record in group) + if distance > ZERO_REFERENCE_MAXIMUM_DISTANCE_U8: + continue + selected.extend( + record + for record in group + if abs(int(record["command_u8"]) - zero) == distance + ) + return selected + + +def _baseline_reference( + records: Sequence[Mapping[str, Any]], zero_command_u8: int +) -> tuple[float, float, float, float]: + groups: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {} + for record in records: + groups.setdefault(_reference_group_key(record), []).append(record) + values = [ + rotation.as_quat() + for group in groups.values() + if ( + rotation := _interpolate_reference_rotation( + group, zero_command_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}" + ) + return robust_rotation_summary(values)[0] + + +def fit_rotation_joint_curve( + records: Sequence[Mapping[str, Any]], *, zero_command_u8: int +) -> JointCurveFit: + """Fit a command curve from full parent-to-child tag orientations.""" + samples = [dict(record) for record in records] + if len(samples) < 12: + raise ValueError("rotation trajectory requires at least 12 samples") + vectors: list[np.ndarray] = [] + commands: list[int] = [] + values_by_record: list[float] = [] + references: dict[int, Rotation] = {} + for cycle in sorted({int(record["cycle"]) for record in samples}): + cycle_records = [ + record for record in samples if int(record["cycle"]) == cycle + ] + references[cycle] = Rotation.from_quat( + _baseline_reference(cycle_records, zero_command_u8) + ) + for record in samples: + observed = Rotation.from_quat(_relative_rotation(record)) + vector = ( + references[int(record["cycle"])].inv() * observed + ).as_rotvec() + vectors.append(vector) + commands.append(int(record["command_u8"])) + axis = fit_rotation_axis(vectors, commands) + values_by_record = [float(vector @ axis) for vector in vectors] + curves, correction, hysteresis = _fit_joint_curve(samples, values_by_record) + for key in ("angle_rad", "decreasing_rad", "increasing_rad"): + values = np.asarray(curves[key], dtype=float) + values -= float(values[int(zero_command_u8)]) + curves[key] = [round(float(value), 8) for value in values] + orthogonal = [ + float(np.linalg.norm(vector - float(vector @ axis) * axis)) + for vector in vectors + ] + return JointCurveFit( + angle_rad=tuple(float(value) for value in curves["angle_rad"]), + decreasing_rad=tuple(float(value) for value in curves["decreasing_rad"]), + increasing_rad=tuple(float(value) for value in curves["increasing_rad"]), + circle={ + "space": "relative_rotation_3d", + "axis_xyz": [float(value) for value in axis], + "zero_command_u8": int(zero_command_u8), + "reference_quaternion_xyzw": [ + float(value) + for value in _baseline_reference(samples, zero_command_u8) + ], + }, + maximum_monotonic_correction_rad=float(correction), + maximum_hysteresis_rad=float(hysteresis), + quality={ + "rotation_orthogonal_rms_rad": float( + np.sqrt(np.mean(np.square(orthogonal))) + ), + "arc_rad": float( + max(curves["angle_rad"]) - min(curves["angle_rad"]) + ), + }, + ) + + +def measure_rotation_joint_observation( + fit: JointCurveFit, quaternion_xyzw: Sequence[float] +) -> float: + """Measure one parent-to-child orientation with a fitted 3-D curve.""" + if fit.circle.get("space") != "relative_rotation_3d": + raise ValueError("joint fit is not a relative-rotation curve") + reference = Rotation.from_quat( + _vector( + fit.circle["reference_quaternion_xyzw"], + 4, + name="reference quaternion", + ) + ) + observed = Rotation.from_quat( + _vector(quaternion_xyzw, 4, name="observed quaternion") + ) + axis = _vector(fit.circle["axis_xyz"], 3, name="rotation axis") + axis /= np.linalg.norm(axis) + return float((reference.inv() * observed).as_rotvec() @ axis) + + +def rotation_curve_holdout_errors( + fit: JointCurveFit, + records: Sequence[Mapping[str, Any]], + *, + zero_command_u8: int, +) -> tuple[float, ...]: + """Validate a fitted curve on an untouched scan cycle.""" + samples = [dict(record) for record in records] + if not samples: + raise ValueError("holdout records are empty") + reference = Rotation.from_quat( + _baseline_reference(samples, zero_command_u8) + ) + axis = _vector(fit.circle["axis_xyz"], 3, name="rotation axis") + axis /= np.linalg.norm(axis) + errors: list[float] = [] + for record in samples: + observed = Rotation.from_quat(_relative_rotation(record)) + angle = float((reference.inv() * observed).as_rotvec() @ axis) + command = int(record["command_u8"]) + direction = str(record["direction"]) + expected_curve = ( + fit.decreasing_rad + if direction == "decreasing" + else fit.increasing_rad + ) + errors.append(angle - float(expected_curve[command])) + return tuple(errors) + + +@dataclass(frozen=True) +class JointAxisMeasurement: + joint: str + cycle: int + axis_common_xyz: tuple[float, float, float] + point_common_xyz_m: tuple[float, float, float] + condition_state_u8: tuple[float, ...] + plane_rms_m: float + radial_rms_m: float + rotation_circle_axis_difference_rad: float + + +def fit_joint_axis_measurement( + joint: str, + records: Sequence[Mapping[str, Any]], + *, + cycle: int, + zero_command_u8: int, + axis_common_constraint: Sequence[float] | None = None, +) -> JointAxisMeasurement: + """Fit one physical screw axis from one complete scan cycle.""" + samples = [ + dict(record) for record in records if int(record["cycle"]) == int(cycle) + ] + if len(samples) < 12: + raise ValueError(f"{joint} cycle {cycle + 1} has too few samples") + points = np.asarray( + [record["relative_translation_xyz_m"] for record in samples], dtype=float + ) + free_circle_axis, free_plane_rms = _fit_plane_axis([points]) + + reference = Rotation.from_quat( + _baseline_reference(samples, zero_command_u8) + ) + rotation_vectors = [ + (reference.inv() * Rotation.from_quat(_relative_rotation(record))).as_rotvec() + for record in samples + ] + commands = [int(record["command_u8"]) for record in samples] + rotation_axis_child = fit_rotation_axis(rotation_vectors, commands) + # reference maps the child Tag frame at baseline into the parent Tag + # frame. The quaternion delta axis is expressed in that child frame, + # while the fitted centre circle is expressed in the parent frame. This + # conversion is what makes arbitrary Tag mounting rotations harmless. + rotation_axis = reference.apply(rotation_axis_child) + if float(rotation_axis @ free_circle_axis) < 0.0: + free_circle_axis = -free_circle_axis + disagreement = math.acos( + float(np.clip(rotation_axis @ free_circle_axis, -1.0, 1.0)) + ) + + zero_records = _near_zero_records(samples, zero_command_u8) + if not zero_records: + raise ValueError( + f"{joint} cycle has no record near baseline {zero_command_u8}" + ) + parent_poses = np.asarray( + [_pose_matrix(record["parent_pose_common"]) for record in zero_records] + ) + parent_translation = np.median(parent_poses[:, :3, 3], axis=0) + parent_quaternion = robust_rotation_summary( + [ + Rotation.from_matrix(matrix[:3, :3]).as_quat() + for matrix in parent_poses + ] + )[0] + parent_rotation = Rotation.from_quat(parent_quaternion) + + if axis_common_constraint is not None: + common_axis = _vector( + axis_common_constraint, 3, name="common axis constraint" + ) + common_axis /= np.linalg.norm(common_axis) + fitted_axis = parent_rotation.inv().apply(common_axis) + if float(fitted_axis @ rotation_axis) < 0.0: + fitted_axis = -fitted_axis + circle = _fit_circle_with_axis(points, fitted_axis) + plane_rms = float(circle["plane_rms_m"]) + elif str(joint) in CONSTRAINED_CIRCLE_JOINTS: + # For a monocular view of a shallow arc, the Tag centre samples are + # nearly collinear in 3-D. Planar-PnP depth noise can consequently + # rotate an unconstrained best-fit plane by tens of degrees even when + # the full Tag orientations repeat to a fraction of a degree. The + # orientation trajectory observes the screw-axis direction directly; + # use it to constrain the 3-D circle and estimate only the axis line. + # This remains a full-SE(3) fit and is independent of camera level and + # arbitrary Tag mounting rotation. + fitted_axis = rotation_axis + circle = _fit_circle_with_axis(points, fitted_axis) + plane_rms = float(circle["plane_rms_m"]) + else: + # Oblique trajectories with an observable 3-D motion plane retain the + # independent rotation/centre cross-check and fuse both estimates. + free_circle = _fit_circle_with_axis(points, free_circle_axis) + fitted_axis = rotation_axis + free_circle_axis + if float(np.linalg.norm(fitted_axis)) < 1.0e-9: + raise ValueError(f"{joint} rotation and centre axes are opposed") + fitted_axis /= np.linalg.norm(fitted_axis) + circle = free_circle + plane_rms = max( + float(free_plane_rms), float(circle["plane_rms_m"]) + ) + axis_common = parent_rotation.apply(fitted_axis) + point_common = parent_rotation.apply( + np.asarray(circle["center_xyz_m"], dtype=float) + ) + parent_translation + state = np.median( + np.asarray([record["state_u8"] for record in zero_records], dtype=float), + axis=0, + ) + return JointAxisMeasurement( + joint=str(joint), + cycle=int(cycle), + axis_common_xyz=tuple(float(value) for value in axis_common), + point_common_xyz_m=tuple(float(value) for value in point_common), + condition_state_u8=tuple(float(value) for value in state), + plane_rms_m=float(plane_rms), + radial_rms_m=float(circle["radial_rms_m"]), + rotation_circle_axis_difference_rad=float(disagreement), + ) + + +@dataclass(frozen=True) +class _UrdfJoint: + name: str + parent: str + child: str + origin: np.ndarray + axis: np.ndarray + mimic_joint: str | None + mimic_multiplier: float + mimic_offset: float + + +class UrdfKinematicModel: + def __init__(self, source: str | Path) -> None: + self.source = Path(source).expanduser().resolve() + if not self.source.is_file(): + raise ValueError(f"source URDF does not exist: {self.source}") + root = ET.parse(self.source).getroot() + self.joints: dict[str, _UrdfJoint] = {} + self.parent_joint_by_child: dict[str, str] = {} + for element in root.findall("joint"): + if element.get("type") == "fixed": + pass + name = str(element.get("name")) + parent = element.find("parent") + child = element.find("child") + if parent is None or child is None: + continue + origin_node = element.find("origin") + xyz = _parse_triplet( + "0 0 0" if origin_node is None else origin_node.get("xyz", "0 0 0") + ) + rpy = _parse_triplet( + "0 0 0" if origin_node is None else origin_node.get("rpy", "0 0 0") + ) + origin = np.eye(4) + origin[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix() + origin[:3, 3] = xyz + axis_node = element.find("axis") + axis = _parse_triplet( + "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0") + ) + axis /= np.linalg.norm(axis) + mimic = element.find("mimic") + model = _UrdfJoint( + name=name, + parent=str(parent.get("link")), + child=str(child.get("link")), + origin=origin, + axis=axis, + mimic_joint=None if mimic is None else str(mimic.get("joint")), + mimic_multiplier=( + 1.0 if mimic is None else float(mimic.get("multiplier", "1")) + ), + mimic_offset=( + 0.0 if mimic is None else float(mimic.get("offset", "0")) + ), + ) + self.joints[name] = model + self.parent_joint_by_child[model.child] = name + + def _chain(self, target_joint: str) -> list[_UrdfJoint]: + if target_joint not in self.joints: + raise ValueError(f"URDF is missing joint {target_joint}") + result: list[_UrdfJoint] = [] + current = self.joints[target_joint] + while True: + result.append(current) + parent_joint = self.parent_joint_by_child.get(current.parent) + if parent_joint is None: + break + current = self.joints[parent_joint] + result.reverse() + return result + + def axis_line( + self, + target_joint: str, + *, + zero_offsets: Mapping[str, float], + joint_angles: Mapping[str, float], + ) -> tuple[np.ndarray, np.ndarray]: + transform = np.eye(4) + resolved_angles = dict(joint_angles) + for joint in self._chain(target_joint): + corrected_origin = joint.origin @ _axis_rotation( + joint.axis, float(zero_offsets.get(joint.name, 0.0)) + ) + joint_frame = transform @ corrected_origin + if joint.name == target_joint: + return ( + joint_frame[:3, :3] @ joint.axis, + joint_frame[:3, 3].copy(), + ) + if joint.mimic_joint is None: + angle = float(resolved_angles.get(joint.name, 0.0)) + else: + angle = ( + joint.mimic_multiplier + * float(resolved_angles.get(joint.mimic_joint, 0.0)) + + joint.mimic_offset + ) + resolved_angles[joint.name] = angle + transform = joint_frame @ _axis_rotation(joint.axis, angle) + raise RuntimeError(f"could not resolve axis line for {target_joint}") + + +def _parse_triplet(value: str) -> np.ndarray: + result = np.asarray([float(item) for item in str(value).split()], dtype=float) + if result.shape != (3,) or not np.all(np.isfinite(result)): + raise ValueError(f"invalid URDF vector: {value}") + return result + + +def _axis_rotation(axis: Sequence[float], angle: float) -> np.ndarray: + result = np.eye(4) + result[:3, :3] = Rotation.from_rotvec( + _vector(axis, 3, name="joint axis") * float(angle) + ).as_matrix() + return result + + +@dataclass(frozen=True) +class ZeroSolveResult: + direct_offsets_rad: Mapping[str, float] + all_active_offsets_rad: Mapping[str, float] + base_translation_xyz_m: tuple[float, float, float] + base_quaternion_xyzw: tuple[float, float, float, float] + validation_errors_rad: tuple[float, ...] + validation_error_by_joint_rad: Mapping[str, float] + validation_line_error_by_joint_m: Mapping[str, float] + axis_line_rms_m: float + passed: bool + + +def _angles_from_state( + state_u8: Sequence[float], + *, + curves: Mapping[str, JointCurveFit], + motor_by_joint: Mapping[str, int], +) -> dict[str, float]: + state = np.asarray(state_u8, dtype=float) + result: dict[str, float] = {} + for joint, motor in motor_by_joint.items(): + source = INHERITED_ZERO_JOINTS.get(joint, joint) + if source not in curves: + continue + command = int(np.clip(np.rint(state[int(motor)]), 0, 255)) + result[joint] = float(curves[source].angle_rad[command]) + return result + + +def solve_urdf_zero_offsets( + *, + source_urdf: str | Path, + measurements: Sequence[JointAxisMeasurement], + curves: Mapping[str, JointCurveFit], + motor_by_joint: Mapping[str, int], + training_cycles: Sequence[int] = (0, 1), + validation_cycle: int = 2, + maximum_offset_rad: float = math.radians(20.0), + maximum_validation_mae_rad: float = math.radians(1.0), + maximum_validation_p95_rad: float = math.radians(2.0), +) -> ZeroSolveResult: + model = UrdfKinematicModel(source_urdf) + training = [m for m in measurements if m.cycle in set(training_cycles)] + validation = [m for m in measurements if m.cycle == int(validation_cycle)] + expected = set(AXIS_JOINTS) + if {m.joint for m in training} != expected or {m.joint for m in validation} != expected: + raise ValueError("axis measurements do not contain all required joints/cycles") + + def predicted_local( + measurement: JointAxisMeasurement, + offsets: Mapping[str, float], + ) -> tuple[np.ndarray, np.ndarray]: + angles = _angles_from_state( + measurement.condition_state_u8, + curves=curves, + motor_by_joint=motor_by_joint, + ) + return model.axis_line( + measurement.joint, + zero_offsets=offsets, + joint_angles=angles, + ) + + zero_offsets = {name: 0.0 for name in DIRECT_ZERO_JOINTS} + urdf_axes = [] + measured_axes = [] + urdf_points = [] + measured_points = [] + for measurement in training: + axis, point = predicted_local(measurement, zero_offsets) + urdf_axes.append(axis) + urdf_points.append(point) + measured_axes.append(np.asarray(measurement.axis_common_xyz)) + measured_points.append(np.asarray(measurement.point_common_xyz_m)) + urdf_axes_array = np.asarray(urdf_axes) + measured_axes_array = np.asarray(measured_axes) + urdf_points_array = np.asarray(urdf_points) + measured_points_array = np.asarray(measured_points) + + # A fitted axis is geometrically an unoriented line. Generate base-pose + # starts with iteratively resolved signs so a mixture of motor/URDF axis + # conventions cannot trap the optimizer in a wrong 180-degree basin. + generator = np.random.default_rng(20260806) + rotation_seeds = [Rotation.identity()] + rotation_seeds.extend(Rotation.random(11, random_state=generator)) + rotation_starts: list[Rotation] = [] + for seed in rotation_seeds: + candidate = seed + for _ in range(8): + predicted = candidate.apply(urdf_axes_array) + signs = np.where( + np.sum(predicted * measured_axes_array, axis=1) < 0.0, + -1.0, + 1.0, + ) + candidate, _ = Rotation.align_vectors( + measured_axes_array * signs[:, None], urdf_axes_array + ) + if not any( + (existing.inv() * candidate).magnitude() < math.radians(0.1) + for existing in rotation_starts + ): + rotation_starts.append(candidate) + lower = np.concatenate( + ( + -np.ones(3) * math.pi, + -np.ones(3) * 2.0, + -np.ones(len(OPTIMIZED_ZERO_JOINTS)) * maximum_offset_rad, + ) + ) + upper = np.concatenate( + ( + np.ones(3) * math.pi, + np.ones(3) * 2.0, + np.ones(len(OPTIMIZED_ZERO_JOINTS)) * maximum_offset_rad, + ) + ) + + def offsets_from_parameters(parameters: np.ndarray) -> dict[str, float]: + offsets = { + name: float(value) + for name, value in FIXED_DIRECT_ZERO_OFFSETS_RAD.items() + } + offsets.update( + { + name: float(value) + for name, value in zip( + OPTIMIZED_ZERO_JOINTS, parameters[6:] + ) + } + ) + return offsets + + def residual(parameters: np.ndarray, selected: Sequence[JointAxisMeasurement]) -> np.ndarray: + base_rotation = Rotation.from_rotvec(parameters[:3]) + base_translation = parameters[3:6] + offsets = offsets_from_parameters(parameters) + values: list[float] = [] + for measurement in selected: + axis, point = predicted_local(measurement, offsets) + axis = base_rotation.apply(axis) + point = base_rotation.apply(point) + base_translation + observed_axis = np.asarray(measurement.axis_common_xyz, dtype=float) + observed_point = np.asarray(measurement.point_common_xyz_m, dtype=float) + if float(axis @ observed_axis) < 0.0: + observed_axis = -observed_axis + if measurement.joint in ZERO_ROOT_ANCHOR_JOINTS: + values.extend( + ((axis - observed_axis) / math.radians(1.0)).tolist() + ) + elif measurement.joint in ZERO_AXIS_PARENT_JOINT: + parent_axis, _ = model.axis_line( + ZERO_AXIS_PARENT_JOINT[measurement.joint], + zero_offsets=offsets, + joint_angles=_angles_from_state( + measurement.condition_state_u8, + curves=curves, + motor_by_joint=motor_by_joint, + ), + ) + parent_axis = base_rotation.apply(parent_axis) + values.append( + _zero_sensitive_axis_error_rad( + axis, observed_axis, parent_axis + ) + / math.radians(1.0) + ) + if measurement.joint in ZERO_LINE_OBSERVATION_JOINTS: + values.extend( + ( + np.cross(point - observed_point, observed_axis) / 0.003 + ).tolist() + ) + return np.asarray(values, dtype=float) + + starts: list[np.ndarray] = [] + for rotation_start in rotation_starts: + translation_start = np.median( + measured_points_array - rotation_start.apply(urdf_points_array), + axis=0, + ) + candidate = np.concatenate( + ( + rotation_start.as_rotvec(), + translation_start, + generator.uniform( + -math.radians(3.0), + math.radians(3.0), + size=len(OPTIMIZED_ZERO_JOINTS), + ), + ) + ) + starts.append( + np.clip(candidate, lower + 1.0e-9, upper - 1.0e-9) + ) + + def optimise( + selected: Sequence[JointAxisMeasurement], + candidates: Sequence[np.ndarray], + ) -> Any: + solutions = [ + least_squares( + residual, + start, + args=(selected,), + bounds=(lower, upper), + loss="soft_l1", + f_scale=1.0, + max_nfev=5000, + ) + for start in candidates + ] + return min( + solutions, key=lambda item: float(np.sum(np.square(item.fun))) + ) + + solution = optimise(training, starts) + if not solution.success: + raise ValueError(f"URDF zero optimization failed: {solution.message}") + offsets = offsets_from_parameters(solution.x) + bound_hits = { + name: math.degrees(value) + for name, value in offsets.items() + if abs(value) >= maximum_offset_rad - math.radians(0.01) + } + if bound_hits: + details = ", ".join( + f"{name}={value:+.3f}deg" + for name, value in sorted(bound_hits.items()) + ) + all_details = ", ".join( + f"{name}={math.degrees(value):+.3f}deg" + for name, value in sorted(offsets.items()) + ) + raise ValueError( + "URDF zero offset reached the configured " + f"{math.degrees(maximum_offset_rad):.3f} degree bound: {details}; " + f"all_offsets: {all_details}" + ) + + base_rotation = Rotation.from_rotvec(solution.x[:3]) + base_translation = solution.x[3:6] + validation_errors: list[float] = [] + line_errors: list[float] = [] + validation_error_by_joint: dict[str, float] = {} + validation_line_error_by_joint: dict[str, float] = {} + for measurement in validation: + axis, point = predicted_local(measurement, offsets) + axis = base_rotation.apply(axis) + point = base_rotation.apply(point) + base_translation + observed_axis = np.asarray(measurement.axis_common_xyz, dtype=float) + observed_point = np.asarray(measurement.point_common_xyz_m, dtype=float) + if float(axis @ observed_axis) < 0.0: + observed_axis = -observed_axis + if measurement.joint in ZERO_AXIS_PARENT_JOINT: + parent_axis, _ = model.axis_line( + ZERO_AXIS_PARENT_JOINT[measurement.joint], + zero_offsets=offsets, + joint_angles=_angles_from_state( + measurement.condition_state_u8, + curves=curves, + motor_by_joint=motor_by_joint, + ), + ) + parent_axis = base_rotation.apply(parent_axis) + axis_error = abs( + _zero_sensitive_axis_error_rad( + axis, observed_axis, parent_axis + ) + ) + else: + dot = abs(float(np.clip(axis @ observed_axis, -1.0, 1.0))) + axis_error = math.acos(dot) + line_error = float( + np.linalg.norm(np.cross(point - observed_point, observed_axis)) + ) + validation_errors.append(axis_error) + line_errors.append(line_error) + validation_error_by_joint[measurement.joint] = axis_error + validation_line_error_by_joint[measurement.joint] = line_error + zero_axis_errors = np.asarray( + [ + error + for measurement, error in zip(validation, validation_errors) + if measurement.joint in ZERO_AXIS_OBSERVATION_JOINTS + and measurement.joint not in ZERO_ROOT_ANCHOR_JOINTS + ], + dtype=float, + ) + zero_line_errors = np.asarray( + [ + error + for measurement, error in zip(validation, line_errors) + if measurement.joint in ZERO_LINE_OBSERVATION_JOINTS + and measurement.joint not in ZERO_ROOT_ANCHOR_JOINTS + ], + dtype=float, + ) + zero_line_rms = float( + np.sqrt(np.mean(np.square(zero_line_errors))) + ) + passed = bool( + zero_axis_errors.size + and zero_line_errors.size + and float(np.mean(zero_axis_errors)) <= maximum_validation_mae_rad + and float(np.percentile(zero_axis_errors, 95.0)) + <= maximum_validation_p95_rad + and zero_line_rms <= 0.003 + ) + + # The untouched third cycle is used only to decide whether the result is + # admissible. Once it passes, refit with all three cycles so the delivered + # offset uses every valid observation without weakening holdout validation. + if passed: + all_solution = optimise(list(measurements), [solution.x]) + if not all_solution.success: + raise ValueError( + f"final URDF zero optimization failed: {all_solution.message}" + ) + solution = all_solution + offsets = offsets_from_parameters(solution.x) + final_bound_hits = { + name: math.degrees(value) + for name, value in offsets.items() + if abs(value) >= maximum_offset_rad - math.radians(0.01) + } + if final_bound_hits: + details = ", ".join( + f"{name}={value:+.3f}deg" + for name, value in sorted(final_bound_hits.items()) + ) + all_details = ", ".join( + f"{name}={math.degrees(value):+.3f}deg" + for name, value in sorted(offsets.items()) + ) + raise ValueError( + "final URDF zero offset reached the configured " + f"{math.degrees(maximum_offset_rad):.3f} degree bound: " + f"{details}; all_offsets: {all_details}" + ) + base_rotation = Rotation.from_rotvec(solution.x[:3]) + base_translation = solution.x[3:6] + all_offsets = dict(offsets) + for target, source in INHERITED_ZERO_JOINTS.items(): + all_offsets[target] = offsets[source] + return ZeroSolveResult( + direct_offsets_rad=offsets, + all_active_offsets_rad=all_offsets, + base_translation_xyz_m=tuple(float(value) for value in base_translation), + base_quaternion_xyzw=tuple(float(value) for value in base_rotation.as_quat()), + validation_errors_rad=tuple(float(value) for value in validation_errors), + validation_error_by_joint_rad=validation_error_by_joint, + validation_line_error_by_joint_m=validation_line_error_by_joint, + # Report only the line components that are observable by, and used + # to validate, the encoder-zero solve. Other measured line residuals + # describe fixed CAD/PnP geometry and must not be presented as zero + # calibration error. + axis_line_rms_m=zero_line_rms, + passed=passed, + ) + + +def write_zero_corrected_urdf( + *, + source_urdf: str | Path, + output_directory: str | Path, + serial_number: str, + offsets_rad: Mapping[str, float], + timestamp: str | None = None, + joint_angle_ranges_rad: Mapping[str, Sequence[float]] | None = None, + joint_limit_margin_rad: float = math.radians(0.5), +) -> Path: + source = Path(source_urdf).expanduser().resolve() + output = Path(output_directory).expanduser().resolve() + if not source.is_file(): + raise ValueError(f"source URDF does not exist: {source}") + if ( + "zero_calibrated" in source.stem.lower() + or re.search(r"calibrated_20\d{6}", source.stem.lower()) + ): + raise ValueError( + "source_urdf must be the original CAD URDF, not a calibrated URDF" + ) + output.mkdir(parents=True, exist_ok=True) + stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") + safe_serial = "".join( + character if character.isalnum() or character in "_.-" else "_" + for character in str(serial_number) + ) + destination = output / f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf" + if destination == source or destination.exists(): + raise ValueError(f"refusing to overwrite URDF: {destination}") + tree = ET.parse(source) + root = tree.getroot() + original_text = source.read_text(encoding="utf-8") + replacement_rpy: dict[str, str] = {} + replacement_limits: dict[str, tuple[str, str]] = {} + found: set[str] = set() + range_targets = dict(joint_angle_ranges_rad or {}) + limit_margin = float(joint_limit_margin_rad) + if not math.isfinite(limit_margin) or limit_margin < 0.0: + raise ValueError("joint_limit_margin_rad must be finite and non-negative") + for joint in root.findall("joint"): + name = str(joint.get("name")) + if name not in offsets_rad and name not in range_targets: + continue + if name in offsets_rad: + axis_node = joint.find("axis") + axis = _parse_triplet( + "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0") + ) + axis /= np.linalg.norm(axis) + origin_node = joint.find("origin") + if origin_node is None: + raise ValueError( + f"joint {name} has no origin; refusing a non-minimal rewrite" + ) + rpy = _parse_triplet(origin_node.get("rpy", "0 0 0")) + original = Rotation.from_euler("xyz", rpy).as_matrix() + corrected = original @ Rotation.from_rotvec( + axis * float(offsets_rad[name]) + ).as_matrix() + corrected_rpy = Rotation.from_matrix(corrected).as_euler("xyz") + replacement_rpy[name] = " ".join( + f"{float(value):.15g}" for value in corrected_rpy + ) + if name in range_targets: + values = np.asarray(range_targets[name], dtype=float).reshape(-1) + if values.size == 0 or not np.all(np.isfinite(values)): + raise ValueError(f"joint {name} angle range must be finite and non-empty") + limit_node = joint.find("limit") + if ( + limit_node is None + or limit_node.get("lower") is None + or limit_node.get("upper") is None + ): + raise ValueError(f"joint {name} has no finite lower/upper limit") + lower = float(limit_node.get("lower")) + upper = float(limit_node.get("upper")) + if not math.isfinite(lower) or not math.isfinite(upper): + raise ValueError(f"joint {name} has a non-finite URDF limit") + measured_lower = float(np.min(values)) + measured_upper = float(np.max(values)) + expanded_lower = ( + measured_lower - limit_margin + if measured_lower < lower + else lower + ) + expanded_upper = ( + measured_upper + limit_margin + if measured_upper > upper + else upper + ) + if expanded_lower >= expanded_upper: + raise ValueError(f"joint {name} has an invalid expanded URDF limit") + replacement_limits[name] = ( + f"{expanded_lower:.15g}", + f"{expanded_upper:.15g}", + ) + found.add(name) + missing = sorted((set(offsets_rad) | set(range_targets)) - found) + if missing: + raise ValueError("source URDF is missing target joints: " + ",".join(missing)) + joint_pattern = re.compile( + r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>" + r".*?", + re.DOTALL, + ) + edits: list[tuple[int, int, str]] = [] + for match in joint_pattern.finditer(original_text): + name = match.group("name") + if name not in replacement_rpy and name not in replacement_limits: + continue + block = match.group(0) + if name in replacement_rpy: + origin_match = re.search( + r"]*\brpy\s*=\s*([\"'])(?P[^\"']*)\1", + block, + re.DOTALL, + ) + if origin_match is None: + raise ValueError(f"joint {name} origin has no rpy attribute") + start = match.start() + origin_match.start("rpy") + end = match.start() + origin_match.end("rpy") + edits.append((start, end, replacement_rpy[name])) + if name in replacement_limits: + limit_match = re.search(r"]*/?>", block, re.DOTALL) + if limit_match is None: + raise ValueError(f"joint {name} limit could not be located") + limit_text = limit_match.group(0) + for attribute, value in zip( + ("lower", "upper"), replacement_limits[name] + ): + attribute_match = re.search( + rf"\b{attribute}\s*=\s*([\"'])(?P[^\"']*)\1", + limit_text, + ) + if attribute_match is None: + raise ValueError( + f"joint {name} limit has no {attribute} attribute" + ) + start = ( + match.start() + + limit_match.start() + + attribute_match.start("value") + ) + end = ( + match.start() + + limit_match.start() + + attribute_match.end("value") + ) + edits.append((start, end, value)) + expected_edit_count = len(replacement_rpy) + 2 * len(replacement_limits) + if len(edits) != expected_edit_count: + raise ValueError("could not locate every target joint field in source URDF text") + corrected_text = original_text + for start, end, value in reversed(edits): + corrected_text = corrected_text[:start] + value + corrected_text[end:] + temporary = destination.with_suffix(".urdf.tmp") + with temporary.open("w", encoding="utf-8") as stream: + stream.write(corrected_text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, destination) + return destination diff --git a/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py b/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py index d976c67..eb11bb1 100644 --- a/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py +++ b/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py @@ -180,7 +180,8 @@ def _launch_stack(context): LaunchConfiguration("calibration_speed"), value_type=int ), "startup_torque": 80, - "state_poll_rate": 10.0, + # Match 30 Hz cameras so state/image p95 skew stays below 50 ms. + "state_poll_rate": 30.0, "repeat_position_commands": False, "is_touch": False, } @@ -197,6 +198,17 @@ def _launch_stack(context): { "serial_number": hand_serial, "session_dir": str(session_dir), + "camera_extrinsics_file": LaunchConfiguration( + "camera_extrinsics_file" + ), + "source_urdf_path": LaunchConfiguration("source_urdf_path"), + "corrected_urdf_output_dir": LaunchConfiguration( + "corrected_urdf_output_dir" + ), + **{ + f"{view}_camera_serial": camera_serials[view] + for view in VIEWS + }, "commands_enabled": ParameterValue( LaunchConfiguration("commands_enabled"), value_type=bool ), @@ -263,6 +275,25 @@ def generate_launch_description() -> LaunchDescription: get_package_share_directory("g20_thumb_apriltag_calibration") ) info_root = Path.home() / ".ros" / "camera_info" + workspace_source_urdf = ( + Path.cwd() + / "src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/" + "linker_hand/g20_left/linkerhand_g20_left.urdf" + ) + try: + linkerhand_share = Path( + get_package_share_directory("linkerhand_retarget") + ) + installed_source_urdf = linkerhand_share / ( + "assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf" + ) + except Exception: + installed_source_urdf = workspace_source_urdf + default_source_urdf = ( + workspace_source_urdf + if workspace_source_urdf.is_file() + else installed_source_urdf + ) return LaunchDescription( [ SetEnvironmentVariable( @@ -315,6 +346,18 @@ def generate_launch_description() -> LaunchDescription: "index_flex_calibration_speed", default_value="10" ), DeclareLaunchArgument("validation_enabled", default_value="false"), + DeclareLaunchArgument( + "camera_extrinsics_file", + default_value=str( + Path.cwd() / "config" / "g20_three_camera_extrinsics.yaml" + ), + ), + DeclareLaunchArgument( + "source_urdf_path", default_value=str(default_source_urdf) + ), + DeclareLaunchArgument( + "corrected_urdf_output_dir", default_value="" + ), DeclareLaunchArgument("commands_enabled", default_value="true"), DeclareLaunchArgument("start_cameras", default_value="true"), DeclareLaunchArgument("start_sdk", default_value="true"), diff --git a/src/g20_thumb_apriltag_calibration/launch/three_camera_extrinsics.launch.py b/src/g20_thumb_apriltag_calibration/launch/three_camera_extrinsics.launch.py new file mode 100644 index 0000000..503eb57 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/launch/three_camera_extrinsics.launch.py @@ -0,0 +1,177 @@ +"""Launch three Hikrobot cameras for one-time checkerboard extrinsics.""" + +from pathlib import Path + +from ament_index_python.packages import get_package_share_directory +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, OpaqueFunction, SetEnvironmentVariable +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import ComposableNodeContainer, Node +from launch_ros.descriptions import ComposableNode +from launch_ros.parameter_descriptions import ParameterValue + + +VIEWS = ("front", "side", "top") + + +def _launch(context): + cameras = [] + rectifiers = [] + serials = {} + for view in VIEWS: + serial = LaunchConfiguration(f"{view}_camera_serial").perform(context) + if not serial: + raise RuntimeError(f"{view}_camera_serial is required") + serials[view] = serial + namespace = f"/g20_extrinsics/{view}/camera" + cameras.append( + Node( + package="g20_thumb_apriltag_calibration", + executable="hikrobot_camera_node", + name="hikrobot_camera", + namespace=namespace, + output="screen", + emulate_tty=True, + parameters=[ + { + "serial_number": serial, + "expected_model": LaunchConfiguration("camera_model"), + "camera_name": LaunchConfiguration( + f"{view}_camera_name" + ), + "frame_id": f"g20_extrinsics_{view}_optical_frame", + "image_width": 1624, + "image_height": 1240, + "frame_rate": ParameterValue( + LaunchConfiguration("camera_frame_rate"), + value_type=float, + ), + "exposure_time_us": ParameterValue( + LaunchConfiguration("exposure_time_us"), + value_type=float, + ), + "gain_db": ParameterValue( + LaunchConfiguration("gain_db"), value_type=float + ), + "auto_exposure": False, + "camera_info_url": LaunchConfiguration( + f"{view}_camera_info_url" + ), + } + ], + ) + ) + rectifiers.append( + ComposableNode( + package="image_proc", + plugin="image_proc::RectifyNode", + name=f"rectify_{view}", + namespace=namespace, + remappings=[ + ("image", f"{namespace}/image_raw"), + ("camera_info", f"{namespace}/camera_info"), + ("image_rect", f"{namespace}/image_rect"), + ], + parameters=[{"queue_size": 1}], + extra_arguments=[{"use_intra_process_comms": True}], + ) + ) + container = ComposableNodeContainer( + name="g20_extrinsics_vision", + namespace="/", + package="rclcpp_components", + executable="component_container_mt", + composable_node_descriptions=rectifiers, + output="screen", + ) + solver = Node( + package="g20_thumb_apriltag_calibration", + executable="three_camera_extrinsics_node", + name="g20_camera_extrinsics", + output="screen", + emulate_tty=True, + parameters=[ + { + "output_file": LaunchConfiguration("output_file"), + "checkerboard_columns": ParameterValue( + LaunchConfiguration("checkerboard_columns"), value_type=int + ), + "checkerboard_rows": ParameterValue( + LaunchConfiguration("checkerboard_rows"), value_type=int + ), + "square_size_m": ParameterValue( + LaunchConfiguration("square_size_m"), value_type=float + ), + "enable_gui": ParameterValue( + LaunchConfiguration("enable_gui"), value_type=bool + ), + "gui_refresh_hz": ParameterValue( + LaunchConfiguration("gui_refresh_hz"), value_type=float + ), + "maximum_reprojection_rms_px": ParameterValue( + LaunchConfiguration("maximum_reprojection_rms_px"), + value_type=float, + ), + "auto_capture_default": ParameterValue( + LaunchConfiguration("auto_capture_default"), + value_type=bool, + ), + "auto_capture_stable_seconds": ParameterValue( + LaunchConfiguration("auto_capture_stable_seconds"), + value_type=float, + ), + **{ + f"{view}_camera_serial": serials[view] + for view in VIEWS + }, + } + ], + ) + return [*cameras, container, solver] + + +def generate_launch_description() -> LaunchDescription: + package_share = Path( + get_package_share_directory("g20_thumb_apriltag_calibration") + ) + camera_info = Path.home() / ".ros" / "camera_info" + return LaunchDescription( + [ + SetEnvironmentVariable( + name="FASTRTPS_DEFAULT_PROFILES_FILE", + value=str( + package_share / "config" / "fastdds_large_images.xml" + ), + ), + DeclareLaunchArgument("front_camera_serial", default_value="DB2163742"), + DeclareLaunchArgument("side_camera_serial", default_value="DB2163749"), + DeclareLaunchArgument("top_camera_serial", default_value="DB2163739"), + DeclareLaunchArgument("camera_model", default_value="MV-CS020-10U"), + DeclareLaunchArgument("front_camera_name", default_value="hikrobot_front_DB2163742"), + DeclareLaunchArgument("side_camera_name", default_value="hikrobot_side_DB2163749"), + DeclareLaunchArgument("top_camera_name", default_value="hikrobot_top_DB2163739"), + DeclareLaunchArgument("front_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163742.yaml")), + DeclareLaunchArgument("side_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163749.yaml")), + DeclareLaunchArgument("top_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163739.yaml")), + DeclareLaunchArgument("camera_frame_rate", default_value="15.0"), + DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), + DeclareLaunchArgument("gain_db", default_value="0.0"), + DeclareLaunchArgument("checkerboard_columns", default_value="8"), + DeclareLaunchArgument("checkerboard_rows", default_value="5"), + DeclareLaunchArgument("square_size_m", default_value="0.027"), + DeclareLaunchArgument("enable_gui", default_value="true"), + DeclareLaunchArgument("gui_refresh_hz", default_value="2.0"), + DeclareLaunchArgument( + "maximum_reprojection_rms_px", default_value="1.2" + ), + DeclareLaunchArgument("auto_capture_default", default_value="false"), + DeclareLaunchArgument( + "auto_capture_stable_seconds", default_value="1.0" + ), + DeclareLaunchArgument( + "output_file", + default_value=str(Path.cwd() / "config" / "g20_three_camera_extrinsics.yaml"), + ), + OpaqueFunction(function=_launch), + ] + ) diff --git a/src/g20_thumb_apriltag_calibration/setup.py b/src/g20_thumb_apriltag_calibration/setup.py index ab423d8..f70e788 100644 --- a/src/g20_thumb_apriltag_calibration/setup.py +++ b/src/g20_thumb_apriltag_calibration/setup.py @@ -47,6 +47,10 @@ setup( "three_camera_calibration_node = " "g20_thumb_apriltag_calibration.three_camera_node:main" ), + ( + "three_camera_extrinsics_node = " + "g20_thumb_apriltag_calibration.extrinsics_node:main" + ), ( "camera_alignment_view = " "g20_thumb_apriltag_calibration.alignment_view:main" diff --git a/src/g20_thumb_apriltag_calibration/test/test_config.py b/src/g20_thumb_apriltag_calibration/test/test_config.py index 4b9797c..0038144 100644 --- a/src/g20_thumb_apriltag_calibration/test/test_config.py +++ b/src/g20_thumb_apriltag_calibration/test/test_config.py @@ -57,6 +57,8 @@ def test_three_camera_tag_ids_and_topics_are_disjoint() -> None: key = f"/g20_calibration/{view}/apriltag/apriltag" parameters = tags[key]["ros__parameters"] assert parameters["tag"]["ids"] == ids + assert parameters["size"] == 0.016 + assert parameters["tag"]["sizes"] == [0.016] * len(ids) assert parameters["qos_profile"] == "sensor_data" assert parameters["detector"]["decimate"] == 1.5 all_ids.update(ids) @@ -69,6 +71,8 @@ def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None: ) parameters = config["g20_calibration"]["ros__parameters"] + assert parameters["tag_size_m"] == 0.016 + assert parameters["baseline_command_u8"] == [ 255, 255, @@ -102,10 +106,18 @@ def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None: assert parameters["minimum_detection_rate"] == 0.95 assert parameters["minimum_detection_hz"] == 15.0 assert parameters["minimum_state_span_u8"] >= 240.0 + assert parameters["endpoint_tolerance_u8"] == 2.0 + assert parameters["thumb_yaw_zero_endpoint_tolerance_u8"] == 3.0 assert parameters["minimum_sweep_bins"] >= 32 assert parameters["maximum_bin_gap"] <= 16 assert parameters["position_timeout_seconds"] >= 20.0 - assert parameters["zero_maximum_round_difference_deg"] <= 1.0 + assert parameters["maximum_state_image_skew_ms"] <= 50.0 + assert parameters["axis_maximum_rotation_circle_difference_deg"] <= 1.0 + assert parameters["passive_axis_maximum_plane_rms_m"] == 0.004 + assert parameters["active_maximum_rotation_orthogonal_rms_deg"] == 2.5 + assert parameters["passive_maximum_rotation_orthogonal_rms_deg"] == 7.5 + assert parameters["zero_maximum_axis_cycle_difference_deg"] <= 0.75 + assert parameters["zero_maximum_offset_deg"] <= 20.0 assert parameters["image_trajectory_maximum_radial_rms_px"] <= 2.0 assert parameters["image_trajectory_maximum_radial_p95_px"] <= 3.5 assert parameters["image_trajectory_minimum_radius_px"] >= 20.0 diff --git a/src/g20_thumb_apriltag_calibration/test/test_extrinsics_node.py b/src/g20_thumb_apriltag_calibration/test/test_extrinsics_node.py new file mode 100644 index 0000000..4352d5a --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/test/test_extrinsics_node.py @@ -0,0 +1,194 @@ +"""Focused tests for checkerboard frame pairing.""" + +import cv2 +import numpy as np +from scipy.spatial.transform import Rotation + +from g20_thumb_apriltag_calibration.extrinsics_node import ( + BoardPose, + StereoCapture, + _fit_stereo_robust, + _minimum_history_skew_ns, + _select_latest_synchronised_pair, + _summarize_transform_repeatability, +) + + +def _pose(stamp_ns: int) -> BoardPose: + return BoardPose( + stamp_ns=stamp_ns, + camera_from_board_candidates=(), + reprojection_rms_px=0.1, + ) + + +def test_pairing_uses_recent_history_instead_of_mismatched_latest_frames(): + front = [_pose(1_000_000_000), _pose(1_200_000_000)] + side = [_pose(1_030_000_000), _pose(1_370_000_000)] + + pair = _select_latest_synchronised_pair(front, side, 100_000_000) + + assert pair is not None + selected_front, selected_side, skew = pair + assert selected_front.stamp_ns == 1_000_000_000 + assert selected_side.stamp_ns == 1_030_000_000 + assert skew == 30_000_000 + + +def test_pairing_prefers_newest_valid_common_pair(): + front = [_pose(1_000_000_000), _pose(1_200_000_000)] + side = [_pose(1_010_000_000), _pose(1_240_000_000)] + + pair = _select_latest_synchronised_pair(front, side, 100_000_000) + + assert pair is not None + selected_front, selected_side, skew = pair + assert selected_front.stamp_ns == 1_200_000_000 + assert selected_side.stamp_ns == 1_240_000_000 + assert skew == 40_000_000 + + +def test_pairing_reports_minimum_skew_when_no_pair_passes(): + front = [_pose(1_000_000_000), _pose(1_200_000_000)] + side = [_pose(1_370_000_000)] + + assert _select_latest_synchronised_pair( + front, side, 100_000_000 + ) is None + assert _minimum_history_skew_ns(front, side) == 170_000_000 + + +def _transform(rotation_deg: float, translation_m: float) -> np.ndarray: + value = np.eye(4) + value[:3, :3] = Rotation.from_euler( + "z", rotation_deg, degrees=True + ).as_matrix() + value[0, 3] = translation_m + return value + + +def test_repeatability_summary_accepts_consistent_capture_set(): + captures = [ + [_transform(-0.1, -0.0005)], + [_transform(0.0, 0.0)], + [_transform(0.1, 0.0005)], + ] + + _, selected, rotation_error, translation_error = ( + _summarize_transform_repeatability(captures) + ) + + assert len(selected) == 3 + np.testing.assert_allclose( + np.degrees(rotation_error), 0.1, atol=1.0e-6 + ) + np.testing.assert_allclose(translation_error, 0.0005, atol=1.0e-9) + + +def test_repeatability_summary_exposes_current_capture_outlier(): + captures = [ + [_transform(0.0, 0.0)], + [_transform(0.1, 0.0005)], + [_transform(1.0, 0.008)], + ] + + _, _, rotation_error, translation_error = ( + _summarize_transform_repeatability(captures) + ) + + assert np.degrees(rotation_error) > 0.3 + assert translation_error > 0.0015 + + +def test_joint_stereo_fit_recovers_transform_and_rejects_bad_view(): + random = np.random.default_rng(7) + object_points = np.zeros((40, 3), dtype=np.float32) + object_points[:, :2] = ( + np.mgrid[0:8, 0:5].T.reshape(-1, 2) * 0.027 + ) + matrix = np.asarray( + [[1800.0, 0.0, 812.0], [0.0, 1795.0, 620.0], [0.0, 0.0, 1.0]] + ) + other_from_front = np.eye(4) + other_from_front[:3, :3] = Rotation.from_euler( + "xyz", [2.0, 18.0, -1.0], degrees=True + ).as_matrix() + other_from_front[:3, 3] = [0.20, -0.01, 0.04] + expected_front_from_other = np.linalg.inv(other_from_front) + captures = [] + for index in range(21): + front_from_board = np.eye(4) + front_from_board[:3, :3] = Rotation.from_euler( + "xyz", + [ + -8.0 + index * 0.7, + 5.0 + (index % 5) * 2.0, + -5.0 + (index % 4) * 3.0, + ], + degrees=True, + ).as_matrix() + front_from_board[:3, 3] = [ + -0.08 + (index % 5) * 0.035, + -0.04 + (index % 4) * 0.025, + 0.75 + (index % 3) * 0.08, + ] + other_from_board = other_from_front @ front_from_board + front_rvec = Rotation.from_matrix( + front_from_board[:3, :3] + ).as_rotvec() + other_rvec = Rotation.from_matrix( + other_from_board[:3, :3] + ).as_rotvec() + front_points, _ = cv2.projectPoints( + object_points, + front_rvec, + front_from_board[:3, 3], + matrix, + np.zeros(5), + ) + other_points, _ = cv2.projectPoints( + object_points, + other_rvec, + other_from_board[:3, 3], + matrix, + np.zeros(5), + ) + front_points = front_points.reshape(-1, 2) + other_points = other_points.reshape(-1, 2) + front_points += random.normal(0.0, 0.12, front_points.shape) + other_points += random.normal(0.0, 0.12, other_points.shape) + if index == 20: + other_points += random.normal(0.0, 4.0, other_points.shape) + captures.append( + StereoCapture( + front_points_px=front_points.astype(np.float32), + other_points_px=other_points.astype(np.float32), + transform_candidates=(expected_front_from_other.copy(),), + pair_reprojection_rms_px=0.2, + skew_ns=10_000_000, + ) + ) + + result = _fit_stereo_robust( + captures, + object_points, + matrix, + matrix, + (1624, 1240), + minimum_inliers=15, + maximum_rms_px=1.2, + maximum_rotation_stability_rad=np.deg2rad(0.3), + maximum_translation_stability_m=0.0015, + ) + + assert result.passed + assert 20 in result.rejected_indices + assert len(result.inlier_indices) >= 15 + rotation_error = Rotation.from_matrix( + result.front_from_other[:3, :3] + ).inv() * Rotation.from_matrix(expected_front_from_other[:3, :3]) + assert np.degrees(rotation_error.magnitude()) < 0.05 + assert np.linalg.norm( + result.front_from_other[:3, 3] + - expected_front_from_other[:3, 3] + ) < 0.001 diff --git a/src/g20_thumb_apriltag_calibration/test/test_full_hand.py b/src/g20_thumb_apriltag_calibration/test/test_full_hand.py index 0c78b75..b2d6e13 100644 --- a/src/g20_thumb_apriltag_calibration/test/test_full_hand.py +++ b/src/g20_thumb_apriltag_calibration/test/test_full_hand.py @@ -1,4 +1,5 @@ import math +from dataclasses import replace import numpy as np import pytest @@ -257,24 +258,28 @@ def test_splay_uses_angular_midpoint_not_fixed_command_midpoint() -> None: def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None: base = fit_joint_center_curve(_records()) - splay, zero_command, midpoint = center_splay_curve(base) + splay, zero_command, _ = center_splay_curve(base) + splay = replace( + splay, + angle_rad=tuple( + value - splay.angle_rad[zero_command] + for value in splay.angle_rad + ), + ) measured = { name: splay if name == "index_mcp_roll" else base for name in MEASURED_JOINTS } - projected = { - name: 0.01 * index - for index, (name, spec) in enumerate(JOINT_SPECS.items()) - if spec.zero_kind == "projected" - } + offsets = {name: 0.01 for name in ACTIVE_JOINTS} + baseline = [255] * 20 + baseline[6:10] = [zero_command] * 4 payload = build_compact_payload( serial_number="G20_LEFT_001", measured_fits=measured, - projected_zeros_rad=projected, - splay_zero_command_u8=zero_command, - splay_midpoint_rad=midpoint, + urdf_zero_offsets_rad=offsets, validation_errors_rad=[0.01, -0.02], passed=True, + baseline=baseline, ) validate_compact_payload(payload) assert set(payload) == { @@ -298,7 +303,7 @@ def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None: == payload["joints"]["index_mcp_roll"]["angle_rad"] ) assert payload["joints"]["index_mcp_roll"]["zero_angles"] == { - "travel_midpoint_rad": pytest.approx(0.35, abs=1.0e-4) + "urdf_zero_offset_rad": pytest.approx(0.01) } for name in SPLAY_JOINTS: assert payload["joints"][name]["zero_command_u8"] == zero_command @@ -306,25 +311,29 @@ def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None: def test_compact_payload_allows_skipped_random_validation() -> None: base = fit_joint_center_curve(_records()) - splay, zero_command, midpoint = center_splay_curve(base) + splay, zero_command, _ = center_splay_curve(base) + splay = replace( + splay, + angle_rad=tuple( + value - splay.angle_rad[zero_command] + for value in splay.angle_rad + ), + ) measured = { name: splay if name == "index_mcp_roll" else base for name in MEASURED_JOINTS } - projected = { - name: 0.0 - for name, spec in JOINT_SPECS.items() - if spec.zero_kind == "projected" - } + offsets = {name: 0.0 for name in ACTIVE_JOINTS} + baseline = [255] * 20 + baseline[6:10] = [zero_command] * 4 payload = build_compact_payload( serial_number="G20_LEFT_001", measured_fits=measured, - projected_zeros_rad=projected, - splay_zero_command_u8=zero_command, - splay_midpoint_rad=midpoint, + urdf_zero_offsets_rad=offsets, validation_errors_rad=[], passed=True, + baseline=baseline, ) validate_compact_payload(payload) diff --git a/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py b/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py index 08caa10..9604b90 100644 --- a/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py +++ b/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py @@ -174,6 +174,32 @@ def test_status_numeric_diagnostics_are_json_serializable() -> None: assert '"maximum_bin_gap": 239' in encoded +def test_urdf_zero_bound_has_specific_chinese_scale_guidance() -> None: + payload = { + "state": "PAUSED", + "reason": ( + "URDF zero offset reached the configured 20.000 degree bound: " + "thumb_cmc_roll=+20.000deg, index_pip=-20.000deg; " + "all_offsets: thumb_cmc_roll=+20.000deg" + ), + "progress": 1.0, + "completed_sweeps": 42, + "total_sweeps": 42, + "active": {}, + "views": {}, + "result_path": "", + } + + text = render_three_camera_status_text_zh(payload) + + assert "触及±20.000°安全边界" in text + assert "拇指CMC滚转=+20.000deg" in text + assert "食指PIP=-20.000deg" in text + assert "不要调用resume" in text + assert "Tag有效黑框边长" in text + assert "未分类原因码" not in text + + def test_index_roll_status_prints_clearance_motor_feedback() -> None: payload = { "state": "SWEEP", diff --git a/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py b/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py index ca933e7..b89b50c 100644 --- a/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py +++ b/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py @@ -1,14 +1,21 @@ import json import math +from dataclasses import replace from types import SimpleNamespace +import numpy as np +from scipy.spatial.transform import Rotation + from g20_thumb_apriltag_calibration.core import ( DIRECTION_DECREASING, DIRECTION_INCREASING, ) from g20_thumb_apriltag_calibration.full_hand import SWEEP_SPECS from g20_thumb_apriltag_calibration.three_camera_node import ( + FrameObservation, G20ThreeCameraCalibrationNode, + STATE_PREPARE_SWEEP, + STATE_SWEEP, SweepItem, ) @@ -22,23 +29,176 @@ def _sweep_items() -> list[SweepItem]: ] -def _image_cycle_records(travels_rad: list[float]) -> list[dict]: +def _frame(view: str, state_u8: list[float]) -> FrameObservation: + return FrameObservation( + stamp_ns=1, + received_at=2.0, + view=view, + state_u8=tuple(state_u8), + state_sync_error_ns=0, + joint_vectors_xyz_m={}, + image_vectors_xy_px={}, + joint_quaternions_xyzw={}, + parent_poses_common={}, + child_poses_common={}, + joint_reprojection_error_px={}, + ) + + +def test_prepare_sweep_retains_synchronised_start_endpoint_frame() -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) + item = SweepItem(spec, 2, DIRECTION_DECREASING) + baseline = [255] * 20 + endpoint_state = list(baseline) + endpoint_state[6] = 253.0 + endpoint_state[7:10] = [0.0, 0.0, 0.0] + outside_state = list(endpoint_state) + outside_state[6] = 252.0 + node = SimpleNamespace( + state=STATE_PREPARE_SWEEP, + active_sweep=item, + baseline_command=tuple(baseline), + endpoint_tolerance_u8=2.0, + preflight_frames=30, + latest_state_u8=tuple(endpoint_state), + sweep_start_frames=[], + ) + node._motion_command_reached = lambda selected, command, state=None: ( + G20ThreeCameraCalibrationNode._motion_command_reached( + node, selected, command, state + ) + ) + node._endpoint_tolerance_for_spec = lambda selected, endpoint: ( + G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( + node, selected, endpoint + ) + ) + + endpoint_frame = _frame("front", endpoint_state) + G20ThreeCameraCalibrationNode._accept_frame(node, endpoint_frame) + G20ThreeCameraCalibrationNode._accept_frame( + node, _frame("front", outside_state) + ) + + assert node.sweep_start_frames == [endpoint_frame] + + +def test_begin_sweep_carries_start_endpoint_frame_into_sweep() -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) + item = SweepItem(spec, 2, DIRECTION_DECREASING) + baseline = [255] * 20 + endpoint_state = list(baseline) + endpoint_state[6] = 253.0 + endpoint_state[7:10] = [0.0, 0.0, 0.0] + endpoint_frame = _frame("front", endpoint_state) + published: list[list[int]] = [] + node = SimpleNamespace( + active_sweep=item, + baseline_command=tuple(baseline), + sweep_frames=[], + sweep_start_frames=[endpoint_frame], + _publish_command=lambda command: published.append(command), + ) + + G20ThreeCameraCalibrationNode._begin_active_sweep(node, 10.0) + + assert node.state == STATE_SWEEP + assert node.sweep_frames == [endpoint_frame] + assert node.sweep_start_frames == [] + assert node.sweep_last_valid_at == 10.0 + assert published[0][6] == 0 + assert published[0][7:10] == [0, 0, 0] + + +def test_only_thumb_yaw_uses_three_count_endpoint_deadband() -> None: + yaw = next(item for item in SWEEP_SPECS if item.motor_index == 10) + index_roll = next(item for item in SWEEP_SPECS if item.motor_index == 6) + node = SimpleNamespace( + endpoint_tolerance_u8=2.0, + thumb_yaw_zero_endpoint_tolerance_u8=3.0, + ) + + assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( + node, yaw, 0 + ) == 3.0 + assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( + node, yaw, 255 + ) == 2.0 + assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( + node, index_roll, 0 + ) == 2.0 + + +def test_thumb_yaw_command_zero_accepts_feedback_three_only_for_swept_motor() -> None: + yaw = next(item for item in SWEEP_SPECS if item.motor_index == 10) + state = [255.0] * 20 + state[10] = 3.0 + state[5] = 143.0 + node = SimpleNamespace( + latest_state_u8=tuple(state), + baseline_command=tuple([255] * 20), + endpoint_tolerance_u8=2.0, + thumb_yaw_zero_endpoint_tolerance_u8=3.0, + ) + node._endpoint_tolerance_for_spec = lambda spec, endpoint: ( + G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( + node, spec, endpoint + ) + ) + + assert G20ThreeCameraCalibrationNode._motion_command_reached( + node, yaw, 0, tuple(state) + ) + state[5] = 142.0 + assert not G20ThreeCameraCalibrationNode._motion_command_reached( + node, yaw, 0, tuple(state) + ) + + +def _image_cycle_records( + travels_rad: list[float], *, depth_slope: float = 0.0 +) -> list[dict]: commands = list(range(0, 256, 16)) + [255] records = [] for cycle, travel in enumerate(travels_rad): for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): for command in commands: angle = travel * (255.0 - command) / 255.0 + x = 0.03 * math.cos(angle) + y = 0.03 * math.sin(angle) + z = float(depth_slope) * (x - 0.03) records.append( { "cycle": cycle, "direction": direction, "command_u8": command, - "relative_translation_xyz_m": [0.01, 0.0, 0.0], + "relative_translation_xyz_m": [ + x, + y, + z, + ], "image_relative_xy_px": [ 100.0 * math.cos(angle), 100.0 * math.sin(angle), ], + "relative_quaternion_xyzw": Rotation.from_rotvec( + np.asarray([0.0, 0.0, angle]) + ).as_quat().tolist(), + "parent_pose_common": { + "translation_xyz_m": [0.0, 0.0, 0.0], + "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], + }, + "child_pose_common": { + "translation_xyz_m": [ + x, + y, + z, + ], + "quaternion_xyzw": Rotation.from_rotvec( + np.asarray([0.0, 0.0, angle]) + ).as_quat().tolist(), + }, + "state_u8": [float(command)] + [255.0] * 19, } ) return records @@ -61,16 +221,23 @@ def _fit_check_node(records_by_joint: dict) -> SimpleNamespace: maximum_hysteresis_rad=math.radians(5.0), passive_maximum_monotonic_correction_rad=math.radians(3.0), passive_maximum_hysteresis_rad=math.radians(7.5), - zero_minimum_radius_px=20.0, - zero_maximum_radial_rms_px=2.0, - zero_maximum_radial_p95_px=3.5, - zero_maximum_round_difference_rad=math.radians(1.0), + baseline_command=[255] * 20, + axis_maximum_plane_rms_m=0.003, + passive_axis_maximum_plane_rms_m=0.004, + axis_maximum_radial_rms_m=0.003, + axis_maximum_rotation_circle_difference_rad=math.radians(1.0), + active_maximum_rotation_orthogonal_rms_rad=math.radians(2.5), + passive_maximum_rotation_orthogonal_rms_rad=math.radians(7.5), + zero_maximum_axis_cycle_difference_rad=math.radians(0.75), ) node._fit_joint_records = lambda name, records, relaxed=False: ( G20ThreeCameraCalibrationNode._fit_joint_records( node, name, records, relaxed=relaxed ) ) + node._fit_axis_measurement = lambda name, cycle: ( + G20ThreeCameraCalibrationNode._fit_axis_measurement(node, name, cycle) + ) return node @@ -80,6 +247,7 @@ def test_fit_failure_rewinds_to_failed_specs_first_direction(tmp_path) -> None: sweep_items=_sweep_items(), sweep_attempts={spec.motor_index: 1}, retry_sweep_spec=None, + retry_resume_index=None, fit_failure={}, sweep_index=24, raw_path=tmp_path / "raw_samples.jsonl", @@ -133,7 +301,7 @@ def test_resume_discards_only_failed_specs_samples(tmp_path) -> None: assert records["thumb_ip"] == [] assert records["thumb_cmc_pitch"] == [{"keep": True}] assert node.sweep_attempts[15] == 2 - assert node.retry_sweep_spec is None + assert node.retry_sweep_spec == spec assert node.fit_failure == {} event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) assert event == { @@ -168,3 +336,127 @@ def test_provisional_fit_rejects_inconsistent_cycle_travel() -> None: assert cycle_failure["joint"] == "thumb_cmc_pitch" assert cycle_failure["actual"] == 8.0 assert cycle_failure["limit"] == 3.0 + + +def test_provisional_fit_accepts_constrained_circle_for_image_joint() -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 0) + node = _fit_check_node( + { + "thumb_cmc_pitch": _image_cycle_records( + [math.radians(47.0)] * 3, + depth_slope=2.00, + ) + } + ) + + failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( + node, spec + ) + + assert not [ + failure + for failure in failures + if failure["metric"] + in { + "rotation_circle_axis_difference_deg", + "axis_cycle_difference_deg", + "axis_plane_rms_mm", + "axis_radial_rms_mm", + } + ] + + +def test_thumb_ip_axis_uses_same_cycle_thumb_mcp_direction() -> None: + records = _image_cycle_records([math.radians(47.0)] * 3) + node = _fit_check_node( + { + "thumb_cmc_pitch": records, + "thumb_mcp": records, + "thumb_ip": records, + } + ) + + upstream = node._fit_axis_measurement("thumb_mcp", 0) + passive = node._fit_axis_measurement("thumb_ip", 0) + + assert abs( + float( + np.asarray(upstream.axis_common_xyz) + @ np.asarray(passive.axis_common_xyz) + ) + ) > math.cos(math.radians(0.01)) + + +def test_provisional_fit_uses_orientation_constrained_circle_for_yaw() -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 10) + node = _fit_check_node( + { + "thumb_cmc_yaw": _image_cycle_records( + [math.radians(47.0)] * 3, + depth_slope=0.30, + ) + } + ) + + failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( + node, spec + ) + + assert not [ + failure + for failure in failures + if failure["metric"] + in { + "rotation_circle_axis_difference_deg", + "axis_cycle_difference_deg", + "axis_plane_rms_mm", + "axis_radial_rms_mm", + } + ] + + +def test_provisional_fit_uses_active_and_passive_axis_model_limits() -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 15) + records = _image_cycle_records([math.radians(47.0)] * 3) + node = _fit_check_node( + { + "thumb_mcp": records, + "thumb_ip": records, + } + ) + residuals_deg = {"thumb_mcp": 2.0, "thumb_ip": 6.0} + + def fitted(name, selected, relaxed=False): + fit = G20ThreeCameraCalibrationNode._fit_joint_records( + node, name, selected, relaxed=relaxed + ) + quality = dict(fit.quality) + quality["rotation_orthogonal_rms_rad"] = math.radians( + residuals_deg[name] + ) + return replace(fit, quality=quality) + + node._fit_joint_records = fitted + failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( + node, spec + ) + assert not [ + failure + for failure in failures + if failure["metric"] == "rotation_orthogonal_rms_deg" + ] + + residuals_deg["thumb_mcp"] = 3.0 + residuals_deg["thumb_ip"] = 8.0 + failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( + node, spec + ) + axis_failures = [ + failure + for failure in failures + if failure["metric"] == "rotation_orthogonal_rms_deg" + ] + assert {(item["joint"], item["limit"]) for item in axis_failures} == { + ("thumb_mcp", 2.5), + ("thumb_ip", 7.5), + } diff --git a/src/g20_thumb_apriltag_calibration/test/test_urdf_zero.py b/src/g20_thumb_apriltag_calibration/test/test_urdf_zero.py new file mode 100644 index 0000000..9c62507 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/test/test_urdf_zero.py @@ -0,0 +1,550 @@ +import math +from pathlib import Path +import xml.etree.ElementTree as ET + +import numpy as np +import pytest +from scipy.spatial.transform import Rotation + +from g20_thumb_apriltag_calibration.extrinsics import ( + camera_info_fingerprint, + dump_three_camera_extrinsics, + load_three_camera_extrinsics, +) +from g20_thumb_apriltag_calibration.urdf_zero import ( + AXIS_JOINTS, + DIRECT_ZERO_JOINTS, + INHERITED_ZERO_JOINTS, + JointAxisMeasurement, + UrdfKinematicModel, + _angles_from_state, + _zero_sensitive_axis_error_rad, + fit_joint_axis_measurement, + fit_rotation_joint_curve, + solve_urdf_zero_offsets, + write_zero_corrected_urdf, +) +from g20_thumb_apriltag_calibration.full_hand import ( + ACTIVE_JOINTS, + JOINT_SPECS, + MEASURED_JOINTS, + PASSIVE_JOINTS, + JointCurveFit, +) + + +REPOSITORY = Path(__file__).resolve().parents[3] +SOURCE_URDF = REPOSITORY / ( + "src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/" + "linker_hand/g20_left/linkerhand_g20_left.urdf" +) + + +def test_zero_sensitive_axis_error_ignores_fixed_cone_angle_mismatch(): + parent = np.asarray([0.0, 0.0, 1.0]) + predicted = np.asarray([1.0, 0.0, 0.0]) + cone_mismatch = np.asarray( + [math.cos(math.radians(10.0)), 0.0, math.sin(math.radians(10.0))] + ) + zero_mismatch = np.asarray( + [math.cos(math.radians(3.0)), math.sin(math.radians(3.0)), 0.0] + ) + + assert _zero_sensitive_axis_error_rad( + predicted, cone_mismatch, parent + ) == pytest.approx(0.0, abs=1.0e-12) + assert math.degrees( + _zero_sensitive_axis_error_rad(predicted, zero_mismatch, parent) + ) == pytest.approx(3.0, abs=1.0e-9) + + +def _payload(transform: np.ndarray) -> dict[str, list[float]]: + return { + "translation_xyz_m": transform[:3, 3].tolist(), + "quaternion_xyzw": Rotation.from_matrix( + transform[:3, :3] + ).as_quat().tolist(), + } + + +def _arbitrary_tag_records() -> tuple[list[dict], np.ndarray, np.ndarray]: + axis_parent = np.asarray([0.23, -0.31, 0.922], dtype=float) + axis_parent /= np.linalg.norm(axis_parent) + centre_parent = np.asarray([0.012, -0.008, 0.021]) + radial = np.cross(axis_parent, np.asarray([0.7, 0.1, -0.2])) + radial = 0.035 * radial / np.linalg.norm(radial) + child_tag_mount = Rotation.from_euler( + "xyz", [1.1, -0.7, 0.45] + ) + common_from_parent = np.eye(4) + common_from_parent[:3, :3] = Rotation.from_euler( + "xyz", [-0.8, 0.55, 1.3] + ).as_matrix() + common_from_parent[:3, 3] = [0.41, -0.12, 0.73] + expected_axis = common_from_parent[:3, :3] @ axis_parent + expected_point = ( + common_from_parent[:3, :3] @ centre_parent + + common_from_parent[:3, 3] + ) + + commands = list(range(0, 256, 16)) + [255] + records = [] + for cycle in range(3): + for direction in ("decreasing", "increasing"): + for command in commands: + angle = math.radians(62.0) * (255.0 - command) / 255.0 + motion = Rotation.from_rotvec(axis_parent * angle) + relative_rotation = motion * child_tag_mount + relative_translation = centre_parent + motion.apply(radial) + child_common = common_from_parent.copy() + child_common[:3, :3] = ( + common_from_parent[:3, :3] + @ relative_rotation.as_matrix() + ) + child_common[:3, 3] = ( + common_from_parent[:3, :3] @ relative_translation + + common_from_parent[:3, 3] + ) + state = [255.0] * 20 + state[5] = float(command) + records.append( + { + "cycle": cycle, + "direction": direction, + "command_u8": command, + "relative_translation_xyz_m": relative_translation.tolist(), + "relative_quaternion_xyzw": relative_rotation.as_quat().tolist(), + "parent_pose_common": _payload(common_from_parent), + "child_pose_common": _payload(child_common), + "state_u8": state, + } + ) + return records, expected_axis, expected_point + + +def test_axis_and_curve_ignore_camera_and_tag_mount_rotation() -> None: + records, expected_axis, expected_point = _arbitrary_tag_records() + curve = fit_rotation_joint_curve(records, zero_command_u8=255) + measurement = fit_joint_axis_measurement( + "thumb_cmc_roll", records, cycle=0, zero_command_u8=255 + ) + + observed_axis = np.asarray(measurement.axis_common_xyz) + observed_point = np.asarray(measurement.point_common_xyz_m) + assert float(observed_axis @ expected_axis) > math.cos(math.radians(0.05)) + assert np.linalg.norm( + np.cross(observed_point - expected_point, expected_axis) + ) < 1.0e-6 + assert curve.angle_rad[255] == pytest.approx(0.0, abs=1.0e-9) + assert curve.angle_rad[0] == pytest.approx(math.radians(62.0), abs=1.0e-6) + + +def test_image_plane_joint_uses_rotation_axis_to_constrain_noisy_depth() -> None: + records, expected_axis, expected_point = _arbitrary_tag_records() + parent_rotation = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3]) + axis_parent = parent_rotation.inv().apply(expected_axis) + tangent = np.cross(axis_parent, np.asarray([0.4, -0.2, 0.7])) + tangent /= np.linalg.norm(tangent) + # Reproduce monocular planar-PnP depth bias: the centre trajectory remains + # precise in its dominant directions but receives a command-correlated + # component that makes a free 3-D plane normal substantially wrong. + biased_records = [] + for record in records: + biased = dict(record) + point = np.asarray(record["relative_translation_xyz_m"], dtype=float) + depth_bias = 0.30 * float(point @ tangent) + biased["relative_translation_xyz_m"] = ( + point + depth_bias * axis_parent + ).tolist() + biased_records.append(biased) + + measurement = fit_joint_axis_measurement( + "thumb_cmc_pitch", biased_records, cycle=0, zero_command_u8=255 + ) + + observed_axis = np.asarray(measurement.axis_common_xyz) + observed_point = np.asarray(measurement.point_common_xyz_m) + assert abs(float(observed_axis @ expected_axis)) > math.cos( + math.radians(0.05) + ) + assert np.linalg.norm( + np.cross(observed_point - expected_point, expected_axis) + ) < 0.003 + assert measurement.rotation_circle_axis_difference_rad > math.radians(5.0) + assert measurement.plane_rms_m < 0.003 + assert measurement.radial_rms_m < 0.003 + + +def test_splay_zero_interpolates_when_scan_does_not_hit_command_127() -> None: + records, expected_axis, _ = _arbitrary_tag_records() + assert not any(record["command_u8"] == 127 for record in records) + + curve = fit_rotation_joint_curve(records, zero_command_u8=127) + measurement = fit_joint_axis_measurement( + "index_mcp_roll", records, cycle=0, zero_command_u8=127 + ) + + observed_axis = np.asarray(measurement.axis_common_xyz) + assert abs(float(observed_axis @ expected_axis)) > math.cos( + math.radians(0.05) + ) + assert curve.angle_rad[127] == pytest.approx(0.0, abs=1.0e-9) + + +def test_passive_axis_can_use_trusted_upstream_direction_constraint() -> None: + records, expected_axis, expected_point = _arbitrary_tag_records() + common_from_parent = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3]) + physical_axis_parent = common_from_parent.inv().apply(expected_axis) + wrong_axis_parent = np.cross( + physical_axis_parent, np.asarray([0.2, 0.8, -0.1]) + ) + wrong_axis_parent /= np.linalg.norm(wrong_axis_parent) + mount = Rotation.from_quat(records[0]["relative_quaternion_xyzw"]) + contradictory = [] + for record in records: + changed = dict(record) + angle = math.radians(62.0) * ( + 255.0 - float(record["command_u8"]) + ) / 255.0 + changed["relative_quaternion_xyzw"] = ( + Rotation.from_rotvec(wrong_axis_parent * angle) * mount + ).as_quat().tolist() + contradictory.append(changed) + + measurement = fit_joint_axis_measurement( + "index_dip", + contradictory, + cycle=0, + zero_command_u8=255, + axis_common_constraint=expected_axis, + ) + + observed_axis = np.asarray(measurement.axis_common_xyz) + observed_point = np.asarray(measurement.point_common_xyz_m) + assert abs(float(observed_axis @ expected_axis)) > math.cos( + math.radians(0.05) + ) + assert np.linalg.norm( + np.cross(observed_point - expected_point, expected_axis) + ) < 1.0e-6 + + +def test_extrinsics_round_trip_keeps_camera_identity(tmp_path: Path) -> None: + cameras = { + view: { + "serial_number": f"SERIAL_{view}", + "width": 1624, + "height": 1240, + "intrinsics_sha256": camera_info_fingerprint( + width=1624, + height=1240, + camera_matrix=np.asarray( + [[1100.0, 0.0, 812.0], [0.0, 1099.0, 620.0], [0.0, 0.0, 1.0]] + ), + ), + } + for view in ("front", "side", "top") + } + transforms = {"front": np.eye(4), "side": np.eye(4), "top": np.eye(4)} + transforms["side"][:3, :3] = Rotation.from_euler("y", 0.7).as_matrix() + transforms["side"][:3, 3] = [0.2, 0.0, 0.1] + transforms["top"][:3, :3] = Rotation.from_euler("x", -0.9).as_matrix() + transforms["top"][:3, 3] = [-0.1, 0.3, 0.2] + destination = tmp_path / "extrinsics.yaml" + + dump_three_camera_extrinsics( + destination, + cameras=cameras, + front_from_view=transforms, + quality={ + "passed": True, + "reprojection_rms_px": 0.3, + "maximum_rotation_repeatability_deg": 0.2, + "maximum_translation_repeatability_m": 0.001, + "front_side_captures": 15, + "front_top_captures": 15, + }, + ) + loaded = load_three_camera_extrinsics(destination) + + assert loaded.cameras["front"].serial_number == "SERIAL_front" + assert np.allclose(loaded.transform("side"), transforms["side"]) + assert np.allclose(loaded.transform("top"), transforms["top"]) + assert loaded.camera_matches( + "front", + serial_number="SERIAL_front", + width=1624, + height=1240, + intrinsics_sha256=cameras["front"]["intrinsics_sha256"], + ) + assert not loaded.camera_matches( + "front", + serial_number="WRONG_SERIAL", + width=1624, + height=1240, + intrinsics_sha256=cameras["front"]["intrinsics_sha256"], + ) + + +def _joint_origin(path: Path, name: str) -> tuple[np.ndarray, np.ndarray]: + joint = next( + element + for element in ET.parse(path).getroot().findall("joint") + if element.get("name") == name + ) + origin = joint.find("origin") + axis = joint.find("axis") + xyz = np.asarray([float(value) for value in origin.get("xyz").split()]) + rpy = np.asarray([float(value) for value in origin.get("rpy").split()]) + axis_xyz = np.asarray([float(value) for value in axis.get("xyz").split()]) + return np.block( + [ + [Rotation.from_euler("xyz", rpy).as_matrix(), xyz[:, None]], + [np.asarray([[0.0, 0.0, 0.0, 1.0]])], + ] + ), axis_xyz / np.linalg.norm(axis_xyz) + + +def _joint_limit(path: Path, name: str) -> tuple[float, float]: + joint = next( + element + for element in ET.parse(path).getroot().findall("joint") + if element.get("name") == name + ) + limit = joint.find("limit") + return float(limit.get("lower")), float(limit.get("upper")) + + +def test_urdf_writer_postmultiplies_joint_axis_and_never_overwrites(tmp_path: Path) -> None: + offset = math.radians(7.3) + destination = write_zero_corrected_urdf( + source_urdf=SOURCE_URDF, + output_directory=tmp_path, + serial_number="G20_LEFT_001", + offsets_rad={"thumb_cmc_yaw": offset}, + timestamp="20260806_120000", + ) + original, axis = _joint_origin(SOURCE_URDF, "thumb_cmc_yaw") + corrected, _ = _joint_origin(destination, "thumb_cmc_yaw") + expected = original.copy() + expected[:3, :3] = original[:3, :3] @ Rotation.from_rotvec( + axis * offset + ).as_matrix() + + assert destination != SOURCE_URDF + assert np.allclose(corrected, expected, atol=1.0e-12) + with pytest.raises(ValueError, match="refusing to overwrite"): + write_zero_corrected_urdf( + source_urdf=SOURCE_URDF, + output_directory=tmp_path, + serial_number="G20_LEFT_001", + offsets_rad={"thumb_cmc_yaw": offset}, + timestamp="20260806_120000", + ) + with pytest.raises(ValueError, match="original CAD URDF"): + write_zero_corrected_urdf( + source_urdf=destination, + output_directory=tmp_path, + serial_number="G20_LEFT_001", + offsets_rad={"thumb_cmc_yaw": offset}, + timestamp="20260806_120001", + ) + + +def test_urdf_writer_changes_only_the_16_active_zero_origins( + tmp_path: Path, +) -> None: + before = SOURCE_URDF.read_bytes() + offsets = { + name: math.radians(0.25 * (index + 1)) + for index, name in enumerate(ACTIVE_JOINTS) + } + destination = write_zero_corrected_urdf( + source_urdf=SOURCE_URDF, + output_directory=tmp_path, + serial_number="G20_LEFT_001", + offsets_rad=offsets, + timestamp="20260807_180000", + ) + + assert len(offsets) == 16 + assert SOURCE_URDF.read_bytes() == before + for name in ACTIVE_JOINTS: + original, axis = _joint_origin(SOURCE_URDF, name) + corrected, corrected_axis = _joint_origin(destination, name) + expected = original.copy() + expected[:3, :3] = original[:3, :3] @ Rotation.from_rotvec( + axis * offsets[name] + ).as_matrix() + assert np.allclose(corrected, expected, atol=1.0e-12) + assert np.allclose(corrected_axis, axis, atol=1.0e-12) + for name in PASSIVE_JOINTS: + original, axis = _joint_origin(SOURCE_URDF, name) + corrected, corrected_axis = _joint_origin(destination, name) + assert np.allclose(corrected, original, atol=1.0e-12) + assert np.allclose(corrected_axis, axis, atol=1.0e-12) + + +def test_urdf_writer_expands_only_requested_limits_with_margin( + tmp_path: Path, +) -> None: + margin = math.radians(0.5) + measured_ranges = { + "index_mcp_roll": (-0.25113634, 0.24813698), + "index_mcp_pitch": (0.0, 1.22424601), + "index_pip": (0.0, 1.74215779), + } + destination = write_zero_corrected_urdf( + source_urdf=SOURCE_URDF, + output_directory=tmp_path, + serial_number="G20_LEFT_001", + offsets_rad={"thumb_cmc_yaw": math.radians(1.0)}, + timestamp="20260807_181500", + joint_angle_ranges_rad=measured_ranges, + joint_limit_margin_rad=margin, + ) + + roll_lower, roll_upper = _joint_limit(destination, "index_mcp_roll") + pitch_lower, pitch_upper = _joint_limit(destination, "index_mcp_pitch") + pip_lower, pip_upper = _joint_limit(destination, "index_pip") + assert roll_lower == pytest.approx(measured_ranges["index_mcp_roll"][0] - margin) + assert roll_upper == pytest.approx(measured_ranges["index_mcp_roll"][1] + margin) + assert pitch_lower == pytest.approx(_joint_limit(SOURCE_URDF, "index_mcp_pitch")[0]) + assert pitch_upper == pytest.approx(measured_ranges["index_mcp_pitch"][1] + margin) + assert pip_lower == pytest.approx(_joint_limit(SOURCE_URDF, "index_pip")[0]) + assert pip_upper == pytest.approx(measured_ranges["index_pip"][1] + margin) + assert _joint_limit(destination, "thumb_cmc_yaw") == pytest.approx( + _joint_limit(SOURCE_URDF, "thumb_cmc_yaw") + ) + assert _joint_limit(destination, "middle_mcp_roll") == pytest.approx( + _joint_limit(SOURCE_URDF, "middle_mcp_roll") + ) + + +def _synthetic_curve(zero_command: int, travel: float) -> JointCurveFit: + values = np.asarray( + [travel * (255.0 - command) / 255.0 for command in range(256)] + ) + values -= values[zero_command] + data = tuple(float(value) for value in values) + return JointCurveFit( + angle_rad=data, + decreasing_rad=data, + increasing_rad=data, + circle={}, + maximum_monotonic_correction_rad=0.0, + maximum_hysteresis_rad=0.0, + quality={}, + ) + + +def test_joint_chain_solver_recovers_offsets_and_yaw_uses_roll_145() -> None: + baseline = [255.0] * 20 + baseline[6:10] = [127.0] * 4 + curves = { + name: _synthetic_curve( + int(baseline[JOINT_SPECS[name].motor_index]), + math.radians(50.0), + ) + for name in MEASURED_JOINTS + } + motor_by_joint = { + name: spec.motor_index for name, spec in JOINT_SPECS.items() + } + true_offsets = { + name: math.radians(value) + for name, value in zip( + DIRECT_ZERO_JOINTS, [2.0, -3.0, 4.0, 0.0, 0.0, 0.0, 0.0] + ) + } + model = UrdfKinematicModel(SOURCE_URDF) + base_rotation = Rotation.from_euler("xyz", [0.5, -0.4, 0.8]) + base_translation = np.asarray([0.31, -0.19, 0.72]) + measurements = [] + yaw_axis_without_clearance = None + yaw_axis_with_clearance = None + for cycle in range(3): + for joint in AXIS_JOINTS: + state = list(baseline) + if joint == "thumb_cmc_yaw": + state[5] = 145.0 + angles = _angles_from_state( + state, curves=curves, motor_by_joint=motor_by_joint + ) + axis, point = model.axis_line( + joint, + zero_offsets=true_offsets, + joint_angles=angles, + ) + if joint == "thumb_cmc_yaw": + yaw_axis_with_clearance = axis.copy() + baseline_angles = _angles_from_state( + baseline, curves=curves, motor_by_joint=motor_by_joint + ) + yaw_axis_without_clearance = model.axis_line( + joint, + zero_offsets=true_offsets, + joint_angles=baseline_angles, + )[0] + measurements.append( + JointAxisMeasurement( + joint=joint, + cycle=cycle, + axis_common_xyz=tuple(base_rotation.apply(axis)), + point_common_xyz_m=tuple( + base_rotation.apply(point) + base_translation + ), + condition_state_u8=tuple(state), + plane_rms_m=0.0002, + radial_rms_m=0.0002, + rotation_circle_axis_difference_rad=math.radians(0.1), + ) + ) + + result = solve_urdf_zero_offsets( + source_urdf=SOURCE_URDF, + measurements=measurements, + curves=curves, + motor_by_joint=motor_by_joint, + ) + + assert math.degrees( + math.acos( + np.clip(yaw_axis_with_clearance @ yaw_axis_without_clearance, -1.0, 1.0) + ) + ) > 1.0 + assert result.passed is True + for name, expected in true_offsets.items(): + assert result.direct_offsets_rad[name] == pytest.approx( + expected, abs=math.radians(0.05) + ) + assert set(result.all_active_offsets_rad) == set(ACTIVE_JOINTS) + assert result.all_active_offsets_rad["thumb_mcp"] == pytest.approx( + 0.0, abs=1.0e-12 + ) + for target, source in INHERITED_ZERO_JOINTS.items(): + assert result.all_active_offsets_rad[target] == pytest.approx( + result.direct_offsets_rad[source], abs=1.0e-12 + ) + for joint in ( + "index_mcp_roll", + "middle_mcp_roll", + "ring_mcp_roll", + "pinky_mcp_roll", + ): + assert result.all_active_offsets_rad[joint] == pytest.approx( + 0.0, abs=1.0e-12 + ) + for joint in ( + "index_mcp_pitch", + "middle_mcp_pitch", + "ring_mcp_pitch", + "pinky_mcp_pitch", + ): + assert result.all_active_offsets_rad[joint] == pytest.approx( + 0.0, abs=1.0e-12 + ) + for joint in ("index_pip", "middle_pip", "ring_pip", "pinky_pip"): + assert result.all_active_offsets_rad[joint] == pytest.approx( + 0.0, abs=1.0e-12 + )