起因

有台 Piper 六轴机械臂,我给它布置了个任务:把桌面上散落的五个正方形字块——「8」「周」「年」「快」「乐」——一个个抓起来,按顺序摆成一排,凑出「8周年快乐」四个字加一个数字。要求也不高:每个字放下之后,字头得朝上、正着朝向你,别倒着、别横着。

听起来简单,抓起来摆个积木嘛。结果它干出来的活儿:字是抓起来了,排也排成一排了,方向却全乱了——有的偏左 90°、有的偏右 90°,个别直接 180° 倒过来,而且每次跑出来的偏向都不一样。更气人的是,我看了一眼它识别的中间结果,方向明明判的是对的。

这篇就复盘一下:这东西到底怎么搭的、怎么认方向的,以及最后那个「方向全乱」是怎么查出来——根因不是一个复杂的算法问题,而是一个符号写反了。

代码和调试记录都开源了:nbsheep/roboarm-placement。

系统长啥样

先说硬件和底座,一句话概括:Piper 六轴机械臂(USB-CAN 连接)+ Orbbec Gemini 336L 深度相机(USB),跑在一个 VMware 的 Ubuntu 虚拟机里,软件栈是 LeRobot 那套框架,二次开发基于 cloud666666666/AIIT-Roboarm。

自己搭的时候有几个纯折腾人的坑,都记在部署指南里了:CAN 得每次开机手动激活(can0、1Mbps),Orbbec 相机要先装 udev 权限规则、VMware 的 USB 控制器还得从 2.0 改 3.1,不然相机直接 openUsbDevice failed。这些跟今天要讲的主题无关,跳过。

识别链路:检测 → 摆平 → 判方向 → 放下

要让机械臂把字摆正,整条视觉链路是这样的:

1
2
3
4
5
6
7
8
9
10
OBB 旋转框检测 (YOLO11x-obb)
│ 给出每个字块的四角点
▼
透视摆平 (deskew,单应变换 → 96×96 灰度图)
│ 把歪着的字块"掰正"成标准小图
▼
方向分类 (OrientCNN,4 分类:还需转 0/90/180/270° 才正立)
│ 输出字头朝向角 angle_up
▼
机械臂放置补转角(按 angle_up 反着转,把字头转回朝上)

两层模型各有分工:

  • 检测用 YOLO11x-obb,旋转框(OBB)能直接给出带角度的框,比普通水平框更适合旋转摆放的字符。训练时有个关键点——禁止左右/上下翻转,因为中文字符镜像之后就是无效字,翻转增强只会把模型教坏。
  • 方向分类用自己写的小网络 OrientCNN,输入是透视摆平后的灰度图,输出 4 类的 softmax,分别对应「这个字块还需要逆时针转 0/90/180/270° 才正立」。

检测负责「哪个字在哪儿、框是啥角度」,分类负责「这个字的头朝哪边」,两者接力,理论上就能把每个字摆得端端正正。

出问题:方向认对了,摆出来还是歪

链子搭好后,一跑就发现不对:字能抓起、能摆成排,方向却乱套。而且那个乱法很典型——干干净净地偏 90°,或者 180°,不是随便偏个 17°、23° 这种。

干净的 90° 本身就给了第一条线索:能让结果精确落在 90° 倍数上的,只能是「以 90° 为单位的量」——比如 4 分类判出来的 k,或者正方形「哪条算长边」的判定。连续量(标定误差、关节偏差)不会给出这么整齐的 90°。所以我一开始就盯着这两个 90° 源怀疑。

弯路一:先怀疑分类器

第一反应是 OrientCNN 认错了方向。尤其「周」「快」这种字,正着倒着都有点像,4 分类的 k 判错一个,方向就翻 90°。

于是加了多帧软投票(跨帧累加 softmax)+ 置信度门限,想把 k 稳住。

结果被自己打脸:单独跑了个自查脚本 check_orient.py,把每个字「摆正之后」的图拼出来看——所有字都是正的,置信度 0.86~0.99。识别没问题。

这里栽了个认知坑:check_orient 只能证明「图像里认得对」,不代表「摆得对」。从图像认对,到机械臂真正摆对,中间还隔着图像→机械臂的坐标转换、实际夹取、逆运动学三个环节,这些自查脚本一个都不碰。

弯路二:再怀疑正方形

第二个怀疑对象是「正方形」本身。积木 w≈h,gripper_angle_by_longer 判「哪条是长边」时会随机翻,抓取角跟着跳 90°,字块进夹爪时就被物理带转 90°。

