9 Commits
bd ... bd3

Author SHA1 Message Date
admin 286581bcba 大拇指单独标定,yaw正确和稳定性修改 2026-08-31 17:17:39 +08:00
admin 4dadfb954b 大拇指零位正确性稳定性修改 2026-08-30 17:35:23 +08:00
admin 83c69b48c2 标定稳定性 2026-08-27 16:24:01 +08:00
admin 4e594ddb09 G20右手四指独立标定(少thumb_mcp) 2026-08-24 10:12:23 +08:00
admin ef65681230 G20四指单独标定(少末端tag) 2026-08-21 12:21:39 +08:00
admin a609d521a0 g20右手标定 2026-08-11 15:51:36 +08:00
admin 41ff4a61a9 新零位相机外参标定方案 2026-08-07 16:22:24 +08:00
admin 4107da4c22 三机位标定 2026-08-05 18:43:10 +08:00
admin 5d206bcb73 三机位标定 2026-08-05 18:32:51 +08:00
58 changed files with 45031 additions and 2237 deletions
+11 -1
View File
@@ -50,6 +50,7 @@ Thumbs.db
# Runtime and calibration scratch files
/logs/
/MvSdkLog/
*.tmp
*.log
*.bak
@@ -61,13 +62,20 @@ Thumbs.db
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
/profiles/
/calibration_output/
/config/*_three_camera_extrinsics.yaml
*.wear_check.json
*.checkpoint.json
*.verification.json
*_mapping_quality.json
# Device-specific robot descriptions derived from local CMC pitch calibration
# 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
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_cmc_pitch_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_zero_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_G20_RIGHT_tag.urdf
# ROS bag / MCAP recordings and CAN captures
rosbag2_*/
@@ -84,3 +92,5 @@ candump-*
# Local Codex/agent workspace metadata
/.agents/
/.codex/
/.codebuddy/
/.zcode/
+626 -16
View File
@@ -1,6 +1,561 @@
# G20 左手拇指正面 AprilTag 标定
# G20 左手 AprilTag 标定
该包启动 RealSense、彩色图像校正、`apriltag_ros`、Linker Hand SDK 和标定状态机,
## G20右手正式一键标定
固定三相机和19张Tag安装完成后,用户只运行:
```bash
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right
```
完全独立地只标定大拇指4项任务时,使用:
```bash
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
--scope thumb
```
该模式不读取任何已标定四指数据。拇指零位求解只使用5条拇指轴、两条顶部同相机
方向观测以及拇指自己的机械端点;输出URDF从原始CAD生成,只修改4个拇指主动
关节,12个四指关节保持CAD零位。独立结果发布到`latest_thumb_passed`,其JSON是
拇指标定/诊断产物,不冒充可直接运行的完整整手曲线JSON。
如果确实需要把新的拇指结果合并到一份已经通过的完整整手标定,才额外使用:
```bash
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
--scope thumb \
--base-session calibration_output/G20_RIGHT_001/latest_passed
```
合并模式会冻结基础会话中的12个四指主动零位;发布前再次读取基础会话JSON核对,
任何四指零位变化都会拒绝发布。基础四指数据仍不参与4个拇指零位的数值求解。
拇指专项结果重复性通过后,不必再做原来的16项整手扫描。以该拇指会话为基础,
只重新采集12项四指任务并合成完整整手URDF:
```bash
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
--scope fingers \
--base-session calibration_output/G20_RIGHT_001/<已通过的拇指会话时间戳>
```
`fingers`模式严格冻结基础会话中的4个拇指主动零位;最终完整整手URDF中的拇指
零位与专项会话schema-v4数值完全一致。默认`full`也先调用与`thumb`完全相同的
独立拇指内核,再冻结这4个结果求解12个四指零位,因此四指数据不能反向改写
拇指结果。原来的默认`full`仍保留,用于需要16项全部重新采集的情况。
采样文件中的运动域是显式且不可混用的:
`requested_command_u8` 表示下发命令,`feedback_u8` 表示电机反馈。
在线拟合和离线重放通过同一个数据契约投影到曲线索引;新会话不会把含糊的
`command_u8` 写入 `raw_samples.jsonl`。基础会话导入期间状态会显示为
`IMPORTING_BASE``REVALIDATING_INHERITED`,完成复核后才允许机械手运动。
提供`--base-session`时,它必须解析到同一序列号目录下的完整PASS会话;启动前会校验源CAD
URDF、相机外参和标定配置哈希。新会话从原始CAD重新生成完整URDF,不在旧校准
URDF上叠加。两种thumb模式都只重采`thumb_cmc_pitch``thumb_cmc_roll`
`thumb_cmc_yaw``thumb_mcp/thumb_ip`四项物理任务,其余任务的原始记录导入后仍按
数据契约和产物哈希检查,但不会以历史四指拟合结果否决本次拇指专项标定。
开发阶段若上一次会话失败,同一命令会自动校验硬件/几何哈希,并恢复已经
完整提交的关节任务;失败中的当前任务始终丢弃重做,位于它后面但已经完整通过的
独立任务仍会复用,不再因“连续前缀”限制整段重采。导入的任务会立即用与
最终验收相同的硬门限复检(不含视口实时有效率):只以预警带余量通过的旧数据
当场剔除并从其在扫掠顺序中的原始位置重采,避免全部任务采完后才在最终验收
失败、把会话拉回靠前的关节。方向级自动重扫事件是追加日志中的持久失效标记;
恢复时只读取该标记之后的替代采集,不能把同一尝试编号下重扫前后的稳态点合并。
因此已经在线硬门限验收的任务保持已完成,暂停中的任务从任务开头重采,不会因
日志中仍保留被自动重扫淘汰的旧点而倒退到更早任务。运行中的多视角任务按正面主测量和侧面校验测量
独立保留;单轮转轴异常且其余三轮形成一致簇时只补扫异常轮的两个方向。侧面
轴线位置若也能明确定位为单轮异常,同样只补扫该轮;补扫会保留任务预检和前次
采集确定的PnP分支参考,不会因重新初始化切换到另一组平面Tag镜像解。侧面
校验视角的任务级有效率只记录为诊断;G20右手预检若逐帧识别率低于标称值,
但同步有效位姿已经完整覆盖端点、中点、最小分箱数和最大分箱空洞,也按完整
轨迹通过。正式扫描仍逐方向执行相同的硬分箱覆盖检查,轴线、曲线和模型质量
门限保持不变。每个任务的低速往返预检、首轮交接和四轮双向正式扫描属于同一
采集事务:相邻方向共享已验证端点和任务级PnP参考。G20右手正式扫描固定使用
产品审定速度,不再根据单次识别密度自动提速,确保不同会话测量的是同一动态
过程。顶部单目`thumb_cmc_yaw`在最终求解后另做零偏轮次重复性检查:前三轮
极差默认不得超过0.5°,95%置信半宽不得超过0.75°。若两轮形成不超过门限20%的
紧密簇、仅另一轮越界,自动补采该轮两个方向;无法明确定位时只重采完整yaw任务,
不会回退重采整手。与`latest_passed`中上一正式结果相差超过0.75°时另写入
`thumb_yaw_cross_session_diagnostic`提示检查机械手位置和Tag安装,但该历史差值
不直接否决当前会话,也不会用旧结果约束新零位。需要强制从第一个关节
重新采集时使用 `--no-resume`。升级前已经分别完成的正面/侧面roll也会合并为
一个完整同步任务断点;只有两边数据都完整时才复用。
命令自动完成产品哈希预检、运动、当前任务补扫、前三轮训练、第四轮隔离留出、
16个会话数据求解主动关节URDF零位修正、
21条视觉实测命令曲线发布;四指PIP/DIP的动态曲线均实测,四指DIP静态零位保留CAD。
终端只显示中文进度和问题;失败时复制“请复制以下内容给开发者”块即可。
四指末端的16 mm Tag允许使用刚性延长杆避挡;软件不假设末端Tag平面与中节Tag
平面平行。延长杆和Tag在一次标定期间必须完全刚性,不能晃动、扭转或重新调整。
侧面掌部基准Tag(ID 4)与各活动指节Tag也不要求安装面平行:首次联合PnP使用
静态多帧刚性、重投影误差和跨轮任务参考选择分支,不再用固定15°安装角门限阻断扫描。
单Tag独立位姿仍保留75°倾角保护;对包含锁定掌部基准和完整父子链的任务,倾角保护只
限制独立选择,不会在联合选择前删除正深度、低重投影的IPPE候选。联合跟踪继续用相邻帧
绝对/相对位姿连续性约束这些斜视候选,最终轴线残差、四轮重复性和隔离留出门限不放宽。
终端中的Tag计数表示“可见”;等待扫描起点时会另列PnP初始化进度和累计拒绝原因。
若联合候选仍然失败,`raw_samples.jsonl` 会按8个反馈计数的区间保存
`group_pnp_candidate_event`,其中包含缺失角色、逐Tag候选数/倾角/重投影、角点和相机内参
哈希,可直接定位运动到哪个机械位置后开始失效,而不需要再次盲扫整条流程。
正式结果位于 `calibration_output/G20_RIGHT_001/latest_passed`。该指针只在
JSON、URDF数值等价、mesh完整性、21条曲线CAD限位、被动关节保护和隔离留出验证
全部通过后更新。
## G20右手19-Tag底层调试入口
以下内容仅保留给旧会话回放和开发调试;正式一键命令只发布上面的精简
schema v4 JSON,不再生成schema v5运行文件。
新布局用独立参数启用,原有左右手11-Tag流程仍默认使用
`tag_layout:=legacy_11`,两套配置和结果schema互不覆盖:
```bash
ros2 launch g20_thumb_apriltag_calibration three_camera_calibration.launch.py \
hand_type:=right \
tag_layout:=g20_right_19 \
serial_number:=G20_RIGHT_001 \
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
source_urdf_expected_sha256:=<CAD负责人确认的G20右手源URDF_SHA256>
```
当前产品布局共19张`tag36h11`,所有Tag的黑色码区边长均为16 mm;四指末节ID为
`7,14,16,18`。正面为`0,1,2,3,10,11,12,13`,侧面为
`4,5,6,7,14,15,16,17,18`,上面为`8,9`。详细角色和逐ID尺寸以
`config/three_camera_tags_g20_right_19.yaml`为唯一软件配置源。程序执行4项拇指任务,
以及小指、无名指、中指、食指各自的正面+侧面同步roll、侧面pitch、
侧面PIP/DIP联合任务,共16个物理运动任务。同步roll只驱动电机一次,但两台相机仍分别拟合并通过
各自的观测质量门限。每个PIP任务只驱动一次对应电机,同时用“手掌→中节Tag”实测PIP、
用“中节Tag→末节Tag”实测被动DIP;四个DIP不再由URDF mimic系数生成,并参加完整视觉
拟合和质量门限。每项正式四轮之前自动低速往返预检
0/127/255可见性;
基准形态恢复完成后,程序先用至少30帧稳健锁定正面ID 0、侧面ID 4和顶部ID 8的
固定掌部位姿。小指和无名指弯曲避让会遮住正面ID 0,因此四指正面+侧面同步roll中
允许ID 0暂时不可见,并使用本会话基准锁定值;运动连杆Tag仍必须实时可见,门限不
放宽。终端用`锁`表示该固定参考有效,例如`正面[0锁,12✓]``✗`才表示需要处理的
实时Tag。锁定后本次标定运行中不得再移动相机、手掌底座或整只手,否则缓存参考
失效,必须重新启动标定;两次独立会话之间轻微调整整只手的位置不会改变机械端点
零位基准。任务级Tag有效率门限按"当前任务所需角色生效期间的采集帧"统计;
任务结束后角色要求会切回预检全套标签,静止期帧不参与该门限,避免把
采集质量良好的任务误判为可见性失败。
恢复末端Tag后,程序能够独立实测四指PIP和DIP的转轴及动态命令曲线。但同一次
相机外参和同一套Tag安装下的重复扫描无法排除固定安装相位偏差;实体手在反馈0端
能够触掌是独立的机械端点约束。三个thumb CMC主动轴、`thumb_mcp`、四指MCP pitch
和PIP的URDF静态零位均由本次实测全行程与CAD机械端点之差求出,不再把跨相机的
平面PnP绝对相位直接当作编码器零位,也不写死为0。生成URDF时同步修正这些关节的
坐标上限以及相关被动关节的mimic坐标偏置,
保证非零零偏不会缩短最大闭合量。末节Tag继续用于DIP动态曲线、轴线质量、遮挡和
第四轮留出检查。正式数据求解静态修正范围为拇指CMC三个主动关节、`thumb_mcp`
四指MCP roll、MCP pitch及PIP,共16个。`thumb_mcp`与四指屈伸关节一样使用
实测全屈曲行程和CAD机械端点联合求解,不直接采用MCP/IP耦合运动的单目PnP相位。
侧面累计避障按“PIP→MCP pitch→roll”的安全顺序分阶段进入,并按逆序分阶段退出;
同类辅助电机(全部邻指滚转、全部PIP、全部MCP pitch)合并为同一个并行航点同时
运动,被测通道最后单独进入。“滚转全部回中前不展开弯曲手指”“每指pitch先于PIP”
等已评审不变量保持不变,过渡仍受类别限速、逐航点到位确认、停滞检测和超时保护。
`parallel_pose_transitions`(默认true)置false可回退旧的逐电机顺序。
同一任务的预检和四轮正式扫描会保持完整避障姿态连续执行,只在任务切换时退出,
不再每轮重复展开/弯曲辅助手指。跨手指组切换时,下一组避障姿态仍然需要、且
当前已经在位(含反馈容差)的辅助电机保持原位,只有下一组不再使用的避障电机
退回基准,避免"先展开回基准、马上又折回"的多余动作;已评审的
"滚转先回中再展开""先滚开再弯曲"顺序保持不变。预检正反方向若都保留至少64个电机分箱且最大空缺
不超过8,只作为采集能力诊断。G20右手四轮正式速度始终使用产品配置的固定值,
不会因本次预检帧率或识别密度而改变;旧11-Tag布局仍保留自适应速度兼容逻辑。
四指roll不再把同一反馈127误当成方向无关的唯一机械姿态:以`255→127`为标准物理
零位,反向到达127的实测偏差保留在`increasing_rad`中。方向分支间隙上限1.5°、
四轮间隙极差上限0.3°;其他关节仍使用严格的0.5°baseline回差门限。
预检、正式四轮和拟合重扫始终使用速度5。
正式roll的每个方向会在经过127时先到位保持0.5秒,再独立保存至少10帧静止Tag/反馈;
方向分支检查和动态曲线的127相位都使用这两组双向静止数据,运动中经过127的帧不再
替代静态保持姿态。
前三轮只用于训练,第四轮完全留出;留出轮不参与显著性、Student-t置信区间或最终重拟合。
每个任务只在低速递减预检起点执行一次8帧PnP静态初始化;预检往返和四轮正式
扫描连续复用同一帧间分支与任务参考,不再让每一轮独立选择平面Tag解。同一任务第1轮
已确立的端点相对姿态作为后3轮的分支锚点,防止独立初始化选到相反的
IPPE镜像解。baseline标准接近和全部质量门限保持不变。
电机15任务会利用源URDF中已确认的`thumb_ip mimic=1.03`,只在逐帧IPPE双解中
排除与MCP同步运动明显矛盾(残差超过7.5°)的ID3镜像候选。该先验不生成或缩放
`thumb_ip`曲线;通过分支选择后的`ID2→ID3`姿态仍独立拟合并接受完整留出验证。
当前19-Tag产品流程发布精简schema v4:21条运行时曲线全部来自当前会话的视觉实测。
URDF零位字段覆盖拇指4个主动关节和四指各自的`mcp_roll/mcp_pitch/pip`,共16个,
其中三个thumb CMC轴、`thumb_mcp`及四指`mcp_pitch/pip`共12个字段由实测旋转行程
与机械端点联合求解;
写出非零`thumb_mcp`零偏时同步平移其关节坐标上限,并更新被动`thumb_ip`
`mimic offset`,因此不会改变CAD定义的最大屈曲实体姿态;
`thumb_ip`及四指DIP静态零位保留源CAD。旧schema v5文件仅作历史回放兼容,
当前一键流程不再生成它。正面/侧面roll在同一次运动中独立拟合;方向、
轴线和动态曲线均通过时做不确定度加权轴融合。侧面PIP连杆标签在滚转扫掠中
相对侧相机视线倾斜约13°~20°,平面标签的单目IPPE姿态二义性会给侧视姿态引入
数度的系统性"绕视线"偏差(亚像素重投影无法发现,会话20260820_105535实测
前后轴向稳定相差11.4°),因此侧视PIP连杆姿态不再参与MCP轴向融合或角曲线验收,
正侧姿态差只写入`cross_view_roll_axis_diagnostic`。四根MCP侧摆轴在产品URDF中
严格平行:小指作为先采集的参考轴,其余三指复用该公共方向并各自独立拟合轴线位置,
避免平面PnP分支在不同会话中改变轴向。前视侧摆连杆受丝杆平移影响,其纯旋转拟合得到的是
随手指结构变化的伪轴线,不能与侧视PIP连杆的物理轴线使用统一距离门限;
两者线距仅记录在诊断中。侧视校验通道
`*_mcp_roll_side`)的
姿态分支间隙跨轮极差和独立姿态轴方向极差只作诊断,不触发重复采集;这两个量来自
近掠射平面Tag的非发布姿态分量。绝对分支间隙1.5°上限保持不变,真正发布的正面主轴
仍使用原跨轮严格门限。侧视逐帧`axis_pose_line_rms`同样只作诊断,组合轴线改用四轮
位置RMS验收;径向、平面、圆一致性及可见性门限全部保留。
侧面端视roll的圆轨迹方向已经受
姿态轴约束,因此自由三维圆平面与姿态轴的夹角只保留诊断,不再被重复作为硬门限;
径向残差和四轮轴线位置RMS仍是硬门限。正式MCP动态曲线统一使用正面Tag中心的
二维投影圆角度,侧面姿态曲线仅保留为诊断;任一正式视角自身四轮不重复或第四轮
留出失败仍会拒绝发布。任一静态目标、第四轮留出、
遮挡、PnP或跨机位检查失败时,只保留原始轨迹和`passed:false`诊断,不发布正式URDF。
8个组合姿态仅保留为开发诊断,正式产品默认不执行。轴线零位求解不提供适合绝对笛卡尔
位置验收的手基座变换,因此不能用该诊断推翻已经通过的单关节隔离留出结果。三个CMC轴恢复使用
`a609d521`验证过的完整四轮相对旋转曲线;全部实测关节均由隔离第四轮逐关节验收。
现场需要区分某根手指的roll机构回差与单机位误差时,可设置
`cross_view_roll_diagnostic_finger:=pinky|ring|middle|index`。该会话只执行目标手指的一次
正面+侧面同步roll,共10个预检/正式方向;任一机位数据不足会重扫同一物理任务,
双机位数据齐全后即使存在轴质量失败也不再自动重采,而是把失败项随双机位结果
一起写入`cross_view_roll_diagnostic`并立即暂停。诊断会话永久
锁定URDF发布,不能用`resume`转换成正式标定。
schema v5明确声明曲线输入域为真实反馈u8;运行桥默认订阅
`/g20/cb_right_hand_state`,并按反馈增减方向选择正程/反程曲线,停止时锁存最后运动
方向。尚未观察到运动方向时使用`255→127`标准分支,不使用两个机械分支的平均值。
schema v4继续兼容旧
命令域。两者都只发布动态角度,不重复叠加已写入URDF的静态偏移。
## 三机位三维关节轴零位标定(schema v4)
正式入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机,只有
`/g20_calibration` 一个节点拥有机械手命令发布权。相机不需要水平,Tag方向也不需要
贴正;相机和Tag在一次标定中必须固定。默认绑定为:
```text
front = DB2163742Tag 0/1/2/3/10
side = DB2163749Tag 4/5/6/7
top = DB2163739Tag 8/9
```
11 张 `tag36h11` 的程序角色必须与贴纸所在刚性件一致:
| ID | 机位 | 固定位置/运动件 |
|---:|---|---|
| 0 | 正面 | 正面掌壳固定基准 |
| 1 | 正面;右手电机0时也由侧面观测 | 拇指 CMC 后连杆 |
| 2 | 正面 | 拇指 MCP 后连杆 |
| 3 | 正面 | 拇指 IP 后末节 |
| 4 | 侧面 | 掌壳侧面固定基准(最底下) |
| 5 | 侧面 | 左手食指/右手小指 MCP 后连杆 |
| 6 | 侧面 | 左手食指/右手小指 PIP 后连杆 |
| 7 | 侧面 | 左手食指/右手小指 DIP 后末节 |
| 8 | 上面 | 上面相机可见的掌壳/底座固定基准 |
| 9 | 上面 | 拇指 CMC yaw 运动件 |
| 10 | 正面 | 左手食指/右手小指根部侧摆运动件 |
ID 4 不贴在侧面相机看不到的掌心正面;ID 8 必须始终固定且可见,
ID 9 必须在拇指横摆的完整行程中持续可见。
贴纸不能跨关节、贴在软胶上或在扫描过程中翘起。
每台相机必须有独立内参文件:
```text
~/.ros/camera_info/hikrobot_DB2163742.yaml
~/.ros/camera_info/hikrobot_DB2163749.yaml
~/.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` 联合优化唯一旋转/平移。采集准入和最终验收分离:FRONT和
配对相机的单帧RMS分别不得超过1.5 px,同时组合RMS不得超过1.2 px;界面中
单相机1.2 px以内显示绿色、1.2~1.5 px显示黄色且仍可采集、超过1.5 px显示红色。
新姿态会与全部已采姿态比较,避免在少数姿态间反复采集。拟合先剔除粗大异常组,
再在不低于15个内点的前提下有界裁剪联合误差最高的候选,最终1.2 px门限不会被
放宽。两组均得到
至少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、旋转稳定性和平移稳定性;`BATCH FAIL` 后会直接列出
`INLIERS``RMS``ROT``TRANS` 失败项。保存门限为:联合重投影RMS不超过
1.2 px、三折重拟外参最大旋转差不超过0.3°、最大平移差不超过1.5 mm。文件同时
绑定三台相机序列号、分辨率和内参哈希;不满足任一项时不会保存通过结果,正式
标定也不会运动。
### 2. 预检和正式标定
先使用禁止运动模式检查三个机位、外参、内参和标签:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
hand_type:=left \
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 \
--remap image:=/g20_calibration/front/camera/image_rect
ros2 run image_view image_view --ros-args \
--remap image:=/g20_calibration/side/camera/image_rect
ros2 run image_view image_view --ros-args \
--remap image:=/g20_calibration/top/camera/image_rect
```
确认全行程安全、MVS客户端已关闭且没有其他命令发布者后,重启正式流程:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
hand_type:=left \
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
```
右手使用同一入口;默认自动选择右手SDK话题和原始URDF:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
hand_type:=right \
serial_number:=G20_RIGHT_001 \
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
can_interface:=can0
```
状态显示三个机位均“就绪”后只调用一次:
```bash
ros2 topic echo /g20_calibration/status_text
ros2 service call /g20_calibration/start std_srvs/srv/Trigger {}
```
收到 `start` 后,程序先下发并确认以下20通道基准姿态,稳定保持0.5秒后才开始
第一条轨迹扫描:
```text
[255, 255, 255, 255, 255, 255, 127, 127, 127, 127,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
```
左手依次扫描电机 `0/5/15/6/1/16/10`,右手依次扫描
`0/5/15/9/4/19/10`,每项三轮 `255→0→255`。轨迹角由父/子Tag完整相对
四元数的旋转向量投影到三维拟合轴得到。
轴方向使用相对姿态旋转轴和可信上游轴约束;仅对斜视、非约束关节将中心圆作为独立
交叉检查并参与融合。轴线上一点则由整段
相对SE(3)轨迹的 `(I-R)p=t` 方程鲁棒拟合,不再把单目Tag中心自由三维圆的圆心直接
当成机械轴心。正面/侧面端视关节只使用图像平面内可观分量,丢弃无法由单目确定的
光轴深度;斜视轨迹仍保留姿态轴和独立三维圆轴的交叉检查。每条主动曲线在其baseline命令
严格归零:普通通道255,四指侧摆127。左手将食指动态轨迹、右手将小指动态轨迹继承
给其余三指。11-Tag布局只能可靠恢复参考指的动态命令—角度曲线,不能证明四根独立
电机的绝对装配相位相同;因此四指全部MCP侧摆、MCP屈伸和PIP静态URDF零偏都保留
原始CAD的0,只继承动态曲线,避免参考指弯曲或四指整体同向倾斜。
零位求解使用行程更充分的根轴方向和保持原始CAD直立的参考指MCP pitch实测轴方向确定
掌部朝向;两条平行根轴线只确定平移,不再用其单目三维深度间距确定绕根轴的旋转,避免
稳定PnP深度偏差被写成拇指roll零偏。另一条短行程根轴方向只作诊断。随后按两条运动链逐关节
进行一维鲁棒求解,避免远端异常把已确定的上游零位一起拖到边界。非平行相邻轴先将上下游
轴投影到父轴法平面,再计算精确有符号方位角;父子轴夹角是零位无法改变的几何不变量,偏差
超过5°直接判定模型失败。平行相邻轴比较两轴之间的径向相位,三维路径忽略连杆长度和沿轴
Tag位置,接近轴向观察时改用相机图像平面相位并丢弃单目PnP深度。轴线SE(3)拟合RMS超过
1 mm也不允许写URDF。四指静态零位不参与相机相位覆盖,拇指可观测零偏上限20°;
小于0.3°或未超过3倍不确定度的稳定偏移保留原始零位0。
`thumb_mcp` 的动态角度曲线仍由电机15的三轮轨迹直接测量,但其绝对静态零位只可通过
被动 `thumb_ip` 的轴线圆心相位间接推断。固定正面单目机位下这条浅圆弧的姿态轴/圆轨迹轴
偏差可达数十度,重复性不能排除稳定系统误差,因此不得把该相位写入URDF;左右手
`thumb_mcp` 都保留原始CAD零位0。该保护只冻结静态 `origin.rpy`,不会冻结或复制其
`angle_rad[256]` 实测轨迹。
前两轮拟合,第三轮强制留出验证;轨迹与零位角度MAE必须≤1°、P95≤2°,三轮轴/零位
差≤0.75°、径向RMS≤3 mm、轴线SE(3)残差≤1 mm。非零修正必须在第三轮优于原始URDF,并通过按三轮分组的
训练周期Student-t 95%改善下界检查。最终门限不会因自动重试而放宽。
单轮姿态相对理想固定轴的轴外RMS与跨轮重复性分别判定:主动关节上限2.5°,被动
耦合关节上限7.5°。较宽的被动模型门限只容纳可重复的机构耦合和双Tag PnP系统误差,
不会替代三轮轴方向≤0.75°和第三轮MAE/P95留出验证。
四指参考源的MCP pitch虽有约70°大行程,但侧面机位接近沿转轴观察,单目PnP深度偏差
仍可能把低残差的Tag中心圆平面稳定地倾斜。因此MCP pitch与其他端视关节一样,始终用
完整相对姿态确定轴方向,Tag中心轨迹只参与轴线位置拟合;不再按10°分界在两种轴模型
之间切换。固定Tag安装旋转会在相对旋转中抵消,不需要中心圆回退。被动PIP/DIP继承
上游轴方向时不重复报告同一项跨轮轴失败。
四指MCP侧摆的动态曲线仍由参考指三轮实测并继承,但绝对静态侧摆零位固定使用原始CAD
的0。仅凭下游pitch轴相对CAD掌坐标反推roll相位,会把稳定的跨视角/固定几何偏差写成
约4°的整指倾斜;重复扫描与同源留出不能排除这种系统偏差,因此不得写入URDF。
四指MCP屈伸和PIP采用同一静态策略:参考指轨迹仍参与动态曲线、轴质量和机构诊断,
但拟合出的共同掌坐标相位不写入四指 `origin.rpy`;只发布各指相对四指中值的实测
装配偏差。拇指CMC roll/yaw/pitch的非零修正来自当前会话的完整相对旋转行程与机械
端点,视觉轴链继续用于轴线、PnP和留出诊断;代码和配置中不保存任何按左右手或
序列号写死的拇指零位角。电机5的256点动态曲线同样使用本机四轮实测结果。
视觉依赖链仍为:yaw轴检查拇指roll、pitch轴检查拇指yaw、MCP轴线相位检查拇指
pitch、IP轴线相位检查拇指MCP。该链用于几何和PnP诊断,不再决定四个具有机械端点
的拇指主动关节绝对零位;四指PIP/DIP轴线相位也继续用于机构诊断。
原始URDF的 `origin.xyz``axis.xyz`、连杆长度、mesh和被动结构固定。yaw扫描时电机5
保持145,求解器使用实测 `angle_rad[145]` 还原该条件,不会把145误当成baseline。
偏移超过各关节专用上限时整次失败。数值求解会在更宽的诊断范围内继续估计,因此状态和原始JSONL
会显示实际估计值及配置上限,而不是把所有超限结果都截断成恰好±20°或±3°;该诊断搜索
不会放宽正式结果的硬门限。
生成修正URDF时只修改通过验收的主动关节 `origin.rpy`,不会修改任何关节的
`origin.xyz`、转轴、mimic关系或原始CAD/机械安全限位。256项实测轨迹只保存在最终
JSON;任一曲线点越过CAD限位都会阻止正式发布,程序不会自动扩大URDF限位。
坏帧只丢弃。短时Tag丢失、同步帧中断、扫描超时、端点/分箱不足会自动保持当前位置、
重置当前机位PnP、返回基准后重扫当前方向,最多3次;速度依次降为80%/60%/50%
端点保持延长到0.75/1.0/1.25秒,扫描超时按降速比例同步延长。若反馈在远离目标时
连续8秒没有至少1个u8的进展,则按机械碰撞/摩擦或硬件故障立即保持当前反馈位置并
暂停,不消耗三次采样重试预算。单轮拟合失败只重扫该轮两个方向,全局不一致才重扫
完整关节,每关节最多自动重采2轮。过程指标落在最终门限的1.25倍内时会标记为黄色
预警,但只要仍超过硬门限,就在当前关节立即使用剩余重试预算
`provisional_fit_warning_rescan`);第三次仍超限则当场暂停,不允许预警数据继续到
后续关节。最终拟合仍按原硬门限验收,因此不会在全部任务采完后才回头重采靠前
关节。零位触边、稳定留出误差或URDF几何无法解释属于模型失败,程序
只暂停一次且不再自动重扫,防止重复运动;此时也拒绝`resume`形成死循环。其他可恢复
失败在预算耗尽后才暂停,`resume`从最小失败单元继续,已通过数据保留。所有失败尝试
仍保存在 `raw_samples.jsonl`。若连续两次完整重扫出现轮次和数值都重复的
PnP双簇行程,程序将它判为系统性分支失败并当场停止,不再浪费第3次全关节重扫。
每个新机位/Tag组合开始运动前,不使用单个端点帧直接决定平面Tag的IPPE姿态分支。
程序在静止端点联合8帧候选,按相邻Tag相对姿态的跨帧稳定性和重投影误差选择整组
分支;侧面Tag 4/5/6/7贴面在该端点应近似平行,初始化还会比较相邻Tag法向,避免
错误镜像分支虽然8帧稳定且重投影很小仍被选中。每轮 `255→0` 前都会在静止端点独立
重置并重新选择分支,使第三轮同时成为PnP初始化留出,而不是三轮共享同一错误分支。
再开始正式轨迹采集。初始化帧不写入轨迹;最终单轴、跨轮和留出门限不变。
左手测食指roll时将电机7/8/9固定到0;右手测小指roll时因左右手侧摆机构镜像,
将电机6/7/8固定到255。两者均为相机画面向右的物理避挡方向,速度分别为
`[15,5,15,15,15]``[15,15,15,15,5]`。参考指MCP pitch/PIP扫描分别使用
电机1/16(左)或4/19(右),参考指速度10。
右手扫描拇指CMC俯仰(电机0)前,程序将拇指横摆电机10和拇指侧摆电机5都固定到
255,确认两个辅助关节到位后才允许电机0执行全行程。该关节由正面机位使用掌部
Tag 0和运动Tag 1同帧测量。每帧都会保留两个辅助关节
的实测条件值,轴线经外参转换到公共坐标系,零位求解按URDF上游关节链补偿;左手
仍沿用原有正面机位和基准姿态。
标定 `thumb_cmc_yaw`(电机10)时,程序将
`thumb_cmc_roll`(电机5)固定为145,并在它到位后才开始采样,以保持运动Tag
ID 9的可见性和PnP稳定性。当前方向自动重试、失败轮次重试和人工 `resume` 都保持
电机5为145,只让电机10返回待重扫方向的起点;电机10全部三轮完成后,电机5才
恢复基准值255。自动恢复直接发送恢复目标,不会短暂发送保持当前位置命令;操作员
暂停/终止、恢复预算耗尽或机械停滞时仍保持当前位置。最终JSON的
`baseline_command_u8` 不变。
右手标定小指PIP(电机19)时,命令0对应的固件反馈可能稳定饱和在5。只有电机19
的0端使用±5反馈容差,并将该实测机械端点归入命令0端点分箱;255端和其他电机仍
使用默认±2。轨迹仍须覆盖至少240个u8并通过完整拟合门限,所以中途卡滞不会被误判
为端点到达。
右手拇指横摆电机10在命令255时多次实测稳定饱和在250,因此仅右手电机10的255端
使用±5反馈容差;其命令0端实测反馈为4,仍使用±4,其他电机和中间位置不放宽。基准姿态、作为
电机0辅助避挡姿态以及电机10自身扫描端点都使用同一条专用判定。
三机位流程默认 `validation_enabled:=false`,即不增加随机机械动作,但第三轮留出验证
始终启用且不能关闭;最终 `quality.validation_mae_rad/p95_rad` 正是第三轮轨迹误差。
状态中的扫描进度和总体进度分开显示:42/42只表示计划轨迹已采完,总体进度在拟合和
验证完成、正式JSON与URDF成功写入之前不会显示100%。
上面机位在Tag二维质量合格但PnP连续无效达到1秒时,会自动重置该机位的单Tag
和双Tag连续性跟踪器,并从下一帧重新建链,早于3秒采集超时。暂停恢复时只要求
当前活动机位就绪;首次调用 `start` 仍要求三个机位全部通过预检。
### 3. 输出
通过后生成精简JSON和一个新URDF:
```text
calibration_output/G20_LEFT_001/<时间戳>/
g20_left_G20_LEFT_001_calibration.json
linkerhand_g20_left_zero_calibrated_G20_LEFT_001_<时间戳>.urdf
meshes/*.STL
calibration_output/G20_RIGHT_001/<时间戳>/
g20_right_G20_RIGHT_001_calibration.json
linkerhand_g20_right_zero_calibrated_G20_RIGHT_001_<时间戳>.urdf
meshes/*.STL
```
文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`
`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从
指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或
覆盖原文件;只有通过独立求解验证或明确机械装配基准授权的主动关节 `origin.rpy` 可能
改变;当前19-Tag右手的16个主动静态零位字段全部由本会话数据求解;四指DIP和
`thumb_ip`为被动关节,发布实测动态曲线并保留CAD静态零位,其mimic坐标偏置只随
上游主动关节坐标系变换作等价调整。
未观测关节和其他URDF文本保持不变。源URDF中的相对mesh资源会按原相对路径复制到
同一会话,保证会话内URDF可独立加载,并在正式发布时逐文件记录SHA256。每帧Tag SE(3)、
图像时间戳、20通道状态、同步误差和PnP误差只进入 `raw_samples.jsonl`
完整 `raw_samples.jsonl` 已存在时,可以按当前算法离线重放,不连接相机、不发送电机
命令。`--output-tag` 为新产物增加安全后缀,已有JSON、URDF和验证报告不会被覆盖:
```bash
python3 -m g20_thumb_apriltag_calibration.offline_replay \
calibration_output/G20_RIGHT_001/20260811_120146 \
--output-tag AXIS_FRAME_V3 \
--write
```
下面保留原有正面拇指独立标定说明和兼容入口。
### 4. 修正URDF的运行时关节映射
修正URDF已经把 `zero_angles.urdf_zero_offset_rad` 写入关节
`origin.rpy`。仿真运行时只能再使用同一台、同一侧机械手JSON中的256点
`angle_rad` 动态曲线,不能把 `urdf_zero_offset_rad` 再加一次,也不能把左手曲线
用于右手URDF。可用桥接节点将GUI的20通道u8命令转换为完整21关节
`JointState`(包括5个被动关节):
```bash
ros2 launch g20_thumb_apriltag_calibration calibrated_joint_state_bridge.launch.py \
hand_type:=right \
calibration_file:=$PWD/calibration_output/G20_RIGHT_001/20260811_120146/g20_right_G20_RIGHT_001_calibration.json
```
schema v5默认订阅 `/g20/cb_right_hand_state`schema v4默认订阅
`/g20/cb_right_hand_control_cmd`。两者均发布
`/sim/mujoco/g20/right/joint_state`。启动前必须停止任何旧的同名话题桥,避免两个
发布者同时驱动仿真。节点会拒绝左右手不匹配、质量未通过、字段不完整或非有限命令,
因此不会静默退回旧标定。
该包启动海康机器人 MVS USB3 Vision 黑白相机、图像校正、`apriltag_ros`
Linker Hand SDK 和标定状态机,
只扫描 G20 左手命令下标 `0``15`。默认使用单终点连续模式:每个方向只发送一次
终点命令,速度保持在固件能稳定响应的 `15`。SDK 以独立时间戳反馈实际 20 维位置,
程序把每帧 AprilTag 三维中心与同一时刻的实际电机位置插值配对并按整数位置分箱。
@@ -36,7 +591,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 必须在全行程内均可见。需要短时检查标记时,
@@ -50,11 +606,11 @@ PnP 双分支跟踪和整段刚体复核仍保留,用于选出稳定的三维
```bash
sudo apt-get update
sudo apt-get install -y \
ros-jazzy-realsense2-camera \
ros-jazzy-realsense2-description \
ros-jazzy-image-pipeline \
ros-jazzy-apriltag-ros \
ros-jazzy-apriltag-msgs
ros-jazzy-apriltag-msgs \
ros-jazzy-camera-calibration \
python3-yaml
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
@@ -63,6 +619,58 @@ colcon build --symlink-install \
source install/setup.bash
```
相机节点直接使用海康 MVS SDK。当前机器的默认安装位置是 `/opt/MVS`,需要存在:
```text
/opt/MVS/lib/64/libMvCameraControl.so
/opt/MVS/Samples/64/Python/MvImport/MvCameraControl_class.py
```
正面相机默认按序列号 `DB2163742` 绑定(MVS 显示的 GUID 是
`2BDFB2163742`),型号校验为 `MV-CS020-10UM`。三台相机同时连接时程序不会按枚举
顺序猜测机位。启动 ROS 节点前必须关闭 MVS 客户端中的相机连接,否则设备可能被占用。
`1624x1240 mono8` 每帧约 2.0 MB,超过 Fast DDS 2.14 默认约 512 KB 的共享内存段。
三相机标定 launch 会固定使用 `rmw_fastrtps_cpp`,并通过新旧两个 Fast DDS 环境变量
加载 `config/fastdds_large_images.xml`,使用 64 MB 共享内存段;否则相机内部虽为 30 Hz,
大图订阅端通常只能收到约 1~4 Hz。修改配置后必须重启相关 ROS 进程才能生效。
首次使用必须先标定该相机和当前镜头的内参。主 launch 默认从
`~/.ros/camera_info/hikrobot_DB2163742.yaml` 加载标准 ROS CameraInfo YAML;文件缺失时
仍可预览 `mono8` 原图,但发布的内参无效,轨迹标定预检不会解锁运动。
先单独启动相机(不会连接机械手,也不会发送关节命令):
```bash
ros2 run g20_thumb_apriltag_calibration hikrobot_camera_node --ros-args \
--remap __ns:=/camera/camera/color \
-p serial_number:=DB2163742 \
-p camera_info_url:=$HOME/.ros/camera_info/hikrobot_DB2163742.yaml
```
测速时优先检查同帧发布的小消息和原图;两者正常值都应接近 30 Hz:
```bash
ros2 topic hz /camera/camera/color/camera_info
ros2 topic hz /camera/camera/color/image_raw
```
使用标定板采集内参。下面的 `8x6` 是内角点数量、`0.020` 是单格边长 20 mm,必须按
实际标定板修改:
```bash
ros2 run camera_calibration cameracalibrator \
--size 8x6 --square 0.020 \
--camera_name hikrobot_front_DB2163742 \
--ros-args \
--remap image:=/camera/camera/color/image_raw \
--remap camera/set_camera_info:=/camera/camera/color/set_camera_info
```
在标定界面完成采样后点击 `CALIBRATE`,确认重投影误差,再点击 `COMMIT`。相机节点会
原子写入上述 YAML,并立即开始发布有效内参。内参只适用于标定时的镜头焦距、对焦、
分辨率和 ROI;改变任何一项都要重新标定。
连接 CAN 后先确认 `can0` 已启动。不要同时运行其他会发布
`/g20/cb_left_hand_control_cmd` 的程序。
@@ -74,6 +682,7 @@ source install/setup.bash
```bash
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
serial_number:=G20_LEFT_001 \
camera_serial_number:=DB2163742 \
commands_enabled:=false
```
@@ -84,6 +693,7 @@ ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
```bash
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
serial_number:=G20_LEFT_001 \
camera_serial_number:=DB2163742 \
can_interface:=can0 \
calibration_speed:=15 \
continuous_motion_mode:=endpoint \
@@ -92,7 +702,7 @@ ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
use_roi:=false
```
默认关闭 ROIAprilTag 使用完整的 1280×720 校正画面。查看实际送入 AprilTag
默认关闭 ROIAprilTag 使用完整的 1624×1240 校正画面。查看实际送入 AprilTag
的完整画面:
```bash
@@ -241,16 +851,16 @@ for name, joint in data["joints"].items():
```
如果相机或 SDK 已由外部进程启动,可传
`start_camera:=false``start_sdk:=false``camera_serial_number:=<序列号>`
可绑定指定 RealSense
`start_camera:=false``start_sdk:=false``camera_serial_number` 同时接受 MVS
序列号和 GUID,但推荐使用稳定且简短的序列号 `DB2163742`
D405 的彩色流来自 `depth_module`,启动文件会同时设置
`depth_module.color_profile``rgb_camera.color_profile`,默认均为
`1280x720x30`。连续模式默认关闭深度和 rosbag,以减少 USB、CPU 和磁盘负担;
它们都不参与角度计算。需要完整诊断留档时增加
`enable_depth:=true record_bag:=true`
海康相机默认输出 `1624x1240@30Hz mono8`,全局快门,曝光时间 `5000us`、增益
`0dB`,并使用“只取最新帧”策略避免视觉延迟。现场亮度不足时优先增加照明;必要时可用
`exposure_time_us``gain_db` 调整,或临时传 `auto_exposure:=true`。正式轨迹采集建议固定
曝光,避免自动曝光在运动过程中改变角点质量。rosbag 默认关闭;需要诊断留档时增加
`record_bag:=true`
默认对完整 1280×720 原图进行畸变校正和 AprilTag 检测。三维中心 PnP 必须使用
默认对完整 1624×1240 原图进行畸变校正和 AprilTag 检测。三维中心 PnP 必须使用
`image_rect` 的角点及同一条处理链对应的 `CameraInfo`,启动文件已自动保证二者配对。
校正和 AprilTag 组件运行
在同一个多线程容器内并启用进程内传输,避免在处理链路中重复序列化、复制大图像。
@@ -260,7 +870,7 @@ D405 的彩色流来自 `depth_module`,启动文件会同时设置
不影响 AprilTag 的 ROI 输入。
静态预检先在单 Tag 层拒绝高重投影误差,再检查三组相对中心的位置内点率和毫米级 RMS。
当前 3038 px 的 10 mm Tag 属于试标定尺寸,如果中心位置 RMS 持续不合格,应优先增加照明、缩短
当前末节16 mm Tag如果中心位置 RMS 持续不合格,应优先增加照明、缩短
相机距离或提高 Tag 有效像素,而不是放宽最终随机复测精度。
启用 rosbag 后保存裁剪后的原始图像和配套 `CameraInfo`,避免新增一个全分辨率图像
@@ -44,7 +44,7 @@ g20_thumb_calibration:
minimum_detection_hz: 15.0
maximum_hamming: 0
minimum_decision_margin: 30.0
# Trial threshold for the current 10 mm tags (observed at 32-38 px).
# Trial threshold for small/far tags (historically observed at 32-38 px).
# Final acceptance is still guarded by static RMS and random validation.
minimum_edge_pixels: 30.0
# Current 30 px tags measure about 0.50-0.53 deg RMS while stationary.
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<dds>
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<transport_descriptors>
<transport_descriptor>
<transport_id>g20_udp_transport</transport_id>
<type>UDPv4</type>
<sendBufferSize>10485760</sendBufferSize>
<receiveBufferSize>10485760</receiveBufferSize>
</transport_descriptor>
<transport_descriptor>
<transport_id>g20_shm_transport</transport_id>
<type>SHM</type>
<segment_size>67108864</segment_size>
<port_queue_capacity>512</port_queue_capacity>
<healthy_check_timeout_ms>1000</healthy_check_timeout_ms>
</transport_descriptor>
</transport_descriptors>
<participant
profile_name="g20_large_image_participant"
is_default_profile="true">
<rtps>
<userTransports>
<transport_id>g20_udp_transport</transport_id>
<transport_id>g20_shm_transport</transport_id>
</userTransports>
<useBuiltinTransports>false</useBuiltinTransports>
</rtps>
</participant>
</profiles>
</dds>
@@ -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]
@@ -0,0 +1,37 @@
schema_version: 1
model: G20
side: right
tag_layout: g20_right_19
serial_number: G20_RIGHT_001
can_interface: can0
output_root: calibration_output
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right.urdf
source_urdf_sha256: eeb6ffb0e95d2a6acd4c26331ae68062e0d74160de4b552b4f6d395cce5ca4e8
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
calibration_config: src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
calibration_config_sha256: 0faaf891ebb616c4c8a3bb3052c48fa4b6c8aa0c5fdc5abaaa89f4fc29cca1c3
tag_config: src/g20_thumb_apriltag_calibration/config/three_camera_tags_g20_right_19.yaml
tag_config_sha256: b1ab45e97ae42d57b0a3a63c725107b5aa3828c06b2ced16f8222d6e9ebadc41
release:
# Each task already contains three training cycles plus an isolated fourth
# holdout, so a second complete hardware session duplicates hours of motion.
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -1,5 +0,0 @@
# This is a flat parameter mapping consumed by realsense2_camera/rs_launch.py.
# Keep image and CameraInfo durability identical so image_transport can
# synchronize them for image_proc and apriltag_ros.
color_qos: DEFAULT
color_info_qos: DEFAULT
@@ -0,0 +1,193 @@
g20_calibration:
ros__parameters:
command_topic: /g20/cb_left_hand_control_cmd
state_topic: /g20/cb_left_hand_state
info_topic: /g20/cb_left_hand_info
setting_topic: /g20/cb_hand_setting_cmd
front_camera_info_topic: /g20_calibration/front/camera/camera_info
front_detections_topic: /g20_calibration/front/apriltag/detections
side_camera_info_topic: /g20_calibration/side/camera/camera_info
side_detections_topic: /g20_calibration/side/apriltag/detections
top_camera_info_topic: /g20_calibration/top/camera/camera_info
top_detections_topic: /g20_calibration/top/apriltag/detections
# /start先下发并确认这个20通道基准姿态,稳定后才进入第一条扫描。
baseline_command_u8: [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
normal_calibration_speed: 15
index_roll_calibration_speed: 5
index_flex_calibration_speed: 10
# 19-Tag产品预检仍使用上面保守速度;只有正反预检都留出至少双倍正式分箱余量,
# 才把非roll任务正式扫描最多提速1.5倍。四指roll受0.5°回差门限约束,
# 始终保持速度5;任一方向采样余量不足也保持原速度。
adaptive_formal_speed_enabled: true
adaptive_formal_speed_max_scale: 1.5
adaptive_formal_speed_minimum_bins: 64
adaptive_formal_speed_maximum_bin_gap: 8
speed_setting_settle_seconds: 0.25
# tag36h11尺寸是检测角点围成的黑色正方形边长,不包含外围白边。
# 19张Tag的黑色码区外边长均为16 mm。自定义PnP必须与
# apriltag_ros逐ID尺寸一致,禁止用纸张/白边尺寸代替码区尺寸。
tag_size_m: 0.016
# ROS 2无法从YAML空数组推断整数/浮点数组类型。这四个
# 末端Tag仍显式写16 mm,防止节点启动时得到未初始化参数。
tag_size_override_ids: [7, 14, 16, 18]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016]
repetitions: 3
# 19-Tag产品正式零位使用前三轮训练、最后一轮完全留出;旧11-Tag仍读取repetitions=3。
g20_right_19_repetitions: 4
preflight_frames: 60
minimum_detection_rate: 0.95
minimum_detection_hz: 15.0
minimum_feedback_hz: 25.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_reprojection_tie_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
# 标定任务不再用第一帧决定平面Tag的IPPE分支;静止端点联合8帧选择整组最稳定解。
pnp_group_initialization_frames: 8
# 侧面当前任务所需Tag在初始化端点的贴面法向应一致;用此先验消除静态IPPE镜像双解。
pnp_group_normal_alignment_scale_deg: 5.0
pnp_group_maximum_normal_alignment_deg: 15.0
# 三个拇指顶部任务共用预检时冻结的Tag 8位姿。Tag 8仍须实时可见;
# 任一角点相对会话基准漂移超过2 px并连续5帧时,判定标定中基准被移动。
fixed_base_maximum_corner_drift_px: 2.0
fixed_base_movement_confirmation_frames: 5
# 仅在拇指MCP/IP同步运动且至少一个候选落入可信区间时,用源URDF mimic
# 关系辅助选择IPPE分支;若全部候选超限则退回纯视觉,绝不丢帧,也不生成、
# 缩放或替代被动IP的自身Tag实测曲线。
thumb_ip_pnp_coupling_multiplier: 1.03
thumb_ip_pnp_coupling_scale_deg: 3.0
thumb_ip_pnp_maximum_coupling_residual_deg: 7.5
top_pnp_invalid_reset_seconds: 1.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
# 整段相对SE(3)运动拟合轴线点;端视关节会投影掉单目PnP光轴深度。
axis_maximum_pose_line_rms_m: 0.001
# 仅用于运动平面在三维中可观测的斜视关节;近图像平面关节使用姿态轴
# 约束三维圆,不让单目平面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
# 零位无法改变父子轴夹角;超过该值属于CAD/PnP几何错误,不能吸收到零位。
zero_maximum_axis_cone_mismatch_deg: 5.0
zero_maximum_observability_condition_number: 10000000000.0
zero_maximum_offset_deg: 20.0
# 四指MCP roll保留严格的装配保护范围。thumb CMC三轴由多轴视觉几何
# 求解且不假定电气端点等于CAD上限;thumb_mcp及四指MCP pitch/PIP
# 静态零位由实测全行程与CAD机械端点联合求解,不写死为0。
zero_finger_maximum_offset_deg: 3.0
# 只对实物已确认等同CAD端点的关节使用该限制;CMC电气端点不作此假设。
mechanical_endpoint_maximum_offset_deg: 5.0
endpoint_tolerance_u8: 2.0
# 请求命令与固件反馈是两个标定域。稳态检查点允许小幅死区,但反馈
# 必须已经稳定;大残差仍由机械卡滞保护处理。
steady_checkpoint_command_feedback_tolerance_u8: 8.0
steady_checkpoint_maximum_feedback_range_u8: 2.0
# 电机10在命令0时实测会稳定反馈为4;该0端使用±4。
thumb_yaw_zero_endpoint_tolerance_u8: 4.0
# 右手电机10在命令255时多次实测稳定反馈为250;仅右手该端点使用±5。
right_thumb_yaw_255_endpoint_tolerance_u8: 5.0
# 右手小指PIP电机19在命令0时固件反馈稳定饱和为5;仅其0端使用±5。
pinky_pip_zero_endpoint_tolerance_u8: 5.0
endpoint_hold_seconds: 0.5
# roll零位127必须从两个方向到位并静止采集,禁止用运动中经过127的帧判回差。
baseline_hold_seconds: 0.5
minimum_baseline_hold_frames: 10
# 19-Tag产品每项正式四轮前先做一次低速往返,端点Tag稳定至少2秒。
task_precheck_hold_seconds: 2.0
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
# 启动宽限1秒后,反馈连续2秒没有至少1个u8的进展,按机械卡滞立即暂停;
# 这类故障不进入遮挡/超时的三次自动重扫。
# 低速5也应持续产生反馈进展;5秒无进展即停,减少机构持续顶死时间。
motor_stall_timeout_seconds: 2.0
motor_stall_startup_grace_seconds: 1.0
motor_stall_minimum_progress_u8: 1.0
invalid_timeout_seconds: 3.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
# 可恢复的采样失败自动重扫当前方向;超过次数才暂停等待人工处理。
automatic_sweep_retry_limit: 2
# 轨迹拟合失败优先只重扫失败轮次;零位/URDF模型失败不重复运动。
automatic_fit_retry_limit: 2
automatic_motion_retry_limit: 2
# 留空为正式标定;设为pinky/ring/middle/index时只采该指正面+侧面roll
# 即使正面baseline回差失败也继续完成侧面对照,并永久锁定本会话URDF发布。
cross_view_roll_diagnostic_finger: ""
# 过程检查允许25%的黄色预警带,最终验收仍使用下面的严格门限。
provisional_warning_ratio: 1.25
retry_minimum_speed: 3
retry_speed_scales: [0.8, 0.6]
retry_endpoint_hold_seconds: [0.75, 1.0]
trajectory_maximum_plane_rms_m: 0.004
trajectory_maximum_radial_rms_m: 0.004
trajectory_minimum_radius_m: 0.003
trajectory_minimum_arc_deg: 15.0
# 以下二维参数只供旧轨迹工具兼容,三机位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
maximum_monotonic_correction_deg: 2.0
# 旧布局仍用连续扫描正反程差门限;19-Tag产品的连续运动包含速度相关滞后,
# 由方向曲线和最终留出验证建模,不再重复硬判。其绝对正反程门禁使用下面
# 的九点稳态command_maximum_direction_gap_deg。
maximum_hysteresis_deg: 2.0
# 19-Tag产品模式额外要求每轮正反方向在各自baseline处绕实测关节轴的角度差
# 不超过0.5°;四指roll例外:127以255→127为唯一物理零位,反向分支
# 保留实测偏差,并改为检查分支间隙上限及跨轮稳定性。
baseline_maximum_hysteresis_deg: 0.5
directional_zero_maximum_branch_gap_deg: 2.0
directional_zero_maximum_branch_gap_range_deg: 0.3
cross_view_roll_maximum_branch_gap_difference_deg: 0.3
# 正面roll是Tag中心的二维投影角,侧面roll是三维姿态角。允许一个有界的
# 固定比例吸收Tag安装倾角/偏置带来的投影缩放,再严格比较两条曲线形状;
# 比例过大、方向相反、形状RMS及两视角各自的四轮重复性仍会失败。
cross_view_roll_maximum_shape_rms_deg: 1.25
cross_view_roll_maximum_projection_scale_ratio: 1.5
passive_maximum_monotonic_correction_deg: 3.0
passive_maximum_hysteresis_deg: 2.0
# 九点姿态先按实测反馈重新对齐,再用此门限检查固有机械回差;请求命令域
# 的两条运行曲线仍原样保留固件方向死区,不能把command/feedback差算成回差。
command_maximum_direction_gap_deg: 2.0
# 默认无额外随机动作;19-Tag产品最终一轮始终作为不可关闭的留出验证。
validation_enabled: false
# 第四轮留出求解后必须再走8个固定安全组合姿态;三机位规定Tag全部可见
# 且实测20通道到位才允许发布。只保存Tag位姿,不保存原始图像。
# Developer diagnostic only. The formal fourth sweep cycle already gives
# every isolated PIP/DIP pair an independent holdout.
combination_validation_enabled: false
combination_validation_frames: 10
combination_maximum_position_p95_m: 0.003
combination_maximum_orientation_p95_deg: 2.0
validation_command_count: 3
validation_frames: 10
validation_seed: 20260804
validation_timeout_seconds: 20.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
# 19-Tag产品模式使用更严格的任一点及静态零偏95%置信区间门限。
maximum_validation_error_deg: 3.0
zero_maximum_confidence_half_width_deg: 1.5
@@ -0,0 +1,62 @@
/g20_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 10]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll]
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.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 7]
frames: [side_base, index_mcp, index_pip, index_dip]
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.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,62 @@
/g20_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 10, 11, 12, 13]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, pinky_roll, ring_roll, middle_roll, index_roll]
sizes: [0.016, 0.016, 0.016, 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.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 15, 17]
frames: [side_base, ring_pip, pinky_pip, middle_pip, index_pip]
sizes: [0.016, 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.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,63 @@
/g20_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 10, 11, 12, 13]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, pinky_roll, ring_roll, middle_roll, index_roll]
sizes: [0.016, 0.016, 0.016, 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.016
profile: false
max_hamming: 0
detector:
threads: 4
# Distal Tags use the same measured 16 mm black-code edge as all others.
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 7, 14, 15, 16, 17, 18]
frames: [side_base, ring_pip, pinky_pip, pinky_dip, ring_dip, middle_pip, middle_dip, index_pip, index_dip]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 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.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -51,6 +51,39 @@ def tag_quality_is_valid(
)
def update_pnp_reset_watchdog(
*,
detection_good: bool,
pnp_valid: bool,
now: float,
invalid_since: float | None,
reset_after_seconds: float,
) -> tuple[float | None, bool]:
"""Track continuous PnP-only failures and request a throttled reset."""
reset_after = float(reset_after_seconds)
if reset_after <= 0.0:
raise ValueError("reset_after_seconds must be positive")
if not detection_good or pnp_valid:
return None, False
since = float(now) if invalid_since is None else float(invalid_since)
if float(now) - since >= reset_after:
# Start a new interval so a permanently bad view is not reset on every
# frame. The next valid frame clears the interval.
return float(now), True
return since, False
def required_resume_views(active_view: str | None) -> tuple[str, ...]:
"""Require only the active view on resume; start still checks all views."""
all_views = ("front", "side", "top")
if active_view is None:
return all_views
view = str(active_view)
if view not in all_views:
raise ValueError(f"unknown calibration view: {view}")
return (view,)
@dataclass(frozen=True)
class Observation:
stamp_ns: int
@@ -0,0 +1,358 @@
"""Visual roll-alignment aid for one G20 calibration camera."""
from __future__ import annotations
from collections import deque
import math
import time
from typing import Any, Mapping, Sequence
from apriltag_msgs.msg import AprilTagDetectionArray
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 sensor_msgs.msg import Image
from .full_hand import VIEW_TAGS
from .hikrobot_camera import configure_fastdds_large_image_transport
from .zero_calibration import detect_reference_alignment_line
VIEWS = ("front", "side", "top")
def summarize_alignment_measurements(
measurements: Sequence[Mapping[str, Any] | None],
) -> dict[str, Any] | None:
"""Return a median-smoothed physical reference-line measurement."""
valid = [measurement for measurement in measurements if measurement]
if not valid:
return None
return {
"line_xyxy_px": np.median(
np.asarray(
[measurement["line_xyxy_px"] for measurement in valid],
dtype=float,
),
axis=0,
).tolist(),
"angle_rad": float(
np.median(
[float(measurement["angle_rad"]) for measurement in valid]
)
),
"vertical_offset_px": float(
np.median(
[
float(measurement["vertical_offset_px"])
for measurement in valid
]
)
),
"detected_frames": len(valid),
"window_frames": len(measurements),
}
class G20CameraAlignmentView(Node):
"""Publish a red/blue roll aid based on a physical scene edge."""
def __init__(self) -> None:
"""Configure one view without taking ownership of hand commands."""
super().__init__("g20_camera_alignment_view")
self.declare_parameter("view", "front")
view = str(self.get_parameter("view").value).strip().lower()
if view not in VIEWS:
raise ValueError(f"view must be one of {VIEWS}")
self.view = view
namespace = f"/g20_calibration/{view}"
self.required_tag_ids = {
int(value) for value in VIEW_TAGS[view].values()
}
self.declare_parameter("image_topic", f"{namespace}/camera/image_rect")
self.declare_parameter(
"detections_topic", f"{namespace}/apriltag/detections"
)
self.declare_parameter("reference_y_ratio", 0.90)
self.declare_parameter("roi_y_min_ratio", 0.55)
self.declare_parameter("roi_y_max_ratio", 0.98)
self.declare_parameter("minimum_line_length_ratio", 0.30)
self.declare_parameter("maximum_candidate_angle_deg", 15.0)
self.declare_parameter("maximum_alignment_error_deg", 0.5)
self.declare_parameter("maximum_vertical_offset_px", 12.0)
self.declare_parameter("maximum_hamming", 0)
self.declare_parameter("minimum_decision_margin", 20.0)
self.declare_parameter("minimum_edge_pixels", 20.0)
self.declare_parameter("smoothing_frames", 10)
self.declare_parameter("maximum_line_age_seconds", 1.0)
self.declare_parameter("maximum_tag_age_seconds", 1.0)
self.declare_parameter("maximum_publish_rate_hz", 10.0)
self.declare_parameter("output_scale", 0.75)
def value(name: str) -> Any:
return self.get_parameter(name).value
self.image_topic = str(value("image_topic"))
self.detections_topic = str(value("detections_topic"))
self.reference_y_ratio = float(value("reference_y_ratio"))
self.roi_y_min_ratio = float(value("roi_y_min_ratio"))
self.roi_y_max_ratio = float(value("roi_y_max_ratio"))
self.minimum_line_length_ratio = float(
value("minimum_line_length_ratio")
)
self.maximum_candidate_angle_rad = math.radians(
float(value("maximum_candidate_angle_deg"))
)
self.maximum_alignment_error_rad = math.radians(
float(value("maximum_alignment_error_deg"))
)
self.maximum_vertical_offset_px = float(
value("maximum_vertical_offset_px")
)
self.maximum_hamming = int(value("maximum_hamming"))
self.minimum_decision_margin = float(value("minimum_decision_margin"))
self.minimum_edge_pixels = float(value("minimum_edge_pixels"))
self.maximum_line_age_seconds = float(
value("maximum_line_age_seconds")
)
self.maximum_tag_age_seconds = float(value("maximum_tag_age_seconds"))
self.maximum_publish_rate_hz = float(value("maximum_publish_rate_hz"))
self.output_scale = float(value("output_scale"))
smoothing_frames = int(value("smoothing_frames"))
if not (
0.0
<= self.roi_y_min_ratio
< self.reference_y_ratio
< self.roi_y_max_ratio
<= 1.0
):
raise ValueError(
"ratios must satisfy 0 <= roi_min < reference < roi_max <= 1"
)
if not 0.0 < self.minimum_line_length_ratio <= 1.0:
raise ValueError("minimum_line_length_ratio must be in (0, 1]")
if not (
0.0
< self.maximum_alignment_error_rad
< self.maximum_candidate_angle_rad
< math.pi / 2.0
):
raise ValueError(
"angle limits must satisfy 0 < alignment < candidate < 90"
)
if self.maximum_vertical_offset_px <= 0.0:
raise ValueError("maximum_vertical_offset_px must be positive")
if smoothing_frames < 1:
raise ValueError("smoothing_frames must be positive")
if self.maximum_line_age_seconds <= 0.0:
raise ValueError("maximum_line_age_seconds must be positive")
if self.maximum_tag_age_seconds <= 0.0:
raise ValueError("maximum_tag_age_seconds must be positive")
if self.maximum_publish_rate_hz <= 0.0:
raise ValueError("maximum_publish_rate_hz must be positive")
if not 0.1 <= self.output_scale <= 1.0:
raise ValueError("output_scale must be in [0.1, 1.0]")
self.bridge = CvBridge()
self.line_history: deque[dict[str, Any] | None] = deque(
maxlen=smoothing_frames
)
self.last_line_at = 0.0
self.latest_tag_corners: dict[int, np.ndarray] = {}
self.latest_tag_at: dict[int, float] = {}
self.last_publish_at = 0.0
self.publisher = self.create_publisher(
Image, "~/image", qos_profile_sensor_data
)
self.create_subscription(
AprilTagDetectionArray,
self.detections_topic,
self._detections_callback,
qos_profile_sensor_data,
)
self.create_subscription(
Image,
self.image_topic,
self._image_callback,
qos_profile_sensor_data,
)
self.get_logger().info(
f"{view} alignment view uses physical long-edge detection; "
f"Tag orientation is ignored; input={self.image_topic}; "
f"output={self.get_name()}/image"
)
def _detections_callback(self, message: AprilTagDetectionArray) -> None:
now = time.monotonic()
for detection in message.detections:
tag_id = int(detection.id)
if tag_id not in self.required_tag_ids:
continue
corners = np.asarray(
[
[float(point.x), float(point.y)]
for point in detection.corners
],
dtype=float,
)
if corners.shape != (4, 2) or not np.all(np.isfinite(corners)):
continue
edges = np.linalg.norm(
corners - np.roll(corners, -1, axis=0), axis=1
)
if (
int(detection.hamming) > self.maximum_hamming
or float(detection.decision_margin)
< self.minimum_decision_margin
or float(np.mean(edges)) < self.minimum_edge_pixels
):
continue
self.latest_tag_corners[tag_id] = corners
self.latest_tag_at[tag_id] = now
def _draw_tags(self, image: np.ndarray, now: float) -> None:
for tag_id in sorted(self.required_tag_ids):
corners = self.latest_tag_corners.get(tag_id)
detected_at = self.latest_tag_at.get(tag_id, 0.0)
if (
corners is None
or now - detected_at > self.maximum_tag_age_seconds
):
continue
points = np.rint(corners * self.output_scale).astype(np.int32)
cv2.polylines(image, [points], True, (0, 220, 0), 2)
centre = np.rint(np.mean(points, axis=0)).astype(int)
cv2.putText(
image,
f"ID {tag_id}",
(int(centre[0]) + 5, int(centre[1]) - 7),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(0, 220, 0),
2,
)
def _image_callback(self, message: Image) -> None:
# Avoid conversion and Hough work until an image viewer subscribes.
if self.publisher.get_subscription_count() < 1:
return
now = time.monotonic()
if now - self.last_publish_at < 1.0 / self.maximum_publish_rate_hz:
return
self.last_publish_at = now
try:
image = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
except Exception as error:
self.get_logger().warning(
f"alignment image conversion failed: {error}"
)
return
if self.output_scale != 1.0:
image = cv2.resize(
image,
None,
fx=self.output_scale,
fy=self.output_scale,
interpolation=cv2.INTER_AREA,
)
height, width = image.shape[:2]
reference_y = self.reference_y_ratio * float(height - 1)
detected = detect_reference_alignment_line(
image,
reference_y_px=reference_y,
roi_y_min_ratio=self.roi_y_min_ratio,
roi_y_max_ratio=self.roi_y_max_ratio,
minimum_length_ratio=self.minimum_line_length_ratio,
maximum_candidate_angle_rad=self.maximum_candidate_angle_rad,
)
self.line_history.append(detected)
if detected is not None:
self.last_line_at = now
measurement = summarize_alignment_measurements(self.line_history)
if now - self.last_line_at > self.maximum_line_age_seconds:
measurement = None
red_y = int(round(reference_y))
cv2.line(
image,
(15, red_y),
(max(15, width - 15), red_y),
(0, 0, 255),
4,
)
if measurement is not None:
line = np.rint(measurement["line_xyxy_px"]).astype(int)
blue_ok, blue_start, blue_end = cv2.clipLine(
(0, 0, width, height),
(int(line[0]), int(line[1])),
(int(line[2]), int(line[3])),
)
if blue_ok:
cv2.line(image, blue_start, blue_end, (255, 0, 0), 3)
angle_rad = float(measurement["angle_rad"])
offset_px = float(measurement["vertical_offset_px"])
aligned = bool(
abs(angle_rad) <= self.maximum_alignment_error_rad
and abs(offset_px) <= self.maximum_vertical_offset_px
)
status = "ALIGNED" if aligned else "ADJUST CAMERA"
status_text = (
f"{self.view.upper()} red-blue "
f"{math.degrees(angle_rad):+.2f} deg "
f"dy {offset_px:+.1f}px {status}"
)
status_color = (0, 220, 0) if aligned else (0, 165, 255)
else:
status_text = (
f"{self.view.upper()} PHYSICAL REFERENCE LINE NOT DETECTED"
)
status_color = (0, 165, 255)
cv2.putText(
image,
status_text,
(20, 34),
cv2.FONT_HERSHEY_SIMPLEX,
0.72,
status_color,
2,
)
self._draw_tags(image, now)
cv2.putText(
image,
"RED=target BLUE=physical edge GREEN=Tags (angle ignored)",
(20, max(64, height - 24)),
cv2.FONT_HERSHEY_SIMPLEX,
0.60,
(255, 255, 255),
2,
)
output = self.bridge.cv2_to_imgmsg(image, encoding="bgr8")
output.header = message.header
self.publisher.publish(output)
def main(args: list[str] | None = None) -> None:
"""Run the single-view alignment helper."""
configure_fastdds_large_image_transport()
rclpy.init(args=args)
node: G20CameraAlignmentView | None = None
try:
node = G20CameraAlignmentView()
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,297 @@
"""Map G20 u8 feedback to URDF joint angles using one calibration JSON.
The static encoder-zero corrections in ``zero_angles`` are already baked into
the corrected URDF joint origins. This bridge therefore publishes only the
dynamic ``angle_rad`` values and never adds the static offsets a second time.
Schema-v5 trajectories are fitted against timestamp-synchronised hardware
feedback, not controller set-points. They must therefore be queried with the
SDK ``hand_state`` topic. The retained schema-v4 path is command-indexed for
backwards compatibility only.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any, Mapping, Sequence
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from .full_hand import (
get_hand_calibration_profile,
infer_compact_payload_layout,
validate_compact_payload,
)
G20_COMMAND_NAMES: tuple[str, ...] = (
"thumb_cmc_pitch",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
"thumb_cmc_roll",
"index_mcp_roll",
"middle_mcp_roll",
"ring_mcp_roll",
"pinky_mcp_roll",
"thumb_cmc_yaw",
"reserved_11",
"reserved_12",
"reserved_13",
"reserved_14",
"thumb_mcp",
"index_pip",
"middle_pip",
"ring_pip",
"pinky_pip",
)
# Match the stable ordering used by the existing MuJoCo bridge. JointState
# consumers must use names, but retaining the ordering also keeps logs and
# direct comparisons deterministic.
G20_URDF_JOINT_NAMES: tuple[str, ...] = (
"index_dip",
"index_mcp_pitch",
"index_mcp_roll",
"index_pip",
"middle_dip",
"middle_mcp_pitch",
"middle_mcp_roll",
"middle_pip",
"pinky_dip",
"pinky_mcp_pitch",
"pinky_mcp_roll",
"pinky_pip",
"ring_dip",
"ring_mcp_pitch",
"ring_mcp_roll",
"ring_pip",
"thumb_cmc_pitch",
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_ip",
"thumb_mcp",
)
class CalibratedCommandMapper:
"""Validated, side-specific lookup from G20 u8 values to URDF radians."""
def __init__(
self, payload: Mapping[str, Any], *, expected_side: str | None = None
) -> None:
validate_compact_payload(payload)
side = str(payload["side"]).lower()
if expected_side is not None and side != str(expected_side).lower():
raise ValueError(
f"calibration side {side!r} does not match requested side "
f"{str(expected_side).lower()!r}"
)
quality = payload["quality"]
if quality.get("passed") is not True:
raise ValueError("calibration quality.passed must be true")
layout_id = infer_compact_payload_layout(payload)
profile = get_hand_calibration_profile(side, layout_id)
self.side = side
self.layout_id = layout_id
self.serial_number = str(payload["serial_number"])
schema_version = int(payload["schema_version"])
self.input_domain = str(
payload.get(
"curve_input_domain",
"command_u8" if schema_version == 4 else "",
)
)
if self.input_domain not in {"command_u8", "feedback_u8"}:
raise ValueError("calibration curve_input_domain is invalid")
self._motor_by_joint = {
name: int(profile.joint_specs[name].motor_index)
for name in G20_URDF_JOINT_NAMES
}
self._curves = {
name: tuple(
float(value)
for value in payload["joints"][name]["angle_rad"]
)
for name in G20_URDF_JOINT_NAMES
}
self._decreasing_curves = {
name: tuple(
float(value)
for value in payload["joints"][name].get(
"decreasing_rad", payload["joints"][name]["angle_rad"]
)
)
for name in G20_URDF_JOINT_NAMES
}
self._increasing_curves = {
name: tuple(
float(value)
for value in payload["joints"][name].get(
"increasing_rad", payload["joints"][name]["angle_rad"]
)
)
for name in G20_URDF_JOINT_NAMES
}
self._previous_by_motor: dict[int, float] = {}
self._direction_by_motor: dict[int, str] = {}
self.direction_deadband_u8 = 0.5
@staticmethod
def _command_index(value: float) -> int:
command = float(value)
if not math.isfinite(command):
raise ValueError("G20 command positions must be finite")
return max(0, min(255, int(math.floor(command + 0.5))))
def map_positions(
self, positions: Sequence[float], names: Sequence[str] = ()
) -> tuple[float, ...]:
values = tuple(float(value) for value in positions)
if names:
if len(names) != len(values):
raise ValueError(
"JointState names and positions must have equal length"
)
if len(set(names)) != len(names):
raise ValueError("JointState names must be unique")
by_name = dict(zip((str(name) for name in names), values))
missing = [name for name in G20_COMMAND_NAMES if name not in by_name]
if missing:
raise ValueError(
"G20 command is missing named channels: " + ",".join(missing)
)
command = tuple(by_name[name] for name in G20_COMMAND_NAMES)
else:
if len(values) != len(G20_COMMAND_NAMES):
raise ValueError(
"unnamed G20 command must contain exactly 20 positions"
)
command = values
indices = tuple(self._command_index(value) for value in command)
direction_by_motor: dict[int, str | None] = {}
for motor, value in enumerate(command):
previous = self._previous_by_motor.get(motor)
direction = self._direction_by_motor.get(motor)
if previous is not None:
if value > previous + self.direction_deadband_u8:
direction = "increasing"
elif value < previous - self.direction_deadband_u8:
direction = "decreasing"
direction_by_motor[motor] = direction
result: list[float] = []
for name in G20_URDF_JOINT_NAMES:
motor = self._motor_by_joint[name]
direction = direction_by_motor[motor]
curves = (
self._increasing_curves
if direction == "increasing"
else self._decreasing_curves
if direction == "decreasing"
else self._curves
)
result.append(curves[name][indices[motor]])
for motor, value in enumerate(command):
self._previous_by_motor[motor] = value
direction = direction_by_motor[motor]
if direction is not None:
self._direction_by_motor[motor] = direction
return tuple(result)
def load_calibrated_command_mapper(
calibration_file: str | Path, *, expected_side: str | None = None
) -> CalibratedCommandMapper:
path = Path(calibration_file).expanduser().resolve()
if not path.is_file():
raise ValueError(f"calibration JSON does not exist: {path}")
payload = json.loads(path.read_text(encoding="utf-8"))
return CalibratedCommandMapper(payload, expected_side=expected_side)
def default_input_topic(hand_type: str, input_domain: str) -> str:
side = str(hand_type).lower()
if side not in {"left", "right"}:
raise ValueError("hand_type must be left or right")
if input_domain == "feedback_u8":
return f"/g20/cb_{side}_hand_state"
if input_domain == "command_u8":
return f"/g20/cb_{side}_hand_control_cmd"
raise ValueError("calibration curve_input_domain is invalid")
class CalibratedJointStateBridge(Node):
def __init__(self) -> None:
super().__init__("g20_calibrated_joint_state_bridge")
self.declare_parameter("hand_type", "right")
self.declare_parameter("calibration_file", "")
self.declare_parameter("input_topic", "")
self.declare_parameter("output_topic", "")
hand_type = str(self.get_parameter("hand_type").value).lower()
if hand_type not in {"left", "right"}:
raise ValueError("hand_type must be left or right")
calibration_file = str(self.get_parameter("calibration_file").value)
if not calibration_file:
raise ValueError("calibration_file is required")
self.mapper = load_calibrated_command_mapper(
calibration_file, expected_side=hand_type
)
input_topic = str(self.get_parameter("input_topic").value).strip()
output_topic = str(self.get_parameter("output_topic").value).strip()
self.input_topic = input_topic or default_input_topic(
hand_type, self.mapper.input_domain
)
self.output_topic = (
output_topic or f"/sim/mujoco/g20/{hand_type}/joint_state"
)
self.publisher = self.create_publisher(JointState, self.output_topic, 10)
self.subscription = self.create_subscription(
JointState, self.input_topic, self._command_callback, 10
)
self._last_error = ""
self.get_logger().info(
f"loaded {hand_type} G20 calibration for {self.mapper.serial_number}: "
f"{self.input_topic} ({self.mapper.input_domain}) -> "
f"{self.output_topic}"
)
def _command_callback(self, command: JointState) -> None:
try:
positions = self.mapper.map_positions(command.position, command.name)
except ValueError as error:
message = str(error)
if message != self._last_error:
self.get_logger().error(message)
self._last_error = message
return
self._last_error = ""
result = JointState()
result.header = command.header
result.name = list(G20_URDF_JOINT_NAMES)
result.position = list(positions)
self.publisher.publish(result)
def main(args: Sequence[str] | None = None) -> None:
rclpy.init(args=args)
node: CalibratedJointStateBridge | None = None
try:
node = CalibratedJointStateBridge()
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,644 @@
"""ROS 2 image publisher for Hikrobot MVS USB3 Vision cameras."""
from __future__ import annotations
from ctypes import byref, cast, memset, POINTER, sizeof, string_at
from dataclasses import dataclass
import importlib
import os
from pathlib import Path
import sys
from threading import Event, Lock, Thread
from typing import Any, Sequence
from urllib.parse import unquote, urlparse
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo, Image
from sensor_msgs.srv import SetCameraInfo
import yaml
DEFAULT_MVS_PYTHON_PATH = "/opt/MVS/Samples/64/Python/MvImport"
DEFAULT_MVS_RUNTIME_PATH = "/opt/MVS/lib"
@dataclass(frozen=True)
class DeviceDescriptor:
"""Stable identity fields for one enumerated USB3 Vision camera."""
index: int
model: str
serial: str
guid: str
@dataclass(frozen=True)
class CameraCalibration:
"""Validated subset of a standard ROS camera calibration YAML file."""
camera_name: str
width: int
height: int
distortion_model: str
d: tuple[float, ...]
k: tuple[float, ...]
r: tuple[float, ...]
p: tuple[float, ...]
def decode_c_string(value: Any) -> str:
"""Decode a null-terminated MVS SDK character array."""
raw = bytes(value).split(b"\0", 1)[0]
for encoding in ("utf-8", "gbk", "latin-1"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
return raw.decode("latin-1", errors="replace")
def select_device(
devices: Sequence[DeviceDescriptor],
selector: str,
expected_model: str,
) -> DeviceDescriptor:
"""Select exactly one camera by serial or GUID and check its model."""
if not devices:
raise RuntimeError("MVS did not enumerate any USB3 Vision cameras")
selector = selector.strip()
if selector:
matches = [
device
for device in devices
if selector in {device.serial, device.guid}
]
if not matches:
available = ", ".join(
f"{device.model}:{device.serial}:{device.guid}"
for device in devices
)
raise RuntimeError(
f"camera selector {selector!r} was not found; "
f"available cameras: {available}"
)
elif len(devices) == 1:
matches = [devices[0]]
else:
available = ", ".join(device.serial for device in devices)
raise RuntimeError(
"camera selector is required when multiple cameras are present; "
f"available serials: {available}"
)
if len(matches) != 1:
raise RuntimeError(f"camera selector {selector!r} is not unique")
selected = matches[0]
if expected_model and expected_model not in selected.model:
raise RuntimeError(
f"camera {selected.serial} is {selected.model!r}, expected a model "
f"containing {expected_model!r}"
)
return selected
def resolve_camera_info_path(url_or_path: str) -> Path | None:
"""Resolve a plain path or file:// URL used for CameraInfo persistence."""
value = url_or_path.strip()
if not value:
return None
parsed = urlparse(value)
if parsed.scheme not in {"", "file"}:
raise ValueError(
"camera_info_url must be a filesystem path or file:// URL"
)
if parsed.scheme == "file":
if parsed.netloc not in {"", "localhost"}:
raise ValueError("remote file:// camera_info_url is not supported")
value = unquote(parsed.path)
return Path(value).expanduser().resolve()
def _matrix_data(mapping: dict[str, Any], name: str, length: int) -> tuple[float, ...]:
section = mapping.get(name)
if not isinstance(section, dict):
raise ValueError(f"camera calibration is missing {name}")
data = section.get("data")
if not isinstance(data, list) or len(data) != length:
raise ValueError(f"{name}.data must contain exactly {length} values")
result = tuple(float(value) for value in data)
if not all(value == value and abs(value) != float("inf") for value in result):
raise ValueError(f"{name}.data contains a non-finite value")
return result
def load_camera_calibration(path: Path) -> CameraCalibration:
"""Load and validate a standard ROS camera calibration YAML file."""
mapping = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(mapping, dict):
raise ValueError("camera calibration YAML root must be a mapping")
width = int(mapping.get("image_width", 0))
height = int(mapping.get("image_height", 0))
if width <= 0 or height <= 0:
raise ValueError("camera calibration image dimensions must be positive")
distortion = mapping.get("distortion_coefficients")
if not isinstance(distortion, dict) or not isinstance(
distortion.get("data"), list
):
raise ValueError(
"camera calibration is missing distortion_coefficients.data"
)
d = tuple(float(value) for value in distortion["data"])
calibration = CameraCalibration(
camera_name=str(mapping.get("camera_name", "hikrobot_camera")),
width=width,
height=height,
distortion_model=str(mapping.get("distortion_model", "plumb_bob")),
d=d,
k=_matrix_data(mapping, "camera_matrix", 9),
r=_matrix_data(mapping, "rectification_matrix", 9),
p=_matrix_data(mapping, "projection_matrix", 12),
)
if calibration.k[0] <= 0.0 or calibration.k[4] <= 0.0:
raise ValueError("camera calibration focal lengths must be positive")
if calibration.p[0] <= 0.0 or calibration.p[5] <= 0.0:
raise ValueError("camera calibration projection focal lengths must be positive")
return calibration
def calibration_to_mapping(
camera_name: str, camera_info: CameraInfo
) -> dict[str, Any]:
"""Convert CameraInfo into the standard ROS YAML representation."""
return {
"image_width": int(camera_info.width),
"image_height": int(camera_info.height),
"camera_name": camera_name,
"camera_matrix": {
"rows": 3,
"cols": 3,
"data": [float(value) for value in camera_info.k],
},
"distortion_model": camera_info.distortion_model,
"distortion_coefficients": {
"rows": 1,
"cols": len(camera_info.d),
"data": [float(value) for value in camera_info.d],
},
"rectification_matrix": {
"rows": 3,
"cols": 3,
"data": [float(value) for value in camera_info.r],
},
"projection_matrix": {
"rows": 3,
"cols": 4,
"data": [float(value) for value in camera_info.p],
},
}
def configure_fastdds_large_image_transport() -> Path:
"""Select the package's large-image SHM profile before creating ROS nodes."""
from ament_index_python.packages import get_package_share_directory
profile = (
Path(get_package_share_directory("g20_thumb_apriltag_calibration"))
/ "config"
/ "fastdds_large_images.xml"
)
if not profile.is_file():
raise RuntimeError(f"Fast DDS large-image profile not found: {profile}")
os.environ.setdefault("FASTRTPS_DEFAULT_PROFILES_FILE", str(profile))
return profile
class HikrobotCameraNode(Node):
"""Publish synchronized Mono8 Image and CameraInfo messages from MVS."""
def __init__(self) -> None:
super().__init__("hikrobot_camera")
self._declare_parameters()
self._stop_event = Event()
self._camera_info_lock = Lock()
self._grab_thread: Thread | None = None
self._camera: Any | None = None
self._mvs: Any | None = None
self._sdk_initialized = False
self._handle_created = False
self._camera_opened = False
self._grabbing = False
self._closed = False
self._frame_count = 0
self._timeout_count = 0
self.selector = str(self.get_parameter("serial_number").value)
self.expected_model = str(self.get_parameter("expected_model").value)
self.frame_id = str(self.get_parameter("frame_id").value)
self.camera_name = str(self.get_parameter("camera_name").value)
self.image_width = int(self.get_parameter("image_width").value)
self.image_height = int(self.get_parameter("image_height").value)
if self.image_width <= 0 or self.image_height <= 0:
raise ValueError("image_width and image_height must be positive")
if float(self.get_parameter("frame_rate").value) <= 0.0:
raise ValueError("frame_rate must be positive")
if int(self.get_parameter("grab_timeout_ms").value) <= 0:
raise ValueError("grab_timeout_ms must be positive")
self.camera_info_path = resolve_camera_info_path(
str(self.get_parameter("camera_info_url").value)
)
self._calibration = self._load_calibration()
self.image_publisher = self.create_publisher(
Image, "image_raw", qos_profile_sensor_data
)
self.camera_info_publisher = self.create_publisher(
CameraInfo, "camera_info", qos_profile_sensor_data
)
self.create_service(
SetCameraInfo, "set_camera_info", self._set_camera_info_callback
)
try:
self._open_camera()
self._grab_thread = Thread(
target=self._grab_loop,
name="hikrobot_mvs_grab",
daemon=True,
)
self._grab_thread.start()
except Exception:
self.close()
raise
def _declare_parameters(self) -> None:
self.declare_parameter("serial_number", "")
self.declare_parameter("expected_model", "MV-CS020-10UM")
self.declare_parameter("camera_name", "hikrobot_front")
self.declare_parameter("frame_id", "camera_color_optical_frame")
self.declare_parameter("image_width", 1624)
self.declare_parameter("image_height", 1240)
self.declare_parameter("frame_rate", 30.0)
self.declare_parameter("exposure_time_us", 5000.0)
self.declare_parameter("gain_db", 0.0)
self.declare_parameter("auto_exposure", False)
self.declare_parameter("camera_info_url", "")
self.declare_parameter("mvs_python_path", DEFAULT_MVS_PYTHON_PATH)
self.declare_parameter("mvs_runtime_path", DEFAULT_MVS_RUNTIME_PATH)
self.declare_parameter("grab_timeout_ms", 1000)
def _load_calibration(self) -> CameraCalibration | None:
if self.camera_info_path is None or not self.camera_info_path.exists():
target = self.camera_info_path or "an unset camera_info_url"
self.get_logger().warning(
f"No camera calibration found at {target}; publishing "
"uncalibrated CameraInfo. Trajectory calibration will remain "
"locked until valid intrinsics are installed."
)
return None
calibration = load_camera_calibration(self.camera_info_path)
self._validate_calibration_dimensions(calibration)
self.get_logger().info(
f"Loaded camera intrinsics from {self.camera_info_path}"
)
return calibration
def _validate_calibration_dimensions(
self, calibration: CameraCalibration
) -> None:
if (
calibration.width != self.image_width
or calibration.height != self.image_height
):
raise ValueError(
"camera calibration dimensions "
f"{calibration.width}x{calibration.height} do not match "
f"configured stream {self.image_width}x{self.image_height}"
)
def _import_mvs(self) -> Any:
python_path = Path(
str(self.get_parameter("mvs_python_path").value)
).expanduser()
runtime_path = Path(
str(self.get_parameter("mvs_runtime_path").value)
).expanduser()
if not python_path.is_dir():
raise RuntimeError(f"MVS Python bindings not found: {python_path}")
if not runtime_path.is_dir():
raise RuntimeError(f"MVS runtime not found: {runtime_path}")
os.environ.setdefault("MVCAM_COMMON_RUNENV", str(runtime_path))
if str(python_path) not in sys.path:
sys.path.insert(0, str(python_path))
return importlib.import_module("MvCameraControl_class")
def _enumerate_usb_cameras(
self, mvs: Any
) -> tuple[Any, list[DeviceDescriptor]]:
device_list = mvs.MV_CC_DEVICE_INFO_LIST()
result = mvs.MvCamera.MV_CC_EnumDevices(
mvs.MV_USB_DEVICE, device_list
)
self._require_ok("enumerate USB3 Vision devices", result)
descriptors: list[DeviceDescriptor] = []
for index in range(device_list.nDeviceNum):
device_info = cast(
device_list.pDeviceInfo[index], POINTER(mvs.MV_CC_DEVICE_INFO)
).contents
usb_info = device_info.SpecialInfo.stUsb3VInfo
descriptors.append(
DeviceDescriptor(
index=index,
model=decode_c_string(usb_info.chModelName),
serial=decode_c_string(usb_info.chSerialNumber),
guid=decode_c_string(usb_info.chDeviceGUID),
)
)
return device_list, descriptors
def _open_camera(self) -> None:
mvs = self._import_mvs()
self._mvs = mvs
self._require_ok("initialize MVS SDK", mvs.MvCamera.MV_CC_Initialize())
self._sdk_initialized = True
device_list, descriptors = self._enumerate_usb_cameras(mvs)
selected = select_device(descriptors, self.selector, self.expected_model)
device_info = cast(
device_list.pDeviceInfo[selected.index],
POINTER(mvs.MV_CC_DEVICE_INFO),
).contents
camera = mvs.MvCamera()
self._require_ok("create camera handle", camera.MV_CC_CreateHandle(device_info))
self._camera = camera
self._handle_created = True
self._require_ok(
"open camera",
camera.MV_CC_OpenDevice(mvs.MV_ACCESS_Exclusive, 0),
)
self._camera_opened = True
self._set_required_enum("AcquisitionMode", "Continuous")
self._set_required_enum("TriggerMode", "Off")
self._set_required_enum("PixelFormat", "Mono8")
self._set_required_integer("OffsetX", 0)
self._set_required_integer("OffsetY", 0)
self._set_required_integer("Width", self.image_width)
self._set_required_integer("Height", self.image_height)
self._set_optional_bool("AcquisitionFrameRateEnable", True)
self._set_optional_float(
"AcquisitionFrameRate",
float(self.get_parameter("frame_rate").value),
)
if bool(self.get_parameter("auto_exposure").value):
self._set_required_enum("ExposureAuto", "Continuous")
else:
self._set_required_enum("ExposureAuto", "Off")
self._set_optional_float(
"ExposureTime",
float(self.get_parameter("exposure_time_us").value),
)
self._set_required_enum("GainAuto", "Off")
self._set_optional_float(
"Gain", float(self.get_parameter("gain_db").value)
)
self._require_ok(
"set latest-frame grab strategy",
camera.MV_CC_SetGrabStrategy(mvs.MV_GrabStrategy_LatestImagesOnly),
)
self._require_ok("start image acquisition", camera.MV_CC_StartGrabbing())
self._grabbing = True
self.get_logger().info(
"Opened Hikrobot camera "
f"model={selected.model} serial={selected.serial} "
f"guid={selected.guid} stream={self.image_width}x{self.image_height} "
"encoding=mono8"
)
def _set_required_enum(self, name: str, value: str) -> None:
assert self._camera is not None
self._require_ok(
f"set {name}={value}",
self._camera.MV_CC_SetEnumValueByString(name, value),
)
def _set_required_integer(self, name: str, value: int) -> None:
assert self._camera is not None
self._require_ok(
f"set {name}={value}",
self._camera.MV_CC_SetIntValueEx(name, value),
)
def _set_optional_bool(self, name: str, value: bool) -> None:
assert self._camera is not None
result = self._camera.MV_CC_SetBoolValue(name, value)
if result != 0:
self.get_logger().warning(
f"MVS could not set {name}={value}: 0x{result:08x}"
)
def _set_optional_float(self, name: str, value: float) -> None:
assert self._camera is not None
result = self._camera.MV_CC_SetFloatValue(name, value)
if result != 0:
self.get_logger().warning(
f"MVS could not set {name}={value}: 0x{result:08x}"
)
@staticmethod
def _require_ok(operation: str, result: int) -> None:
if result != 0:
hint = ""
if result == 0x80000203:
hint = (
"; access denied: stop acquisition and close the MVS "
"viewer or any other process using this camera"
)
raise RuntimeError(
f"MVS failed to {operation}: 0x{result:08x}{hint}"
)
def _grab_loop(self) -> None:
assert self._camera is not None
assert self._mvs is not None
timeout_ms = int(self.get_parameter("grab_timeout_ms").value)
while not self._stop_event.is_set():
frame = self._mvs.MV_FRAME_OUT()
memset(byref(frame), 0, sizeof(frame))
result = self._camera.MV_CC_GetImageBuffer(frame, timeout_ms)
if result != 0:
self._timeout_count += 1
if self._timeout_count == 1 or self._timeout_count % 10 == 0:
self.get_logger().warning(
"MVS image acquisition timed out or failed: "
f"0x{result:08x}; consecutive={self._timeout_count}"
)
continue
try:
self._timeout_count = 0
self._publish_frame(frame)
except Exception as error:
self.get_logger().error(f"Failed to publish MVS frame: {error}")
finally:
free_result = self._camera.MV_CC_FreeImageBuffer(frame)
if free_result != 0:
self.get_logger().error(
f"MVS failed to release frame: 0x{free_result:08x}"
)
def _publish_frame(self, frame: Any) -> None:
assert self._mvs is not None
info = frame.stFrameInfo
width = int(info.nWidth)
height = int(info.nHeight)
if width != self.image_width or height != self.image_height:
raise RuntimeError(
f"received {width}x{height}, expected "
f"{self.image_width}x{self.image_height}"
)
if int(info.enPixelType) != int(self._mvs.PixelType_Gvsp_Mono8):
raise RuntimeError(
f"received pixel type 0x{int(info.enPixelType):x}, expected Mono8"
)
expected_size = width * height
if int(info.nFrameLen) < expected_size:
raise RuntimeError(
f"frame contains {info.nFrameLen} bytes, expected {expected_size}"
)
stamp = self.get_clock().now().to_msg()
image = Image()
image.header.stamp = stamp
image.header.frame_id = self.frame_id
image.height = height
image.width = width
image.encoding = "mono8"
image.is_bigendian = 0
image.step = width
image.data = string_at(frame.pBufAddr, expected_size)
camera_info = self._make_camera_info(width, height)
camera_info.header.stamp = stamp
camera_info.header.frame_id = self.frame_id
self.image_publisher.publish(image)
self.camera_info_publisher.publish(camera_info)
self._frame_count += 1
def _make_camera_info(self, width: int, height: int) -> CameraInfo:
message = CameraInfo()
message.width = width
message.height = height
with self._camera_info_lock:
calibration = self._calibration
if calibration is None:
message.distortion_model = "plumb_bob"
message.d = []
message.k = [0.0] * 9
message.r = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
message.p = [0.0] * 12
return message
message.distortion_model = calibration.distortion_model
message.d = list(calibration.d)
message.k = list(calibration.k)
message.r = list(calibration.r)
message.p = list(calibration.p)
return message
def _set_camera_info_callback(
self,
request: SetCameraInfo.Request,
response: SetCameraInfo.Response,
) -> SetCameraInfo.Response:
try:
if self.camera_info_path is None:
raise ValueError(
"camera_info_url is empty; set it before saving calibration"
)
message = request.camera_info
if (
int(message.width) != self.image_width
or int(message.height) != self.image_height
):
raise ValueError(
f"calibration is {message.width}x{message.height}, expected "
f"{self.image_width}x{self.image_height}"
)
mapping = calibration_to_mapping(self.camera_name, message)
self.camera_info_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = self.camera_info_path.with_suffix(
self.camera_info_path.suffix + ".tmp"
)
temporary_path.write_text(
yaml.safe_dump(mapping, sort_keys=False), encoding="utf-8"
)
temporary_path.replace(self.camera_info_path)
calibration = load_camera_calibration(self.camera_info_path)
self._validate_calibration_dimensions(calibration)
with self._camera_info_lock:
self._calibration = calibration
response.success = True
response.status_message = (
f"saved camera calibration to {self.camera_info_path}"
)
self.get_logger().info(response.status_message)
except Exception as error:
response.success = False
response.status_message = str(error)
self.get_logger().error(
f"Rejected camera calibration: {response.status_message}"
)
return response
def close(self) -> None:
if self._closed:
return
self._closed = True
self._stop_event.set()
if self._grab_thread is not None:
self._grab_thread.join(timeout=2.0)
if self._camera is not None and self._camera_opened:
if self._grabbing:
result = self._camera.MV_CC_StopGrabbing()
if result != 0:
self.get_logger().warning(
f"MVS failed to stop grabbing: 0x{result:08x}"
)
self._grabbing = False
result = self._camera.MV_CC_CloseDevice()
if result != 0:
self.get_logger().warning(
f"MVS failed to close camera: 0x{result:08x}"
)
self._camera.MV_CC_DestroyHandle()
elif self._camera is not None and self._handle_created:
self._camera.MV_CC_DestroyHandle()
self._camera = None
self._handle_created = False
self._camera_opened = False
if self._sdk_initialized and self._mvs is not None:
self._mvs.MvCamera.MV_CC_Finalize()
self._sdk_initialized = False
def destroy_node(self) -> None:
self.close()
super().destroy_node()
def main(args: list[str] | None = None) -> None:
configure_fastdds_large_image_transport()
rclpy.init(args=args)
node: HikrobotCameraNode | None = None
try:
node = HikrobotCameraNode()
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -1,4 +1,4 @@
"""ROS 2 hardware calibration node for a front-facing RealSense camera."""
"""ROS 2 hardware calibration node for a front-facing industrial camera."""
from __future__ import annotations
@@ -0,0 +1,727 @@
"""One-command runner for a registered hand-calibration product."""
from __future__ import annotations
import argparse
from datetime import datetime
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
import traceback
from typing import Any, Mapping
from ament_index_python.packages import get_package_share_directory
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
from std_srvs.srv import Trigger
from .hikrobot_camera import configure_fastdds_large_image_transport
from .operator_report import ProgressEstimator, build_failure_report, render_progress_zh
from .product import ProductConfig, load_product_config, sha256_file
from .publication import atomic_session_pointer, finalize_session_artifacts
from .storage import atomic_write_json
EXIT_PASS = 0
EXIT_QUALITY = 2
EXIT_SAFETY = 3
STATUS_TIMEOUT_SECONDS = 90.0
FITTING_STATUS_TIMEOUT_SECONDS = 600.0
def _status_timeout_seconds(status: Mapping[str, Any]) -> float:
"""Return the watchdog deadline for the node's current phase.
Motion and acquisition are expected to publish twice a second and retain
the strict transport watchdog. The final 3-D fit is intentionally a
synchronous, CPU-bound operation, so its executor cannot service the
status timer until the fit returns. The node publishes an explicit
FITTING status immediately before entering that operation; allow that
known phase enough time without weakening motion safety checks.
"""
if str(status.get("state", "")) == "FITTING":
return FITTING_STATUS_TIMEOUT_SECONDS
return STATUS_TIMEOUT_SECONDS
def _calibration_node_exited_before_status(log_path: Path) -> bool:
"""Detect a launch child crash while the parent launch is still alive."""
try:
with log_path.open("rb") as stream:
stream.seek(0, os.SEEK_END)
size = stream.tell()
stream.seek(max(0, size - 65536), os.SEEK_SET)
tail = stream.read().decode("utf-8", errors="replace")
except OSError:
return False
return (
"[three_camera_calibration_node-" in tail
and "]: process has died" in tail
)
class CalibrationMonitor(Node):
def __init__(self) -> None:
super().__init__("g20_calibration_product_runner")
self.latest_status: dict[str, Any] = {}
self.last_status_at = time.monotonic()
self.start_requested = False
self.start_future: Any = None
self.abort_future: Any = None
self.create_subscription(String, "/g20_calibration/status", self._status, 10)
self.start_client = self.create_client(Trigger, "/g20_calibration/start")
self.abort_client = self.create_client(Trigger, "/g20_calibration/abort")
def _status(self, message: String) -> None:
try:
payload = json.loads(message.data)
except (TypeError, json.JSONDecodeError):
return
if isinstance(payload, dict):
self.latest_status = payload
self.last_status_at = time.monotonic()
def maybe_start(self) -> None:
if self.start_requested or self.latest_status.get("state") != "WAIT_START":
return
if not self.start_client.service_is_ready():
self.start_client.wait_for_service(timeout_sec=0.05)
return
self.start_requested = True
self.start_future = self.start_client.call_async(Trigger.Request())
def abort(self) -> None:
if not self.abort_client.service_is_ready():
self.abort_client.wait_for_service(timeout_sec=1.0)
if self.abort_client.service_is_ready():
self.abort_future = self.abort_client.call_async(Trigger.Request())
class ProgressConsole:
def __init__(self, serial_number: str) -> None:
self.serial_number = serial_number
self.estimator = ProgressEstimator.start()
self.last_text = ""
self.last_issue = ""
def update(self, status: Mapping[str, Any]) -> None:
text = render_progress_zh(self.serial_number, status, self.estimator)
if text == self.last_text:
return
self.last_text = text
if sys.stdout.isatty():
sys.stdout.write("\x1b[2J\x1b[H" + text + "\n")
sys.stdout.flush()
else:
print(text, flush=True)
reason = str(status.get("reason", ""))
if reason.startswith("automatic_retry_") and reason != self.last_issue:
self.last_issue = reason
active = status.get("active", {})
print(
"\n".join(
[
f"⚠ 当前任务出现问题:{reason.removeprefix('automatic_retry_')}",
f"系统处理:只重扫当前任务(第 {active.get('automatic_retry_count', 1)}/2 次)",
]
),
flush=True,
)
def _default_product_config() -> Path:
try:
installed = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
) / "config" / "g20_right_product.yaml"
if installed.is_file():
return installed
except Exception:
pass
return (
Path.cwd()
/ "src/g20_thumb_apriltag_calibration/config/g20_right_product.yaml"
).resolve()
def _launch_command(
config: ProductConfig,
session: Path,
*,
resume_from: Path | None = None,
recalibration_scope: str = "full",
) -> list[str]:
values = {
"model": config.model,
"hand_type": config.side,
"tag_layout": config.tag_layout,
"serial_number": config.serial_number,
"can_interface": config.can_interface,
"session_dir": str(session),
"output_root": str(config.output_root),
"camera_extrinsics_file": str(config.camera_extrinsics),
"source_urdf_path": str(config.source_urdf),
"source_urdf_expected_sha256": config.source_urdf_sha256,
"corrected_urdf_output_dir": str(session),
"calibration_config": str(config.calibration_config),
"tag_config": str(config.tag_config),
"commands_enabled": "true",
"start_cameras": "true",
"start_sdk": "true",
"record_bag": "false",
"validation_enabled": "false",
"recalibration_scope": recalibration_scope,
}
if resume_from is not None:
values["resume_raw_samples_path"] = str(
resume_from / "raw_samples.jsonl"
)
for view, camera in config.cameras.items():
values[f"{view}_camera_serial"] = camera["serial_number"]
values[f"{view}_camera_name"] = camera["camera_name"]
values[f"{view}_camera_info_url"] = camera["camera_info"]
return [
"ros2",
"launch",
"g20_thumb_apriltag_calibration",
"three_camera_calibration.launch.py",
*(f"{name}:={value}" for name, value in values.items()),
]
def _stop_stack(process: subprocess.Popen[Any]) -> None:
if process.poll() is not None:
return
try:
os.killpg(process.pid, signal.SIGINT)
except ProcessLookupError:
return
try:
process.wait(timeout=15.0)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
process.wait(timeout=5.0)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
return
process.wait(timeout=5.0)
def _write_trace(log_path: Path, error: BaseException) -> None:
with log_path.open("a", encoding="utf-8") as stream:
stream.write("\n[one-command exception]\n")
traceback.print_exception(type(error), error, error.__traceback__, file=stream)
def _request_safe_abort(monitor: CalibrationMonitor, timeout_seconds: float = 35.0) -> None:
monitor.abort()
deadline = time.monotonic() + float(timeout_seconds)
while time.monotonic() < deadline and rclpy.ok():
rclpy.spin_once(monitor, timeout_sec=0.1)
if monitor.latest_status.get("state") == "ABORTED":
return
def _run_hardware_session(
config: ProductConfig,
session: Path,
*,
resume_from: Path | None = None,
recalibration_scope: str = "full",
) -> tuple[dict[str, Any], int]:
session.mkdir(parents=True, exist_ok=False)
(session / "raw_samples.jsonl").touch()
log_path = session / "calibration.log"
log_stream = log_path.open("a", encoding="utf-8", buffering=1)
atomic_session_pointer(config.session_root, "latest_attempt", session)
monitor = CalibrationMonitor()
console = ProgressConsole(config.serial_number)
process: subprocess.Popen[Any] | None = None
latest_status: dict[str, Any] = {
"state": "PREFLIGHT",
"reason": "starting_ros_stack",
"progress": 0.0,
"views": {},
"feedback_hz": 0.0,
}
exit_code = EXIT_QUALITY
try:
process = subprocess.Popen(
_launch_command(
config,
session,
resume_from=resume_from,
recalibration_scope=recalibration_scope,
),
cwd=config.workspace,
stdout=log_stream,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
launched_at = time.monotonic()
last_render = 0.0
last_startup_log_check = 0.0
while True:
rclpy.spin_once(monitor, timeout_sec=0.1)
if monitor.latest_status:
latest_status = monitor.latest_status
monitor.maybe_start()
now = time.monotonic()
if now - last_render >= 0.5:
console.update(latest_status)
last_render = now
if monitor.start_future is not None and monitor.start_future.done():
response = monitor.start_future.result()
if response is None or not response.success:
message = "start service failed" if response is None else response.message
raise RuntimeError(f"CFG-START-008:{message}")
monitor.start_future = None
state = str(latest_status.get("state", ""))
if state == "COMPLETE":
exit_code = EXIT_PASS
break
if state in {"PAUSED", "ABORTED"}:
reason = str(latest_status.get("reason", "calibration_paused"))
exit_code = EXIT_SAFETY if "stall" in reason or state == "ABORTED" else EXIT_QUALITY
if state == "PAUSED" and "stall" not in reason:
# Ordinary quality failures return to the reviewed baseline
# before the process tree is stopped. Mechanical stalls
# deliberately skip this path and keep the current pose.
failure_status = dict(latest_status)
_request_safe_abort(monitor)
latest_status = failure_status
break
if process.poll() is not None:
raise RuntimeError(f"PUB-STACK-602:ROS stack exited with {process.returncode}")
if (
not monitor.latest_status
and now - last_startup_log_check >= 0.5
):
last_startup_log_check = now
log_stream.flush()
if _calibration_node_exited_before_status(log_path):
raise RuntimeError(
"CAM-STATUS-202:calibration node exited before status"
)
if (
not monitor.latest_status
and now - launched_at > STATUS_TIMEOUT_SECONDS
):
raise RuntimeError("CAM-STATUS-202:no calibration status received")
if (
monitor.latest_status
and now - monitor.last_status_at
> _status_timeout_seconds(monitor.latest_status)
):
raise RuntimeError("MOTION-COMM-303:calibration status stopped")
except KeyboardInterrupt as error:
latest_status["state"] = "ABORTED"
latest_status["reason"] = "operator_abort"
_request_safe_abort(monitor)
_write_trace(log_path, error)
exit_code = EXIT_SAFETY
except BaseException as error:
latest_status["state"] = "PAUSED"
latest_status["reason"] = str(error)
_write_trace(log_path, error)
exit_code = EXIT_QUALITY
finally:
if process is not None:
_stop_stack(process)
monitor.destroy_node()
log_stream.flush()
os.fsync(log_stream.fileno())
log_stream.close()
if exit_code != EXIT_PASS:
_, block = build_failure_report(
config,
session,
latest_status,
reason=str(latest_status.get("reason", "unknown_failure")),
)
print(block, flush=True)
return latest_status, exit_code
def _startup_failure_block(path: Path, error: BaseException) -> str:
return "\n".join(
[
"========== 请复制以下内容给开发者 ==========",
"结果:FAIL",
"错误代码:CFG-PRODUCT-001",
"失败阶段:启动静态预检",
f"问题:{error}",
f"产品配置:{path}",
"自动处理:未启动相机、SDK或机械手运动",
"建议:复制本诊断块给开发者,不要手工修改哈希绕过检查。",
"========== 复制结束 ==========",
]
)
def _automatic_resume_candidate(config: ProductConfig) -> Path | None:
"""Return the newest compatible failed attempt, never a passed session.
Do not trust only ``latest_attempt``. A process interrupted during the
device-only startup gate may have already moved that pointer while still
containing no ``session_start`` checkpoint. In that case walk backwards
to the preceding usable failed session instead of throwing away hours of
completed tasks.
"""
root = config.session_root
try:
resolved_root = root.resolve(strict=True)
except OSError:
return None
candidates: list[Path] = []
pointer = config.session_root / "latest_attempt"
if pointer.exists():
try:
candidates.append(pointer.resolve(strict=True))
except OSError:
pass
try:
candidates.extend(
sorted(
(
path
for path in root.iterdir()
if path.is_dir() and not path.name.startswith("latest_")
),
key=lambda path: path.name,
reverse=True,
)
)
except OSError:
return None
passed_pointer = config.session_root / "latest_passed"
passed: Path | None = None
if passed_pointer.exists():
try:
passed = passed_pointer.resolve(strict=True)
except OSError:
pass
seen: set[Path] = set()
for unresolved in candidates:
try:
candidate = unresolved.resolve(strict=True)
except OSError:
continue
if candidate in seen:
continue
seen.add(candidate)
if candidate.parent != resolved_root or not candidate.is_dir():
continue
# A failed attempt older than the current formal release is stale and
# must not seed a new independent calibration.
if passed is not None and candidate.name <= passed.name:
continue
raw_path = candidate / "raw_samples.jsonl"
if not raw_path.is_file():
continue
summary_path = candidate / "calibration_summary_zh.json"
summary: dict[str, Any] | None = None
if summary_path.is_file():
try:
loaded = json.loads(summary_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(loaded, dict) or loaded.get("result") != "FAIL":
continue
summary = loaded
hashes = summary.get("hashes", {})
if not isinstance(hashes, Mapping):
continue
if (
str(hashes.get("source_urdf_sha256", ""))
!= config.source_urdf_sha256
or str(hashes.get("camera_extrinsics_sha256", ""))
!= config.camera_extrinsics_sha256
):
continue
start: dict[str, Any] | None = None
try:
with raw_path.open("r", encoding="utf-8") as stream:
for line in stream:
if not line.strip():
continue
value = json.loads(line)
if (
isinstance(value, dict)
and value.get("kind") == "session_start"
):
start = value
break
except (OSError, json.JSONDecodeError):
continue
if (
start is None
or start.get("hand_type") != config.side
or start.get("tag_layout") != config.tag_layout
or start.get("source_urdf_sha256")
!= config.source_urdf_sha256
):
continue
if summary is None:
# Ctrl+C can terminate the ROS launch tree before the wrapper gets
# a chance to create calibration_summary_zh.json. The immutable
# checkpoint itself is enough to resume only after independently
# proving that its external geometry still matches the product.
try:
checkpoint_extrinsics = Path(
str(start["camera_extrinsics_file"])
).expanduser().resolve(strict=True)
if sha256_file(checkpoint_extrinsics) != (
config.camera_extrinsics_sha256
):
continue
except (KeyError, OSError, ValueError):
continue
return candidate
return None
def _resolve_partial_base_session(
config: ProductConfig, base_session: str | Path | None
) -> Path:
"""Validate the complete passed session that donates non-target tasks."""
if base_session is None or not str(base_session).strip():
raise ValueError(
"partial scope requires --base-session pointing to a passed "
"complete G20 right session"
)
candidate = Path(base_session).expanduser().resolve(strict=True)
root = config.session_root.resolve()
if candidate.parent != root or not candidate.is_dir():
raise ValueError(
"base session must resolve to a direct session directory under "
f"{root}"
)
raw_path = candidate / "raw_samples.jsonl"
summary_path = candidate / "calibration_summary_zh.json"
payload_path = (
candidate
/ f"g20_right_{config.serial_number}_calibration.json"
)
for required in (raw_path, summary_path, payload_path):
if not required.is_file():
raise ValueError(f"base session is missing required artifact: {required}")
try:
summary = json.loads(summary_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ValueError("base session summary is invalid JSON") from error
if (
not isinstance(summary, dict)
or summary.get("result") != "PASS"
or not bool(summary.get("quality", {}).get("passed"))
):
raise ValueError("base session is not a formally passed session")
hashes = summary.get("hashes", {})
expected_hashes = {
"source_urdf_sha256": config.source_urdf_sha256,
"camera_extrinsics_sha256": config.camera_extrinsics_sha256,
"calibration_config_sha256": config.calibration_config_sha256,
}
if not isinstance(hashes, Mapping) or any(
str(hashes.get(name, "")) != expected
for name, expected in expected_hashes.items()
):
raise ValueError(
"base session source URDF, camera extrinsics or calibration "
"configuration differs from the current product"
)
return candidate
def run(
config_path: str | Path,
*,
workspace: str | Path | None = None,
preflight_only: bool = False,
allow_resume: bool = True,
scope: str = "full",
base_session: str | Path | None = None,
) -> int:
path = Path(config_path).expanduser().resolve()
try:
# Resolve every file and camera identity before allowing a hardware
# process to start. A second load enables the real CAN existence gate.
config = load_product_config(path, workspace=workspace, check_can=False)
load_product_config(path, workspace=workspace, check_can=True)
selected_scope = str(scope).strip().lower()
if selected_scope not in {"full", "thumb", "fingers"}:
raise ValueError("scope must be one of: full, thumb, fingers")
if selected_scope == "full" and base_session is not None:
raise ValueError(
"--base-session is valid only with --scope thumb/fingers"
)
partial_base = None
if selected_scope == "fingers" or base_session is not None:
partial_base = _resolve_partial_base_session(config, base_session)
except BaseException as error:
print(_startup_failure_block(path, error), flush=True)
return EXIT_QUALITY
if preflight_only:
print("PASS:产品文件、相机内外参、19张Tag配置和CAN接口静态预检通过。")
return EXIT_PASS
config.session_root.mkdir(parents=True, exist_ok=True)
resume_candidate = (
partial_base
if selected_scope != "full"
else (_automatic_resume_candidate(config) if allow_resume else None)
)
if resume_candidate is not None:
if selected_scope == "thumb":
print(
"拇指专项标定:四指任务继承自已通过会话 "
f"{resume_candidate.name}4项拇指任务将全部重新采集,"
"四指零位保持不变。",
flush=True,
)
elif selected_scope == "fingers":
print(
"四指专项标定:拇指任务和4个拇指零位继承自已通过会话 "
f"{resume_candidate.name}12项四指任务将全部重新采集。",
flush=True,
)
else:
print(
"检测到兼容的失败会话,将恢复已完整通过的关节任务:"
f"{resume_candidate.name}。失败中的当前任务会从头重做。",
flush=True,
)
elif selected_scope == "thumb":
print(
"独立拇指标定:不导入四指会话;仅采集4项拇指任务,"
"四指URDF零位保持原始CAD值。",
flush=True,
)
maximum_sessions = config.required_independent_passes
for pass_index in range(maximum_sessions):
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session = config.session_root / stamp
while session.exists():
time.sleep(1.0)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session = config.session_root / stamp
if maximum_sessions > 1:
print(f"正式标定复验:第 {pass_index + 1}/{maximum_sessions}", flush=True)
status, code = _run_hardware_session(
config,
session,
resume_from=(
resume_candidate
if selected_scope != "full" or pass_index == 0
else None
),
recalibration_scope=selected_scope,
)
if code != EXIT_PASS:
return code
# Keep the exact node-side completion contract durable before the
# independent publication layer starts. If publication itself fails,
# developers can re-run artifact checks without repeating motion or
# inventing lost combination-validation metrics.
atomic_write_json(session / "node_status.json", status)
try:
summary, release_ready = finalize_session_artifacts(
config, session, node_status=status
)
except BaseException as error:
_write_trace(session / "calibration.log", error)
status = dict(status)
status["state"] = "PAUSED"
status["reason"] = f"PUB-ARTIFACT-601:{error}"
_, block = build_failure_report(config, session, status, reason=status["reason"])
print(block, flush=True)
return EXIT_QUALITY
if release_ready:
result_pointer = (
config.session_root / "latest_thumb_passed"
if selected_scope == "thumb" and partial_base is None
else config.session_root / "latest_passed"
)
print(
"\n".join(
[
f"PASS{config.model} {config.side} 标定、URDF修正和复验全部通过。",
f"正式结果:{result_pointer}",
f"JSON{session / summary['artifacts']['json']}",
f"URDF{summary['artifacts']['urdf']}",
]
),
flush=True,
)
return EXIT_PASS
print("本次会话质量PASS;正在自动执行第二次独立完整复验。", flush=True)
return EXIT_QUALITY
def main(args: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="配置驱动的机械手精密标定")
parser.add_argument("--config", default=str(_default_product_config()))
parser.add_argument("--workspace", default=None)
parser.add_argument("--preflight-only", action="store_true")
parser.add_argument(
"--scope",
choices=("full", "thumb", "fingers"),
default="full",
help=(
"full重新标定全手;thumb仅重采4项拇指任务;"
"fingers复用已认证拇指并仅重采12项四指任务"
),
)
parser.add_argument(
"--base-session",
default=None,
help=(
"可选:thumb模式将结果合并到该完整会话;"
"fingers模式必须提供该基础会话"
),
)
parser.add_argument(
"--no-resume",
action="store_true",
help="忽略失败会话,从第一个关节开始全新采集",
)
arguments = parser.parse_args(args)
configure_fastdds_large_image_transport()
ros_log_dir = Path(
os.environ.setdefault("ROS_LOG_DIR", "/tmp/g20_calibration_ros_logs")
)
ros_log_dir.mkdir(parents=True, exist_ok=True)
rclpy.init()
try:
code = run(
arguments.config,
workspace=arguments.workspace,
preflight_only=arguments.preflight_only,
allow_resume=not arguments.no_resume,
scope=arguments.scope,
base_session=arguments.base_session,
)
finally:
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == "__main__":
main()
@@ -0,0 +1,590 @@
"""Stable Chinese progress and copy/paste diagnostics for non-expert users."""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
from pathlib import Path
import time
from typing import Any, Mapping
from .product import ProductConfig
from .storage import atomic_write_json
from .three_camera_diagnostics import JOINT_NAMES_ZH, STATE_NAMES_ZH, three_camera_reason_zh
def _duration(seconds: float | None) -> str:
if seconds is None or not math.isfinite(seconds) or seconds < 0.0:
return "计算中"
value = int(round(seconds))
return f"{value // 60}{value % 60:02d}"
def _task_tag_id_status(views: Mapping[str, Any]) -> str:
"""Render every currently required Tag ID with its live visibility."""
labels = {"front": "正面", "side": "侧面", "top": "顶部"}
ordered_views = [
*[name for name in ("front", "side", "top") if name in views],
*sorted(name for name in views if name not in labels),
]
groups: list[str] = []
for name in ordered_views:
item = views.get(name, {})
if not isinstance(item, Mapping):
continue
required = sorted({int(value) for value in item.get("required_tag_ids", [])})
if not required:
continue
visible = {int(value) for value in item.get("detected_tag_ids", [])}
locked = {
int(value)
for value in item.get("locked_reference_tag_ids", [])
}
ids = ",".join(
f"{tag_id}{'' if tag_id in locked else '' if tag_id in visible else ''}"
for tag_id in required
)
groups.append(f"{labels.get(name, name)}[{ids}]")
if not groups:
return ""
return (
" 当前任务ID"
+ " ".join(groups)
+ "(✓实时可见/锁=基准锁定/✗不可用)"
)
def _pnp_wait_status(views: Mapping[str, Any]) -> str:
"""Explain why visible Tags have not produced a usable 3-D pose."""
labels = {"front": "正面", "side": "侧面", "top": "顶部"}
details: list[str] = []
for name in ("front", "side", "top"):
item = views.get(name, {})
if not isinstance(item, Mapping) or item.get("pnp_pose_valid"):
continue
parts: list[str] = []
progress = item.get("pnp_initialization_progress")
if isinstance(progress, Mapping):
parts.append(
"初始化"
f"{int(progress.get('accepted', 0))}/"
f"{int(progress.get('required', 0))}"
)
counts: dict[str, int] = {}
for field in ("pnp_rejection_counts", "group_pnp_rejection_counts"):
values = item.get(field, {})
if not isinstance(values, Mapping):
continue
for reason, count in values.items():
counts[str(reason)] = counts.get(str(reason), 0) + int(count)
if counts:
common = sorted(
counts.items(), key=lambda pair: (-pair[1], pair[0])
)[:2]
parts.append(
"拒绝=" + ",".join(f"{reason}×{count}" for reason, count in common)
)
if parts:
details.append(f"{labels[name]} " + "".join(parts))
if not details:
return ""
return "PnP" + " | ".join(details) + "Tag可见不等于三维位姿有效)"
@dataclass
class ProgressEstimator:
started_at: float
last_progress: float = 0.0
last_progress_at: float = 0.0
seconds_per_fraction: float | None = None
@classmethod
def start(cls) -> "ProgressEstimator":
now = time.monotonic()
return cls(started_at=now, last_progress_at=now)
def remaining(self, progress: float, now: float | None = None) -> float | None:
current = time.monotonic() if now is None else float(now)
value = float(max(0.0, min(1.0, progress)))
delta = value - self.last_progress
elapsed = current - self.last_progress_at
if delta >= 0.002 and elapsed > 0.0:
estimate = elapsed / delta
self.seconds_per_fraction = (
estimate
if self.seconds_per_fraction is None
else 0.8 * self.seconds_per_fraction + 0.2 * estimate
)
self.last_progress = value
self.last_progress_at = current
if value >= 1.0:
return 0.0
if self.seconds_per_fraction is not None:
return max(0.0, self.seconds_per_fraction * (1.0 - value))
if value >= 0.02:
return max(0.0, (current - self.started_at) * (1.0 - value) / value)
return None
def render_progress_zh(
serial_number: str,
status: Mapping[str, Any],
estimator: ProgressEstimator,
) -> str:
progress = float(status.get("progress", 0.0))
active = status.get("active", {})
if not isinstance(active, Mapping):
active = {}
state = str(status.get("state", "PREFLIGHT"))
stage = STATE_NAMES_ZH.get(state, state)
preflight_mode = str(status.get("preflight_mode", ""))
if preflight_mode == "device_only_before_baseline":
stage = "设备连接预检"
elif preflight_mode == "recovering_baseline":
stage = "安全恢复基准形态"
elif preflight_mode == "baseline_tags_after_recovery":
stage = "基准姿态标签预检"
elif str(status.get("reason", "")) == (
"holding_same_finger_clearance_before_next_task"
):
stage = "保持避让姿态,切换同指下一项"
elif str(status.get("reason", "")) == (
"waiting_for_task_tags_at_sweep_start"
):
stage = "已到扫描起点,等待任务Tag"
joints = active.get("joints", [])
joint = "/".join(JOINT_NAMES_ZH.get(str(name), str(name)) for name in joints)
if str(active.get("task_name", "")).endswith("_roll_multiview") and joints:
joint = (
JOINT_NAMES_ZH.get(str(joints[0]), str(joints[0]))
+ "(正面+侧面同步)"
)
if not joint:
import_progress = status.get("base_import", {})
import_fraction = (
float(import_progress.get("fraction", 0.0))
if isinstance(import_progress, Mapping)
else 0.0
)
joint = str(active.get("label_zh", "")) or (
f"导入基础会话 {100.0 * import_fraction:.1f}%"
if state in {"IMPORTING_BASE", "REVALIDATING_INHERITED"}
else (
"等待设备" if state in {"PREFLIGHT", "WAIT_START"} else "全手"
)
)
direction = {
"decreasing": "递减",
"increasing": "递增",
}.get(str(active.get("direction", "")), "-")
cycle = active.get("cycle", "-")
repetitions = active.get("repetitions", 4)
requested = active.get(
"current_motion_target_u8",
active.get("command_u8", active.get("pose_name", "-")),
)
actual = active.get("actual_u8", "-")
views = status.get("views", {})
if not isinstance(views, Mapping):
views = {}
ready_cameras = sum(
bool(item.get("camera_info_valid") and item.get("camera_extrinsics_valid"))
for item in views.values()
if isinstance(item, Mapping)
)
required_tags = sum(
len(item.get("required_tag_ids", []))
for item in views.values()
if isinstance(item, Mapping)
)
visible_tags = sum(
len(
{
*item.get("detected_tag_ids", []),
*item.get("locked_reference_tag_ids", []),
}
)
for item in views.values()
if isinstance(item, Mapping)
)
locked_tag_count = sum(
len(item.get("locked_reference_tag_ids", []))
for item in views.values()
if isinstance(item, Mapping)
)
configured_tags = sum(
len(item.get("configured_tag_ids", item.get("required_tag_ids", [])))
for item in views.values()
if isinstance(item, Mapping)
)
visible_configured_tags = sum(
len(
item.get(
"visible_configured_tag_ids",
item.get("detected_tag_ids", []),
)
)
for item in views.values()
if isinstance(item, Mapping)
)
occlusion_allowed = bool(
state in {"PREFLIGHT", "WAIT_START"}
or active.get("pose_name")
)
if preflight_mode == "device_only_before_baseline":
tag_status = (
f"当前 {visible_configured_tags}/{configured_tags}"
"(基准恢复后检查)"
)
elif preflight_mode == "recovering_baseline":
tag_status = (
f"当前 {visible_configured_tags}/{configured_tags}"
"(恢复中不作为门槛)"
)
elif occlusion_allowed:
tag_status = (
f"当前 {visible_configured_tags}/{configured_tags}(允许遮挡) "
f"本阶段必需 {visible_tags}/{required_tags}"
)
else:
tag_status = (
f"{visible_tags}/{required_tags} 可见"
+ (f"(含锁定 {locked_tag_count}" if locked_tag_count else "")
)
task_tag_ids = (
_task_tag_id_status(views)
if state
in {
"PREPARE_SWEEP",
"SWEEP",
"VALIDATION_MOVE",
"VALIDATION_CAPTURE",
}
else ""
)
retry = int(active.get("automatic_retry_count", 0))
fit_attempt = int(active.get("fit_attempt", 1))
fit_attempt_limit = int(active.get("fit_attempt_limit", 1))
feedback_hz = float(status.get("feedback_hz", 0.0))
eta = (
"等待Tag"
if str(status.get("reason", ""))
== "waiting_for_task_tags_at_sweep_start"
else _duration(estimator.remaining(progress))
)
lines = [
f"[{serial_number}] 标定中 {progress * 100:5.1f}% 预计剩余 {eta}",
f"阶段:{stage}(第 {cycle}/{repetitions} 轮)",
f"任务:{joint} 命令/反馈:{requested}/{actual} 方向:{direction}",
f"Tag{tag_status}{task_tag_ids} 相机:{ready_cameras}/3 正常 反馈:{feedback_hz:.1f} Hz",
f"质量:有效帧 {active.get('valid_frames', 0)} 已自动重扫 {retry}",
]
if str(status.get("reason", "")) == "waiting_for_task_tags_at_sweep_start":
pnp_wait = _pnp_wait_status(views)
if pnp_wait:
lines.append(pnp_wait)
fit_retry_cycles = [
int(cycle) for cycle in active.get("fit_retry_cycles", [])
]
if fit_attempt > 1 and fit_retry_cycles:
cycle_text = "/".join(str(cycle) for cycle in fit_retry_cycles)
if len(fit_retry_cycles) >= repetitions:
lines.append(
"拟合:首次拟合未能定位单一异常轮,"
f"联合补采第 {cycle_text} 轮双向"
)
else:
lines.append(
f"拟合:首次拟合定位第 {cycle_text} 轮需复核,"
"仅补采对应轮次双向;其余轮次数据保留"
)
elif fit_attempt_limit > 1:
lines.append(
f"拟合:任务级硬门限检查(最多允许 "
f"{fit_attempt_limit - 1} 次异常轮补采)"
)
resume = status.get("resume", {})
if isinstance(resume, Mapping) and resume.get("used"):
lines.append(
"断点:已恢复 "
f"{int(resume.get('completed_task_count', 0))}/"
f"{int(resume.get('total_task_count', 16))} 个完整任务;"
"失败任务已丢弃并重新采集"
)
return "\n".join(lines)
def classify_error(reason: str, status: Mapping[str, Any]) -> tuple[str, str, str]:
value = str(reason)
active = status.get("active", {})
if not isinstance(active, Mapping):
active = {}
views = status.get("views", {})
missing = [
tag
for item in views.values()
if isinstance(item, Mapping)
for tag in item.get("missing_tag_ids", [])
] if isinstance(views, Mapping) else []
group_pnp_reasons = [
str(item.get("group_pnp_reason"))
for item in views.values()
if isinstance(item, Mapping) and item.get("group_pnp_reason")
] if isinstance(views, Mapping) else []
pnp_diagnostics_present = bool(
group_pnp_reasons
or (
isinstance(views, Mapping)
and any(
isinstance(item, Mapping)
and any(
item.get(field)
for field in (
"pnp_rejection_counts",
"group_pnp_rejection_counts",
"pnp_initialization_progress",
"group_missing_candidate_roles",
)
)
for item in views.values()
)
)
)
if value.startswith("CFG-"):
return value, "产品配置或文件预检失败", "不要移动相机;复制本诊断块给开发者。"
if value.startswith("DATA-CONTRACT-"):
return (
value.split(":", 1)[0],
"标定样本的请求命令与反馈值数据契约不完整",
"机械手无需重新采集;保留原始会话并用离线重放验证,"
"复制契约错误给开发者。",
)
if value.startswith("CAM-STATUS-202"):
return (
"CAM-STATUS-202",
"标定节点启动后未发布状态",
"机械手尚未开始运动;查看calibration.log中首个节点异常后从断点继续。",
)
if value.startswith("MOTION-COMM-303"):
return (
"MOTION-COMM-303",
"标定节点状态心跳停止更新",
"已采集的完整任务会保留;检查calibration.log后从断点继续,不要重新采集。",
)
if value == "fixed_base_reference_moved":
return (
"CAM-REFERENCE-204",
"顶部基准Tag在本次标定开始后发生移动",
"Tag 8可在新会话预检前重新摆放;固定Tag 8和顶部相机后启动新会话。",
)
if "palm_orientation_quality_failed" in value:
return (
"VAL-QUALITY-501",
"掌部方向校正的可观测轨迹不足",
"保持Tag安装不变;调整标定前手位,使正面Tag 10–13至少三枚"
"在对应运动起始段可见。",
)
if "motor_state_stalled" in value:
return "MOTION-STALL-301", "电机反馈停止向目标推进,程序已保持当前位置", "先检查机械卡阻,未排除前不要重复强推。"
if any(
token in value
for token in (
"sweep_missing_endpoint_bin",
"sweep_bins_too_few",
"sweep_bin_gap_too_large",
"task_precheck_missing_command_127",
)
):
return "OBS-SAMPLE-104", "当前任务的有效视觉轨迹不完整", "根据诊断中的关节、机位和缺失区间处理遮挡或反光后重新运行。"
if (
("synchronised" in value or "sweep_start_tag_timeout" in value)
and not missing
and pnp_diagnostics_present
):
problem = (
"Tag可见,但三维PnP轨迹在运动中失效"
if value.startswith("synchronised")
and int(active.get("valid_frames", 0) or 0) > 0
else "Tag可见,但三维PnP位姿初始化未完成"
)
return (
"CAM-GEOMETRY-201",
problem,
"不要根据二维码可见性调整Tag;复制累计PnP拒绝原因给开发者。",
)
if missing or "tag" in value or "detection" in value or "synchronised" in value:
return "OBS-TAG-103", f"所需Tag不可用或持续丢失:{missing}", "检查Tag是否脱落、翘起、反光或被遮挡后重新运行。"
if "state" in value and ("timeout" in value or "lost" in value):
return "MOTION-COMM-302", "机械手反馈中断或频率不足", "检查CAN接口和机械手供电后重新运行。"
if "camera" in value or "pnp" in value or "extrinsics" in value:
return "CAM-GEOMETRY-201", "相机、内外参或位姿求解未通过", "确认三台相机没有移动,然后复制本诊断块给开发者。"
if "checkpoint" in value:
return "FIT-CHECKPOINT-402", "稳态检查点采集流程未完整结束", "完整样本已保留;复制本诊断块给开发者检查采集状态机。"
if "fit" in value or "trajectory" in value or "axis" in value:
return "FIT-MODEL-401", "关节轴或动态曲线拟合未达到精度门限", "不要放宽门限;复制本诊断块给开发者分析原始样本。"
if (
"endpoint_zero_offsets' is not defined" in value
or "validated_endpoint_zero_state" in value
):
return (
"PUB-ARTIFACT-601",
"已验证零位状态未能完整传递到URDF发布阶段",
"采集与验证数据仍可保留;不要移动相机或Tag,复制诊断块给开发者。",
)
if "validation" in value or "combination" in value or "quality" in value or "zero" in value:
return "VAL-QUALITY-501", "留出验证或URDF零位验证未通过", "结果不会发布;复制本诊断块给开发者。"
if "publish" in value or "artifact" in value or "URDF" in value:
return "PUB-ARTIFACT-601", "结果文件校验或原子发布失败", "原始URDF未被覆盖;复制本诊断块给开发者。"
return "PUB-UNEXPECTED-699", "标定程序出现未分类异常", "复制本诊断块给开发者,完整堆栈已写入calibration.log。"
def build_failure_report(
config: ProductConfig,
session: str | Path,
status: Mapping[str, Any],
*,
reason: str,
) -> tuple[dict[str, Any], str]:
directory = Path(session).resolve()
code, problem, suggestion = classify_error(reason, status)
active = status.get("active", {})
if not isinstance(active, Mapping):
active = {}
else:
active = dict(active)
views = status.get("views", {})
if isinstance(views, Mapping):
group_pnp_reasons = {
str(view): str(item.get("group_pnp_reason"))
for view, item in views.items()
if isinstance(item, Mapping) and item.get("group_pnp_reason")
}
if group_pnp_reasons:
active["group_pnp_reasons"] = group_pnp_reasons
for field in (
"pnp_rejection_counts",
"group_pnp_rejection_counts",
"pnp_initialization_progress",
"group_missing_candidate_roles",
"pnp_candidate_diagnostics",
):
values = {
str(view): item.get(field)
for view, item in views.items()
if isinstance(item, Mapping) and item.get(field)
}
if values:
active[field] = values
try:
explanation, automatic_action = three_camera_reason_zh(
str(status.get("state", "")), reason, active
)
except Exception:
explanation, automatic_action = problem, "已停止本次发布并保留全部诊断数据"
camera_state = {
view: (
"OK"
if isinstance(item, Mapping)
and item.get("camera_info_valid", item.get("ready"))
and item.get("camera_extrinsics_valid", item.get("ready"))
and item.get("stream_alive", item.get("ready"))
else "NOT_READY"
)
for view, item in (views.items() if isinstance(views, Mapping) else [])
}
session_id = f"{config.serial_number}_{directory.name}"
metrics = {
"valid_frames": active.get("valid_frames"),
"sample": active.get("sample", {}),
"detection_frames": active.get("detection_frames"),
"detection_valid_frames": active.get("detection_valid_frames"),
"detection_rate": active.get("detection_rate"),
"detection_rate_by_view": active.get("detection_rate_by_view", {}),
"automatic_retry_count": active.get("automatic_retry_count", 0),
}
pnp_diagnostics = {
str(view): {
field: item.get(field)
for field in (
"group_pnp_reason",
"group_missing_candidate_roles",
"pnp_rejections",
"pnp_rejection_counts",
"group_pnp_rejection_counts",
"pnp_initialization_progress",
"pnp_candidate_diagnostics",
)
if item.get(field)
}
for view, item in (
views.items() if isinstance(views, Mapping) else []
)
if isinstance(item, Mapping)
and any(
item.get(field)
for field in (
"group_pnp_reason",
"group_missing_candidate_roles",
"pnp_rejections",
"pnp_rejection_counts",
"group_pnp_rejection_counts",
"pnp_initialization_progress",
"pnp_candidate_diagnostics",
)
)
}
payload: dict[str, Any] = {
"schema_version": 1,
"serial_number": config.serial_number,
"session_id": session_id,
"result": "FAIL",
"error_code": code,
"stage": str(status.get("state", "startup")),
"reason": str(reason),
"problem_zh": problem,
"explanation_zh": explanation,
"automatic_action_zh": automatic_action,
"suggestion_zh": suggestion,
"metrics": metrics,
"pnp_diagnostics": pnp_diagnostics,
"camera_state": camera_state,
"feedback_hz": status.get("feedback_hz", 0.0),
"hashes": {
"product_config_sha256": __import__("hashlib").sha256(config.path.read_bytes()).hexdigest(),
"camera_extrinsics_sha256": config.camera_extrinsics_sha256,
"calibration_config_sha256": config.calibration_config_sha256,
"source_urdf_sha256": config.source_urdf_sha256,
},
"session_dir": str(directory),
"quality": {"passed": False},
}
atomic_write_json(directory / "calibration_summary_zh.json", payload)
block = "\n".join(
[
"========== 请复制以下内容给开发者 ==========",
f"会话编号:{session_id}",
"结果:FAIL",
f"错误代码:{code}",
f"失败阶段:{payload['stage']}",
f"问题:{problem}",
f"详细说明:{explanation}",
f"自动处理:{automatic_action}",
f"关键指标:{json.dumps(metrics, ensure_ascii=False, separators=(',', ':'))}",
"PnP诊断:"
+ json.dumps(
pnp_diagnostics,
ensure_ascii=False,
separators=(",", ":"),
),
f"相机状态:{json.dumps(camera_state, ensure_ascii=False, separators=(',', ':'))}",
f"反馈状态:{float(payload['feedback_hz'] or 0.0):.1f} Hz",
f"配置哈希:{payload['hashes']['product_config_sha256']}",
f"外参哈希:{config.camera_extrinsics_sha256}",
f"源 URDF 哈希:{config.source_urdf_sha256}",
f"会话目录:{directory}",
f"建议:{suggestion}",
"========== 复制结束 ==========",
]
)
return payload, block
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
"""Immutable product identity and shared calibration preflight.
Model-specific kinematics live in registered calibration contracts. The
loader itself only verifies that the selected contract, Tag configuration,
cameras and immutable artifacts agree, so adding another hand does not
require another copy of the one-command runner.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
from pathlib import Path
import re
from typing import Any, Mapping
import yaml
from .extrinsics import camera_info_fingerprint, load_three_camera_extrinsics
from .full_hand import (
G20_RIGHT_19_LAYOUT,
HandCalibrationProfile,
get_hand_calibration_profile,
)
from .urdf_zero import ZeroCalibrationProfile, get_zero_calibration_profile
VIEWS = ("front", "side", "top")
@dataclass(frozen=True)
class ProductCalibrationContract:
"""Declarative boundary between one hand model and the shared engine."""
model: str
side: str
layout_id: str
profile: HandCalibrationProfile
zero_profile: ZeroCalibrationProfile
@property
def required_tag_ids(self) -> frozenset[int]:
return frozenset(
int(tag_id)
for tags in self.profile.view_tags.values()
for tag_id in tags.values()
)
@property
def views(self) -> tuple[str, ...]:
return tuple(self.profile.view_tags)
_PRODUCT_CONTRACTS: dict[
tuple[str, str, str], ProductCalibrationContract
] = {}
def register_product_calibration_contract(
contract: ProductCalibrationContract,
) -> None:
"""Register one model/side/layout without modifying shared workflow code."""
model = str(contract.model).strip().upper()
side = str(contract.side).strip().lower()
layout = str(contract.layout_id).strip().lower()
if not model or side not in {"left", "right"} or not layout:
raise ValueError("product calibration contract identity is invalid")
if contract.profile.side != side:
raise ValueError("product contract side differs from hand profile")
if contract.profile.layout_id.lower() != layout:
raise ValueError("product contract layout differs from hand profile")
if contract.zero_profile.hand != contract.profile:
raise ValueError("zero-calibration profile differs from hand profile")
if len(contract.profile.baseline_command) != contract.profile.command_count:
raise ValueError("profile baseline and command names differ in length")
if not contract.required_tag_ids:
raise ValueError("product contract must declare at least one Tag")
key = (model, side, layout)
existing = _PRODUCT_CONTRACTS.get(key)
if existing is not None and existing != contract:
raise ValueError(f"product calibration contract already registered: {key}")
_PRODUCT_CONTRACTS[key] = contract
def get_product_calibration_contract(
model: str, side: str, layout_id: str
) -> ProductCalibrationContract:
key = (
str(model).strip().upper(),
str(side).strip().lower(),
str(layout_id).strip().lower(),
)
try:
return _PRODUCT_CONTRACTS[key]
except KeyError as error:
supported = ", ".join("/".join(item) for item in sorted(_PRODUCT_CONTRACTS))
raise ValueError(
f"unsupported calibration product {key}; registered={supported}"
) from error
register_product_calibration_contract(
ProductCalibrationContract(
model="G20",
side="right",
layout_id=G20_RIGHT_19_LAYOUT,
profile=get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT),
zero_profile=get_zero_calibration_profile(
"right", G20_RIGHT_19_LAYOUT
),
)
)
for _legacy_side in ("left", "right"):
register_product_calibration_contract(
ProductCalibrationContract(
model="G20",
side=_legacy_side,
layout_id="legacy_11",
profile=get_hand_calibration_profile(_legacy_side, "legacy_11"),
zero_profile=get_zero_calibration_profile(
_legacy_side, "legacy_11"
),
)
)
def sha256_file(path: str | Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _mapping(value: Any, name: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError(f"{name} must be a mapping")
return value
def _resolve_path(value: Any, *, workspace: Path, name: str) -> Path:
text = str(value).strip()
if not text:
raise ValueError(f"{name} is required")
candidate = Path(text).expanduser()
if not candidate.is_absolute():
candidate = workspace / candidate
return candidate.resolve()
def _camera_info_fingerprint(path: Path) -> str:
with path.open("r", encoding="utf-8") as stream:
payload = _mapping(yaml.safe_load(stream), str(path))
def values(key: str) -> list[float]:
item = _mapping(payload.get(key, {}), key)
return [float(value) for value in item.get("data", [])]
return camera_info_fingerprint(
width=int(payload["image_width"]),
height=int(payload["image_height"]),
camera_matrix=values("camera_matrix"),
distortion=values("distortion_coefficients"),
rectification=values("rectification_matrix"),
projection=values("projection_matrix"),
)
def _tag_sizes_m_by_id(path: Path) -> dict[int, float]:
with path.open("r", encoding="utf-8") as stream:
payload = _mapping(yaml.safe_load(stream), str(path))
result: dict[int, float] = {}
for node in payload.values():
parameters = _mapping(_mapping(node, "tag node").get("ros__parameters"), "ros__parameters")
tag = _mapping(parameters.get("tag"), "tag")
ids = [int(value) for value in tag.get("ids", [])]
sizes = [float(value) for value in tag.get("sizes", [])]
if len(ids) != len(sizes):
raise ValueError("Tag IDs and sizes must have the same length")
if set(result).intersection(ids):
raise ValueError("Tag IDs must be unique across the three views")
if any(not 0.0 < size < 0.1 for size in sizes):
raise ValueError("Tag sizes must be in (0, 0.1) metres")
result.update(zip(ids, sizes))
return result
def _custom_pnp_tag_sizes_m_by_id(
path: Path, configured_ids: set[int]
) -> dict[int, float]:
with path.open("r", encoding="utf-8") as stream:
payload = _mapping(yaml.safe_load(stream), str(path))
parameters = _mapping(
_mapping(payload.get("g20_calibration"), "g20_calibration").get(
"ros__parameters"
),
"ros__parameters",
)
default = float(parameters.get("tag_size_m", 0.0))
ids = [int(value) for value in parameters.get("tag_size_override_ids", [])]
sizes = [float(value) for value in parameters.get("tag_size_overrides_m", [])]
if len(ids) != len(sizes) or len(set(ids)) != len(ids):
raise ValueError("custom PnP Tag-size overrides are invalid")
if any(tag_id not in configured_ids for tag_id in ids):
raise ValueError("custom PnP Tag-size override ID is not configured")
result = {tag_id: default for tag_id in configured_ids}
result.update(zip(ids, sizes))
return result
@dataclass(frozen=True)
class ProductConfig:
path: Path
workspace: Path
model: str
side: str
tag_layout: str
calibration_contract: ProductCalibrationContract
serial_number: str
can_interface: str
source_urdf: Path
source_urdf_sha256: str
camera_extrinsics: Path
camera_extrinsics_sha256: str
calibration_config: Path
calibration_config_sha256: str
tag_config: Path
tag_config_sha256: str
output_root: Path
cameras: Mapping[str, Mapping[str, str]]
required_independent_passes: int
static_repeatability_rad: float
@property
def session_root(self) -> Path:
return self.output_root / self.serial_number
def load_product_config(
path: str | Path,
*,
workspace: str | Path | None = None,
check_can: bool = True,
) -> ProductConfig:
"""Load and verify a product against its registered calibration contract."""
source = Path(path).expanduser().resolve()
if not source.is_file():
raise ValueError(f"product config does not exist: {source}")
with source.open("r", encoding="utf-8") as stream:
raw = _mapping(yaml.safe_load(stream), str(source))
if int(raw.get("schema_version", -1)) != 1:
raise ValueError("product config schema_version must be 1")
root = Path.cwd().resolve() if workspace is None else Path(workspace).resolve()
serial = str(raw.get("serial_number", ""))
if re.fullmatch(r"[A-Za-z0-9_.-]+", serial) is None:
raise ValueError("serial_number is invalid")
model = str(raw.get("model", "")).strip().upper()
side = str(raw.get("side", "")).strip().lower()
# Keep the deployed G20 schema compatible while making the layout an
# explicit product choice for all new configurations.
layout = str(
raw.get(
"tag_layout",
G20_RIGHT_19_LAYOUT if (model, side) == ("G20", "right") else "",
)
).strip().lower()
contract = get_product_calibration_contract(model, side, layout)
can_interface = str(raw.get("can_interface", "")).strip()
if not can_interface:
raise ValueError("can_interface is required")
if check_can and not (Path("/sys/class/net") / can_interface).exists():
raise ValueError(f"CAN interface does not exist: {can_interface}")
artifacts = _mapping(raw.get("artifacts"), "artifacts")
source_urdf = _resolve_path(
artifacts.get("source_urdf"), workspace=root, name="source_urdf"
)
extrinsics = _resolve_path(
artifacts.get("camera_extrinsics"),
workspace=root,
name="camera_extrinsics",
)
calibration_config = _resolve_path(
artifacts.get("calibration_config"),
workspace=root,
name="calibration_config",
)
tag_config = _resolve_path(
artifacts.get("tag_config"), workspace=root, name="tag_config"
)
for name, candidate in (
("source URDF", source_urdf),
("camera extrinsics", extrinsics),
("calibration config", calibration_config),
("Tag config", tag_config),
):
if not candidate.is_file():
raise ValueError(f"{name} does not exist: {candidate}")
expected_source_hash = str(artifacts.get("source_urdf_sha256", "")).lower()
expected_extrinsics_hash = str(
artifacts.get("camera_extrinsics_sha256", "")
).lower()
expected_tag_hash = str(artifacts.get("tag_config_sha256", "")).lower()
expected_calibration_hash = str(
artifacts.get("calibration_config_sha256", "")
).lower()
hashes = (
("source URDF", source_urdf, expected_source_hash),
("camera extrinsics", extrinsics, expected_extrinsics_hash),
("Tag config", tag_config, expected_tag_hash),
("calibration config", calibration_config, expected_calibration_hash),
)
for name, candidate, expected in hashes:
if re.fullmatch(r"[0-9a-f]{64}", expected) is None:
raise ValueError(f"{name} expected SHA-256 is invalid")
actual = sha256_file(candidate)
if actual != expected:
raise ValueError(f"{name} SHA-256 mismatch: expected={expected} actual={actual}")
expected_tag_ids = set(contract.required_tag_ids)
tag_sizes = _tag_sizes_m_by_id(tag_config)
if set(tag_sizes) != expected_tag_ids:
raise ValueError(
f"{model}/{side}/{layout} Tag IDs differ from the registered "
f"profile: expected={sorted(expected_tag_ids)} "
f"actual={sorted(tag_sizes)}"
)
if _custom_pnp_tag_sizes_m_by_id(
calibration_config, expected_tag_ids
) != tag_sizes:
raise ValueError(
"custom PnP Tag sizes do not match the apriltag_ros Tag config"
)
camera_raw = _mapping(raw.get("cameras"), "cameras")
required_views = tuple(contract.views)
if set(required_views) != set(VIEWS):
raise ValueError(
"the current shared engine requires front/side/top views"
)
if set(camera_raw) != set(required_views):
raise ValueError(f"cameras must contain {'/'.join(required_views)}")
loaded_extrinsics = load_three_camera_extrinsics(extrinsics)
cameras: dict[str, dict[str, str]] = {}
serials: set[str] = set()
for view in required_views:
item = _mapping(camera_raw[view], f"cameras.{view}")
camera_serial = str(item.get("serial_number", ""))
info_path = _resolve_path(
item.get("camera_info"), workspace=root, name=f"{view}.camera_info"
)
if not info_path.is_file():
raise ValueError(f"{view} camera info does not exist: {info_path}")
if camera_serial in serials:
raise ValueError("the three camera serial numbers must be unique")
serials.add(camera_serial)
identity = loaded_extrinsics.cameras[view]
fingerprint = _camera_info_fingerprint(info_path)
if camera_serial != identity.serial_number:
raise ValueError(f"{view} camera serial does not match extrinsics")
if fingerprint != identity.intrinsics_sha256:
raise ValueError(f"{view} camera intrinsics do not match extrinsics")
cameras[view] = {
"serial_number": camera_serial,
"camera_info": str(info_path),
"camera_name": str(item.get("camera_name", f"hikrobot_{view}_{camera_serial}")),
}
output_root = _resolve_path(
raw.get("output_root", "calibration_output"),
workspace=root,
name="output_root",
)
release = _mapping(raw.get("release", {}), "release")
passes = int(release.get("required_independent_passes", 2))
if passes not in {1, 2}:
raise ValueError("required_independent_passes must be 1 or 2")
repeatability_deg = float(release.get("static_repeatability_deg", 1.0))
if not 0.0 < repeatability_deg <= 5.0:
raise ValueError("static_repeatability_deg must be in (0, 5]")
return ProductConfig(
path=source,
workspace=root,
model=model,
side=side,
tag_layout=layout,
calibration_contract=contract,
serial_number=serial,
can_interface=can_interface,
source_urdf=source_urdf,
source_urdf_sha256=expected_source_hash,
camera_extrinsics=extrinsics,
camera_extrinsics_sha256=expected_extrinsics_hash,
calibration_config=calibration_config,
calibration_config_sha256=expected_calibration_hash,
tag_config=tag_config,
tag_config_sha256=expected_tag_hash,
output_root=output_root,
cameras=cameras,
required_independent_passes=passes,
static_repeatability_rad=repeatability_deg * 3.141592653589793 / 180.0,
)
@@ -0,0 +1,890 @@
"""Validate and atomically publish one inseparable G20 JSON/URDF session."""
from __future__ import annotations
from datetime import datetime, timezone
import copy
import json
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.spatial.transform import Rotation
from .full_hand import (
G20_COMBINATION_REQUIRED_TARGET_KEYS,
G20_RIGHT_19_LAYOUT,
get_hand_calibration_profile,
validate_compact_payload,
)
from .product import ProductConfig, sha256_file
from .storage import atomic_write_json
from .urdf_zero import (
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
get_zero_calibration_profile,
)
PASSIVE_JOINTS = frozenset(
{"thumb_ip", "index_dip", "middle_dip", "ring_dip", "pinky_dip"}
)
VISUALLY_MEASURED_FINGER_DIPS = frozenset(
{"index_dip", "middle_dip", "ring_dip", "pinky_dip"}
)
ACTIVE_ZERO_JOINTS = frozenset(
get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT).direct_zero_joints
)
RETAINED_ACTIVE_ZERO_JOINTS = frozenset(
get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT).active_joints
) - ACTIVE_ZERO_JOINTS
SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD = 5.1e-9
def _load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as stream:
payload = json.load(stream)
if not isinstance(payload, dict):
raise ValueError(f"{path} must contain a JSON object")
return payload
def _joint_blocks(text: str) -> dict[str, str]:
pattern = re.compile(
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
re.DOTALL,
)
return {match.group("name"): match.group(0) for match in pattern.finditer(text)}
def _mask_origin_rpy(block: str) -> str:
return re.sub(
r"(<origin\b[^>]*\brpy\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_RPY__\2",
block,
count=1,
)
def _mask_active_origin_rpy_fields(text: str) -> str:
pattern = re.compile(
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
re.DOTALL,
)
def replace(match: re.Match[str]) -> str:
block = match.group(0)
if match.group("name") not in ACTIVE_ZERO_JOINTS:
return block
masked = _mask_origin_rpy(block)
if masked == block:
raise ValueError(
f"active joint {match.group('name')} has no origin.rpy"
)
return masked
return pattern.sub(replace, text)
def _mask_endpoint_coordinate_fields(text: str) -> str:
"""Mask only limit/mimic fields induced by endpoint zero coordinates."""
pattern = re.compile(
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
re.DOTALL,
)
def replace(match: re.Match[str]) -> str:
block = match.group(0)
name = match.group("name")
if name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS:
return re.sub(
r"(<limit\b[^>]*\bupper\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_UPPER__\2",
block,
count=1,
)
mimic = re.search(
r"<mimic\b[^>]*\bjoint\s*=\s*([\"'])(?P<source>[^\"']+)\1[^>]*>",
block,
re.DOTALL,
)
if (
mimic is not None
and mimic.group("source") in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
):
return re.sub(
r"(<mimic\b[^>]*\boffset\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_MIMIC_OFFSET__\2",
block,
count=1,
)
return block
return pattern.sub(replace, text)
def _triplet(value: str) -> np.ndarray:
result = np.asarray([float(item) for item in value.split()], dtype=float)
if result.shape != (3,) or not np.all(np.isfinite(result)):
raise ValueError(f"invalid URDF triplet: {value}")
return result
def _joint_elements(path: str | Path) -> dict[str, ET.Element]:
return {
str(joint.get("name")): joint
for joint in ET.parse(Path(path)).getroot().findall("joint")
}
def _verify_expected_origin_offsets(
source: str | Path,
corrected: str | Path,
expected_offsets_rad: Mapping[str, float],
) -> None:
offsets = {str(name): float(value) for name, value in expected_offsets_rad.items()}
if set(offsets) != ACTIVE_ZERO_JOINTS or any(
not math.isfinite(value) for value in offsets.values()
):
raise ValueError("expected offsets must contain all finite active static-zero values")
before = _joint_elements(source)
after = _joint_elements(corrected)
maximum_rotation_error = 0.0
for name, original_joint in before.items():
corrected_joint = after[name]
original_origin = original_joint.find("origin")
corrected_origin = corrected_joint.find("origin")
if original_origin is None or corrected_origin is None:
if original_origin is not corrected_origin:
raise ValueError(f"corrected URDF changed origin presence for {name}")
continue
original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
if not np.allclose(original_xyz, corrected_xyz, atol=1.0e-12, rtol=0.0):
raise ValueError(f"corrected URDF changed origin.xyz for {name}")
original_rotation = Rotation.from_euler(
"xyz", _triplet(original_origin.get("rpy", "0 0 0"))
)
corrected_rotation = Rotation.from_euler(
"xyz", _triplet(corrected_origin.get("rpy", "0 0 0"))
)
expected_rotation = original_rotation
if name in offsets:
axis_node = original_joint.find("axis")
axis = _triplet(
"1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0")
)
norm = float(np.linalg.norm(axis))
if norm <= 1.0e-12:
raise ValueError(f"source URDF joint {name} has a degenerate axis")
expected_rotation = expected_rotation * Rotation.from_rotvec(
axis / norm * offsets[name]
)
error = float((expected_rotation.inv() * corrected_rotation).magnitude())
maximum_rotation_error = max(maximum_rotation_error, error)
# Schema v4 intentionally stores zero offsets at eight decimal places.
# Older sessions wrote the corrected URDF from the full-precision solve,
# so comparing that file with the published JSON necessarily permits half
# of one last-place unit. This is about 2.9e-7 degrees and is far below
# any calibration or URDF numerical significance.
if maximum_rotation_error > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD:
raise ValueError(
"corrected URDF origin.rpy does not match the published zero offsets"
)
def verify_corrected_urdf(
source: str | Path,
corrected: str | Path,
*,
expected_offsets_rad: Mapping[str, float] | None = None,
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
) -> tuple[str, ...]:
"""Prove that only active-joint origin.rpy attributes changed.
Passive joint blocks, including their mimic elements, are compared as raw
UTF-8 text so formatting and numeric spelling are protected as well.
"""
source_text = Path(source).read_text(encoding="utf-8")
corrected_text = Path(corrected).read_text(encoding="utf-8")
before = _joint_blocks(source_text)
after = _joint_blocks(corrected_text)
if set(before) != set(after):
raise ValueError("corrected URDF changed the joint set")
# Text patchers may normalize only the final newline. It has no URDF/XML
# semantics; every joint block and every non-rpy byte remains guarded
# below.
masked_source = _mask_endpoint_coordinate_fields(
_mask_active_origin_rpy_fields(source_text)
).rstrip("\r\n")
masked_corrected = _mask_endpoint_coordinate_fields(
_mask_active_origin_rpy_fields(corrected_text)
).rstrip("\r\n")
if masked_source != masked_corrected:
raise ValueError(
"corrected URDF changed content outside active origin.rpy or "
"endpoint-calibrated fields"
)
changed: list[str] = []
for name in before:
if before[name] == after[name]:
continue
if name not in ACTIVE_ZERO_JOINTS and name not in PASSIVE_JOINTS:
raise ValueError(f"corrected URDF changed protected joint {name}")
if name in ACTIVE_ZERO_JOINTS:
changed.append(name)
if expected_offsets_rad is not None:
_verify_expected_origin_offsets(source, corrected, expected_offsets_rad)
endpoint_offsets = {
str(name): float(value)
for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
}
if endpoint_offsets:
source_joints = _joint_elements(source)
corrected_joints = _joint_elements(corrected)
for name, offset in endpoint_offsets.items():
original_limit = source_joints[name].find("limit")
corrected_limit = corrected_joints[name].find("limit")
expected_upper = float(original_limit.get("upper")) - offset
# Schema v4 stores the corresponding zero at eight decimal
# places, while the URDF is written from the full-precision solve.
# Match the half-last-place tolerance used for origin rotations.
if (
abs(float(corrected_limit.get("upper")) - expected_upper)
> SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
):
raise ValueError(f"corrected URDF has invalid {name} upper limit")
for name in PASSIVE_JOINTS:
original_mimic = source_joints[name].find("mimic")
corrected_mimic = corrected_joints[name].find("mimic")
if original_mimic is None or corrected_mimic is None:
continue
source_name = str(original_mimic.get("joint"))
multiplier = float(original_mimic.get("multiplier", "1"))
expected = float(original_mimic.get("offset", "0"))
expected += multiplier * endpoint_offsets.get(source_name, 0.0)
# The active endpoint offset comes from schema-v4 JSON rounded to
# eight decimal places, while the URDF mimic was written from the
# full-precision solve. Propagate exactly the same accepted
# quantization through the mimic multiplier; retain a much
# smaller allowance for XML decimal formatting itself.
tolerance = (
abs(multiplier)
* SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
+ 1.0e-12
)
if abs(float(corrected_mimic.get("offset", "0")) - expected) > tolerance:
raise ValueError(f"corrected URDF has invalid {name} mimic offset")
return tuple(sorted(changed))
def verify_urdf_mesh_resources(urdf: str | Path) -> dict[str, Path]:
"""Return every session-local mesh after proving it resolves safely."""
path = Path(urdf).expanduser().resolve()
root = ET.parse(path).getroot()
resources: dict[str, Path] = {}
for mesh in root.findall(".//mesh"):
filename = str(mesh.get("filename", "")).strip()
if not filename or "://" in filename or filename.startswith("package:"):
continue
relative = Path(filename)
if relative.is_absolute() or ".." in relative.parts:
raise ValueError(f"URDF has an unsafe local mesh path: {filename}")
resolved = (path.parent / relative).resolve()
try:
resolved.relative_to(path.parent)
except ValueError as error:
raise ValueError(f"URDF mesh escapes the session: {filename}") from error
if not resolved.is_file():
raise ValueError(f"URDF mesh resource is missing: {filename}")
resources[relative.as_posix()] = resolved
return dict(sorted(resources.items()))
def validate_runtime_curves_against_urdf_limits(
payload: Mapping[str, Any], runtime_urdf: str | Path
) -> None:
"""Reject a curve whose q leaves its runtime URDF coordinate limits.
Curves in schema v4 are expressed in the corrected URDF joint coordinate,
not in the source-CAD coordinate. Endpoint zero calibration can therefore
move a corrected coordinate limit while preserving the same physical CAD
endpoint; callers publishing a calibrated pair must pass that corrected
URDF here.
"""
validate_compact_payload(payload)
runtime_joints = _joint_elements(runtime_urdf)
for name, calibration in payload["joints"].items():
joint = runtime_joints.get(str(name))
if joint is None:
raise ValueError(f"runtime URDF is missing joint {name}")
limit = joint.find("limit")
if limit is None or limit.get("lower") is None or limit.get("upper") is None:
raise ValueError(f"runtime URDF joint {name} has no finite position limit")
lower = float(limit.get("lower"))
upper = float(limit.get("upper"))
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
raise ValueError(f"runtime URDF joint {name} has invalid position limits")
curve = np.asarray(calibration["angle_rad"], dtype=float)
minimum = float(np.min(curve))
maximum = float(np.max(curve))
tolerance = 1.0e-7
if minimum < lower - tolerance or maximum > upper + tolerance:
raise ValueError(
f"runtime curve exceeds runtime URDF limit for {name}: "
f"[{minimum:.9g}, {maximum:.9g}] not within "
f"[{lower:.9g}, {upper:.9g}]"
)
def clamp_compact_payload_to_urdf_limits(
payload: Mapping[str, Any], runtime_urdf: str | Path
) -> tuple[dict[str, Any], dict[str, int]]:
"""Return a schema-preserving payload bounded in its runtime coordinates."""
result = copy.deepcopy(dict(payload))
validate_compact_payload(result)
runtime_joints = _joint_elements(runtime_urdf)
clipped_by_joint: dict[str, int] = {}
for name, calibration in result["joints"].items():
joint = runtime_joints.get(str(name))
limit = None if joint is None else joint.find("limit")
if (
limit is None
or limit.get("lower") is None
or limit.get("upper") is None
):
raise ValueError(f"runtime URDF joint {name} has no finite position limit")
lower = float(limit.get("lower"))
upper = float(limit.get("upper"))
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
raise ValueError(f"runtime URDF joint {name} has invalid position limits")
source = np.asarray(calibration["angle_rad"], dtype=float)
bounded = np.clip(source, lower, upper)
count = int(np.count_nonzero(bounded != source))
if count:
clipped_by_joint[str(name)] = count
calibration["angle_rad"] = [
round(float(value), 8) for value in bounded
]
validate_compact_payload(result)
return result, clipped_by_joint
def build_mujoco_validation_commands(
baseline: Sequence[int],
) -> dict[str, Any]:
if len(baseline) != 20:
raise ValueError("baseline must contain exactly 20 commands")
base = [int(value) for value in baseline]
if any(not 0 <= value <= 255 for value in base):
raise ValueError("baseline commands must be in [0, 255]")
def pose(name: str, label: str, changes: Mapping[int, int]) -> dict[str, Any]:
values = list(base)
for index, value in changes.items():
values[int(index)] = int(value)
return {"name": name, "label_zh": label, "command_u8": values}
return {
"schema_version": 1,
"topic": "/g20/cb_right_hand_control_cmd",
"serial_number": "G20_RIGHT_001",
"poses": [
pose("all_open", "全开", {}),
pose("thumb_middle", "拇指中位", {0: 160, 5: 160, 10: 160, 15: 160}),
pose("index_middle", "食指中位", {1: 160, 6: 127, 16: 160}),
pose("middle_middle", "中指中位", {2: 160, 7: 127, 17: 160}),
pose("ring_middle", "无名指中位", {3: 160, 8: 127, 18: 160}),
pose("pinky_middle", "小指中位", {4: 160, 9: 127, 19: 160}),
pose(
"half_grip",
"四指半握",
{1: 160, 2: 160, 3: 160, 4: 160, 16: 160, 17: 160, 18: 160, 19: 160},
),
pose(
"light_pinch",
"轻捏",
{0: 176, 5: 176, 10: 176, 15: 176, 1: 176, 6: 127, 16: 176},
),
],
}
def atomic_session_pointer(root: str | Path, name: str, session: str | Path) -> Path:
parent = Path(root).resolve()
target = Path(session).resolve()
if target.parent != parent:
raise ValueError("session pointer target must be a direct child of the serial root")
if not target.is_dir():
raise ValueError(f"session directory does not exist: {target}")
if name not in {"latest_attempt", "latest_passed", "latest_thumb_passed"}:
raise ValueError("unsupported session pointer name")
parent.mkdir(parents=True, exist_ok=True)
destination = parent / name
temporary = parent / f".{name}.{os.getpid()}.tmp"
if temporary.is_symlink():
temporary.unlink()
elif temporary.exists():
raise ValueError(f"temporary pointer path is occupied: {temporary}")
os.symlink(target.name, temporary, target_is_directory=True)
os.replace(temporary, destination)
return destination
def active_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
validate_compact_payload(payload)
result = {
name: float(payload["joints"][name]["zero_angles"]["urdf_zero_offset_rad"])
for name in ACTIVE_ZERO_JOINTS
}
if set(result) != ACTIVE_ZERO_JOINTS or any(
not math.isfinite(value) for value in result.values()
):
raise ValueError(
"payload does not contain all finite observable active zero offsets"
)
return result
def standalone_thumb_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
"""Validate a standalone thumb artifact and expand CAD finger zeros."""
if (
payload.get("schema_version") != 1
or payload.get("artifact_type")
!= "g20_right_standalone_thumb_calibration"
or payload.get("side") != "right"
or payload.get("non_thumb_zero_policy") != "source_cad_unchanged"
or not bool(payload.get("quality", {}).get("passed"))
):
raise ValueError("standalone thumb JSON contract is invalid")
joints = payload.get("joints")
expected_joint_names = {
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
"thumb_ip",
}
if not isinstance(joints, Mapping) or set(joints) != expected_joint_names:
raise ValueError("standalone thumb JSON has the wrong joint set")
thumb_names = expected_joint_names - {"thumb_ip"}
result = {name: 0.0 for name in ACTIVE_ZERO_JOINTS}
for name in thumb_names:
try:
value = float(joints[name]["zero_angles"]["urdf_zero_offset_rad"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError(
f"standalone thumb JSON is missing zero for {name}"
) from error
curve = np.asarray(joints[name].get("angle_rad", ()), dtype=float)
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
raise ValueError(f"standalone thumb JSON curve is invalid: {name}")
result[name] = value
passive_curve = np.asarray(
joints["thumb_ip"].get("angle_rad", ()), dtype=float
)
if (
passive_curve.shape != (256,)
or not np.all(np.isfinite(passive_curve))
or not bool(joints["thumb_ip"].get("passive"))
or any(not math.isfinite(value) for value in result.values())
):
raise ValueError("standalone thumb JSON passive curve is invalid")
return result
def verify_partial_scope_preserves_certified_zeros(
*,
scope: str,
source_session: str | Path,
serial_root: str | Path | None = None,
serial_number: str,
current_offsets: Mapping[str, float],
) -> None:
"""Reject a partial artifact that changes any certified non-target zero."""
selected = str(scope).strip().lower()
if selected not in {"thumb", "fingers"}:
return
source = Path(source_session).expanduser()
if not source.is_absolute():
if serial_root is None:
raise ValueError(
"relative partial base session requires its serial root"
)
source = Path(serial_root).expanduser() / source
source = source.resolve()
base_payload = _load_json(
source / f"g20_right_{serial_number}_calibration.json"
)
base_offsets = (
standalone_thumb_offsets(base_payload)
if base_payload.get("artifact_type")
== "g20_right_standalone_thumb_calibration"
else active_offsets(base_payload)
)
preserve_thumb = selected == "fingers"
changed = {
name: abs(float(current_offsets[name]) - float(base_offsets[name]))
for name in ACTIVE_ZERO_JOINTS
if name.startswith("thumb_") == preserve_thumb
and abs(float(current_offsets[name]) - float(base_offsets[name]))
> SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
}
if changed:
raise ValueError(
f"partial {selected} calibration changed certified non-target "
"zeros: "
+ ",".join(sorted(changed))
)
def compare_session_offsets(
first: Mapping[str, Any],
second: Mapping[str, Any],
*,
maximum_difference_rad: float,
) -> dict[str, float]:
first_standalone = (
first.get("artifact_type")
== "g20_right_standalone_thumb_calibration"
)
second_standalone = (
second.get("artifact_type")
== "g20_right_standalone_thumb_calibration"
)
if first_standalone != second_standalone:
raise ValueError("cannot compare standalone thumb and full-hand artifacts")
left = (
standalone_thumb_offsets(first)
if first_standalone
else active_offsets(first)
)
right = (
standalone_thumb_offsets(second)
if second_standalone
else active_offsets(second)
)
differences = {
name: abs(left[name] - right[name])
for name in sorted(left)
}
failed = {name: value for name, value in differences.items() if value > maximum_difference_rad}
if failed:
details = ", ".join(
f"{name}={math.degrees(value):.3f}deg" for name, value in failed.items()
)
raise ValueError(f"independent-session static-zero mismatch: {details}")
return differences
def session_artifact_paths(session: str | Path, serial_number: str) -> dict[str, Path]:
directory = Path(session).resolve()
urdfs = sorted(directory.glob(f"*zero_calibrated_{serial_number}_*.urdf"))
if len(urdfs) != 1:
raise ValueError("session must contain exactly one corrected URDF")
return {
"json": directory / f"g20_right_{serial_number}_calibration.json",
"urdf": urdfs[0],
"summary": directory / "calibration_summary_zh.json",
"commands": directory / "mujoco_validation_commands.json",
"raw": directory / "raw_samples.jsonl",
"log": directory / "calibration.log",
}
def find_compatible_prior_session(
config: ProductConfig,
current: str | Path,
current_payload: Mapping[str, Any],
) -> tuple[Path | None, dict[str, float]]:
current_path = Path(current).resolve()
for candidate in sorted(config.session_root.glob("20??????_??????"), reverse=True):
if candidate.resolve() == current_path:
continue
json_path = candidate / f"g20_right_{config.serial_number}_calibration.json"
summary_path = candidate / "calibration_summary_zh.json"
if not json_path.is_file() or not summary_path.is_file():
continue
try:
summary = _load_json(summary_path)
if not bool(summary.get("quality", {}).get("passed")):
continue
payload = _load_json(json_path)
differences = compare_session_offsets(
payload,
current_payload,
maximum_difference_rad=config.static_repeatability_rad,
)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
continue
return candidate, differences
return None, {}
def _verify_combination_validation(combination: Mapping[str, Any]) -> None:
# The formal product uses the independently held-out fourth sweep cycle.
# The optional eight-pose check remains a developer diagnostic because the
# axis-line zero solve does not establish an absolute Cartesian hand-base
# transform. When explicitly enabled, retain its strict coverage and
# error checks.
if not bool(combination.get("enabled")):
return
position_p95 = float(combination.get("position_p95_m", float("inf")))
orientation_p95 = float(
combination.get("orientation_p95_rad", float("inf"))
)
required = {str(value) for value in combination.get("required_targets", ())}
expected = set(G20_COMBINATION_REQUIRED_TARGET_KEYS)
observations = combination.get("observation_counts")
validations = combination.get("validation_counts")
if required != expected:
raise ValueError("combination validation has the wrong required-target set")
if not isinstance(observations, Mapping) or not isinstance(validations, Mapping):
raise ValueError("combination validation is missing per-target coverage")
missing_observations = sorted(
key for key in expected if int(observations.get(key, 0)) < 2
)
missing_validations = sorted(
key for key in expected if int(validations.get(key, 0)) < 1
)
if missing_observations or missing_validations:
raise ValueError(
"combination validation target coverage is incomplete: "
f"observations={','.join(missing_observations) or '-'}; "
f"validations={','.join(missing_validations) or '-'}"
)
if (
not bool(combination.get("completed"))
or int(combination.get("completed_poses", 0)) != 8
or position_p95 > 0.003
or orientation_p95 > math.radians(2.0)
):
raise ValueError(
"eight-pose four-finger combination validation is incomplete or failed"
)
def finalize_session_artifacts(
config: ProductConfig,
session: str | Path,
*,
node_status: Mapping[str, Any],
) -> tuple[dict[str, Any], bool]:
"""Create diagnostics and update latest_passed only after every guard."""
directory = Path(session).resolve()
paths = session_artifact_paths(directory, config.serial_number)
for name in ("json", "urdf", "raw", "log"):
if not paths[name].is_file():
raise ValueError(f"session is missing {name}: {paths[name]}")
payload = _load_json(paths["json"])
standalone_thumb = bool(
payload.get("artifact_type")
== "g20_right_standalone_thumb_calibration"
)
if standalone_thumb:
standalone_thumb_offsets(payload)
else:
validate_compact_payload(payload)
if payload.get("schema_version") != 4 or payload.get("side") != "right":
raise ValueError("runtime JSON is not the compact right-hand schema v4")
if not bool(payload.get("quality", {}).get("passed")):
raise ValueError("runtime JSON quality is not passed")
combination = node_status.get("combination_validation")
if not isinstance(combination, Mapping):
raise ValueError("node status is missing combination validation")
_verify_combination_validation(combination)
resume = node_status.get("resume", {})
if not isinstance(resume, Mapping):
raise ValueError("node status has invalid resume provenance")
calibration_scope = str(
resume.get("recalibration_scope", "full")
).strip().lower()
if calibration_scope not in {"full", "thumb", "fingers"}:
raise ValueError("node status has an unsupported recalibration scope")
recalibration_tasks = tuple(
str(value) for value in resume.get("recalibration_task_keys", ())
)
if calibration_scope == "thumb" and not standalone_thumb and (
not bool(resume.get("used"))
or not str(resume.get("source_session", ""))
or len(recalibration_tasks) != 4
or any("thumb_" not in name for name in recalibration_tasks)
):
raise ValueError(
"thumb recalibration is missing its passed base-session provenance"
)
if calibration_scope == "fingers" and (
not bool(resume.get("used"))
or not str(resume.get("source_session", ""))
or len(recalibration_tasks) != 12
or any("thumb_" in name for name in recalibration_tasks)
):
raise ValueError(
"finger recalibration is missing its certified thumb-session "
"provenance"
)
if standalone_thumb and calibration_scope != "thumb":
raise ValueError("standalone thumb artifact has the wrong node scope")
offsets = (
standalone_thumb_offsets(payload)
if standalone_thumb
else active_offsets(payload)
)
if calibration_scope != "full" and not standalone_thumb:
verify_partial_scope_preserves_certified_zeros(
scope=calibration_scope,
source_session=str(resume.get("source_session", "")),
serial_root=directory.parent,
serial_number=config.serial_number,
current_offsets=offsets,
)
endpoint_offsets = {
name: offsets[name]
for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
}
changed_joints = verify_corrected_urdf(
config.source_urdf,
paths["urdf"],
expected_offsets_rad=offsets,
endpoint_anchored_offsets_rad=endpoint_offsets,
)
if standalone_thumb:
clipped_runtime_joints = {}
else:
payload, clipped_runtime_joints = clamp_compact_payload_to_urdf_limits(
payload, paths["urdf"]
)
if clipped_runtime_joints:
atomic_write_json(paths["json"], payload)
validate_runtime_curves_against_urdf_limits(payload, paths["urdf"])
mesh_resources = verify_urdf_mesh_resources(paths["urdf"])
mesh_hashes = {
name: sha256_file(path) for name, path in mesh_resources.items()
}
commands = (
{
"artifact_type": "standalone_thumb_no_full_hand_validation",
"poses": [],
}
if standalone_thumb
else build_mujoco_validation_commands(payload["baseline_command_u8"])
)
atomic_write_json(paths["commands"], commands)
prior, differences = find_compatible_prior_session(config, directory, payload)
release_ready = config.required_independent_passes == 1 or prior is not None
quality = dict(payload["quality"])
preserved_certified_zeros = sorted(
name
for name in ACTIVE_ZERO_JOINTS
if (
calibration_scope == "thumb"
and not name.startswith("thumb_")
)
or (
calibration_scope == "fingers"
and name.startswith("thumb_")
)
)
if standalone_thumb:
preserved_certified_zeros = []
calibrated_zero_joints = (
sorted(
name for name in ACTIVE_ZERO_JOINTS if name.startswith("thumb_")
)
if standalone_thumb
else sorted(ACTIVE_ZERO_JOINTS)
)
summary: dict[str, Any] = {
"schema_version": 1,
"serial_number": config.serial_number,
"session_id": f"{config.serial_number}_{directory.name}",
"calibration_scope": calibration_scope,
"inherited_base_session": (
None
if calibration_scope == "full" or standalone_thumb
else str(resume.get("source_session"))
),
"freshly_calibrated_task_keys": list(recalibration_tasks),
"preserved_certified_zero_joints": preserved_certified_zeros,
"result": "PASS" if release_ready else "PASS_AWAITING_SECOND_SESSION",
"quality": quality,
"runtime_limit_clipped_bins": clipped_runtime_joints,
"runtime_curve_domain": (
"thumb_diagnostic_only"
if standalone_thumb
else "requested_command_u8"
),
"static_zero_calibrated_joints": calibrated_zero_joints,
"non_thumb_zero_policy": (
"source_cad_unchanged" if standalone_thumb else None
),
"retained_active_urdf_zero_joints": sorted(
RETAINED_ACTIVE_ZERO_JOINTS
),
"retained_passive_urdf_joints": sorted(PASSIVE_JOINTS),
"mimic_derived_dynamic_joints": [],
"visually_measured_passive_joints": sorted(
VISUALLY_MEASURED_FINGER_DIPS
),
"changed_urdf_joint_origins": list(changed_joints),
"hashes": {
"source_urdf_sha256": config.source_urdf_sha256,
"camera_extrinsics_sha256": config.camera_extrinsics_sha256,
"calibration_config_sha256": config.calibration_config_sha256,
"corrected_urdf_sha256": sha256_file(paths["urdf"]),
"mesh_resources_sha256": mesh_hashes,
"calibration_json_sha256": sha256_file(paths["json"]),
"raw_samples_sha256": sha256_file(paths["raw"]),
},
"holdout": {
"training_cycles": [0, 1, 2],
"validation_cycle": 3,
"isolated": True,
},
"combination_validation": dict(combination),
"formal_release": {
"passed": release_ready,
"required_independent_passes": config.required_independent_passes,
"comparison_session": None if prior is None else prior.name,
"maximum_static_difference_deg": (
None if not differences else math.degrees(max(differences.values()))
),
},
"node_status": dict(node_status),
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"artifacts": {name: path.name for name, path in paths.items()},
}
atomic_write_json(paths["summary"], summary)
# Recompute hashes after all files exist and validate the pair once more
# immediately before the one atomic publication operation.
if sha256_file(config.source_urdf) != config.source_urdf_sha256:
raise ValueError("source URDF changed during calibration")
verify_corrected_urdf(
config.source_urdf,
paths["urdf"],
expected_offsets_rad=offsets,
endpoint_anchored_offsets_rad=endpoint_offsets,
)
# Revalidate the same coordinate contract immediately before publication:
# verify_corrected_urdf proves that corrected endpoint limits map back to
# the original physical CAD endpoints, while the runtime curves must stay
# inside those corrected-coordinate limits.
if not standalone_thumb:
validate_runtime_curves_against_urdf_limits(payload, paths["urdf"])
final_mesh_hashes = {
name: sha256_file(path)
for name, path in verify_urdf_mesh_resources(paths["urdf"]).items()
}
if final_mesh_hashes != mesh_hashes:
raise ValueError("URDF mesh resources changed during publication")
if release_ready:
atomic_session_pointer(
config.session_root,
"latest_thumb_passed" if standalone_thumb else "latest_passed",
directory,
)
return summary, release_ready
@@ -0,0 +1,192 @@
"""Canonical command/feedback schema for calibration observations.
The hand command and its measured motor feedback are different physical
domains. Durable samples always retain both. Fitting code may still use the
historical ``command_u8`` key, but it is created only as an explicit projection
of a canonical record at the fitting boundary.
"""
from __future__ import annotations
import math
from typing import Any, Iterable, Literal, Mapping
import numpy as np
SAMPLE_KINDS = frozenset(
{
'sample',
'baseline_hold_sample',
'steady_command_sample',
'palm_axis_sample',
}
)
FitDomain = Literal['default', 'requested', 'feedback']
class SampleDataContractError(ValueError):
"""A calibration observation mixes or omits command domains."""
def _finite_u8(value: Any, field: str, *, integral: bool) -> int | float:
try:
number = float(value)
except (TypeError, ValueError) as error:
raise SampleDataContractError(
f'DATA-CONTRACT-701:{field} must be numeric'
) from error
if not math.isfinite(number) or not 0.0 <= number <= 255.0:
raise SampleDataContractError(
f'DATA-CONTRACT-701:{field} must be finite and in [0, 255]'
)
if integral:
rounded = int(round(number))
if not math.isclose(number, rounded, rel_tol=0.0, abs_tol=1.0e-9):
raise SampleDataContractError(
f'DATA-CONTRACT-701:{field} must be an integer command'
)
return rounded
return number
def explicit_domain_value(
source: Mapping[str, Any], domain: Literal['requested', 'feedback']
) -> int | float:
"""Read and validate one explicitly named domain from any observation."""
field = (
'requested_command_u8' if domain == 'requested' else 'feedback_u8'
)
if field not in source or source[field] is None:
raise SampleDataContractError(
f'DATA-CONTRACT-701:observation is missing explicit {field}'
)
return _finite_u8(source[field], field, integral=domain == 'requested')
def canonical_sample_record(
source: Mapping[str, Any],
*,
allow_legacy_command: bool = False,
) -> dict[str, Any]:
"""Return one durable, unambiguous calibration observation.
``allow_legacy_command`` is restricted to importing historical sessions
and unit fixtures. New online observations must provide both explicit
fields and therefore cannot silently reinterpret ``command_u8``.
"""
record = dict(source)
kind = str(record.get('kind', ''))
if not kind and allow_legacy_command:
# Old in-memory steady-curve fixtures predate durable sample kinds.
# This adapter is never enabled by the new online/import contract.
kind = 'steady_command_sample'
record['kind'] = kind
if kind not in SAMPLE_KINDS:
raise SampleDataContractError(
f'DATA-CONTRACT-701:unsupported calibration sample kind {kind!r}'
)
requested = record.get('requested_command_u8')
feedback = record.get('feedback_u8')
legacy = record.get('command_u8')
if requested is None or feedback is None:
if not allow_legacy_command or legacy is None:
missing = [
name
for name, value in (
('requested_command_u8', requested),
('feedback_u8', feedback),
)
if value is None
]
raise SampleDataContractError(
'DATA-CONTRACT-701:'
f'{kind} is missing explicit {",".join(missing)}'
)
# Historical in-memory records used requested commands for settled
# checkpoints and feedback bins for dense/baseline/palm observations.
if requested is None:
requested = legacy
if feedback is None:
feedback = legacy
record.pop('command_u8', None)
record['requested_command_u8'] = explicit_domain_value(
{'requested_command_u8': requested}, 'requested'
)
record['feedback_u8'] = explicit_domain_value(
{'feedback_u8': feedback}, 'feedback'
)
return record
def fitting_sample_record(
source: Mapping[str, Any],
*,
domain: FitDomain = 'default',
allow_legacy_command: bool = False,
snap_requested_endpoints: bool = False,
) -> dict[str, Any]:
"""Project a canonical sample into the legacy curve-fitter interface."""
record = canonical_sample_record(
source, allow_legacy_command=allow_legacy_command
)
kind = str(record['kind'])
selected = domain
if selected == 'default':
selected = (
'requested' if kind == 'steady_command_sample' else 'feedback'
)
if selected not in {'requested', 'feedback'}:
raise SampleDataContractError(
f'DATA-CONTRACT-701:unsupported fitting domain {domain!r}'
)
requested = int(record['requested_command_u8'])
if selected == 'requested' or (
snap_requested_endpoints and requested in {0, 255}
):
index = requested
else:
index = int(
np.clip(np.rint(float(record['feedback_u8'])), 0, 255)
)
record['command_u8'] = index
return record
def fitting_sample_records(
records: Iterable[Mapping[str, Any]],
*,
domain: FitDomain = 'default',
allow_legacy_command: bool = False,
snap_requested_endpoints: bool = False,
) -> list[dict[str, Any]]:
"""Project several canonical samples into one explicit fitting domain."""
return [
fitting_sample_record(
record,
domain=domain,
allow_legacy_command=allow_legacy_command,
snap_requested_endpoints=snap_requested_endpoints,
)
for record in records
]
def validate_sample_records(
records: Iterable[Mapping[str, Any]],
*,
allow_legacy_command: bool = False,
) -> None:
"""Validate a collection without changing its representation."""
for index, record in enumerate(records):
try:
canonical_sample_record(
record, allow_legacy_command=allow_legacy_command
)
except SampleDataContractError as error:
raise SampleDataContractError(
f'{error};record_index={index}'
) from error
@@ -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():
@@ -0,0 +1,893 @@
"""Chinese, operator-facing diagnostics for three-camera calibration."""
from __future__ import annotations
import re
from typing import Any, Mapping, Sequence
STATE_NAMES_ZH = {
"PREFLIGHT": "设备和标签预检",
"WAIT_START": "等待开始标定",
"IMPORTING_BASE": "正在读取基础标定会话",
"REVALIDATING_INHERITED": "正在复核继承的四指数据",
"RETURN_BASELINE": "正在恢复目标姿态",
"PREPARE_SWEEP": "正在到达扫描起点",
"SWEEP": "正在采集轨迹",
"FITTING": "正在拟合轨迹和零位",
"VALIDATION_MOVE": "正在移动到随机复测位置",
"VALIDATION_CAPTURE": "正在采集随机复测数据",
"PAUSED": "标定已暂停",
"ABORTED": "标定已终止",
"COMPLETE": "标定已完成",
}
VIEW_NAMES_ZH = {
"front": "正面",
"side": "侧面",
"top": "上面",
}
JOINT_NAMES_ZH = {
"thumb_cmc_pitch": "拇指CMC俯仰",
"thumb_cmc_roll": "拇指CMC滚转",
"thumb_mcp": "拇指MCP",
"thumb_ip": "拇指IP(被动)",
"index_mcp_roll": "食指MCP侧摆",
"index_mcp_pitch": "食指MCP屈伸",
"index_pip": "食指PIP",
"index_dip": "食指DIP(被动)",
"middle_mcp_roll": "中指MCP侧摆",
"middle_mcp_pitch": "中指MCP屈伸",
"middle_pip": "中指PIP",
"middle_dip": "中指DIP(被动)",
"ring_mcp_roll": "无名指MCP侧摆",
"ring_mcp_pitch": "无名指MCP屈伸",
"ring_pip": "无名指PIP",
"ring_dip": "无名指DIP(被动)",
"pinky_mcp_roll": "小指MCP侧摆",
"pinky_mcp_pitch": "小指MCP屈伸",
"pinky_pip": "小指PIP",
"pinky_dip": "小指DIP(被动)",
"thumb_cmc_yaw": "拇指CMC侧摆",
"index_mcp_roll_side": "食指MCP侧摆(侧面校验)",
"middle_mcp_roll_side": "中指MCP侧摆(侧面校验)",
"ring_mcp_roll_side": "无名指MCP侧摆(侧面校验)",
"pinky_mcp_roll_side": "小指MCP侧摆(侧面校验)",
}
def _format_u8(value: Any) -> str:
if value is None:
return "尚无反馈"
return f"{float(value):.1f}"
def _task_text(active: Mapping[str, Any]) -> str:
if not active:
return "尚无活动任务"
view = VIEW_NAMES_ZH.get(str(active.get("view", "")), str(active.get("view", "")))
if active.get("kind") == "fit_failure":
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
return (
f"{view}机位,{joint_text}拟合检查失败,"
f"电机{active.get('motor_index')}"
f"{active.get('attempt', 1)}次尝试"
)
if active.get("kind") == "zero_model_failure":
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
return (
f"{view}机位,{joint_text}零位/URDF验证失败,"
f"电机{active.get('motor_index')},不会自动重扫"
)
if active.get("kind") == "motion_stall":
return (
f"电机{active.get('motor_index', '?')}运动停滞,目标"
f"{_format_u8(active.get('target_u8'))}、实际"
f"{_format_u8(active.get('actual_u8'))}"
)
if active.get("kind") == "cross_view_roll_diagnostic":
return (
f"{active.get('finger', '?')}侧摆跨机位诊断完成:"
f"正面最大{float(active.get('front_maximum_deg', 0.0)):.2f}°,"
f"侧面最大{float(active.get('side_maximum_deg', 0.0)):.2f}°"
)
if active.get("kind") == "validation":
return (
f"{view}机位,随机复测,电机{active.get('motor_index')}"
f"目标命令{active.get('command_u8')}"
)
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
start = active.get("start_u8")
target = active.get("target_u8")
cycle = active.get("cycle", "?")
repetitions = active.get("repetitions", "?")
task = (
f"{view}机位,{joint_text},电机{active.get('motor_index')}"
f"{cycle}/{repetitions}轮,{start}{target}"
)
fit_attempt = int(active.get("fit_attempt", 1))
if fit_attempt > 1:
retry_cycles = active.get("fit_retry_cycles", [])
if retry_cycles:
task += "(补采异常轮" + "/".join(
str(cycle) for cycle in retry_cycles
) + ""
else:
task += f"(拟合补采第{fit_attempt}次)"
return task
def three_camera_reason_zh(
state: str,
reason: str,
active: Mapping[str, Any],
) -> tuple[str, str]:
"""Translate a reason code and provide one concrete operator action."""
reason = str(reason)
sample = active.get("sample", {}) if active else {}
missing = [int(value) for value in sample.get("missing_endpoint_u8", [])]
sample_range = (
f"{_format_u8(sample.get('minimum_u8'))}"
f"{_format_u8(sample.get('maximum_u8'))}"
)
tolerance = sample.get("endpoint_tolerance_u8", "?")
if reason.startswith("motor_state_stalled:"):
fields = reason.split(":")
context = fields[1] if len(fields) > 1 else "unknown"
error_match = re.search(r"error_u8=([0-9.]+)", reason)
error = error_match.group(1) if error_match else "未知"
timeout_match = re.search(r"timeout_seconds=([0-9.]+)", reason)
timeout_value = active.get("timeout_seconds")
if timeout_value is None and timeout_match is not None:
timeout_value = float(timeout_match.group(1))
duration = (
f"连续{float(timeout_value):g}"
if timeout_value is not None
else "在规定时间内"
)
motor = active.get("motor_index")
if motor is not None:
return (
f"电机{motor}反馈{duration}没有向目标推进;目标"
f"{_format_u8(active.get('target_u8'))}、实际"
f"{_format_u8(active.get('actual_u8'))}、误差{error} u8"
f"允许容差±{_format_u8(active.get('tolerance_u8'))} u8"
f"(阶段={context})。程序已保持当前位置。",
"若实际反馈是稳定的固件端点,应只配置该电机该端点的专用容差后"
"重启;若仍在变化或有摩擦,则先排查机械问题,不要反复resume强推。",
)
return (
f"电机反馈{duration}没有向目标推进;停止位置距目标{error}个u8"
f"(阶段={context})。程序已保持当前位置,防止机械碰撞或摩擦加重。",
"检查该电机是否在机械端点稳定饱和或存在碰撞。若实际反馈已是该型号的"
"正常端点,应配置该电机专用端点容差后重启标定;不要反复调用resume强推。",
)
base_reason, separator, reason_detail = reason.partition(":")
if base_reason in {
"sweep_missing_endpoint_bin",
"sweep_bins_too_few",
"sweep_bin_gap_too_large",
"task_precheck_missing_command_127",
"task_precheck_detection_rate_too_low",
"synchronised_tag_state_timeout",
}:
reason = base_reason
detail_label = (
JOINT_NAMES_ZH.get(reason_detail, reason_detail)
if separator and reason_detail
else ""
)
detail_prefix = f"{detail_label}" if detail_label else ""
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 (
f"{detail_prefix}本方向已有{active.get('valid_frames', 0)}帧同步有效数据,但缺少"
f"电机端点{missing_text}附近的有效分箱;采样到的实际电机范围为"
f"{sample_range},端点容差为±{tolerance}。这通常表示电机虽然运动到"
"端点,但该时刻没有同时取得有效Tag图像和电机状态。",
"确认当前机位所需Tag在整个行程(尤其缺失端点)均可见,然后调用"
"/g20_calibration/resume;程序会重新扫描当前方向,不要调用start。",
)
if reason == "sweep_bins_too_few":
return (
f"{detail_prefix}有效电机分箱只有{sample.get('bin_count', 0)}个,要求至少"
f"{sample.get('minimum_bin_count', '?')}个;当前采样范围{sample_range}",
"检查Tag连续识别和电机状态频率,修正后调用resume重新扫描当前方向。",
)
if reason == "sweep_bin_gap_too_large":
gap_start = sample.get("maximum_bin_gap_start_u8")
gap_end = sample.get("maximum_bin_gap_end_u8")
gap_range = (
""
if gap_start is None or gap_end is None
else f"{gap_start}{gap_end}"
)
return (
f"{detail_prefix}轨迹相邻有效电机分箱的最大空缺为{sample.get('maximum_bin_gap', '?')}"
f"{gap_range}允许值不超过"
f"{sample.get('allowed_maximum_bin_gap', '?')}",
"检查运动中Tag是否间歇丢失;修正遮挡、反光或对焦后调用resume。",
)
if reason == "synchronised_tag_state_timeout":
group_reasons = active.get("group_pnp_reasons", {})
if isinstance(group_reasons, Mapping) and group_reasons:
tag_rejections = active.get("pnp_rejection_counts", {})
group_rejections = active.get(
"group_pnp_rejection_counts", {}
)
missing_roles = active.get(
"group_missing_candidate_roles", {}
)
candidate_diagnostics = active.get(
"pnp_candidate_diagnostics", {}
)
view_details: list[str] = []
for view, value in group_reasons.items():
view_name = str(view)
parts = [str(value)]
missing = (
missing_roles.get(view_name, ())
if isinstance(missing_roles, Mapping)
else ()
)
if isinstance(missing, Sequence) and not isinstance(
missing, (str, bytes)
) and missing:
parts.append(
"缺候选=" + ",".join(str(role) for role in missing)
)
counts: dict[str, int] = {}
for source in (tag_rejections, group_rejections):
values = (
source.get(view_name)
if isinstance(source, Mapping)
else None
)
if isinstance(values, Mapping):
for name, count in values.items():
counts[str(name)] = counts.get(str(name), 0) + int(
count
)
if counts:
common = sorted(
counts.items(), key=lambda pair: (-pair[1], pair[0])
)[:3]
parts.append(
"累计拒绝="
+ ",".join(
f"{name}×{count}" for name, count in common
)
)
view_candidates = (
candidate_diagnostics.get(view_name, {})
if isinstance(candidate_diagnostics, Mapping)
else {}
)
if isinstance(view_candidates, Mapping) and missing:
summaries: list[str] = []
for role in missing:
diagnostic = view_candidates.get(str(role), {})
if not isinstance(diagnostic, Mapping):
continue
summaries.append(
f"{role}(solve="
f"{int(diagnostic.get('solved_candidate_count', 0))},"
"reproj="
f"{int(diagnostic.get('reprojection_candidate_count', 0))},"
"tilt="
f"{int(diagnostic.get('independent_tilt_candidate_count', 0))})"
)
if summaries:
parts.append("候选统计=" + ",".join(summaries))
view_details.append(
f"{VIEW_NAMES_ZH.get(view_name, view_name)}="
+ "".join(parts)
)
reason_text = "".join(view_details)
return (
f"{detail_prefix}已经取得部分有效轨迹,但Tag仍可见且反馈正常时,"
"后续连续图像帧"
"被整组PnP几何检查拒绝"
f"{reason_text}),因此无法与电机状态形成有效轨迹帧。",
"不要调整或反复粘贴Tag;保留当前会话中的"
"group_pnp_candidate_event"
"按缺失角色的候选统计检查PnP分支逻辑。",
)
return (
f"{detail_prefix}运动过程中连续超过允许时间没有取得“所需Tag全部有效且能与电机状态"
"按时间戳配对”的图像帧。",
"查看下面活动机位的缺失Tag,确认状态话题仍在更新;修正后调用resume,"
"程序会重扫当前方向。",
)
if reason == "task_precheck_missing_command_127":
return (
f"{detail_prefix}低速预检没有取得反馈127附近的同步Tag样本。",
"检查中位姿态的Tag遮挡和反光;程序只会重扫当前物理任务。",
)
if reason == "task_precheck_detection_rate_too_low":
return (
f"{detail_prefix}低速预检的有效Tag识别率低于门限。",
"检查该机位当前任务Tag的遮挡、反光和对焦;程序只会重扫当前物理任务。",
)
if reason == "sweep_start_position_timeout":
return (
f"电机{active.get('motor_index')}未在规定时间到达扫描起点"
f"{active.get('start_u8')},当前实际值{_format_u8(active.get('actual_u8'))}",
"检查CAN、机械手使能和是否存在机械卡阻,确认安全后调用resume。",
)
if reason == "sweep_start_tag_timeout":
group_reasons = active.get("group_pnp_reasons", {})
progress_by_view = active.get("pnp_initialization_progress", {})
tag_rejections = active.get("pnp_rejection_counts", {})
group_rejections = active.get("group_pnp_rejection_counts", {})
if any(
isinstance(value, Mapping) and bool(value)
for value in (
group_reasons,
progress_by_view,
tag_rejections,
group_rejections,
)
):
details: list[str] = []
views = set()
for value in (
group_reasons,
progress_by_view,
tag_rejections,
group_rejections,
):
if isinstance(value, Mapping):
views.update(str(view) for view in value)
for view in sorted(views):
parts: list[str] = []
progress = (
progress_by_view.get(view)
if isinstance(progress_by_view, Mapping)
else None
)
if isinstance(progress, Mapping):
parts.append(
"初始化"
f"{int(progress.get('accepted', 0))}/"
f"{int(progress.get('required', 0))}"
)
counts: dict[str, int] = {}
for source in (tag_rejections, group_rejections):
values = (
source.get(view)
if isinstance(source, Mapping)
else None
)
if isinstance(values, Mapping):
for name, count in values.items():
counts[str(name)] = (
counts.get(str(name), 0) + int(count)
)
if counts:
common = sorted(
counts.items(), key=lambda pair: (-pair[1], pair[0])
)[:3]
parts.append(
"累计拒绝="
+ ",".join(
f"{name}×{count}" for name, count in common
)
)
latest = (
group_reasons.get(view)
if isinstance(group_reasons, Mapping)
else None
)
if latest and not str(latest).startswith(
"group_initializing:"
):
parts.append(f"最后状态={latest}")
if parts:
details.append(
f"{VIEW_NAMES_ZH.get(view, view)}=" + "".join(parts)
)
reason_text = "".join(details) or "未形成完整初始化窗口"
return (
"被测电机已经到达扫描起点,所需Tag也可见,但三维PnP位姿初始化"
f"没有完成({reason_text}),因此没有生成同步端点帧。",
"不要根据可见性重复粘贴Tag;保留累计拒绝原因并检查PnP候选选择。",
)
return (
"被测电机已经到达扫描起点,但当前任务所需的实时运动Tag没有形成足够的"
"同步有效帧。允许遮挡的固定掌部Tag会显示为“锁”,不会触发此错误。",
"只检查标记为✗的实时运动Tag、反光和外部遮挡;不要移动相机或手掌底座。",
)
if reason == "sweep_timeout":
return (
"当前方向在规定时间内未完成端点到达、有效帧数和行程覆盖要求。",
"检查电机实际值、Tag连续识别和标定速度,修正后调用resume。",
)
if reason == "return_baseline_timeout":
return (
"一个或多个标定电机未在规定时间返回基准命令。",
"检查机械手状态、CAN和机械卡阻,确认安全后调用resume。",
)
if reason == "validation_move_timeout":
return (
"随机复测时电机未在规定时间到达目标命令。",
"检查机械手状态和机械卡阻,确认安全后调用resume。",
)
if reason == "validation_capture_timeout":
return (
"随机复测位置没有采集到足够的同步有效Tag帧。",
"检查当前机位Tag可见性后调用resume。",
)
if reason == "palm_orientation_quality_failed":
failures = active.get("failures", [])
detail = (
str(failures[0].get("reason", "方向观测不足"))
if failures
else "方向观测不足"
)
if "thumb_cmc_" in detail:
return (
"拇指CMC yaw无法由顶部Tag 8/9的零位邻近短轨迹稳定确定:"
+ detail,
"保持Tag安装不变;确保顶部Tag 8/9在拇指CMC pitch和roll"
"从零位开始的前1/4行程持续可见后重新标定。",
)
return (
"掌部公共方向无法由至少三根手指的短时正面轨迹稳定确定:"
+ detail,
"保持Tag安装不变;让正面Tag 1013在对应MCP-pitch起始段"
"至少可见15°行程后重新标定。",
)
if reason in {"joint_fit_check_failed", "joint_fit_systematic_failure"}:
metric_names = {
"plane_rms_mm": "平面拟合RMS",
"radial_rms_mm": "圆半径拟合RMS",
"radius_mm": "拟合半径",
"image_radial_rms_px": "二维圆半径拟合RMS",
"image_radial_p95_px": "二维圆半径误差P95",
"image_radius_px": "二维拟合半径",
"arc_deg": "实测圆弧",
"monotonic_correction_deg": "最大单调修正",
"hysteresis_deg": "最大正反程差",
"baseline_hysteresis_deg": "baseline正反程关节角差",
"baseline_directional_gap_deg": "baseline方向分支间隙",
"baseline_directional_gap_range_deg": "baseline分支间隙跨轮极差",
"cycle_travel_range_deg": "三轮行程差",
"rotation_orthogonal_rms_deg": "三维旋转轴外残差RMS",
"axis_plane_rms_mm": "三维圆轴向RMS",
"axis_radial_rms_mm": "三维圆半径RMS",
"axis_pose_line_rms_mm": "姿态轨迹轴线RMS",
"axis_line_cycle_rms_mm": "四轮轴线位置RMS",
"rotation_circle_axis_difference_deg": "姿态轴与圆轨迹轴夹角",
"axis_cycle_difference_deg": "各轮转轴方向极差",
"cross_view_roll_curve": "正面/侧面关节角曲线差异RMS",
"third_cycle_axis_holdout_deg": "第三轮留出零位可观测轴向误差",
"third_cycle_axis_line_rms_mm": "第三轮留出轴线RMS",
"third_cycle_trajectory_p95_deg": "第三轮留出轨迹误差P95",
"zero_cycle_offset_range_deg": "训练轮零位极差",
"zero_confidence_95_half_width_deg": "零位95%置信半宽",
"state_image_sync_p95_ms": "图像与电机状态同步误差P95",
"tag_valid_rate_percent": "所需Tag同时有效率",
}
metric_units = {
"plane_rms_mm": "mm",
"radial_rms_mm": "mm",
"radius_mm": "mm",
"image_radial_rms_px": "px",
"image_radial_p95_px": "px",
"image_radius_px": "px",
"arc_deg": "°",
"monotonic_correction_deg": "°",
"hysteresis_deg": "°",
"baseline_hysteresis_deg": "°",
"baseline_directional_gap_deg": "°",
"baseline_directional_gap_range_deg": "°",
"cycle_travel_range_deg": "°",
"rotation_orthogonal_rms_deg": "°",
"axis_plane_rms_mm": "mm",
"axis_radial_rms_mm": "mm",
"axis_pose_line_rms_mm": "mm",
"axis_line_cycle_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": "°",
"zero_cycle_offset_range_deg": "°",
"zero_confidence_95_half_width_deg": "°",
"state_image_sync_p95_ms": "ms",
"tag_valid_rate_percent": "%",
"cross_view_roll_curve": "°",
}
details: list[str] = []
for failure in active.get("failures", []):
joint = JOINT_NAMES_ZH.get(
str(failure.get("joint")), str(failure.get("joint"))
)
metric = str(failure.get("metric", ""))
if metric in metric_names:
comparison = str(failure.get("comparison", ""))
requirement = "不超过" if comparison == "maximum" else "至少"
unit = metric_units[metric]
detail = (
f"{joint}{metric_names[metric]}"
f"{float(failure.get('actual', 0.0)):.2f}{unit}"
f"要求{requirement}{float(failure.get('limit', 0.0)):.2f}{unit}"
)
cycle_travel = failure.get("cycle_travel_deg", [])
if cycle_travel:
detail += "(各轮=" + "/".join(
f"{float(value):.2f}°" for value in cycle_travel
) + ""
cycle_values = failure.get("cycle_values_deg", [])
if not cycle_values:
cycle_values = failure.get("cycle_offset_deg", [])
if cycle_values:
detail += "(各轮=" + "/".join(
f"{float(value):.2f}°" for value in cycle_values
) + ""
details.append(detail)
else:
cycle = failure.get("cycle")
cycle_text = "" if cycle is None else f"{cycle}"
details.append(
f"{joint}{cycle_text}{metric or '轨迹'}拟合失败:"
f"{failure.get('reason', '未知原因')}"
)
detail_text = "".join(details) or "当前关节的轨迹拟合未通过"
directional_gap_failure = any(
str(failure.get("metric", "")).startswith(
"baseline_directional_gap"
)
for failure in active.get("failures", [])
)
if reason == "joint_fit_systematic_failure":
cross_view_systematic = any(
failure.get("classification")
in {
"stable_cross_view_installation_or_model_bias",
"stable_cross_view_direction_conflict",
}
for failure in active.get("failures", [])
)
suggestion = (
"四轮都出现稳定的正面/侧面差异,属于Tag安装外参或跨视角模型偏差,"
"继续重扫不会改善;检查Tag刚性安装与跨视角安装变换,不要放宽门限。"
if cross_view_systematic
else "各轮重复出现同一模型冲突,继续运动不会改善;程序已禁止自动重扫。"
"请直接复制诊断块给开发者,不要放宽门限。"
)
else:
source_task_names = set(active.get("source_task_names", []))
thumb_yaw_source_retry = source_task_names == {
"thumb_cmc_pitch_front",
"thumb_cmc_roll_front",
}
suggestion = (
"方向分支已由软件保留,不要放宽门限;请检查传动回差或高支架刚度,"
"处理后重新执行一键标定命令,程序会从最近可靠断点继续。"
if directional_gap_failure
else (
"保持顶部Tag 8/9无遮挡;程序只替换决定yaw零位的"
"CMC pitch/roll顶部轴观测并重扫"
f"{active.get('directions_to_rescan', 16)}个方向,"
"不会无效重扫yaw侧摆。"
if thumb_yaw_source_retry
else "修正Tag位置、遮挡或机械行程后重新执行一键标定命令;"
"程序只清除当前失败关节的数据并重扫"
f"{active.get('directions_to_rescan', 6)}个方向,"
"不需要手工调用ROS服务。"
)
)
return (
detail_text + "。程序已在当前关节结束后立即停止后续步骤。",
suggestion,
)
if reason == "zero_model_validation_failed":
reason_names = {
"zero_offset_reached_configured_bound": "零位解触及安全边界",
"zero_offset_exceeds_configured_limit": "零位估计超过安全范围",
"zero_offset_reached_diagnostic_bound": "零位估计仍触及诊断搜索边界",
"zero_offset_cycle_difference_too_large": "三轮零位离散过大",
"zero_offset_not_statistically_significant": "零位偏移未达到统计显著性",
"zero_axis_cone_mismatch_too_large": (
"父子轴夹角与原始URDF不一致,零位旋转无法解释"
),
"zero_phase_axis_line_residual_too_large": (
"整段SE(3)运动无法稳定确定平行轴线相位"
),
"zero_offset_did_not_improve_with_95pct_confidence": (
"第三轮留出验证未以95%置信度改善"
),
}
details: list[str] = []
for failure in active.get("failures", []):
joint = JOINT_NAMES_ZH.get(
str(failure.get("joint")), str(failure.get("joint"))
)
if failure.get("metric") == "zero_guard":
reason_text = reason_names.get(
str(failure.get("reason")), str(failure.get("reason"))
)
if "actual_deg" in failure and "limit_deg" in failure:
reason_text += (
f"(估计{float(failure['actual_deg']):+.2f}°,"
f"允许±{float(failure['limit_deg']):.2f}°)"
)
details.append(f"{joint}{reason_text}")
return (
"轨迹采集已完成,但零位/URDF几何验证失败"
+ ("" + "".join(details) + "" if details else "")
+ "。程序没有生成正式JSON或修正URDF。",
"该类稳定模型失败不能靠重复运动修复,程序不会自动重扫;"
"请检查Tag固定、相机外参和原始URDF后重新启动新标定。",
)
if reason in {"waiting_for_devices_and_sdk", "device_preflight_lost"}:
return (
"正在等待三台相机数据、内外参身份以及机械手SDK反馈就绪;此阶段不以Tag可见性阻止基准恢复。",
"保持机械手运动范围无障碍;设备就绪后系统会先安全恢复基准形态,再检查掌部Tag。",
)
if reason in {"waiting_for_three_cameras_tags_and_sdk", "preflight_lost"}:
return (
"正在等待三台相机内参、外参身份匹配、帧率、全部必需Tag以及机械手SDK同时就绪。",
"根据下面每个机位的缺失Tag和有效率排查;全部就绪后程序会进入等待开始状态。",
)
if reason == "waiting_for_baseline_tags_after_recovery":
return (
"机械手已经稳定恢复到基准形态,正在用新采集的画面确认三台相机各自的固定掌部Tag。",
"若某个掌部Tag持续缺失,只调整遮挡手指或检查Tag固定情况,不要移动相机和手掌底座。",
)
if reason == "locking_fixed_base_references":
return (
"基准形态Tag预检已通过,正在把三台相机的固定掌部Tag稳健锁定为本会话参考。",
"无需操作;锁定完成后允许任务姿态遮挡固定掌部Tag。",
)
if reason == "fixed_base_reference_moved":
return (
"顶部Tag 8在本会话基准锁定后连续多帧发生角点位移;程序已立即保持机械手当前位置,"
"本会话中已采集数据不再用于发布。",
"Tag 8允许在下一次标定预检前重新摆放,但本次不能继续;固定Tag 8和顶部相机后"
"重新启动新会话。",
)
if reason == "waiting_for_task_tags_at_sweep_start":
return (
"电机已到扫描起点,正在等待当前任务的实时运动Tag;显示为“锁”的固定掌部Tag"
"允许被手指遮挡。",
"只检查标记为✗的实时运动Tag;若均为✓或锁,程序会自动开始运动。",
)
if reason == "call_start_for_baseline_recovery":
return (
"相机数据和机械手反馈已就绪,等待一键程序触发安全基准恢复。",
"保持机械手运动范围无障碍;程序会自动开始,无需手工调用ROS服务。",
)
if reason == "call_start":
return (
"三机位预检已经通过,等待操作员确认开始。",
"清空机械手运动范围后调用/g20_calibration/start。",
)
if reason == "operator_pause":
return "操作员主动暂停了标定。", "确认安全后调用/g20_calibration/resume。"
if reason == "operator_abort":
return "操作员终止了本次标定,程序保持终止时的当前姿态。", "需要重新启动一次新标定。"
if reason == "collecting_timestamp_synchronised_tag_centres":
return "正在按时间戳配对Tag图像和电机状态并采集当前轨迹。", "无需操作,保持相机、标签和底座不动。"
if reason == "collecting_dedicated_baseline_hold":
return (
"正在从当前方向到达关节baseline并静止采集Tag与电机反馈;这批数据单独用于回差验收。",
"无需操作,保持相机、标签和底座不动。",
)
if reason == "steady checkpoint target is missing":
return (
"首轮稳态检查点已经到达最终端点,但采集状态没有及时切换到端点完成阶段。",
"程序已停止发布并保留已采样数据;这是软件状态切换问题,不需要调整相机、Tag或机械手。",
)
if reason == "cross_view_roll_front_failure_deferred":
return (
"正面侧摆回差不合格已保留,诊断模式将继续采集同一手指的侧面数据。",
"无需操作;该诊断会锁定URDF发布。",
)
if reason == "cross_view_roll_diagnostic_complete":
interpretation = str(active.get("interpretation", ""))
explanations = {
"both_views_confirm_direction_dependent_pose": (
"正面和侧面都确认了方向相关姿态,优先判断为roll输出机构或共同下游链的真实回差。"
),
"front_only_difference_check_roll_tag_bracket_or_front_pnp": (
"只有正面差异超限,优先检查roll Tag高支架刚度和正面PnP。"
),
"side_only_difference_check_side_tag_chain_or_side_pnp": (
"只有侧面差异超限,优先检查侧面Tag链和侧面PnP。"
),
"both_views_within_formal_hysteresis_limit": (
"两个机位的静止回差均满足正式门限。"
),
}
return (
explanations.get(interpretation, "四指侧摆跨机位诊断已经完成。")
+ " 本次为诊断会话,不会生成或发布URDF。",
"保存当前状态和raw_samples.jsonl;根据两机位结论处理后重新启动正式标定。",
)
if reason == "capturing_random_validation_pose":
return "正在当前随机命令位置采集复测数据。", "无需操作,保持设备不动。"
if reason in {"calibration_passed", "calibration_complete"}:
return "三维轨迹、关节轴零位和第三轮留出验证已经完成。", "检查JSON、修正URDF路径和quality.passed。"
if reason == "quality_failed":
return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。"
if reason.startswith("validated_endpoint_zero_state"):
return (
"轨迹和URDF零位验证已经通过,但发布前检测到端点零位状态缺失或与"
"已验证模型不一致;这是程序内部状态生命周期错误,结果未发布。",
"不要移动相机、Tag或机械手底座;保留当前会话并把原因码交给开发者。",
)
if reason.startswith("PUB-ARTIFACT-601:"):
return (
"标定节点已经生成通过质量门限的JSON和候选URDF,但一键程序在正式发布前"
"发现这对产物的坐标、限位、哈希或资源一致性检查失败;原始URDF未被覆盖。",
"不要重新标定相机或调整Tag;保留本会话产物和启动日志供开发者检查发布契约。",
)
if reason == "combination_pose_prediction_failed":
return (
"单关节、零位和URDF几何验证已通过,但当前多关节组合姿态的Tag实测位姿与模型预测超过门限。",
"程序会在原姿态重新初始化PnP并自动复测;若最终仍失败,请把raw_samples.jsonl中的"
"combination_validation_failure记录交给开发者,不要重新采集16个单关节任务。",
)
if reason.startswith("prepare_") or state == "PREPARE_SWEEP":
return "正在把当前电机移动到本方向的扫描起点并等待稳定。", "无需操作。"
if reason == "holding_same_finger_clearance_before_next_task":
return (
"同一根手指的上一项已经完成;相邻手指继续保持当前避让姿态,只调整"
"被测关节以衔接下一项。",
"无需操作,不要手动展开正在避让的手指。",
)
if state == "RETURN_BASELINE":
return "正在把已使用的标定电机恢复到目标姿态。", "无需操作。"
if state == "FITTING":
return "所有扫描已经完成,正在联合拟合三维机械轴和URDF零位偏移。", "无需操作。"
return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。"
def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str:
"""Render the complete operator status; the JSON topic remains unchanged."""
state = str(payload.get("state", ""))
active = payload.get("active", {})
reason_zh, action_zh = three_camera_reason_zh(
state, str(payload.get("reason", "")), active
)
progress = float(payload.get("progress", 0.0))
completed = payload.get("completed_sweeps", 0)
total = payload.get("total_sweeps", 0)
scan_progress = float(
payload.get(
"scan_progress",
0.0 if not total else float(completed) / float(total),
)
)
lines = [
f"状态:{STATE_NAMES_ZH.get(state, state)}{state}",
f"原因:{reason_zh}",
f"建议:{action_zh}",
f"总体进度:{progress:.1%}(计划扫描{completed}/{total}个方向,"
f"扫描进度{scan_progress:.1%}",
f"当前任务:{_task_text(active)}",
]
if state == "RETURN_BASELINE":
baseline_command = payload.get("baseline_command_u8", [])
return_command = payload.get("return_command_u8", baseline_command)
label = "恢复姿态" if return_command != baseline_command else "基准姿态"
lines.append(f"正在确认{label}{return_command}")
if active and active.get("kind") not in {
"fit_failure",
"zero_model_failure",
"motion_stall",
}:
retry_count = int(active.get("automatic_retry_count", 0))
if retry_count:
lines.append(
"自动重试:当前方向已自动重扫"
f"{retry_count}/{active.get('automatic_retry_limit', '?')}次,"
f"速度比例{float(active.get('retry_speed_scale', 1.0)):.0%}"
f"端点保持{float(active.get('endpoint_hold_seconds', 0.0)):.2f}s"
)
sample = active.get("sample", {})
motion_progress = active.get("motion_progress")
motion_text = (
"未知" if motion_progress is None else f"{float(motion_progress):.1%}"
)
lines.append(
"运动采样:"
f"目标{active.get('target_u8', active.get('command_u8', '?'))}"
f"实际{_format_u8(active.get('actual_u8'))}"
f"本方向{motion_text},有效帧{active.get('valid_frames', 0)}"
f"实际采样范围{_format_u8(sample.get('minimum_u8'))}"
f"{_format_u8(sample.get('maximum_u8'))}"
)
detection_frames = int(active.get("detection_frames", 0))
if detection_frames:
lines.append(
"本方向Tag检出:"
f"{float(active.get('detection_rate', 0.0)):.1%}"
f"{active.get('detection_valid_frames', 0)}/"
f"{detection_frames}帧)"
)
auxiliary = active.get("auxiliary_motors", [])
if auxiliary:
lines.append(
"避挡姿态:"
+ "".join(
f"电机{item.get('motor_index')}目标"
f"{item.get('command_u8')}、实际"
f"{_format_u8(item.get('actual_u8'))}"
for item in auxiliary
)
)
speed = active.get("speed", {})
if speed:
lines.append(
"阶段速度:五指目标"
f"{speed.get('commanded_finger_speed')}SDK报告"
f"{speed.get('reported_finger_speed')}"
)
if active.get("sweep_timeout_seconds") is not None:
lines.append(
"运动保护:扫描超时"
f"{float(active['sweep_timeout_seconds']):.1f}s"
"连续"
f"{float(active.get('motor_stall_timeout_seconds', 0.0)):.1f}s"
"进展不足"
f"{float(active.get('motor_stall_minimum_progress_u8', 0.0)):.1f}"
"则立即暂停"
)
lines.append("机位:")
for name, view in payload.get("views", {}).items():
missing = view.get("missing_tag_ids", [])
missing_text = "" if not missing else ",".join(map(str, missing))
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}"
)
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)
@@ -634,6 +634,7 @@ def _fit_joint_curve(
values: Sequence[float],
*,
endpoint_reference: Mapping[str, Sequence[float]] | None = None,
preserve_direction_offset: bool = False,
) -> tuple[dict[str, Any], float, float]:
by_direction: dict[str, list[list[float]]] = {
direction: [[] for _ in range(256)] for direction in DIRECTIONS
@@ -669,9 +670,11 @@ def _fit_joint_curve(
],
dtype=float,
)
raw -= raw[-1]
if not preserve_direction_offset:
raw -= raw[-1]
projected_samples = isotonic_nonincreasing(raw)
projected_samples -= projected_samples[-1]
if not preserve_direction_offset:
projected_samples -= projected_samples[-1]
maximum_correction = max(
maximum_correction,
float(np.max(np.abs(projected_samples - raw))),
@@ -681,7 +684,8 @@ def _fit_joint_curve(
commands.astype(float),
projected_samples,
)
curve -= curve[255]
if not preserve_direction_offset:
curve -= curve[255]
if endpoint_reference is not None:
curve = _regularize_coupled_zero_tail(
curve,
@@ -695,7 +699,8 @@ def _fit_joint_curve(
increasing = np.asarray(fitted[DIRECTION_INCREASING], dtype=float)
hysteresis = float(np.max(np.abs(decreasing - increasing)))
combined = 0.5 * (decreasing + increasing)
combined -= combined[255]
if not preserve_direction_offset:
combined -= combined[255]
return (
{
"angle_rad": [round(float(value), 8) for value in combined],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
"""Publish calibrated G20 URDF angles from raw command/feedback u8 values."""
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
return LaunchDescription(
[
DeclareLaunchArgument("hand_type", default_value="right"),
DeclareLaunchArgument("calibration_file"),
DeclareLaunchArgument("input_topic", default_value=""),
DeclareLaunchArgument("output_topic", default_value=""),
Node(
package="g20_thumb_apriltag_calibration",
executable="calibrated_joint_state_bridge",
name=[
"g20_calibrated_joint_state_bridge_",
LaunchConfiguration("hand_type"),
],
output="screen",
emulate_tty=True,
parameters=[
{
"hand_type": LaunchConfiguration("hand_type"),
"calibration_file": LaunchConfiguration(
"calibration_file"
),
"input_topic": LaunchConfiguration("input_topic"),
"output_topic": LaunchConfiguration("output_topic"),
}
],
),
]
)
@@ -10,12 +10,11 @@ from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
IncludeLaunchDescription,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
@@ -48,29 +47,41 @@ def _launch_stack(context):
tag_config = LaunchConfiguration("tag_config").perform(context)
zero_config = LaunchConfiguration("zero_config").perform(context)
realsense_config = LaunchConfiguration("realsense_config").perform(context)
realsense_launch = (
Path(get_package_share_directory("realsense2_camera"))
/ "launch"
/ "rs_launch.py"
)
camera = IncludeLaunchDescription(
PythonLaunchDescriptionSource(str(realsense_launch)),
camera = Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace="/camera/camera/color",
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_camera")),
launch_arguments={
"camera_name": "camera",
"camera_namespace": "camera",
"serial_no": LaunchConfiguration("camera_serial_number"),
"config_file": realsense_config,
"enable_color": "true",
"rgb_camera.color_profile": LaunchConfiguration("color_profile"),
"depth_module.color_profile": LaunchConfiguration("color_profile"),
"enable_depth": "false",
"enable_sync": "false",
"align_depth.enable": "false",
"diagnostics_period": "1.0",
}.items(),
parameters=[
{
"serial_number": LaunchConfiguration("camera_serial_number"),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration("camera_name"),
"frame_id": LaunchConfiguration("camera_frame_id"),
"image_width": ParameterValue(
LaunchConfiguration("image_width"), value_type=int
),
"image_height": ParameterValue(
LaunchConfiguration("image_height"), value_type=int
),
"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": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration("camera_info_url"),
}
],
)
raw_topic = "/camera/camera/color/image_raw"
@@ -196,10 +207,39 @@ def generate_launch_description() -> LaunchDescription:
)
return LaunchDescription(
[
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument("camera_serial_number", default_value=""),
DeclareLaunchArgument(
"color_profile", default_value="1280x720x30"
"camera_serial_number", default_value="DB2163742"
),
DeclareLaunchArgument(
"camera_model", default_value="MV-CS020-10UM"
),
DeclareLaunchArgument(
"camera_name", default_value="hikrobot_front_DB2163742"
),
DeclareLaunchArgument(
"camera_frame_id", default_value="camera_color_optical_frame"
),
DeclareLaunchArgument("image_width", default_value="1624"),
DeclareLaunchArgument("image_height", default_value="1240"),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument(
"camera_info_url",
default_value=str(
Path.home()
/ ".ros"
/ "camera_info"
/ "hikrobot_DB2163742.yaml"
),
),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
@@ -221,12 +261,6 @@ def generate_launch_description() -> LaunchDescription:
package_share / "config" / "cmc_pitch_zero.yaml"
),
),
DeclareLaunchArgument(
"realsense_config",
default_value=str(
package_share / "config" / "realsense_color_qos.yaml"
),
),
DeclareLaunchArgument(
"tag_config",
default_value=str(
@@ -10,12 +10,11 @@ from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
IncludeLaunchDescription,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
@@ -50,29 +49,41 @@ def _launch_stack(context):
calibration_config = LaunchConfiguration(
"calibration_config"
).perform(context)
realsense_config = LaunchConfiguration("realsense_config").perform(context)
realsense_launch = (
Path(get_package_share_directory("realsense2_camera"))
/ "launch"
/ "rs_launch.py"
)
camera = IncludeLaunchDescription(
PythonLaunchDescriptionSource(str(realsense_launch)),
camera = Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace="/camera/camera/color",
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_camera")),
launch_arguments={
"camera_name": "camera",
"camera_namespace": "camera",
"serial_no": LaunchConfiguration("camera_serial_number"),
"config_file": realsense_config,
"enable_color": "true",
"rgb_camera.color_profile": LaunchConfiguration("color_profile"),
"depth_module.color_profile": LaunchConfiguration("color_profile"),
"enable_depth": "false",
"enable_sync": "false",
"align_depth.enable": "false",
"diagnostics_period": "1.0",
}.items(),
parameters=[
{
"serial_number": LaunchConfiguration("camera_serial_number"),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration("camera_name"),
"frame_id": LaunchConfiguration("camera_frame_id"),
"image_width": ParameterValue(
LaunchConfiguration("image_width"), value_type=int
),
"image_height": ParameterValue(
LaunchConfiguration("image_height"), value_type=int
),
"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": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration("camera_info_url"),
}
],
)
raw_topic = "/camera/camera/color/image_raw"
@@ -198,10 +209,39 @@ def generate_launch_description() -> LaunchDescription:
)
return LaunchDescription(
[
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument("camera_serial_number", default_value=""),
DeclareLaunchArgument(
"color_profile", default_value="1280x720x30"
"camera_serial_number", default_value="DB2163742"
),
DeclareLaunchArgument(
"camera_model", default_value="MV-CS020-10UM"
),
DeclareLaunchArgument(
"camera_name", default_value="hikrobot_front_DB2163742"
),
DeclareLaunchArgument(
"camera_frame_id", default_value="camera_color_optical_frame"
),
DeclareLaunchArgument("image_width", default_value="1624"),
DeclareLaunchArgument("image_height", default_value="1240"),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument(
"camera_info_url",
default_value=str(
Path.home()
/ ".ros"
/ "camera_info"
/ "hikrobot_DB2163742.yaml"
),
),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
@@ -225,12 +265,6 @@ def generate_launch_description() -> LaunchDescription:
/ "cmc_roll_zero_travel.yaml"
),
),
DeclareLaunchArgument(
"realsense_config",
default_value=str(
package_share / "config" / "realsense_color_qos.yaml"
),
),
DeclareLaunchArgument(
"tag_config",
default_value=str(
@@ -11,12 +11,11 @@ from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
ExecuteProcess,
IncludeLaunchDescription,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
@@ -48,7 +47,6 @@ def _launch_stack(context):
calibration_config = LaunchConfiguration("calibration_config").perform(context)
tag_config = LaunchConfiguration("tag_config").perform(context)
realsense_config = LaunchConfiguration("realsense_config").perform(context)
use_roi_text = LaunchConfiguration("use_roi").perform(context).strip().lower()
if use_roi_text not in {"true", "false"}:
raise RuntimeError("use_roi must be true or false")
@@ -66,47 +64,58 @@ def _launch_stack(context):
if roi_values["roi_width"] <= 0 or roi_values["roi_height"] <= 0:
raise RuntimeError("roi_width and roi_height must be positive")
color_profile = LaunchConfiguration("color_profile").perform(context)
profile_match = re.fullmatch(r"(\d+)x(\d+)x(\d+)", color_profile)
if use_roi and profile_match is not None:
image_width = int(profile_match.group(1))
image_height = int(profile_match.group(2))
try:
image_width = int(LaunchConfiguration("image_width").perform(context))
image_height = int(LaunchConfiguration("image_height").perform(context))
except ValueError as error:
raise RuntimeError("image_width and image_height must be integers") from error
if image_width <= 0 or image_height <= 0:
raise RuntimeError("image_width and image_height must be positive")
if use_roi:
if (
roi_values["roi_x"] + roi_values["roi_width"] > image_width
or roi_values["roi_y"] + roi_values["roi_height"] > image_height
):
raise RuntimeError(
"ROI lies outside color_profile "
"ROI lies outside camera image "
f"{image_width}x{image_height}"
)
realsense_launch = Path(
get_package_share_directory("realsense2_camera")
) / "launch" / "rs_launch.py"
camera = IncludeLaunchDescription(
PythonLaunchDescriptionSource(str(realsense_launch)),
camera = Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace="/camera/camera/color",
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_camera")),
launch_arguments={
"camera_name": "camera",
"camera_namespace": "camera",
"serial_no": LaunchConfiguration("camera_serial_number"),
"config_file": realsense_config,
"enable_color": "true",
# D405 exposes color from the stereo/depth module. Keep the RGB
# camera argument as well so the same launch file also works with
# D435/D455-class devices.
"rgb_camera.color_profile": LaunchConfiguration("color_profile"),
"depth_module.color_profile": LaunchConfiguration("color_profile"),
"enable_depth": LaunchConfiguration("enable_depth"),
"depth_module.depth_profile": LaunchConfiguration("depth_profile"),
# Depth is archival/diagnostic only. Synchronising and aligning it
# adds significant D405 processing latency without affecting the
# relative AprilTag rotation calculation.
"enable_sync": "false",
"align_depth.enable": "false",
"diagnostics_period": "1.0",
}.items(),
parameters=[
{
"serial_number": LaunchConfiguration("camera_serial_number"),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration("camera_name"),
"frame_id": LaunchConfiguration("camera_frame_id"),
"image_width": ParameterValue(
LaunchConfiguration("image_width"), value_type=int
),
"image_height": ParameterValue(
LaunchConfiguration("image_height"), value_type=int
),
"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": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration("camera_info_url"),
}
],
)
vision_components = []
@@ -275,7 +284,6 @@ def _launch_stack(context):
str(bag_path),
processed_image_raw_topic,
processed_camera_info_topic,
"/camera/camera/depth/image_rect_raw",
"/apriltag/detections",
"/tf",
"/g20/cb_left_hand_control_cmd",
@@ -312,15 +320,40 @@ def generate_launch_description() -> LaunchDescription:
default_output = str(Path.cwd() / "calibration_output")
return LaunchDescription(
[
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument("camera_serial_number", default_value=""),
DeclareLaunchArgument(
"color_profile", default_value="1280x720x30"
"camera_serial_number", default_value="DB2163742"
),
DeclareLaunchArgument(
"depth_profile", default_value="640x480x30"
"camera_model", default_value="MV-CS020-10UM"
),
DeclareLaunchArgument(
"camera_name", default_value="hikrobot_front_DB2163742"
),
DeclareLaunchArgument(
"camera_frame_id", default_value="camera_color_optical_frame"
),
DeclareLaunchArgument("image_width", default_value="1624"),
DeclareLaunchArgument("image_height", default_value="1240"),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument(
"camera_info_url",
default_value=str(
Path.home()
/ ".ros"
/ "camera_info"
/ "hikrobot_DB2163742.yaml"
),
),
DeclareLaunchArgument("enable_depth", default_value="false"),
DeclareLaunchArgument(
"publish_debug_image", default_value="false"
),
@@ -349,12 +382,6 @@ def generate_launch_description() -> LaunchDescription:
"calibration_config",
default_value=str(package_share / "config" / "calibration.yaml"),
),
DeclareLaunchArgument(
"realsense_config",
default_value=str(
package_share / "config" / "realsense_color_qos.yaml"
),
),
DeclareLaunchArgument(
"tag_config",
default_value=str(package_share / "config" / "front_tags.yaml"),
@@ -0,0 +1,502 @@
"""Launch three Hikrobot views and one complete-G20 calibration owner."""
from __future__ import annotations
from datetime import datetime
import hashlib
from pathlib import Path
import re
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
ExecuteProcess,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
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 _default_source_urdf(hand_type: str) -> Path:
relative = Path(
"assets/robots/hands/linker_hand"
) / f"g20_{hand_type}" / f"linkerhand_g20_{hand_type}.urdf"
workspace = Path.cwd() / "src/linkerhand_retarget/linkerhand_retarget" / relative
try:
installed = Path(get_package_share_directory("linkerhand_retarget")) / relative
except Exception:
installed = workspace
return workspace if workspace.is_file() else installed
def _launch_stack(context):
from g20_thumb_apriltag_calibration.product import (
get_product_calibration_contract,
)
model = LaunchConfiguration("model").perform(context).strip().upper()
hand_type = LaunchConfiguration("hand_type").perform(context).lower()
if hand_type not in {"left", "right"}:
raise RuntimeError("hand_type must be left or right")
tag_layout = LaunchConfiguration("tag_layout").perform(context).lower()
try:
contract = get_product_calibration_contract(
model, hand_type, tag_layout
)
except ValueError as error:
raise RuntimeError(str(error)) from error
requested_tag_config = LaunchConfiguration("tag_config").perform(context)
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
)
tag_config = (
Path(requested_tag_config).expanduser().resolve()
if requested_tag_config
else package_share
/ "config"
/ (
"three_camera_tags_g20_right_19.yaml"
if tag_layout == "g20_right_19"
else "three_camera_tags_g20_right_15.yaml"
if tag_layout == "g20_right_15"
else "three_camera_tags.yaml"
)
)
if not tag_config.is_file():
raise RuntimeError(f"tag config does not exist: {tag_config}")
topic_prefix = f"/{model.lower()}"
command_topic = f"{topic_prefix}/cb_{hand_type}_hand_control_cmd"
state_topic = f"{topic_prefix}/cb_{hand_type}_hand_state"
info_topic = f"{topic_prefix}/cb_{hand_type}_hand_info"
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
source_urdf = (
Path(requested_source).expanduser().resolve()
if requested_source
else _default_source_urdf(hand_type).resolve()
)
if not source_urdf.is_file():
raise RuntimeError(f"source URDF does not exist: {source_urdf}")
expected_source_hash = LaunchConfiguration(
"source_urdf_expected_sha256"
).perform(context).strip().lower()
if contract.profile.supports("urdf_zero_publication"):
if re.fullmatch(r"[0-9a-f]{64}", expected_source_hash) is None:
raise RuntimeError(
"G20 right product layout requires source_urdf_expected_sha256 confirmed "
"by the CAD/hardware owner"
)
actual_source_hash = hashlib.sha256(source_urdf.read_bytes()).hexdigest()
if actual_source_hash != expected_source_hash:
raise RuntimeError(
"source_urdf_expected_sha256 does not match source_urdf_path"
)
hand_serial = LaunchConfiguration("serial_number").perform(context)
if (
not hand_serial
or hand_serial == "UNSET"
or re.fullmatch(r"[A-Za-z0-9_.-]+", hand_serial) is None
or hand_serial in {".", ".."}
):
raise RuntimeError("serial_number must be a safe non-empty hand serial")
requested_session = LaunchConfiguration("session_dir").perform(context)
output_root = Path(
LaunchConfiguration("output_root").perform(context)
).expanduser().resolve()
if requested_session:
session_dir = Path(requested_session).expanduser().resolve()
else:
session_dir = (
output_root
/ hand_serial
/ datetime.now().strftime("%Y%m%d_%H%M%S")
)
session_dir.mkdir(parents=True, exist_ok=True)
camera_serials = {
view: LaunchConfiguration(f"{view}_camera_serial").perform(context)
for view in VIEWS
}
if any(not serial for serial in camera_serials.values()):
raise RuntimeError("all three camera serial numbers are required")
if len(set(camera_serials.values())) != 3:
raise RuntimeError("front/side/top camera serial numbers must be unique")
cameras = []
components = []
raw_topics = []
info_topics = []
detection_topics = []
for view in VIEWS:
namespace = f"/g20_calibration/{view}/camera"
raw_topic = f"{namespace}/image_raw"
info_topic = f"{namespace}/camera_info"
rect_topic = f"{namespace}/image_rect"
detector_namespace = f"/g20_calibration/{view}/apriltag"
detection_topic = f"{detector_namespace}/detections"
raw_topics.append(raw_topic)
info_topics.append(info_topic)
detection_topics.append(detection_topic)
cameras.append(
Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace=namespace,
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_cameras")),
parameters=[
{
"serial_number": LaunchConfiguration(
f"{view}_camera_serial"
),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration(
f"{view}_camera_name"
),
"frame_id": f"g20_calibration_{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": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration(
f"{view}_camera_info_url"
),
}
],
)
)
components.extend(
[
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name=f"rectify_{view}",
namespace=namespace,
remappings=[
("image", raw_topic),
("camera_info", info_topic),
("image_rect", rect_topic),
],
parameters=[{"queue_size": 1}],
extra_arguments=[{"use_intra_process_comms": True}],
),
ComposableNode(
package="apriltag_ros",
plugin="AprilTagNode",
name="apriltag",
namespace=detector_namespace,
parameters=[
str(tag_config),
{
"detector.decimate": ParameterValue(
LaunchConfiguration("apriltag_decimate"),
value_type=float,
)
},
],
remappings=[
("image_rect", rect_topic),
("camera_info", info_topic),
],
extra_arguments=[{"use_intra_process_comms": True}],
),
]
)
vision = ComposableNodeContainer(
name="g20_three_camera_vision",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=components,
output="screen",
emulate_tty=True,
)
sdk = Node(
package="linker_hand_ros2_sdk",
executable="linker_hand_sdk",
name="linker_hand_sdk",
output="screen",
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[
{
"hand_type": hand_type,
"hand_joint": model,
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
"topic_prefix": topic_prefix,
"move_on_startup": False,
"startup_speed": ParameterValue(
LaunchConfiguration("calibration_speed"), value_type=int
),
"startup_torque": 80,
# Match 30 Hz cameras so state/image p95 skew stays below 50 ms.
"state_poll_rate": 30.0,
# Calibration does not consume measured joint velocity. A
# G20 velocity read sends another five synchronous CAN
# queries, so keep it off the trajectory-critical path.
"velocity_poll_rate": 1.0,
# Calibration sends one endpoint command per sweep. Keep
# polling the real motor state during the following motion;
# otherwise the SDK republishes a stale state for 0.2 s and
# creates 17-27 command-unit holes in the trajectory bins.
"defer_state_reads_while_commanding": False,
"repeat_position_commands": False,
"is_touch": False,
}
],
)
calibration = Node(
package="g20_thumb_apriltag_calibration",
executable="three_camera_calibration_node",
name="g20_calibration",
output="screen",
emulate_tty=True,
parameters=[
LaunchConfiguration("calibration_config"),
{
"serial_number": hand_serial,
"model": model,
"hand_type": hand_type,
"tag_layout": tag_layout,
"session_dir": str(session_dir),
"resume_raw_samples_path": LaunchConfiguration(
"resume_raw_samples_path"
),
"recalibration_scope": LaunchConfiguration(
"recalibration_scope"
),
# The SDK performs roughly 25 synchronous CAN queries whenever
# cb_<side>_hand_info has a subscriber. Calibration only used
# that topic to display a speed diagnostic, while those reads
# created 17-33 command-unit holes in position trajectories.
"info_topic": "/g20_calibration/disabled_hand_info",
"command_topic": command_topic,
"state_topic": state_topic,
"camera_extrinsics_file": LaunchConfiguration(
"camera_extrinsics_file"
),
"source_urdf_path": str(source_urdf),
"source_urdf_expected_sha256": LaunchConfiguration(
"source_urdf_expected_sha256"
),
"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
),
"normal_calibration_speed": ParameterValue(
LaunchConfiguration("calibration_speed"), value_type=int
),
"index_roll_calibration_speed": ParameterValue(
LaunchConfiguration("index_roll_calibration_speed"),
value_type=int,
),
"index_flex_calibration_speed": ParameterValue(
LaunchConfiguration("index_flex_calibration_speed"),
value_type=int,
),
"adaptive_formal_speed_enabled": ParameterValue(
LaunchConfiguration("adaptive_formal_speed_enabled"),
value_type=bool,
),
"cross_view_roll_diagnostic_finger": LaunchConfiguration(
"cross_view_roll_diagnostic_finger"
),
"validation_enabled": ParameterValue(
LaunchConfiguration("validation_enabled"), value_type=bool
),
},
],
)
bag = ExecuteProcess(
condition=IfCondition(LaunchConfiguration("record_bag")),
cmd=[
"ros2",
"bag",
"record",
"--storage",
"mcap",
"--storage-preset-profile",
"zstd_fast",
"--max-bag-size",
"10737418240",
"--output",
str(session_dir / "rosbag"),
*raw_topics,
*info_topics,
*detection_topics,
command_topic,
state_topic,
info_topic,
"/g20_calibration/status",
],
output="screen",
)
return [
LogInfo(
msg=(
f"G20 {hand_type} {tag_layout} three-camera session: {session_dir}; "
f"source_urdf={source_urdf}"
)
),
LogInfo(
msg=(
"Camera mapping: front="
f"{camera_serials['front']} side={camera_serials['side']} "
f"top={camera_serials['top']}"
)
),
*cameras,
vision,
sdk,
calibration,
bag,
]
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
)
info_root = Path.home() / ".ros" / "camera_info"
return LaunchDescription(
[
# Camera processes publish ~2 MB frames across DDS. Force the
# matching RMW and provide both current and legacy profile names
# so the configured 64 MB shared-memory segment is actually used.
SetEnvironmentVariable(
name="RMW_IMPLEMENTATION",
value="rmw_fastrtps_cpp",
),
SetEnvironmentVariable(
name="FASTDDS_DEFAULT_PROFILES_FILE",
value=str(package_share / "config" / "fastdds_large_images.xml"),
),
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(package_share / "config" / "fastdds_large_images.xml"),
),
DeclareLaunchArgument("model", default_value="G20"),
DeclareLaunchArgument("hand_type", default_value="left"),
DeclareLaunchArgument("tag_layout", default_value="legacy_11"),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
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(info_root / "hikrobot_DB2163742.yaml"),
),
DeclareLaunchArgument(
"side_camera_info_url",
default_value=str(info_root / "hikrobot_DB2163749.yaml"),
),
DeclareLaunchArgument(
"top_camera_info_url",
default_value=str(info_root / "hikrobot_DB2163739.yaml"),
),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
DeclareLaunchArgument(
"index_roll_calibration_speed", default_value="5"
),
DeclareLaunchArgument(
"index_flex_calibration_speed", default_value="10"
),
DeclareLaunchArgument(
"adaptive_formal_speed_enabled", default_value="true"
),
DeclareLaunchArgument(
"cross_view_roll_diagnostic_finger", default_value=""
),
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=""
),
DeclareLaunchArgument(
"source_urdf_expected_sha256", default_value=""
),
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"),
DeclareLaunchArgument("record_bag", default_value="false"),
DeclareLaunchArgument(
"output_root",
default_value=str(Path.cwd() / "calibration_output"),
),
DeclareLaunchArgument("session_dir", default_value=""),
DeclareLaunchArgument("resume_raw_samples_path", default_value=""),
DeclareLaunchArgument("recalibration_scope", default_value="full"),
DeclareLaunchArgument(
"calibration_config",
default_value=str(
package_share / "config" / "three_camera_calibration.yaml"
),
),
DeclareLaunchArgument(
"tag_config",
default_value="",
),
OpaqueFunction(function=_launch_stack),
]
)
@@ -0,0 +1,199 @@
"""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,
),
"maximum_single_camera_reprojection_rms_px": ParameterValue(
LaunchConfiguration(
"maximum_single_camera_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(
[
# Keep the large-image transport deterministic even when the
# calling shell selected another ROS 2 RMW implementation.
SetEnvironmentVariable(
name="RMW_IMPLEMENTATION",
value="rmw_fastrtps_cpp",
),
SetEnvironmentVariable(
name="FASTDDS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
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(
"maximum_single_camera_reprojection_rms_px",
default_value="1.5",
),
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),
]
)
@@ -3,7 +3,7 @@
<package format="3">
<name>g20_thumb_apriltag_calibration</name>
<version>0.1.0</version>
<description>Front RealSense AprilTag calibration for the left G20 thumb.</description>
<description>One-command three-camera AprilTag calibration and zero-URDF correction for the G20 right hand.</description>
<maintainer email="support@linker-robotics.com">lxp</maintainer>
<license>MIT</license>
@@ -17,7 +17,6 @@
<exec_depend>linker_hand_ros2_sdk</exec_depend>
<exec_depend>rclcpp_components</exec_depend>
<exec_depend>rclpy</exec_depend>
<exec_depend>realsense2_camera</exec_depend>
<exec_depend>rosbag2</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>std_msgs</exec_depend>
@@ -26,6 +25,7 @@
<exec_depend>python3-numpy</exec_depend>
<exec_depend>python3-opencv</exec_depend>
<exec_depend>python3-scipy</exec_depend>
<exec_depend>python3-yaml</exec_depend>
<test_depend>python3-pytest</test_depend>
+39 -3
View File
@@ -15,18 +15,25 @@ setup(
["resource/" + package_name],
),
("share/" + package_name, ["package.xml", "README.md"]),
("share/" + package_name + "/config", glob("config/*.yaml")),
(
"share/" + package_name + "/config",
glob("config/*.yaml") + glob("config/*.xml"),
),
("share/" + package_name + "/launch", glob("launch/*.launch.py")),
],
install_requires=["setuptools", "numpy", "scipy"],
install_requires=["setuptools", "numpy", "scipy", "PyYAML"],
tests_require=["pytest"],
zip_safe=True,
maintainer="lxp",
maintainer_email="support@linker-robotics.com",
description="Front RealSense AprilTag calibration for the left G20 thumb",
description="Profile-driven three-camera AprilTag hand calibration",
license="MIT",
entry_points={
"console_scripts": [
(
"hikrobot_camera_node = "
"g20_thumb_apriltag_calibration.hikrobot_camera:main"
),
"calibration_node = g20_thumb_apriltag_calibration.node:main",
(
"cmc_pitch_zero_node = "
@@ -36,6 +43,35 @@ setup(
"cmc_roll_calibration_node = "
"g20_thumb_apriltag_calibration.zero_node:main"
),
(
"three_camera_calibration_node = "
"g20_thumb_apriltag_calibration.three_camera_node:main"
),
(
"three_camera_extrinsics_node = "
"g20_thumb_apriltag_calibration.extrinsics_node:main"
),
(
"offline_replay = "
"g20_thumb_apriltag_calibration.offline_replay:main"
),
(
"camera_alignment_view = "
"g20_thumb_apriltag_calibration.alignment_view:main"
),
(
"calibrated_joint_state_bridge = "
"g20_thumb_apriltag_calibration."
"calibrated_joint_state_bridge:main"
),
(
"calibrate_g20_right = "
"g20_thumb_apriltag_calibration.one_command:main"
),
(
"calibrate_hand = "
"g20_thumb_apriltag_calibration.one_command:main"
),
],
},
)
@@ -15,11 +15,60 @@ from g20_thumb_apriltag_calibration.acquisition import (
aggregate_observations,
aggregate_sweep_observations,
interpolate_state_u8,
required_resume_views,
tag_quality_is_valid,
update_pnp_reset_watchdog,
)
from g20_thumb_apriltag_calibration.core import PAIR_NAMES
def test_pnp_watchdog_resets_after_one_continuous_invalid_second() -> None:
since, reset = update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=False,
now=10.0,
invalid_since=None,
reset_after_seconds=1.0,
)
assert since == 10.0
assert reset is False
since, reset = update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=False,
now=11.01,
invalid_since=since,
reset_after_seconds=1.0,
)
assert since == 11.01
assert reset is True
def test_pnp_watchdog_clears_on_valid_pose_or_bad_detection() -> None:
assert update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=True,
now=11.0,
invalid_since=10.0,
reset_after_seconds=1.0,
) == (None, False)
assert update_pnp_reset_watchdog(
detection_good=False,
pnp_valid=False,
now=11.0,
invalid_since=10.0,
reset_after_seconds=1.0,
) == (None, False)
def test_resume_requires_only_the_active_view() -> None:
assert required_resume_views("top") == ("top",)
assert required_resume_views("front") == ("front",)
assert required_resume_views(None) == ("front", "side", "top")
with pytest.raises(ValueError, match="unknown"):
required_resume_views("rear")
def _observation(index: int, angle_rad: float = 0.0) -> Observation:
quaternion = tuple(
float(value)
@@ -0,0 +1,39 @@
"""Tests for the independent physical-line alignment overlay."""
import pytest
from g20_thumb_apriltag_calibration.alignment_view import (
summarize_alignment_measurements,
)
def _measurement(angle: float, offset: float, y: float) -> dict:
return {
"line_xyxy_px": [0.0, y, 100.0, y - angle * 100.0],
"angle_rad": angle,
"vertical_offset_px": offset,
}
def test_line_summary_smooths_only_physical_line_measurements() -> None:
"""The overlay smooths scene lines without any Tag orientation input."""
result = summarize_alignment_measurements(
[
_measurement(-0.02, -4.0, 80.0),
None,
_measurement(0.00, 0.0, 82.0),
_measurement(0.02, 4.0, 84.0),
]
)
assert result is not None
assert result["angle_rad"] == pytest.approx(0.0)
assert result["vertical_offset_px"] == pytest.approx(0.0)
assert result["line_xyxy_px"] == pytest.approx([0.0, 82.0, 100.0, 82.0])
assert result["detected_frames"] == 3
assert result["window_frames"] == 4
def test_line_summary_returns_none_without_scene_line() -> None:
"""No blue line is fabricated when the scene has no valid long edge."""
assert summarize_alignment_measurements([None, None]) is None
@@ -0,0 +1,177 @@
import copy
import pytest
from g20_thumb_apriltag_calibration.calibrated_joint_state_bridge import (
G20_COMMAND_NAMES,
G20_URDF_JOINT_NAMES,
CalibratedCommandMapper,
default_input_topic,
)
from g20_thumb_apriltag_calibration.full_hand import (
JointCurveFit,
build_compact_payload,
get_hand_calibration_profile,
)
from g20_thumb_apriltag_calibration.urdf_zero import (
get_zero_calibration_profile,
)
def _payload(side: str = "right") -> dict:
profile = get_hand_calibration_profile(side)
curve = [round((255 - command) * 0.001, 8) for command in range(256)]
joints = {}
for name, spec in profile.joint_specs.items():
joint = {
"motor_index": spec.motor_index,
"angle_rad": list(curve),
}
if spec.active:
joint["zero_command_u8"] = 255
joint["zero_angles"] = {"urdf_zero_offset_rad": 0.0}
else:
joint["passive"] = True
if spec.source_joint is not None:
joint["source_joint"] = spec.source_joint
joints[name] = joint
return {
"schema_version": 4,
"model": "G20",
"side": side,
"serial_number": "TEST_RIGHT" if side == "right" else "TEST_LEFT",
"angle_unit": "rad",
"command_range": [0, 255],
"baseline_command_u8": [255] * 20,
"joints": joints,
"quality": {
"passed": True,
"validation_mae_rad": 0.01,
"validation_p95_rad": 0.02,
},
}
def test_mapper_uses_each_joint_motor_and_includes_passive_joints() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
command = list(range(20))
result = dict(
zip(G20_URDF_JOINT_NAMES, mapper.map_positions(command))
)
assert result["thumb_cmc_pitch"] == pytest.approx(0.255)
assert result["thumb_cmc_roll"] == pytest.approx(0.250)
assert result["thumb_cmc_yaw"] == pytest.approx(0.245)
assert result["thumb_mcp"] == pytest.approx(0.240)
assert result["thumb_ip"] == pytest.approx(0.240)
assert result["pinky_pip"] == pytest.approx(0.236)
assert result["pinky_dip"] == pytest.approx(0.236)
def test_mapper_uses_names_instead_of_message_order() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
command = list(range(20))
names = list(reversed(G20_COMMAND_NAMES))
positions = list(reversed(command))
assert mapper.map_positions(positions, names) == mapper.map_positions(command)
def test_mapper_rejects_wrong_side_and_unapproved_payload() -> None:
with pytest.raises(ValueError, match="does not match"):
CalibratedCommandMapper(_payload("left"), expected_side="right")
payload = copy.deepcopy(_payload())
payload["quality"]["passed"] = False
with pytest.raises(ValueError, match="quality.passed"):
CalibratedCommandMapper(payload, expected_side="right")
def test_mapper_rejects_incomplete_named_command() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
with pytest.raises(ValueError, match="missing named channels"):
mapper.map_positions([255.0], ["thumb_cmc_pitch"])
def test_right_19_schema_v4_mapper_uses_requested_command_midpoint_curve() -> None:
profile = get_hand_calibration_profile("right", "g20_right_15")
zero = get_zero_calibration_profile("right", "g20_right_15")
baseline = [255] * 20
baseline[6:10] = [127] * 4
fits = {}
diagnostics = {}
for name, spec in profile.joint_specs.items():
zero_command = baseline[spec.motor_index]
average = tuple(
0.001 * (zero_command - value) for value in range(256)
)
decreasing = tuple(1.1 * value for value in average)
increasing = tuple(0.9 * value for value in average)
fits[name] = JointCurveFit(
average,
decreasing,
increasing,
{},
0.0,
0.0,
{"rotation_orthogonal_rms_rad": 0.0, "arc_rad": 0.5},
)
diagnostics[name] = {
"cycle_travel_rad": [0.25] * 4,
"cycle_travel_range_rad": 0.0,
"baseline_hysteresis_by_cycle_rad": [0.0] * 4,
"holdout_cycle": 3,
"holdout_cycle_mae_rad": 0.0,
"holdout_cycle_max_rad": 0.0,
}
payload = build_compact_payload(
serial_number="TEST_RIGHT_V5",
measured_fits=fits,
urdf_zero_offsets_rad={name: 0.0 for name in profile.active_joints},
validation_errors_rad=[0.0],
passed=True,
baseline=baseline,
side="right",
layout_id="g20_right_15",
zero_uncertainty_rad={name: 0.0 for name in zero.direct_zero_joints},
zero_cycle_offsets_rad={
name: (0.0, 0.0, 0.0) for name in zero.direct_zero_joints
},
zero_observers=zero.offset_observer_joint,
artifact_hashes={
"source_urdf_sha256": "0" * 64,
"camera_extrinsics_sha256": "1" * 64,
"corrected_urdf_sha256": "2" * 64,
},
cross_view_roll_metrics={
f"{finger}_mcp_roll": {"angle_rad_rms_difference_rad": 0.0}
for finger in ("index", "middle", "ring", "pinky")
},
joint_dynamic_diagnostics=diagnostics,
zero_geometry_diagnostics={
"training_cycles": (0, 1, 2),
"validation_cycle": 3,
"axis_line_rms_m": 0.0,
"validation_line_error_by_joint_m": {},
},
)
mapper = CalibratedCommandMapper(payload, expected_side="right")
command = [255.0] * 20
command[0] = 100.0
mapped = dict(zip(G20_URDF_JOINT_NAMES, mapper.map_positions(command)))
assert mapper.input_domain == "command_u8"
assert mapper.layout_id == "g20_right_19"
# midpoint(1.1, 0.9) is the original 0.001-rad/u8 curve.
assert mapped["thumb_cmc_pitch"] == pytest.approx(0.155)
assert payload["joints"]["pinky_dip"]["passive"] is True
assert "source_joint" not in payload["joints"]["pinky_dip"]
def test_default_topics_use_the_g20_sdk_namespace() -> None:
assert default_input_topic("right", "command_u8") == (
"/g20/cb_right_hand_control_cmd"
)
assert default_input_topic("right", "feedback_u8") == (
"/g20/cb_right_hand_state"
)
@@ -1,4 +1,5 @@
from pathlib import Path
from xml.etree import ElementTree
import yaml
@@ -6,6 +7,36 @@ import yaml
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
def test_fastdds_profile_has_capacity_for_full_resolution_images() -> None:
root = ElementTree.parse(
PACKAGE_ROOT / "config" / "fastdds_large_images.xml"
).getroot()
namespace = {"dds": "http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles"}
profiles = root.find("dds:profiles", namespace)
assert profiles is not None
segment = profiles.find(
".//dds:transport_descriptor[dds:type='SHM']/dds:segment_size",
namespace,
)
assert segment is not None
assert int(segment.text) >= 64 * 1024 * 1024
participant = profiles.find("dds:participant", namespace)
assert participant is not None
assert participant.attrib["is_default_profile"] == "true"
def test_three_camera_launches_force_fastdds_large_image_transport() -> None:
for launch_name in (
"three_camera_calibration.launch.py",
"three_camera_extrinsics.launch.py",
):
launch_text = (PACKAGE_ROOT / "launch" / launch_name).read_text()
assert 'name="RMW_IMPLEMENTATION"' in launch_text
assert 'value="rmw_fastrtps_cpp"' in launch_text
assert 'name="FASTDDS_DEFAULT_PROFILES_FILE"' in launch_text
assert 'name="FASTRTPS_DEFAULT_PROFILES_FILE"' in launch_text
def test_front_tag_parameters_match_namespaced_detector() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "front_tags.yaml").read_text()
@@ -24,6 +55,180 @@ def test_front_tag_parameters_match_namespaced_detector() -> None:
assert detector["detector"]["debug"] is False
def test_three_camera_tag_ids_and_topics_use_eleven_unique_tags() -> None:
tags = yaml.safe_load(
(PACKAGE_ROOT / "config" / "three_camera_tags.yaml").read_text()
)
expected = {
"front": [0, 1, 2, 3, 10],
"side": [4, 5, 6, 7],
"top": [8, 9],
}
all_ids = set()
for view, ids in expected.items():
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)
assert all_ids == set(range(11))
assert tags[
"/g20_calibration/side/apriltag/apriltag"
]["ros__parameters"]["tag"]["frames"][0] == "side_base"
def test_right_19_tag_config_matches_the_physical_layout() -> None:
tags = yaml.safe_load(
(
PACKAGE_ROOT
/ "config"
/ "three_camera_tags_g20_right_19.yaml"
).read_text()
)
expected = {
"front": [0, 1, 2, 3, 10, 11, 12, 13],
"side": [4, 5, 6, 7, 14, 15, 16, 17, 18],
"top": [8, 9],
}
all_ids: set[int] = set()
for view, ids in expected.items():
parameters = tags[
f"/g20_calibration/{view}/apriltag/apriltag"
]["ros__parameters"]
assert parameters["tag"]["ids"] == ids
expected_sizes = [0.016 for _tag_id in ids]
assert parameters["tag"]["sizes"] == expected_sizes
all_ids.update(ids)
assert all_ids == set(range(19))
side_frames = tags[
"/g20_calibration/side/apriltag/apriltag"
]["ros__parameters"]["tag"]["frames"]
assert side_frames == [
"side_base", "ring_pip", "pinky_pip", "pinky_dip", "ring_dip",
"middle_pip", "middle_dip", "index_pip", "index_dip",
]
def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "three_camera_calibration.yaml").read_text()
)
parameters = config["g20_calibration"]["ros__parameters"]
assert parameters["tag_size_m"] == 0.016
assert parameters["tag_size_override_ids"] == [7, 14, 16, 18]
assert parameters["tag_size_overrides_m"] == [0.016] * 4
assert parameters["baseline_command_u8"] == [
255,
255,
255,
255,
255,
255,
127,
127,
127,
127,
255,
255,
255,
255,
255,
255,
255,
255,
255,
255,
]
assert parameters["setting_topic"] == "/g20/cb_hand_setting_cmd"
assert parameters["normal_calibration_speed"] == 15
assert parameters["index_roll_calibration_speed"] == 5
assert parameters["index_flex_calibration_speed"] == 10
assert parameters["adaptive_formal_speed_enabled"] is True
assert parameters["adaptive_formal_speed_max_scale"] == 1.5
assert parameters["adaptive_formal_speed_minimum_bins"] == 64
assert parameters["adaptive_formal_speed_maximum_bin_gap"] == 8
assert parameters["right_thumb_yaw_255_endpoint_tolerance_u8"] == 5.0
assert parameters["speed_setting_settle_seconds"] >= 0.2
assert parameters["top_pnp_invalid_reset_seconds"] == 1.0
assert parameters["pnp_group_initialization_frames"] == 8
assert parameters["pnp_group_normal_alignment_scale_deg"] == 5.0
assert parameters["pnp_group_maximum_normal_alignment_deg"] == 15.0
assert parameters["thumb_ip_pnp_coupling_multiplier"] == 1.03
assert parameters["thumb_ip_pnp_coupling_scale_deg"] == 3.0
assert parameters["thumb_ip_pnp_maximum_coupling_residual_deg"] == 7.5
assert parameters["baseline_hold_seconds"] == 0.5
assert parameters["minimum_baseline_hold_frames"] == 10
assert parameters["directional_zero_maximum_branch_gap_deg"] == 2.0
assert parameters["directional_zero_maximum_branch_gap_range_deg"] == 0.3
assert (
parameters["cross_view_roll_maximum_branch_gap_difference_deg"]
== 0.3
)
assert parameters["cross_view_roll_diagnostic_finger"] == ""
assert parameters["repetitions"] == 3
assert parameters["g20_right_19_repetitions"] >= 4
assert parameters["validation_enabled"] is False
assert parameters["combination_validation_enabled"] is False
assert parameters["combination_validation_frames"] >= 10
assert parameters["combination_maximum_position_p95_m"] <= 0.003
assert parameters["combination_maximum_orientation_p95_deg"] <= 2.0
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"] == 4.0
assert parameters["pinky_pip_zero_endpoint_tolerance_u8"] == 5.0
assert parameters["minimum_sweep_bins"] >= 32
assert parameters["maximum_bin_gap"] <= 16
assert parameters["automatic_sweep_retry_limit"] == 2
assert parameters["automatic_fit_retry_limit"] == 2
assert parameters["motor_stall_timeout_seconds"] == 2.0
assert parameters["motor_stall_startup_grace_seconds"] == 1.0
assert parameters["motor_stall_minimum_progress_u8"] == 1.0
assert parameters["automatic_motion_retry_limit"] == 2
assert parameters["provisional_warning_ratio"] == 1.25
assert parameters["retry_speed_scales"] == [0.8, 0.6]
assert parameters["retry_endpoint_hold_seconds"] == [0.75, 1.0]
assert parameters["position_timeout_seconds"] >= 20.0
assert parameters["maximum_state_image_skew_ms"] <= 50.0
assert parameters["axis_maximum_rotation_circle_difference_deg"] <= 1.0
assert parameters["axis_maximum_pose_line_rms_m"] <= 0.001
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_axis_cone_mismatch_deg"] <= 5.0
assert parameters["zero_maximum_observability_condition_number"] >= 1.0
assert parameters["zero_maximum_offset_deg"] <= 20.0
assert parameters["zero_finger_maximum_offset_deg"] <= 3.0
assert parameters["mechanical_endpoint_maximum_offset_deg"] == 5.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
assert parameters["trajectory_maximum_cycle_travel_difference_deg"] <= 3.0
assert parameters["passive_maximum_cycle_travel_difference_deg"] <= 10.0
assert parameters["passive_maximum_monotonic_correction_deg"] <= 3.0
assert parameters["maximum_hysteresis_deg"] <= 2.0
assert parameters["passive_maximum_hysteresis_deg"] <= 2.0
assert parameters["command_maximum_direction_gap_deg"] <= 2.0
assert parameters["maximum_validation_mae_deg"] <= 1.0
assert parameters["maximum_validation_p95_deg"] <= 2.0
assert parameters["maximum_validation_error_deg"] <= 3.0
assert parameters["zero_maximum_confidence_half_width_deg"] <= 1.5
for view in ("front", "side", "top"):
assert parameters[f"{view}_camera_info_topic"].startswith(
f"/g20_calibration/{view}/"
)
assert parameters[f"{view}_detections_topic"] == (
f"/g20_calibration/{view}/apriltag/detections"
)
def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "calibration.yaml").read_text()
@@ -87,15 +292,6 @@ def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None:
assert parameters["continuous_prepare_timeout_seconds"] >= 30.0
def test_realsense_color_and_camera_info_use_matching_qos() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "realsense_color_qos.yaml").read_text()
)
assert config["color_qos"] == "DEFAULT"
assert config["color_info_qos"] == "DEFAULT"
def test_cmc_pitch_zero_config_uses_three_trajectory_circle_rounds() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "cmc_pitch_zero.yaml").read_text()
@@ -0,0 +1,326 @@
"""Focused tests for checkerboard frame pairing."""
import cv2
import numpy as np
from scipy.spatial.transform import Rotation
import g20_thumb_apriltag_calibration.extrinsics_node as extrinsics_node
from g20_thumb_apriltag_calibration.extrinsics_node import (
BoardPose,
StereoCapture,
_fit_stereo_robust,
_individual_reprojection_passes,
_minimum_history_skew_ns,
_pose_is_novel,
_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_each_camera_must_pass_reprojection_gate_independently():
assert _individual_reprojection_passes(0.8, 1.1, 1.2)
assert not _individual_reprojection_passes(0.5, 1.4, 1.2)
assert _individual_reprojection_passes(0.5, 1.4, 1.5)
assert not _individual_reprojection_passes(0.5, 1.6, 1.5)
assert not _individual_reprojection_passes(float("nan"), 0.5, 1.2)
def test_pose_novelty_is_checked_against_all_previous_captures():
previous = [_transform(0.0, 0.0), _transform(10.0, 0.10)]
assert not _pose_is_novel(
_transform(10.5, 0.105),
previous,
minimum_rotation_rad=np.deg2rad(2.0),
minimum_translation_m=0.015,
)
assert _pose_is_novel(
_transform(14.0, 0.13),
previous,
minimum_rotation_rad=np.deg2rad(2.0),
minimum_translation_m=0.015,
)
def test_joint_fit_trims_moderate_bad_views_without_relaxing_gate(
monkeypatch,
):
captures = [
StereoCapture(
front_points_px=np.zeros((40, 2), dtype=np.float32),
other_points_px=np.zeros((40, 2), dtype=np.float32),
transform_candidates=(np.eye(4),),
pair_reprojection_rms_px=1.0,
skew_ns=1,
)
for _ in range(20)
]
def fake_fit_once(
captures_arg,
indices,
object_points,
front_matrix,
other_matrix,
image_size,
):
del (
captures_arg,
object_points,
front_matrix,
other_matrix,
image_size,
)
errors = np.asarray(
[1.1 if index < 15 else 1.5 for index in indices],
dtype=float,
)
return float(np.sqrt(np.mean(np.square(errors)))), np.eye(4), errors
monkeypatch.setattr(extrinsics_node, "_stereo_fit_once", fake_fit_once)
result = _fit_stereo_robust(
captures,
np.zeros((40, 3), dtype=np.float32),
np.eye(3),
np.eye(3),
(640, 480),
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 result.stereo_rms_px <= 1.2
assert len(result.inlier_indices) >= 15
assert result.rejected_indices
assert set(result.rejected_indices) <= set(range(15, 20))
def test_joint_fit_reports_finite_provisional_metrics_before_minimum(
monkeypatch,
):
captures = [
StereoCapture(
front_points_px=np.zeros((40, 2), dtype=np.float32),
other_points_px=np.zeros((40, 2), dtype=np.float32),
transform_candidates=(np.eye(4),),
pair_reprojection_rms_px=0.8,
skew_ns=1,
)
for _ in range(10)
]
def fake_fit_once(
captures_arg,
indices,
object_points,
front_matrix,
other_matrix,
image_size,
):
del (
captures_arg,
object_points,
front_matrix,
other_matrix,
image_size,
)
return 0.8, np.eye(4), np.full(len(indices), 0.8)
monkeypatch.setattr(extrinsics_node, "_stereo_fit_once", fake_fit_once)
result = _fit_stereo_robust(
captures,
np.zeros((40, 3), dtype=np.float32),
np.eye(3),
np.eye(3),
(640, 480),
minimum_inliers=15,
maximum_rms_px=1.2,
maximum_rotation_stability_rad=np.deg2rad(0.3),
maximum_translation_stability_m=0.0015,
)
assert not result.passed
assert result.stereo_rms_px == 0.8
assert len(result.inlier_indices) == 10
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
from pathlib import Path
import pytest
import yaml
from g20_thumb_apriltag_calibration.hikrobot_camera import (
DeviceDescriptor,
decode_c_string,
load_camera_calibration,
resolve_camera_info_path,
select_device,
)
def test_decode_c_string_stops_at_first_null() -> None:
assert decode_c_string(b"DB2163742\0ignored") == "DB2163742"
def test_select_device_accepts_serial_or_guid() -> None:
devices = [
DeviceDescriptor(
index=0,
model="MV-CS020-10UM",
serial="DB2163742",
guid="2BDFB2163742",
),
DeviceDescriptor(
index=1,
model="MV-CS020-10UM",
serial="DB2163739",
guid="2BDFB2163739",
),
]
assert select_device(devices, "DB2163742", "MV-CS020-10UM").index == 0
assert select_device(devices, "2BDFB2163739", "MV-CS020-10UM").index == 1
def test_select_device_never_guesses_when_multiple_cameras_exist() -> None:
devices = [
DeviceDescriptor(0, "MV-CS020-10UM", "one", "guid-one"),
DeviceDescriptor(1, "MV-CS020-10UM", "two", "guid-two"),
]
with pytest.raises(RuntimeError, match="selector is required"):
select_device(devices, "", "MV-CS020-10UM")
def test_select_device_rejects_wrong_model() -> None:
devices = [DeviceDescriptor(0, "other", "DB2163742", "guid")]
with pytest.raises(RuntimeError, match="expected a model"):
select_device(devices, "DB2163742", "MV-CS020-10UM")
def test_load_standard_camera_calibration(tmp_path: Path) -> None:
path = tmp_path / "camera.yaml"
path.write_text(
yaml.safe_dump(
{
"image_width": 1624,
"image_height": 1240,
"camera_name": "hikrobot_front_DB2163742",
"camera_matrix": {
"rows": 3,
"cols": 3,
"data": [1000.0, 0.0, 812.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 1.0],
},
"distortion_model": "plumb_bob",
"distortion_coefficients": {
"rows": 1,
"cols": 5,
"data": [0.1, -0.2, 0.0, 0.0, 0.1],
},
"rectification_matrix": {
"rows": 3,
"cols": 3,
"data": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
},
"projection_matrix": {
"rows": 3,
"cols": 4,
"data": [1000.0, 0.0, 812.0, 0.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 0.0, 1.0, 0.0],
},
},
sort_keys=False,
),
encoding="utf-8",
)
calibration = load_camera_calibration(path)
assert calibration.width == 1624
assert calibration.height == 1240
assert calibration.k[0] == 1000.0
assert calibration.p[5] == 1001.0
def test_camera_info_url_only_accepts_local_files(tmp_path: Path) -> None:
path = resolve_camera_info_path(str(tmp_path / "front.yaml"))
assert path == (tmp_path / "front.yaml").resolve()
assert resolve_camera_info_path("") is None
with pytest.raises(ValueError, match="filesystem path"):
resolve_camera_info_path("package://example/front.yaml")
@@ -0,0 +1,69 @@
import pytest
from g20_thumb_apriltag_calibration.offline_replay import (
_latest_attempt_records,
_latest_palm_axis_records,
_output_suffix,
)
def _sample(joint: str, cycle: int, direction: str, attempt: int) -> dict:
return {
"kind": "sample",
"joint": joint,
"cycle": cycle,
"direction": direction,
"attempt": attempt,
}
def test_latest_attempt_is_selected_per_joint_cycle_and_direction() -> None:
rows = [
{"kind": "session_start"},
_sample("pinky_pip", 0, "decreasing", 1),
_sample("pinky_pip", 0, "decreasing", 3),
_sample("pinky_pip", 0, "increasing", 1),
_sample("pinky_pip", 1, "decreasing", 2),
_sample("thumb_cmc_yaw", 0, "decreasing", 1),
]
selected = _latest_attempt_records(rows)
assert [
record["attempt"] for record in selected["pinky_pip"]
] == [3, 1, 2]
assert [
record["attempt"] for record in selected["thumb_cmc_yaw"]
] == [1]
def test_latest_palm_axis_attempt_is_selected_per_cycle_and_direction() -> None:
def sample(cycle: int, direction: str, attempt: int) -> dict:
return {
"kind": "palm_axis_sample",
"source_joint": "pinky_mcp_pitch_front_axis",
"cycle": cycle,
"direction": direction,
"attempt": attempt,
}
selected = _latest_palm_axis_records(
[
sample(0, "decreasing", 1),
sample(0, "decreasing", 2),
sample(0, "increasing", 1),
]
)
assert [
record["attempt"]
for record in selected["pinky_mcp_pitch_front_axis"]
] == [2, 1]
def test_output_suffix_is_safe_and_explicit() -> None:
assert _output_suffix(None) == ""
assert _output_suffix("MEASURED_ZERO_V2") == "_MEASURED_ZERO_V2"
for invalid in ("", "../escape", "/absolute", "contains space", "x" * 65):
with pytest.raises(ValueError, match="output tag"):
_output_suffix(invalid)
@@ -234,6 +234,147 @@ def _pose(
)
def test_group_tracker_receives_oblique_reprojection_valid_candidates() -> None:
per_tag = SquareTagPoseTracker(
maximum_reprojection_error_px=1.5,
reprojection_tie_px=1.5,
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
maximum_tag_tilt_rad=np.deg2rad(75.0),
reset_after_seconds=5.0,
)
oblique_rotation = Rotation.from_euler("y", 89.0, degrees=True)
independent, reason = per_tag.estimate(
"child",
_project(oblique_rotation, np.asarray([0.03, 0.0, 0.25])),
tag_size_m=0.01,
camera_matrix=_camera_matrix(),
stamp_ns=1_000_000_000,
)
assert independent is None
assert reason == "no_pose_within_reprojection_or_tilt_limit"
assert per_tag.last_candidates_by_role["child"]
diagnostics = per_tag.last_candidate_diagnostics_by_role["child"]
assert diagnostics["reprojection_candidate_count"] == 2
assert diagnostics["independent_tilt_candidate_count"] == 0
assert diagnostics["minimum_candidate_tilt_deg"] > 75.0
group = SquareTagGroupPoseTracker(
roles=("base", "child"),
adjacent_pairs=(("base", "child"),),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
)
selected, group_reason = group.select(
{
"base": (_pose(0.0, 0.0, 0.05),),
"child": per_tag.last_candidates_by_role["child"],
},
stamp_ns=1_000_000_000,
)
assert group_reason == ""
assert selected is not None
assert "child" in selected
def test_group_tracker_exposes_roles_without_candidates() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("base", "parent", "child"),
adjacent_pairs=(("base", "parent"), ("parent", "child")),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
)
selected, reason = tracker.select(
{
"base": (_pose(0.0, 0.0, 0.05),),
"parent": (),
},
stamp_ns=1_000_000_000,
)
assert selected is None
assert reason == "group_missing_pose_candidates"
assert tracker.last_missing_roles == ("parent", "child")
def test_group_tracker_uses_coupling_to_choose_branch_but_never_rejects_measurement() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("base", "mcp", "ip"),
adjacent_pairs=(("base", "mcp"), ("mcp", "ip")),
maximum_pose_jump_rad=np.deg2rad(360.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(1000.0),
relative_translation_scale_m=1.0,
reprojection_scale_px=0.1,
reprojection_weight=1.0,
reset_after_seconds=5.0,
coupled_rotation_pairs=(
("base", "mcp", "mcp", "ip", 1.03),
),
coupled_rotation_scale_rad=np.deg2rad(3.0),
maximum_coupled_rotation_residual_rad=np.deg2rad(7.5),
)
baseline = {
"base": (_pose(0.0, 0.00, 0.05),),
"mcp": (_pose(0.0, 0.03, 0.05),),
"ip": (_pose(0.0, 0.06, 0.05),),
}
selected, reason = tracker.select(
baseline,
stamp_ns=1_000_000_000,
trajectory_command_u8=255,
trajectory_direction="decreasing",
)
assert selected is not None
assert reason == ""
driver = _pose(10.0, 0.03, 0.05)
measured_ip = _pose(20.3, 0.06, 0.20)
lower_reprojection_mirror = _pose(30.0, 0.06, 0.05)
selected, reason = tracker.select(
{
"base": (_pose(0.0, 0.00, 0.05),),
"mcp": (driver,),
"ip": (lower_reprojection_mirror, measured_ip),
},
stamp_ns=1_033_000_000,
trajectory_command_u8=128,
trajectory_direction="decreasing",
)
assert reason == ""
assert selected is not None
assert selected["ip"] == measured_ip
fallback, fallback_reason = tracker.select(
{
"base": (_pose(0.0, 0.00, 0.05),),
"mcp": (driver,),
"ip": (lower_reprojection_mirror,),
},
stamp_ns=1_066_000_000,
trajectory_command_u8=128,
trajectory_direction="decreasing",
)
assert fallback is not None
assert fallback_reason == ""
assert fallback["ip"] == lower_reprojection_mirror
def test_group_tracker_prevents_incompatible_t4_t5_branch_switch() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("t0", "t3", "t4", "t5"),
@@ -324,6 +465,234 @@ def test_group_tracker_keeps_same_pair_across_sweep_turnaround() -> None:
assert selected == {"t4": return_t4, "t5": return_t5}
def test_group_tracker_uses_outbound_pose_at_same_command_on_return() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("parent", "child"),
adjacent_pairs=(("parent", "child"),),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
)
parent = _pose(0.0, 0.00, 0.05)
for stamp, command, angle in (
(1_000_000_000, 255, 20.0),
(1_033_000_000, 64, 45.0),
(1_066_000_000, 0, 50.0),
):
selected, reason = tracker.select(
{"parent": (parent,), "child": (_pose(angle, 0.03, 0.05),)},
stamp_ns=stamp,
trajectory_command_u8=command,
trajectory_direction="decreasing",
)
assert reason == ""
assert selected is not None
# The true return contains 3 deg of real hysteresis along the learned
# y-axis. The lower-error mirror candidate is temporally smoother but
# adds a 2 deg tilt outside that physical motion axis.
true_return = _pose(42.0, 0.03, 0.20)
smoother_mirror = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("xy", [2.0, 49.0], degrees=True).as_quat()
),
translation_xyz_m=(0.03, 0.0, 0.25),
reprojection_error_px=0.01,
)
selected, reason = tracker.select(
{
"parent": (parent,),
"child": (smoother_mirror, true_return),
},
stamp_ns=1_099_000_000,
trajectory_command_u8=64,
trajectory_direction="increasing",
)
assert reason == ""
assert selected == {"parent": parent, "child": true_return}
def test_group_tracker_initializes_from_multiple_static_frames() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("parent", "child"),
adjacent_pairs=(("parent", "child"),),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
initialization_frames=8,
)
parent = _pose(0.0, 0.00, 0.05)
true_child = _pose(20.0, 0.03, 0.10)
for index in range(7):
false_child = _pose(5.0 * index, 0.03, 0.05)
selected, reason = tracker.select(
{
"parent": (parent,),
"child": (false_child, true_child),
},
stamp_ns=1_000_000_000 + index * 33_000_000,
)
assert selected is None
assert reason == f"group_initializing:{index + 1}/8"
false_child = _pose(35.0, 0.03, 0.05)
selected, reason = tracker.select(
{
"parent": (parent,),
"child": (false_child, true_child),
},
stamp_ns=1_231_000_000,
)
assert reason == ""
assert selected == {"parent": parent, "child": true_child}
assert (
tracker.last_initialization_quality["initialization_search"]
== "static_reference"
)
assert tracker.last_initialization_quality[
"p95_pair_rotation_drift_rad"
] < np.deg2rad(1.0)
def test_group_tracker_preserves_task_branch_anchor_across_cycles() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("parent", "child"),
adjacent_pairs=(("parent", "child"),),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
initialization_frames=8,
)
parent = _pose(0.0, 0.00, 0.05)
anchored_child = _pose(20.0, 0.03, 0.20)
for index in range(8):
selected, reason = tracker.select(
{"parent": (parent,), "child": (anchored_child,)},
stamp_ns=1_000_000_000 + index * 33_000_000,
trajectory_command_u8=255,
trajectory_direction="decreasing",
)
assert reason == ""
assert selected == {"parent": parent, "child": anchored_child}
tracker.reset(preserve_task_reference=True)
lower_error_mirror = _pose(5.0, 0.03, 0.01)
for index in range(8):
selected, reason = tracker.select(
{
"parent": (parent,),
"child": (lower_error_mirror, anchored_child),
},
stamp_ns=2_000_000_000 + index * 33_000_000,
trajectory_command_u8=255,
trajectory_direction="decreasing",
)
assert reason == ""
assert selected == {"parent": parent, "child": anchored_child}
assert tracker.last_initialization_quality["task_reference_used"] == "true"
tracker.reset()
for index in range(8):
selected, reason = tracker.select(
{
"parent": (parent,),
"child": (lower_error_mirror, anchored_child),
},
stamp_ns=3_000_000_000 + index * 33_000_000,
trajectory_command_u8=255,
trajectory_direction="decreasing",
)
assert reason == ""
assert selected == {"parent": parent, "child": lower_error_mirror}
def test_static_group_normal_prior_rejects_stable_ippe_mirror() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("mcp", "pip", "dip"),
adjacent_pairs=(("mcp", "pip"), ("pip", "dip")),
normal_alignment_pairs=(("mcp", "pip"), ("pip", "dip")),
normal_alignment_scale_rad=np.deg2rad(5.0),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
initialization_frames=8,
maximum_normal_alignment_rad=np.deg2rad(15.0),
)
mcp = _pose(12.0, 0.00, 0.05)
correct_pip = _pose(14.0, 0.03, 0.10)
mirror_pip = _pose(50.0, 0.03, 0.05)
dip = _pose(15.0, 0.06, 0.05)
for index in range(8):
selected, reason = tracker.select(
{
"mcp": (mcp,),
"pip": (mirror_pip, correct_pip),
"dip": (dip,),
},
stamp_ns=1_000_000_000 + index * 33_000_000,
)
assert reason == ""
assert selected == {
"mcp": mcp,
"pip": correct_pip,
"dip": dip,
}
assert tracker.last_initialization_quality[
"maximum_normal_alignment_rad"
] < np.deg2rad(3.0)
def test_static_group_rejects_when_no_aligned_branch_exists() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("base", "moving"),
adjacent_pairs=(("base", "moving"),),
normal_alignment_pairs=(("base", "moving"),),
normal_alignment_scale_rad=np.deg2rad(5.0),
maximum_normal_alignment_rad=np.deg2rad(15.0),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
initialization_frames=8,
)
base = _pose(0.0, 0.00, 0.05)
mirror = _pose(38.0, 0.03, 0.05)
for index in range(8):
selected, reason = tracker.select(
{"base": (base,), "moving": (mirror,)},
stamp_ns=1_000_000_000 + index * 33_000_000,
)
assert selected is None
assert reason == "group_normal_alignment"
def test_whole_trajectory_recovers_rigid_group_from_mirror_drift() -> None:
roles = ("t3", "t4", "t5")
mount_rotations = {
@@ -0,0 +1,69 @@
"""Tests for the explicit calibration sample command domains."""
from g20_thumb_apriltag_calibration.sample_schema import (
SampleDataContractError,
canonical_sample_record,
fitting_sample_record,
fitting_sample_records,
)
import pytest
def _sample(kind: str = 'sample') -> dict[str, object]:
return {
'kind': kind,
'joint': 'thumb_cmc_roll',
'cycle': 0,
'direction': 'decreasing',
'requested_command_u8': 0,
'feedback_u8': 17.6,
}
def test_online_and_serialized_replay_use_identical_fit_projection() -> None:
"""Serialization must not change the curve fitter's input record."""
durable = canonical_sample_record(_sample())
online = fitting_sample_record(durable)
serialized = canonical_sample_record(online)
replay = fitting_sample_record(serialized)
assert 'command_u8' not in durable
assert serialized == durable
assert replay == online
assert online['command_u8'] == 18
def test_default_domain_is_requested_only_for_steady_checkpoints() -> None:
"""Settled checkpoints and dense trajectories use declared domains."""
steady = _sample('steady_command_sample')
assert fitting_sample_record(steady)['command_u8'] == 0
assert fitting_sample_record(steady, domain='feedback')['command_u8'] == 18
def test_feedback_projection_can_snap_proven_requested_endpoints() -> None:
"""A settled endpoint remains exact after feedback-domain projection."""
records = fitting_sample_records(
[_sample()],
domain='feedback',
snap_requested_endpoints=True,
)
assert records[0]['command_u8'] == 0
assert records[0]['feedback_u8'] == pytest.approx(17.6)
def test_new_sample_rejects_missing_explicit_feedback_domain() -> None:
"""A new observation cannot rely on the compatibility command key."""
ambiguous = {
'kind': 'sample',
'requested_command_u8': 0,
'command_u8': 0,
}
with pytest.raises(
SampleDataContractError,
match='DATA-CONTRACT-701.*feedback_u8',
):
canonical_sample_record(ambiguous)
@@ -0,0 +1,697 @@
import json
import numpy as np
from g20_thumb_apriltag_calibration.three_camera_diagnostics import (
_task_text,
render_three_camera_status_text_zh,
three_camera_reason_zh,
)
def test_missing_endpoint_has_specific_chinese_reason_and_recovery() -> None:
payload = {
"state": "PAUSED",
"reason": "sweep_missing_endpoint_bin",
"progress": 0.1429,
"completed_sweeps": 6,
"total_sweeps": 42,
"active": {
"kind": "sweep",
"view": "front",
"motor_index": 5,
"joints": ["thumb_cmc_roll"],
"cycle": 1,
"repetitions": 3,
"start_u8": 255,
"target_u8": 0,
"actual_u8": 0.4,
"motion_progress": 0.998,
"valid_frames": 239,
"sample": {
"minimum_u8": 0.4,
"maximum_u8": 248.2,
"bin_count": 180,
"minimum_bin_count": 32,
"maximum_bin_gap": 3,
"allowed_maximum_bin_gap": 16,
"missing_endpoint_u8": [255],
"endpoint_tolerance_u8": 2.0,
},
},
"views": {
"front": {
"ready": False,
"detection_hz": 30.04,
"valid_rate": 0.0,
"missing_tag_ids": [2],
"pnp_rejections": {},
"group_pnp_reason": "group_pose_jump",
"pnp_invalid_seconds": 0.8,
"pnp_reset_count": 2,
},
"side": {
"ready": True,
"detection_hz": 30.02,
"valid_rate": 1.0,
"missing_tag_ids": [],
},
},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "标定已暂停(PAUSED" in text
assert "缺少电机端点255" in text
assert "实际电机范围为0.4248.2" in text
assert "第1/3轮,255→0" in text
assert "计划扫描6/42个方向,扫描进度14.3%" in text
assert "当前缺失Tag=2" in text
assert "group_pose_jump" not in text
assert "PnP拒绝" not in text
assert "自动重置" not in text
assert "/g20_calibration/resume" in text
def test_endpoint_zero_lifecycle_failure_is_not_reported_as_quality() -> None:
payload = {
"state": "PAUSED",
"reason": (
"validated_endpoint_zero_state_incomplete:"
"missing=index_pip;extra=-"
),
"active": {},
"progress": 1.0,
"completed_sweeps": 16,
"total_sweeps": 16,
"views": {},
}
text = render_three_camera_status_text_zh(payload)
assert "程序内部状态生命周期错误" in text
assert "不要移动相机、Tag或机械手底座" in text
def test_artifact_failure_explains_that_collection_need_not_restart() -> None:
payload = {
"state": "PAUSED",
"reason": (
"PUB-ARTIFACT-601:runtime curve exceeds runtime URDF limit "
"for index_mcp_pitch"
),
"active": {},
"progress": 1.0,
"completed_sweeps": 16,
"total_sweeps": 16,
"views": {},
}
text = render_three_camera_status_text_zh(payload)
assert "正式发布前" in text
assert "原始URDF未被覆盖" in text
assert "不要重新标定相机或调整Tag" in text
def test_combination_prediction_failure_is_not_reported_as_unclassified() -> None:
explanation, action = three_camera_reason_zh(
"PAUSED", "combination_pose_prediction_failed", {}
)
assert "多关节组合姿态" in explanation
assert "未分类原因码" not in explanation
assert "combination_validation_failure" in action
assert "不要重新采集16个单关节任务" in action
def test_sweep_start_visible_tags_reports_group_pnp_rejection() -> None:
explanation, action = three_camera_reason_zh(
"PAUSED",
"sweep_start_tag_timeout",
{
"group_pnp_reasons": {
"side": "group_initializing:5/8",
},
"pnp_initialization_progress": {
"side": {"accepted": 5, "required": 8},
},
"pnp_rejection_counts": {
"side": {
"ring_pip:no_pose_within_reprojection_or_tilt_limit": 12,
},
},
},
)
assert "所需Tag也可见" in explanation
assert "初始化5/8" in explanation
assert "no_pose_within_reprojection_or_tilt_limit×12" in explanation
assert "不要根据可见性重复粘贴" in action
def test_mid_sweep_pnp_failure_names_missing_candidate_role() -> None:
explanation, action = three_camera_reason_zh(
"PAUSED",
"synchronised_tag_state_timeout:side",
{
"valid_frames": 150,
"group_pnp_reasons": {
"side": "group_missing_pose_candidates",
},
"group_missing_candidate_roles": {
"side": ["index_dip"],
},
"pnp_rejection_counts": {
"side": {
"index_dip:no_pose_within_reprojection_or_tilt_limit": 90,
},
},
"pnp_candidate_diagnostics": {
"side": {
"index_dip": {
"solved_candidate_count": 2,
"reprojection_candidate_count": 0,
"independent_tilt_candidate_count": 0,
},
},
},
},
)
assert "已经取得部分有效轨迹" in explanation
assert "缺候选=index_dip" in explanation
assert "solve=2,reproj=0,tilt=0" in explanation
assert "group_pnp_candidate_event" in action
def test_multiview_failure_names_the_camera_specific_joint() -> None:
explanation, action = three_camera_reason_zh(
"PAUSED",
"sweep_bin_gap_too_large:pinky_mcp_roll_side",
{
"sample": {
"maximum_bin_gap": 20,
"maximum_bin_gap_start_u8": 100,
"maximum_bin_gap_end_u8": 120,
"allowed_maximum_bin_gap": 16,
}
},
)
assert "小指MCP侧摆(侧面校验)" in explanation
assert "100→120" in explanation
assert "resume" in action
def test_preflight_lists_missing_tags_in_chinese() -> None:
payload = {
"state": "PREFLIGHT",
"reason": "waiting_for_three_cameras_tags_and_sdk",
"progress": 0.0,
"completed_sweeps": 0,
"total_sweeps": 0,
"active": {},
"views": {
"top": {
"ready": False,
"detection_hz": 30.0,
"valid_rate": 0.0,
"missing_tag_ids": [9],
}
},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "设备和标签预检(PREFLIGHT" in text
assert "当前缺失Tag=9" in text
assert "全部必需Tag同时有效率0.0%" in text
def test_joint_fit_failure_names_metric_and_selective_retry() -> None:
payload = {
"state": "PAUSED",
"reason": "joint_fit_check_failed",
"progress": 18 / 42,
"completed_sweeps": 18,
"total_sweeps": 42,
"active": {
"kind": "fit_failure",
"view": "front",
"motor_index": 6,
"joints": ["index_mcp_roll"],
"attempt": 1,
"directions_to_rescan": 6,
"failures": [
{
"joint": "index_mcp_roll",
"metric": "arc_deg",
"actual": 3.98,
"limit": 15.0,
"comparison": "minimum",
}
],
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "食指MCP侧摆的实测圆弧为3.98°" in text
assert "要求至少15.00°" in text
assert "只清除当前失败关节的数据并重扫6个方向" in text
assert "第1次尝试" in text
assert "运动采样:" not in text
def test_palm_orientation_failure_explains_thumb_top_coverage() -> None:
explanation, suggestion = three_camera_reason_zh(
"PAUSED",
"palm_orientation_quality_failed",
{
"failures": [
{
"reason": (
"palm orientation cycle 2 has 1/2 usable sources: "
"thumb_cmc_roll_top_axis:cycle2=too few samples"
)
}
]
},
)
assert "顶部Tag 8/9" in explanation
assert "1/2 usable sources" in explanation
assert "CMC pitch和roll" in suggestion
def test_cross_view_side_line_rms_names_side_source() -> None:
payload = {
"state": "PAUSED",
"reason": "joint_fit_check_failed",
"progress": 0.1,
"completed_sweeps": 8,
"total_sweeps": 80,
"active": {
"kind": "fit_failure",
"view": "front",
"motor_index": 9,
"joints": ["pinky_mcp_roll", "pinky_mcp_roll_side"],
"attempt": 1,
"directions_to_rescan": 8,
"failures": [
{
"joint": "pinky_mcp_roll_side",
"model_joint": "pinky_mcp_roll",
"quality_source_joints": ["pinky_mcp_roll_side"],
"metric": "axis_line_cycle_rms_mm",
"actual": 1.2,
"limit": 1.0,
"comparison": "maximum",
}
],
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "小指MCP侧摆(侧面校验)的四轮轴线位置RMS为1.20mm" in text
assert "要求不超过1.00mm" in text
def test_baseline_hysteresis_failure_shows_values_instead_of_unknown() -> None:
payload = {
"state": "PAUSED",
"reason": "joint_fit_check_failed",
"progress": 0.15,
"completed_sweeps": 24,
"total_sweeps": 160,
"active": {
"kind": "fit_failure",
"view": "front",
"motor_index": 15,
"joints": ["thumb_ip"],
"attempt": 3,
"directions_to_rescan": 6,
"failures": [
{
"joint": "thumb_ip",
"metric": "baseline_hysteresis_deg",
"actual": 1.34,
"limit": 0.5,
"comparison": "maximum",
"cycle_values_deg": [1.34, 0.04, 0.14],
}
],
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "baseline正反程关节角差为1.34°" in text
assert "要求不超过0.50°" in text
assert "各轮=1.34°/0.04°/0.14°" in text
assert "未知原因" not in text
def test_thumb_yaw_repeatability_failure_names_real_source_retry() -> None:
explanation, suggestion = three_camera_reason_zh(
"PAUSED",
"joint_fit_check_failed",
{
"source_task_names": [
"thumb_cmc_pitch_front",
"thumb_cmc_roll_front",
],
"directions_to_rescan": 16,
"failures": [
{
"joint": "thumb_cmc_yaw",
"metric": "zero_cycle_offset_range_deg",
"actual": 0.555836,
"limit": 0.5,
"comparison": "maximum",
"cycle": 3,
"cycle_offset_deg": [-2.650924, -2.615277, -2.095088],
}
],
},
)
assert "训练轮零位极差为0.56°" in explanation
assert "各轮=-2.65°/-2.62°/-2.10°" in explanation
assert "未知原因" not in explanation
assert "CMC pitch/roll顶部轴观测" in suggestion
assert "重扫16个方向" in suggestion
assert "不会无效重扫yaw侧摆" in suggestion
def test_sweep_gap_status_names_gap_bounds_and_full_detection_rate() -> None:
payload = {
"state": "PAUSED",
"reason": "sweep_bin_gap_too_large",
"progress": 0.1,
"completed_sweeps": 1,
"total_sweeps": 10,
"active": {
"kind": "sweep",
"view": "top",
"motor_index": 10,
"joints": ["thumb_cmc_yaw"],
"cycle": 1,
"repetitions": 3,
"start_u8": 255,
"target_u8": 0,
"actual_u8": 3.0,
"valid_frames": 610,
"detection_frames": 644,
"detection_valid_frames": 610,
"detection_rate": 610 / 644,
"sample": {
"minimum_u8": 3.0,
"maximum_u8": 252.0,
"maximum_bin_gap": 17,
"maximum_bin_gap_start_u8": 99,
"maximum_bin_gap_end_u8": 116,
"allowed_maximum_bin_gap": 16,
},
},
"views": {},
}
text = render_three_camera_status_text_zh(payload)
assert "最大空缺为17,(99→116" in text
assert "本方向Tag检出:94.7%610/644帧)" in text
def test_zero_model_failure_explains_that_rescan_will_not_help() -> None:
payload = {
"state": "PAUSED",
"reason": "zero_model_validation_failed",
"progress": 0.92,
"scan_progress": 1.0,
"completed_sweeps": 42,
"total_sweeps": 42,
"active": {
"kind": "zero_model_failure",
"view": "front",
"motor_index": 15,
"joints": ["thumb_mcp", "thumb_ip"],
"directions_to_rescan": 0,
"failures": [
{
"joint": "thumb_mcp",
"metric": "zero_guard",
"reason": "zero_offset_exceeds_configured_limit",
"actual_deg": -39.81,
"limit_deg": 20.0,
}
],
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "拇指MCP:零位估计超过安全范围" in text
assert "估计-39.81°,允许±20.00°" in text
assert "不会自动重扫" in text
assert "总体进度:92.0%" in text
assert "扫描进度100.0%" in text
assert "运动采样:" not in text
def test_sweep_status_shows_localized_fit_retry_cycle() -> None:
active = {
"kind": "sweep",
"view": "front",
"motor_index": 15,
"joints": ["thumb_mcp", "thumb_ip"],
"cycle": 1,
"repetitions": 3,
"start_u8": 255,
"target_u8": 0,
"fit_attempt": 2,
"fit_attempt_limit": 3,
"fit_retry_cycles": [2],
}
assert "补采异常轮2" in _task_text(active)
def test_motor_stall_reason_is_explained_in_chinese() -> None:
reason, action = three_camera_reason_zh(
"PAUSED",
"motor_state_stalled:sweep_motor_19:error_u8=5.000",
{},
)
assert "距目标5.000个u8" in reason
assert "机械端点" in action
assert "不要反复调用resume" in action
def test_baseline_stall_names_motor_target_actual_and_tolerance() -> None:
payload = {
"state": "PAUSED",
"reason": (
"motor_state_stalled:return_baseline:motor_index=10:"
"target_u8=255.0:actual_u8=250.0:tolerance_u8=4.0:"
"timeout_seconds=2.000:"
"error_u8=5.000"
),
"progress": 0.0,
"scan_progress": 0.0,
"completed_sweeps": 0,
"total_sweeps": 42,
"active": {
"kind": "motion_stall",
"stage": "return_baseline",
"motor_index": 10,
"target_u8": 255.0,
"actual_u8": 250.0,
"error_u8": 5.0,
"tolerance_u8": 4.0,
"timeout_seconds": 2.0,
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "电机10反馈连续2秒" in text
assert "目标255.0、实际250.0、误差5.000 u8" in text
assert "允许容差±4.0 u8" in text
assert "当前任务:电机10运动停滞,目标255.0、实际250.0" in text
assert "运动采样:" not in text
def test_return_baseline_prints_the_exact_command() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
payload = {
"state": "RETURN_BASELINE",
"reason": "return_baseline_before_next_sweep",
"progress": 0.0,
"completed_sweeps": 0,
"total_sweeps": 42,
"baseline_command_u8": baseline,
"active": {},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "正在恢复目标姿态(RETURN_BASELINE" in text
assert f"正在确认基准姿态:{baseline}" in text
def test_return_recovery_prints_thumb_yaw_start_and_clearance_pose() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
recovery = list(baseline)
recovery[5] = 145
recovery[10] = 0
payload = {
"state": "RETURN_BASELINE",
"reason": "return_baseline_before_retry_sweep",
"progress": 0.5,
"completed_sweeps": 36,
"total_sweeps": 42,
"baseline_command_u8": baseline,
"return_command_u8": recovery,
"active": {},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert f"正在确认恢复姿态:{recovery}" in text
def test_status_numeric_diagnostics_are_json_serializable() -> None:
bins = [0, 16, 255]
payload = {
"state": "SWEEP",
"reason": "collecting_timestamp_synchronised_tag_centres",
"active": {
"sample": {
# np.diff返回NumPy标量;节点必须在放入状态前转成原生int。
"maximum_bin_gap": int(max(np.diff(bins), default=0)),
}
},
}
encoded = json.dumps(payload, ensure_ascii=False)
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",
"reason": "collecting_timestamp_synchronised_tag_centres",
"progress": 0.43,
"completed_sweeps": 18,
"total_sweeps": 42,
"active": {
"kind": "sweep",
"view": "front",
"motor_index": 6,
"joints": ["index_mcp_roll"],
"cycle": 1,
"repetitions": 3,
"automatic_retry_count": 1,
"automatic_retry_limit": 2,
"start_u8": 255,
"target_u8": 0,
"actual_u8": 44.0,
"motion_progress": 0.827,
"valid_frames": 971,
"sample": {"minimum_u8": 40.0, "maximum_u8": 253.0},
"auxiliary_motors": [
{"motor_index": 7, "command_u8": 0, "actual_u8": 0.0},
{"motor_index": 8, "command_u8": 0, "actual_u8": 1.0},
{"motor_index": 9, "command_u8": 0, "actual_u8": 0.0},
],
"speed": {
"commanded_finger_speed": [15, 5, 15, 15, 15],
"reported_finger_speed": [15, 5, 15, 15, 15],
},
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "避挡姿态:电机7目标0、实际0.0" in text
assert "电机8目标0、实际1.0" in text
assert "电机9目标0、实际0.0" in text
assert "阶段速度:五指目标[15, 5, 15, 15, 15]" in text
assert "SDK报告[15, 5, 15, 15, 15]" in text
assert "自动重试:当前方向已自动重扫1/2次" in text
def test_same_finger_transition_explains_that_clearance_stays_parked() -> None:
explanation, action = three_camera_reason_zh(
"RETURN_BASELINE",
"holding_same_finger_clearance_before_next_task",
{},
)
assert "继续保持当前避让姿态" in explanation
assert "只调整被测关节" in explanation
assert "不要手动展开" in action
def test_fixed_base_reference_movement_requires_a_new_session() -> None:
explanation, action = three_camera_reason_zh(
"PAUSED",
"fixed_base_reference_moved",
{},
)
assert "Tag 8" in explanation
assert "基准锁定后" in explanation
assert "下一次标定预检前重新摆放" in action
assert "重新启动新会话" in action
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -75,10 +75,10 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"点赞": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"握拳": [96, 0, 0, 0, 0, 0, 193, 158, 128, 91, 132, 255, 255, 255, 255, 144, 0, 0, 0, 0],
"张开": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"OK": [148, 110, 255, 255, 255, 44, 164, 100, 114, 127, 178, 255, 255, 255, 255, 94, 71, 255, 255, 255],
"拇指对中指": [191, 255, 55, 255, 255, 96, 95, 100, 114, 127, 105, 255, 255, 255, 255, 94, 255, 108, 255, 255],
"拇指对无名指": [191, 255, 255, 72, 255, 115, 95, 100, 114, 127, 60, 255, 255, 255, 255, 94, 255, 255, 97, 255],
"拇指对小指": [191, 255, 255, 255, 55, 0, 95, 100, 114, 121, 70, 255, 255, 255, 255, 94, 255, 255, 255, 100],
"OK": [0, 0, 255, 255, 255, 138, 147, 148, 105, 42, 109, 255, 255, 255, 255, 255, 211, 255, 255, 255],
"拇指对中指": [0, 255, 0, 255, 255, 107, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 225, 202, 255, 255],
"拇指对无名指": [0, 255, 255, 0, 255, 88, 171, 148, 105, 42, 59, 255, 255, 255, 255, 255, 255, 255, 206, 254],
"拇指对小指": [0, 255, 255, 255, 0, 32, 170, 148, 105, 42, 109, 255, 255, 255, 255, 255, 255, 255, 255, 203],
"准备1": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"": [96, 255, 0, 0, 0, 0, 190, 161, 127, 80, 68, 255, 255, 255, 255, 144, 255, 0, 0, 0],
"": [96, 255, 255, 0, 0, 0, 190, 66, 127, 80, 68, 255, 255, 255, 255, 144, 255, 255, 0, 0],
-31
View File
@@ -1,31 +0,0 @@
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='linker_hand_ros2_sdk',
executable='linker_hand_sdk',
name='linker_hand_sdk_left',
output='screen',
parameters=[{
'hand_type': 'left',
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
'is_touch': True, # 是否带有压力传感器
'can': 'can0', # 这里需要修改为实际的CAN总线名称
}],
),
Node(
package='linker_hand_ros2_sdk',
executable='linker_hand_sdk',
name='linker_hand_sdk_right',
output='screen',
parameters=[{
'hand_type': 'right',
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
'is_touch': True, # 是否带有压力传感器
'can': 'can0', # 这里需要修改为实际的CAN总线名称
}],
),
])
@@ -1,444 +0,0 @@
#!/usr/bin/env python3
import os
import time
from pymodbus.client import ModbusSerialClient
from typing import List, Dict
import numpy as np
_INTERVAL = 0.006 # 8 ms
class LinkerHandL6RS485:
"""L6机械手 Modbus-RTU 控制类"""
# 6个关节名称
JOINT_NAMES = ["thumb_pitch", "thumb_yaw", "index_pitch",
"middle_pitch", "ring_pitch", "little_pitch"]
# 手指名称
FINGER_NAMES = ["thumb", "index", "middle", "ring", "little"]
def __init__(self, hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200):
"""
初始化L6机械手
hand_id: 右手0x27(39), 左手0x28(40)
modbus_port: 串口设备路径
baudrate: 波特率,固定115200
"""
self.slave = hand_id
self.cli = ModbusSerialClient(
port=modbus_port,
baudrate=baudrate,
bytesize=8,
parity="N",
stopbits=1,
timeout=0.05
)
# pymodbus 3.5.1 需要显式连接
self.connected = self.cli.connect()
if not self.connected:
raise ConnectionError(f"RS485连接失败,端口: {modbus_port}")
def _read_input_registers(self, address: int, count: int) -> List[int]:
"""读取输入寄存器"""
time.sleep(_INTERVAL)
result = self.cli.read_input_registers(address=address, count=count, slave=self.slave)
if result.isError():
raise RuntimeError(f"读取输入寄存器失败: address={address}, count={count}")
return result.registers
def _write_register(self, address: int, value: int):
"""写入单个寄存器"""
time.sleep(_INTERVAL)
result = self.cli.write_register(address=address, value=value, slave=self.slave)
if result.isError():
raise RuntimeError(f"写入寄存器失败: address={address}, value={value}")
def _write_registers(self, address: int, values: List[int]):
"""写入多个寄存器"""
time.sleep(_INTERVAL)
result = self.cli.write_registers(address=address, values=values, slave=self.slave)
if result.isError():
raise RuntimeError(f"写入多个寄存器失败: address={address}, values={values}")
# --------------------------------------------------
# 基础读取接口
# --------------------------------------------------
def read_angles(self) -> List[int]:
"""读取6个关节角度 (输入寄存器 0-5)"""
return self._read_input_registers(0, 6)
def read_torques(self) -> List[int]:
"""读取6个关节转矩 (输入寄存器 6-11)"""
return self._read_input_registers(6, 6)
def read_speeds(self) -> List[int]:
"""读取6个关节速度 (输入寄存器 12-17)"""
return self._read_input_registers(12, 6)
def read_temperatures(self) -> List[int]:
"""读取6个关节温度 (输入寄存器 18-23)"""
return self._read_input_registers(18, 6)
def read_error_codes(self) -> List[int]:
"""读取6个关节错误码 (输入寄存器 24-29)"""
return self._read_input_registers(24, 6)
# --------------------------------------------------
# 压力传感器接口
# --------------------------------------------------
# def _pressure(self, finger: int) -> List[int]:
# """内部:选手指 → 读压力数据"""
# # 选择手指 (保持寄存器 36)
# self._write_register(36, finger)
# time.sleep(_INTERVAL)
# # 读取压力数据 (输入寄存器 52-122)
# return np.array(self._read_input_registers(52, 71))
def _pressure(self, finger: int) -> np.ndarray:
"""
6x12 (72点) 矩阵尺寸。
Modbus 地址 60/62。
"""
rows = 12 # 12 行
cols = 6 # 6 列
finger_size = rows * cols # 72 个数据点
# modbus 地址和计数
write_address = 60 # 写入手指选择
read_address = 62 # 读取压力数据
read_count = 96 # 读取 96 个寄存器
skip_count = 10 # 跳过前 10 个校验点
# 0. 参数校验和手指写入值确定
if finger < 1 or finger > 5:
raise ValueError(f"无效的手指编号: {finger}。手指编号应在 1 到 5 之间。")
finger_write_value = finger
# 1. 写入手指选择寄存器 (地址 60)
time.sleep(0.008)
wrsp = self.cli.write_register(address=write_address, value=finger_write_value, slave=self.slave)
if wrsp.isError():
raise RuntimeError(f"写入手指选择 {finger} 到地址 {write_address} 失败: {wrsp}")
# 写入后等待片刻
time.sleep(0.008)
# 2. 读取地址 62 的数据
rrsp = self.cli.read_input_registers(address=read_address, count=read_count, slave=self.slave)
if rrsp.isError():
raise RuntimeError(f"读取地址 {read_address} 压力数据失败: {rrsp}")
registers_16bit: List[int] = rrsp.registers
# 3. 核心数据处理
# a. 提取低 8 位数据 (得到 96 个 8 位数据点)
final_data_96 = [reg_value & 255 for reg_value in registers_16bit]
# b. 跳过前 10 个校验/头部数据点 (得到 86 个有效数据点)
effective_data = np.array(final_data_96[skip_count:], dtype=np.uint8)
# c. 截取当前手指的矩阵数据 (从 86 个有效点中截取 72 个点)
start_idx = 0
end_idx = finger_size # 72
finger_data_flat = effective_data[start_idx:end_idx]
# d. 验证数据长度
if finger_data_flat.size != finger_size:
raise ValueError(
f"数据提取失败。期望 {finger_size} 点 ({rows}x{cols})"
f"但仅截取到 {finger_data_flat.size} 点。请检查协议,确认地址 62 是否一次性返回了所有手指数据。"
)
# e. 重塑为二维矩阵 (12 行 6 列)
finger_matrix = finger_data_flat.reshape((rows, cols))
return finger_matrix
def read_pressure_thumb(self) -> np.ndarray:
"""读取大拇指压力数据"""
return np.array(self._pressure(1), dtype=np.uint8)
def read_pressure_index(self) -> np.ndarray:
"""读取食指压力数据"""
return np.array(self._pressure(2), dtype=np.uint8)
def read_pressure_middle(self) -> np.ndarray:
"""读取中指压力数据"""
return np.array(self._pressure(3), dtype=np.uint8)
def read_pressure_ring(self) -> np.ndarray:
"""读取无名指压力数据"""
return np.array(self._pressure(4), dtype=np.uint8)
def read_pressure_little(self) -> np.ndarray:
"""读取小拇指压力数据"""
return np.array(self._pressure(5), dtype=np.uint8)
# --------------------------------------------------
# 版本信息接口
# --------------------------------------------------
def read_versions(self) -> Dict[str, int]:
"""读取版本信息 (输入寄存器 148-155)"""
result = self._read_input_registers(148, 8)
return {
"hand_freedom": result[0],
"hand_version": result[1],
"hand_number": result[2],
"hand_direction": result[3],
"software_version_major": result[4],
"software_version_minor": result[5] if len(result) > 5 else 0,
"software_version_revision": result[6] if len(result) > 6 else 0,
"hardware_version": result[7] if len(result) > 7 else 0
}
# --------------------------------------------------
# 写入接口
# --------------------------------------------------
def write_angles(self, vals: List[int]):
"""设置6个关节角度 (保持寄存器 0-5)"""
vals = [int(x) for x in vals]
if not self.is_valid_6xuint8(vals):
raise ValueError("需要6个0-255的整数")
self._write_registers(0, vals)
def write_torques(self, vals: List[int]):
"""设置6个关节转矩 (保持寄存器 6-11)"""
vals = [int(x) for x in vals]
if not self.is_valid_6xuint8(vals):
raise ValueError("需要6个0-255的整数")
self._write_registers(6, vals)
def write_speeds(self, vals: List[int]):
"""设置6个关节速度 (保持寄存器 12-17)"""
vals = [int(x) for x in vals]
if not self.is_valid_6xuint8(vals):
raise ValueError("需要6个0-255的整数")
self._write_registers(12, vals)
# --------------------------------------------------
# 上下文管理
# --------------------------------------------------
def close(self):
"""关闭连接"""
if self.connected:
self.cli.close()
self.connected = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# --------------------------------------------------
# API固定接口函数
# --------------------------------------------------
def is_valid_6xuint8(self, lst) -> bool:
"""验证6个0-255的整数列表"""
if len(lst) != 6:
return False
return all(isinstance(x, int) and 0 <= x <= 255 for x in lst)
def set_joint_positions(self, joint_angles=None):
"""设置关节位置"""
joint_angles = joint_angles or [0] * 6
self.write_angles(joint_angles)
def set_speed(self, speed=None):
"""设置速度"""
speed = speed or [200] * 6
self.write_speeds(speed)
def set_torque(self, torque=None):
"""设置扭矩"""
torque = torque or [200] * 6
self.write_torques(torque)
def set_current(self, current=None):
"""设置电流 (L6不支持)"""
print("当前L6不支持设置电流", flush=True)
def get_version(self) -> list:
"""获取版本信息"""
versions = self.read_versions()
return [
versions.get("hand_freedom", 0),
versions.get("hand_version", 0),
versions.get("hand_number", 0),
versions.get("hand_direction", 0),
versions.get("software_version_major", 0),
versions.get("hardware_version", 0)
]
def get_current(self):
"""获取电流 (L6不支持)"""
print("当前L6不支持获取电流", flush=True)
return []
def get_state(self) -> list:
"""获取关节状态"""
return self.read_angles()
def get_state_for_pub(self) -> list:
return self.get_state()
def get_current_status(self) -> list:
return self.get_state()
def get_speed(self) -> list:
"""获取当前速度"""
return self.read_speeds()
def get_joint_speed(self) -> list:
return self.get_speed()
def get_touch_type(self) -> int:
"""获取压感类型 (2=矩阵式)"""
return 2
def get_normal_force(self) -> list:
"""获取压感数据:点式"""
return [-1] * 5
def get_tangential_force(self) -> list:
"""获取压感数据:点式"""
return [-1] * 5
def get_approach_inc(self) -> list:
"""获取压感数据:点式"""
return [-1] * 5
def get_touch(self) -> list:
return [-1] * 5
def get_thumb_matrix_touch(self,sleep_time=0):
return self._pressure(1)
def get_index_matrix_touch(self,sleep_time=0):
return self._pressure(2)
def get_middle_matrix_touch(self,sleep_time=0):
return self._pressure(3)
def get_ring_matrix_touch(self,sleep_time=0):
return self._pressure(4)
def get_little_matrix_touch(self,sleep_time=0):
return self._pressure(5)
def get_matrix_touch(self) -> list:
"""获取压感数据:矩阵式"""
return [self._pressure(1), self._pressure(2), self._pressure(3),
self._pressure(4), self._pressure(5)]
def get_matrix_touch_v2(self) -> list:
"""获取压感数据:矩阵式"""
return self.get_matrix_touch()
def get_torque(self) -> list:
"""获取当前扭矩"""
return self.read_torques()
def get_temperature(self) -> list:
"""获取当前电机温度"""
return self.read_temperatures()
def get_fault(self) -> list:
"""获取当前电机故障码"""
return self.read_error_codes()
def get_serial_number(self):
return [0] * 6
# --------------------------------------------------
# 便捷方法
# --------------------------------------------------
def relax(self):
"""所有手指伸直"""
self.set_joint_positions([255] * 6)
def fist(self):
"""所有手指握拳"""
self.set_joint_positions([0] * 6)
def dump_status(self):
"""打印状态信息"""
print("=" * 50)
print("L6机械手状态信息")
print("=" * 50)
try:
# 关节状态
angles = self.read_angles()
torques = self.read_torques()
speeds = self.read_speeds()
temps = self.read_temperatures()
errors = self.read_error_codes()
print("关节状态:")
for i, name in enumerate(self.JOINT_NAMES):
print(f" {name:15s}: 角度={angles[i]:3d}, 扭矩={torques[i]:3d}, "
f"速度={speeds[i]:3d}, 温度={temps[i]:2d}℃, 错误={errors[i]:2d}")
# 版本信息
versions = self.read_versions()
print("\n版本信息:")
for key, value in versions.items():
print(f" {key:20s}: {value}")
# 压力传感器测试
print("\n压力传感器测试:")
thumb_pressure = self.read_pressure_thumb()
print(f"大拇指压力数据长度: {len(thumb_pressure)}")
except Exception as e:
print(f"读取状态时出错: {e}")
print("=" * 50)
# ------------------- 演示程序 -------------------
if __name__ == "__main__":
# 使用示例
try:
with LinkerHandL6RS485(hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200) as hand:
print("连接成功!")
# 打印状态信息
hand.dump_status()
# 测试基本控制
print("\n测试控制功能...")
print("伸直手指...")
hand.relax()
time.sleep(2)
print("握拳...")
hand.fist()
time.sleep(2)
print("恢复伸直...")
hand.relax()
# 测试压力传感器
print("\n测试压力传感器...")
thumb_matrix = hand.get_thumb_matrix_touch()
print(f"大拇指压力数据: {len(thumb_matrix)}个点")
# 获取所有手指压力数据
all_matrices = hand.get_matrix_touch()
for i, name in enumerate(hand.FINGER_NAMES):
matrix = all_matrices[i]
print(f"{name}手指压力数据长度: {len(matrix)}")
except Exception as e:
print(f"错误: {e}")
@@ -1,414 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''
编译: colcon build --symlink-install
启动命令:ros2 run linker_hand_ros2_sdk linker_hand_sdk
'''
from re import A
import rclpy,sys # ROS2 Python接口库
import time
import numpy as np
from rclpy.node import Node # ROS2 节点类
from rclpy.clock import Clock
from std_msgs.msg import String, Header, Float32MultiArray
from sensor_msgs.msg import JointState, PointCloud2, PointField
import time, json, threading
from linker_hand_ros2_sdk.LinkerHand.linker_hand_api import LinkerHandApi
from linker_hand_ros2_sdk.LinkerHand.utils.color_msg import ColorMsg
from linker_hand_ros2_sdk.LinkerHand.utils.open_can import OpenCan
class LinkerHand(Node):
def __init__(self, name):
super().__init__(name)
# 声明参数(带默认值)
self.declare_parameter('hand_type', 'left')
self.declare_parameter('hand_joint', 'L6')
self.declare_parameter('is_touch', False)
self.declare_parameter('can', 'can0')
self.declare_parameter('modbus', "None")
# ros时间获取
self.stamp_clock = Clock()
# 获取参数值
self.hand_type = self.get_parameter('hand_type').value
self.hand_joint = self.get_parameter('hand_joint').value
self.is_touch = self.get_parameter('is_touch').value
self.can = self.get_parameter('can').value
self.modbus = self.get_parameter('modbus').value
self.sdk_v = 2
self.sleep_time = 0.005
self.cmd_lock = False
self.last_hand_post_cmd = None # 最新手指位置命令
self.last_hand_vel_cmd = None # 最新手指速度命令
self.last_hand_eff_cmd = None # 最新手指力矩命令
self.last_hand_state = [-1] * 10
self.last_hand_vel = [-1] * 10
self.force = [[-1] * 5] * 4
self.matrix_dic = {
"stamp":{
"sec": 0,
"nanosec": 0,
},
"thumb_matrix":[[-1] * 6 for _ in range(12)],
"index_matrix":[[-1] * 6 for _ in range(12)],
"middle_matrix":[[-1] * 6 for _ in range(12)],
"ring_matrix":[[-1] * 6 for _ in range(12)],
"little_matrix":[[-1] * 6 for _ in range(12)]
}
# 压感矩阵合值,单位g 克
self.matrix_mass_dic = {
"stamp":{
"secs": 0,
"nsecs": 0,
},
"thumb_mass":[-1],
"index_mass":[-1],
"middle_mass":[-1],
"ring_mass":[-1],
"little_mass":[-1]
}
self.last_hand_info = {
"version": [-1], # Dexterous hand version number
"hand_joint": self.hand_joint, # Dexterous hand joint type
"speed": [-1] * 10, # Current speed threshold of the dexterous hand
"current": [-1] * 10, # Current of the dexterous hand
"fault": [-1] * 10, # Current fault of the dexterous hand
"motor_temperature": [-1] * 10, # Current motor temperature of the dexterous hand
"torque": [-1] * 10, # Current torque of the dexterous hand
"is_touch":self.is_touch,
"touch_type": -1,
"finger_order": None # Finger motor order
}
self.version = []
self.touch_type = -1
self.hz = 1.0/60.0
self.hand_setting_sub = self.create_subscription(String,'/cb_hand_setting_cmd', self.hand_setting_cb, 10)
self._init_hand()
time.sleep(1)
self.run_count = 0 # 计数器,用于记录运行次数
self.timer = self.create_timer(0.01, self.run) # 100 Hz
self.thread_pub_state = threading.Thread(target=self.pub_state)
self.thread_pub_state.daemon = True
self.thread_pub_state.start()
def _init_hand(self):
self.api = LinkerHandApi(hand_type=self.hand_type, hand_joint=self.hand_joint,modbus=self.modbus,can=self.can)
time.sleep(0.1)
self.touch_type = self.api.get_touch_type()
self.hand_cmd_sub = self.create_subscription(JointState, f'/cb_{self.hand_type}_hand_control_cmd', self.hand_control_cb,10)
self.hand_state_pub = self.create_publisher(JointState, f'/cb_{self.hand_type}_hand_state',10)
self.hand_info_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_info', 10)
if self.is_touch == True:
if self.touch_type > 1:
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Equipped with matrix pressure sensing", color='green')
self.matrix_touch_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_matrix_touch', 10)
self.matrix_touch_pub_pc = self.create_publisher(PointCloud2, f'/cb_{self.hand_type}_hand_matrix_touch_pc', 10)
self.matrix_touch_mass_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_matrix_touch_mass', 10)
elif self.touch_type != -1:
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Equipped with pressure sensor", color="green")
self.touch_pub = self.create_publisher(Float32MultiArray, f'/cb_{self.hand_type}_hand_force', 10)
else:
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Not equipped with any pressure sensors", color="red")
self.is_touch = False
self.embedded_version = self.api.get_embedded_version()
pose = None
torque = [200, 200, 200, 200, 200]
speed = [200, 250, 250, 250, 250]
if self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6" or self.hand_joint.upper() == "L6P":
pose = [200, 255, 255, 255, 255, 180]
torque = [250, 250, 250, 250, 250, 250]
# O6 最大速度阈值
speed = [200, 250, 250, 250, 250, 250]
elif self.hand_joint == "L7":
# The data length of L7 is 7, reinitialize here
pose = [255, 200, 255, 255, 255, 255, 180]
torque = [250, 250, 250, 250, 250, 250, 250]
speed = [120, 250, 250, 250, 250, 250, 250]
elif self.hand_joint == "L10":
torque = [255] * 10
pose = [255, 200, 255, 255, 255, 255, 180, 180, 180, 41]
speed = [200, 250, 250, 250, 250, 250, 250, 250, 250, 250]
elif self.hand_joint == "L20":
pose = [255,255,255,255,255,255,10,100,180,240,245,255,255,255,255,255,255,255,255,255]
elif self.hand_joint == "L21":
pose = [75, 255, 255, 255, 255, 176, 97, 81, 114, 147, 202, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
elif self.hand_joint == "L25":
pose = [75, 255, 255, 255, 255, 176, 97, 81, 114, 147, 202, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
if pose is not None:
for i in range(1):
self.api.set_speed(speed=speed)
time.sleep(0.1)
self.api.set_torque(torque=torque)
time.sleep(0.1)
self.api.finger_move(pose=pose)
time.sleep(0.1)
def list_check(self,pose):
if isinstance(pose, list) == False:
return False
if len(self.last_hand_post_cmd) != len(pose):
return False
return any(abs(self.last_hand_post_cmd - pose) >= 3 for self.last_hand_post_cmd, pose in zip(self.last_hand_post_cmd, pose))
def hand_control_cb(self, msg):
if self.last_hand_post_cmd == None or self.list_check(msg.position) == True:
self.last_hand_post_cmd = msg.position
if self.last_hand_vel_cmd == None or self.list_check(msg.velocity) == True:
self.last_hand_vel_cmd = msg.velocity
if self.last_hand_eff_cmd == None or self.list_check(msg.effort) == True:
self.last_hand_eff_cmd = msg.effort
def run(self):
if self.sdk_v == 1:
self.sleep_time = 0.009
if self.hand_state_pub.get_subscription_count() > 0:
# 优先获取手指状态并且发布
self.last_hand_state = self.api.get_state()
time.sleep(0.003)
self.last_hand_vel = self.api.get_joint_speed()
time.sleep(0.002)
if self.cmd_lock == False:
if self.last_hand_post_cmd != None:
self.api.finger_move(pose=self.last_hand_post_cmd)
self.last_hand_post_cmd = None
if self.last_hand_vel_cmd != None:
vel = list(self.last_hand_vel_cmd)
if all(x == 0 for x in vel):
pass
else:
if (str(self.hand_joint).upper() == "O6" or str(self.hand_joint).upper() == "L6" or str(self.hand_joint).upper() == "L6P") and len(vel) == 6:
speed = vel
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L7" and len(vel) == 7:
speed = vel
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L10" and len(vel) == 10:
speed = [vel[0],vel[2],vel[3],vel[4],vel[5]]
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L20" and len(vel) == 20:
speed = [vel[10],vel[1],vel[2],vel[3],vel[4]]
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L21" and len(vel) == 25:
speed = vel
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L25" and len(vel) == 25:
speed = vel
self.api.set_joint_speed(speed=speed)
self.last_hand_vel_cmd = None
time.sleep(0.003)
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.touch_pub.get_subscription_count() > 0:
"""单点式压力传感器"""
self.force = self.api.get_force()
if self.is_touch == True and self.touch_type > 1 and (self.matrix_touch_pub.get_subscription_count() > 0 or self.matrix_touch_mass_pub.get_subscription_count() > 0 or self.matrix_touch_pub_pc.get_subscription_count() > 0):
"""矩阵式压力传感器"""
if self.run_count == 3:
self.matrix_dic["thumb_matrix"] = self.api.get_thumb_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 4:
self.matrix_dic["index_matrix"] = self.api.get_index_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 5:
self.matrix_dic["middle_matrix"] = self.api.get_middle_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 6:
self.matrix_dic["ring_matrix"] = self.api.get_ring_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 7:
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
time.sleep(0.005)
if self.run_count == 8 and self.hand_info_pub.get_subscription_count() > 0:
"""手部信息"""
self.last_hand_info = {
"version": self.embedded_version, # Dexterous hand version number
"hand_joint": self.hand_joint, # Dexterous hand joint type
"speed": self.api.get_speed(), # Current speed threshold of the dexterous hand
"current": self.api.get_current(), # Current of the dexterous hand
"fault": self.api.get_fault(), # Current fault of the dexterous hand
"motor_temperature": self.api.get_temperature(), # Current motor temperature of the dexterous hand
"torque": self.api.get_torque(), # Current torque of the dexterous hand
"is_touch":self.is_touch,
"touch_type": self.touch_type,
"finger_order": self.api.get_finger_order() # Finger motor order
}
if self.run_count == 9:
self.run_count = 0
self.run_count += 1
time.sleep(0.003)
def pub_state(self):
while True:
if self.hand_state_pub.get_subscription_count() > 0:
msg = self.joint_state_msg(self.last_hand_state, self.last_hand_vel)
self.hand_state_pub.publish(msg)
if self.is_touch == True and self.touch_type == 1 and self.touch_pub.get_subscription_count() > 0:
msg = Float32MultiArray()
msg.data = [float(val) for sublist in self.force for val in sublist]
self.touch_pub.publish(msg)
if self.is_touch == True and self.touch_type > 1 and (self.matrix_touch_pub.get_subscription_count() > 0 or self.matrix_touch_mass_pub.get_subscription_count() > 0 or self.matrix_touch_pub_pc.get_subscription_count() > 0):
# 发布矩阵压感数据JSON格式
self.pub_matrix_dic()
# 发布矩阵压感和值JSON格式
self.pub_matrix_mass(dic=self.matrix_dic)
# 发布矩阵压感点云格式
self.pub_matrix_point_cloud()
if self.hand_info_pub.get_subscription_count() > 0:
msg = String()
msg.data = json.dumps(self.last_hand_info)
self.hand_info_pub.publish(msg)
time.sleep(self.hz)
def pub_matrix_mass(self, dic):
"""发布矩阵数据合值 单位g 克 JSON格式"""
msg = String()
# 获取当前的 ROS 时间
current_time = self.stamp_clock.now()
# 提取 secs 和 nsecs
t_secs = current_time.to_msg().sec
t_nsecs = current_time.to_msg().nanosec
self.matrix_mass_dic["stamp"]["secs"] = t_secs
self.matrix_mass_dic["stamp"]["nsecs"] = t_nsecs
self.matrix_mass_dic["unit"] = "g"
self.matrix_mass_dic["thumb_mass"] = sum(sum(row) for row in dic["thumb_matrix"])
self.matrix_mass_dic["index_mass"] = sum(sum(row) for row in dic["index_matrix"])
self.matrix_mass_dic["middle_mass"] = sum(sum(row) for row in dic["middle_matrix"])
self.matrix_mass_dic["ring_mass"] = sum(sum(row) for row in dic["ring_matrix"])
self.matrix_mass_dic["little_mass"] = sum(sum(row) for row in dic["little_matrix"])
msg.data = json.dumps(self.matrix_mass_dic)
self.matrix_touch_mass_pub.publish(msg)
def pub_matrix_point_cloud(self):
"""发布矩阵数据点云格式"""
tmp_dic = self.matrix_dic.copy()
del tmp_dic['stamp'] # 去掉时间戳字段
all_matrices = list(tmp_dic.values()) # 5 帧,每帧 6×12=72 个数
# 摊平到一维:360 个 float
flat_list = [v for frame in all_matrices for v in frame] # 360
flat = np.concatenate([np.asarray(np.clip(c, 0, 255), dtype=np.uint8) for c in flat_list])
fields = [PointField(
name='val',
offset=0,
datatype=PointField.UINT8,
count=1
)]
pc = PointCloud2()
pc.header.stamp = self.stamp_clock.now().to_msg()
pc.header.frame_id = ''
pc.height = 1
pc.width = flat.size # 360
pc.fields = fields
pc.is_bigendian = False
pc.point_step = 1 # 1 个 float32
pc.row_step = pc.point_step * pc.width
pc.data = flat.tobytes() # 1440 字节
self.matrix_touch_pub_pc.publish(pc)
def pub_matrix_dic(self):
"""发布矩阵数据JSON格式"""
msg = String()
# 获取当前的 ROS 时间
current_time = self.stamp_clock.now()
# 提取 secs 和 nsecs
t_secs = current_time.to_msg().sec
t_nsecs = current_time.to_msg().nanosec
self.matrix_dic["stamp"]["secs"] = t_secs
self.matrix_dic["stamp"]["nsecs"] = t_nsecs
msg.data = json.dumps(self.matrix_dic)
self.matrix_touch_pub.publish(msg)
def joint_state_msg(self, pose,vel=[]):
joint_state = JointState()
joint_state.header = Header()
joint_state.header.stamp = self.get_clock().now().to_msg()
joint_state.name = self.api.get_finger_order()
joint_state.position = [float(x) for x in pose]
if len(vel) > 1:
joint_state.velocity = [float(x) for x in vel]
else:
joint_state.velocity = [0.0] * len(pose)
joint_state.effort = [0.0] * len(pose)
return joint_state
def hand_setting_cb(self,msg):
'''控制命令回调'''
data = json.loads(msg.data)
print(f"Received setting command: {data['setting_cmd']}",flush=True)
try:
if data["params"]["hand_type"] == "left":
hand = self.api
hand_left = True
elif data["params"]["hand_type"] == "right":
hand = self.api
hand_right = True
else:
print("Please specify the hand part to be set",flush=True)
return
self.cmd_lock = True
# Set maximum torque
if data["setting_cmd"] == "set_max_torque_limits": # Set maximum torque
torque = list(data["params"]["torque"])
hand.set_torque(torque=torque)
if data["setting_cmd"] == "set_speed": # Set speed
if isinstance(data["params"]["speed"], list) == True:
speed = data["params"]["speed"]
hand.set_speed(speed=speed)
else:
ColorMsg(msg=f"Speed parameter error, speed must be a list", color="red")
if data["setting_cmd"] == "clear_faults": # Clear faults
if hand_left == True and self.hand_joint == "L10" :
ColorMsg(msg=f"L10 left hand cannot clear faults")
elif hand_right == True and self.hand_joint == "L10" :
ColorMsg(msg=f"L10 right hand cannot clear faults")
else:
hand.clear_faults()
if data["setting_cmd"] == "get_faults": # Get faults
f = hand.get_fault()
ColorMsg(msg=f"Get faults: {f}")
if data["setting_cmd"] == "electric_current": # Get current
ColorMsg(msg=f"Get current: {hand.get_current()}")
if data["setting_cmd"] == "set_electric_current": # Set current
if isinstance(data["params"]["current"], list) == True:
hand.set_current(data["params"]["current"])
if data["setting_cmd"] == "show_fun_table": # Get faults
f = hand.show_fun_table()
except:
print("命令参数错误")
self.cmd_lock = False
finally:
self.cmd_lock = False
def close_can(self):
self.api.open_can.close_can(can=self.can)
sys.exit(0)
def main(args=None):
try:
rclpy.init(args=args)
node = LinkerHand("linker_hand_sdk")
embedded_version = node.embedded_version
if len(embedded_version) == 3 or node.hand_joint.upper() == "O6" or node.hand_joint.upper() == "L6" or node.hand_joint.upper() == "G20":
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
node.sdk_v = 2
elif len(embedded_version) == 6 and node.hand_joint == "L10":
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
node.sdk_v = 2
elif len(embedded_version) > 4 and ((embedded_version[0]==10 and embedded_version[4]>35) or (embedded_version[0]==7 and embedded_version[4]>50) or (embedded_version[0] == 6)):
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
node.sdk_v = 2
else:
ColorMsg(msg=f"SDK V1", color="green")
node.sdk_v = 1
rclpy.spin(node) # 主循环,监听 ROS 回调
except KeyboardInterrupt:
print("收到 Ctrl+C,准备退出...")
finally:
# node.close_can() # 关闭 CAN 或其他硬件资源
# node.destroy_node() # 销毁 ROS 节点
# rclpy.shutdown() # 关闭 ROS
print("程序已退出。")