于是改成「沿两条边轴取 4 个候选腕姿,选放置角最靠近 0° 的」。结果更糟了:候选让抓取角直接跑出了 ±90°(出现了 164°、-154° 这种离谱值),而下爪的趋近偏移 offset×cos(抓取角) 方向随之翻转,夹爪从物体反面下爪,把字块推歪——不光转 90°,位置也歪七扭八、排都不成排了。

只能 git restore 回退到当天最早版本,把工作版备份起来。教训:抓取趋近偏移必须用 ∈[-90°,90°] 的原始几何角,不能让它超范围。

转折:写个只读脚本,一次把整条链量出来

连着猜错两次之后,我决定不猜了,也不拿真机试错了——试错一次太贵。改为写一个只读、不动机械臂的诊断脚本 diag_place_angle.py:

  • 不连 CAN、不发任何运动命令;
  • 用一个纯运动学助手直接从 URDF 建运动学链,读关节限位和手眼标定矩阵,纯做正/逆运动学数值计算;
  • 每个字打印一整串量:angle_up、几何抓取角、逐帧极差、两种放置策略的净转角、干 IK 的「下发 yaw vs 实际可达 yaw」(查腕关节 J6 有没有被限位截断)、以及符号两种假设下的预测字头。

一次把整条放置链拆开量,三个假设当场毙掉两个:

假设 判据 结论
识别不稳 angle_up 逐帧极差 <1° ❌ 排除,识别很稳
正方形几何角乱跳 抓取角逐帧极差 0.4~0.8° ❌ 排除,抓取角很稳
腕关节 J6(±120°) 限位截断 放置位姿干 IK 偏差 ≈0° ❌ 排除,够得着
旋转符号反了 见下 ✅ 命中

锁定:一个符号反了

问题出在「图像坐标系 → 机械臂坐标系」的旋转符号上。代码里有个 place_rotate_sign(±1),决定放置补转角是顺着转还是反着转。它一直是 -1,而正确的值应该是 +1。

符号问题有三条独立证据:

  1. 手眼标定的 homography 矩阵线性部分 det < 0——这是个镜像变换,意味着「图像里逆时针 = 机械臂顺时针」,两边旋向本来就是反的;
  2. 默认放置姿态工具朝下(RY=180),实测「机械臂 rot 每 +1° → 末端 yaw 反而是 -1°」,又叠了一重翻转;
  3. 之前 sign=-1 开分类器跑,误差正好散成 2×angle_up——这正是「符号反了」的数学特征。

放置角公式本来是这样的:

1
2
delta_deg        = place_rotate_sign × (place_text_img_deg − angle_up)
place_rotate_rad = gripper_angle_rad + deg2rad(delta_deg + manual_offset_deg)

最终字头朝向可以推成一个很干净的关系:

1
字头_最终 = angle_up × (1 − s_img × place_rotate_sign)

s_img 是「机械臂转 1° 对应图像里字头转几度」的符号,由相机安装决定,代码里看不出来,只能靠标定矩阵和实测去推。算出来:

  • 若 s_img × place_rotate_sign = +1 → 字头 = 0°(摆正)
  • 若 = −1 → 字头 = 2×angle_up(东倒西歪)

答案一目了然。改 config.yaml 里 place_rotate_sign: -1 → 1,上真机跑一遍,五个字全部摆正。

经验

这次排查花了点时间,但换回来几条现在回头看很值钱的经验:

  • 「认得对」≠「摆得对」。识别正确只是第一环,坐标转换、夹取、逆运动学,每一环都是独立的故障点,自查脚本只管得着第一环。
  • 干净的 90° 只来自「以 90° 量化的量」。先找量化源(4 分类的 k、长短边判定);符号、标定、限位截断这些连续量,给不出整齐的 90°。
  • 真机试错太贵,先做离线只读诊断。diag_place_angle.py 免相机、不动臂,把整条链拆开一次性量完,一次定位,省掉无数次空跑。
  • 手眼矩阵可能是镜像(det<0)。图像系和机械臂系的旋向不一定一致,凡涉及旋转,务必先核对符号。
  • 区分「符号错」和「标定偏置」。符号错 → 各字误差散成 2×angle_up(东倒西歪);标定偏置 → 各字统一偏一个固定角。后者调 place_text_img_deg,别去动符号。

代码

  • 成果仓(只含我自己在框架上的二次开发部分):nbsheep/roboarm-placement
    • stage4_infer.py 端到端推理、orient_cnn.pt 方向分类器权重
    • classification/ 方向分类与放置脚本(含只读诊断 diag_place_angle.py)
    • object_detect/ YOLO OBB 检测与训练
    • docs/ 完整的放置方向调试记录 + 环境部署指南
  • 上游框架:cloud666666666/AIIT-Roboarm