简介:本资源面向机器人视觉抓取方向的学习者与开发者,提供一套基于Python实现的GRCNN机械臂平面抓取完整工程,适合具备一定深度学习与计算机视觉基础、希望复现或二次开发抓取检测算法的高年级本科生及研究生。压缩包共89个文件,约72.55MB,以34个Python脚本为核心,涵盖模型定义、训练与推理流程,并配有PNG、JPG图像样本、TXT说明、SH脚本及PDF论文等辅助材料,目录按models、hardware、utils、data等模块划分,结构清晰。资源内置requirements依赖清单,涉及numpy、opencv-python、torch、torchvision、pyrealsense2等库,并附带Cornell与Jacquard数据集的获取脚本及预训练模型,方便读者直接开展训练与实时抓取实验。目前已有829人学习下载,可帮助读者掌握GRCNN网络结构、抓取生成与相机标定等关键环节,快速搭建机械臂视觉抓取系统。
1. 从一只抓不准的机械臂说起:GRCNN 视觉平面抓取到底在解决什么
去年帮一个做 3C 装配的朋友调一台六轴机械臂,任务简单到离谱:把散落在桌面上的金属垫片一个个抓起来放进料盒。结果机械臂十次里有三次夹空,两次把垫片推飞。他一开始怀疑是机械臂偏差,换了减速器、重新标定,问题依旧。最后定位到根因:相机给的是固定像素坐标,而垫片每次摆放的位姿都在变,抓取点算错了。这就是典型的视觉平面抓取问题——不是机械臂不准,是抓取位姿估计不准。
GRCNN(Grasp Rectangle Convolutional Neural Network,抓取矩形卷积神经网络)就是干这件事的:输入一张俯视 RGB 图,输出一堆候选抓取矩形,每个矩形带一个置信度,告诉你「夹爪放在这个位置、以这个角度闭合,成功率最高」。它属于平面抓取(2D planar grasp)范畴,假设物体躺在桌面上、夹爪垂直向下,不处理三维空间里的任意姿态抓取。适合谁?做桌面分拣、上下料、3D 打印件取件、具身智能机械臂入门验证的团队。用 Python 实现的好处是整条链路——数据标注、训练、推理、和 ROS 对接——都能在一套环境里跑通,不用在 C++ 和 Python 之间反复横跳。
2. GRCNN 的网络结构与抓取表示:为什么不用直接回归坐标
2.1 抓取矩形的五参数表示与角度离散化
平面抓取最常用的表示是五元组(x, y, θ, w, h):x, y是夹爪中心在图像里的像素坐标,θ是夹爪闭合方向与图像水平轴的夹角,w是夹爪张开宽度,h是夹爪指板深度。直接回归这五个数看起来最省事,但实际训练会翻车——角度在 ±90° 附近有周期性,θ=89°和θ=-89°数值上差很远,物理上却几乎一样,L2 loss 会把这个当成大误差去惩罚,网络学得极其痛苦。
GRCNN 的做法是把角度离散成 N 个区间(常见 N=6 或 12),每个区间对应一个通道,网络在每个像素位置预测「这个角度区间下,抓取成功的概率」。宽度也离散成若干档,比如 20 到 100 像素分 5 档。这样每个像素输出N × W个通道的置信度图,取最大值就是最优抓取。好处是训练稳定,坏处是精度受离散粒度限制,N=6 时角度分辨率 30°,对细长物体不够用,实际项目里我一般用 N=12。
2.2 从 ResNet 主干到抓取检测头:GRCNN 的层结构
GRCNN 的主干通常用 ResNet-50 或 ResNet-101 的前几层做特征提取,后面接一个轻量检测头。检测头结构不复杂:几层 3×3 卷积把通道数压到N × W,最后一层 1×1 卷积输出置信度图。关键设计在于特征融合——浅层特征分辨率高、定位准,深层特征语义强、分类准,GRCNN 用类似 FPN 的方式把两者相加,避免小物体在深层特征图上消失。
下面是一个最小可跑的 GRCNN 检测头实现,主干用 torchvision 的 ResNet-50:
import torch import torch.nn as nn import torchvision.models as models class GRCNNHead(nn.Module): def __init__(self, in_channels=2048, mid_channels=512, n_angles=12, n_widths=5): super().__init__() self.n_angles = n_angles self.n_widths = n_widths # 3x3 卷积压缩通道,保持空间分辨率 self.conv1 = nn.Conv2d(in_channels, mid_channels, 3, padding=1) self.bn1 = nn.BatchNorm2d(mid_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(mid_channels, mid_channels, 3, padding=1) self.bn2 = nn.BatchNorm2d(mid_channels) # 输出层:每个像素预测 n_angles * n_widths 个置信度 self.score = nn.Conv2d(mid_channels, n_angles * n_widths, 1) def forward(self, x): x = self.relu(self.bn1(self.conv1(x))) x = self.relu(self.bn2(self.conv2(x))) score = self.score(x) # [B, n_angles*n_widths, H, W] B, C, H, W = score.shape # reshape 成 [B, n_angles, n_widths, H, W],方便取 argmax score = score.view(B, self.n_angles, self.n_widths, H, W) return score class GRCNN(nn.Module): def __init__(self, n_angles=12, n_widths=5): super().__init__() backbone = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) # 去掉最后两层(avgpool 和 fc),保留空间特征 self.backbone = nn.Sequential(*list(backbone.children())[:-2]) self.head = GRCNNHead(2048, 512, n_angles, n_widths) def forward(self, x): feat = self.backbone(x) # [B, 2048, H/32, W/32] score = self.head(feat) # [B, n_angles, n_widths, H/32, W/32] return score逻辑说明:主干输出的是 1/32 原图分辨率的特征图,检测头在这个尺度上做卷积。score的 shape 是[B, n_angles, n_widths, H, W],训练时对每个标注抓取矩形,找到它对应的角度档和宽度档,在对应通道的对应像素位置算交叉熵。推理时对n_angles × n_widths个通道做全局 argmax,拿到(angle_idx, width_idx, y, x),再反算回原图坐标。
参数说明:n_angles=12对应 15° 分辨率,n_widths=5对应宽度档位,这两个值直接决定输出通道数,改大了显存涨、改小了精度掉。mid_channels=512是检测头中间层通道数,实测 256 到 1024 之间对精度影响不大,512 是性价比最高的。主干用预训练权重能省至少一半训练时间,别从零训。
2.3 损失函数:为什么用 focal loss 而不是交叉熵
抓取检测有个天然问题:一张图里正样本(可抓取位置)可能只有几十个像素,负样本几万个,正负比极端不平衡。普通交叉熵会让网络学会「全预测负样本」也能拿到 99% 准确率,但一个抓取都出不来。GRCNN 常用 focal loss 来压制易分负样本的权重:
import torch.nn.functional as F def focal_loss(pred_logits, target, alpha=0.25, gamma=2.0): """ pred_logits: [B, C, H, W] 未过 softmax 的 logits target: [B, H, W] 每个像素的类别索引,-1 表示忽略 """ B, C, H, W = pred_logits.shape pred_logits = pred_logits.permute(0, 2, 3, 1).reshape(-1, C) # [B*H*W, C] target = target.reshape(-1) # [B*H*W] valid = target >= 0 pred_logits = pred_logits[valid] target = target[valid] log_prob = F.log_softmax(pred_logits, dim=1) prob = log_prob.exp() target_log_prob = log_prob.gather(1, target.unsqueeze(1)).squeeze(1) target_prob = prob.gather(1, target.unsqueeze(1)).squeeze(1) loss = -alpha * (1 - target_prob) ** gamma * target_log_prob return loss.mean()逻辑说明:先把空间维度展平,过滤掉-1的忽略像素,然后对每个有效像素算 focal loss。(1 - target_prob) ** gamma这一项让已经分对的样本(target_prob接近 1)损失趋近 0,网络被迫去学那些分错的难样本。alpha=0.25是正样本权重,因为正样本少,给它更高权重。gamma=2.0是聚焦参数,越大越聚焦难样本,一般 1.5 到 3 之间调。
3. 从 Cornell 数据集到自己的标注:数据管线怎么搭
3.1 Cornell Grasp Dataset 的格式与加载
公开数据集里最常用的是 Cornell Grasp Dataset,885 张 RGB-D 图,每张图配一个.txt标注文件,每行是一个抓取矩形,格式是x1 y1 x2 y2 x3 y3 x4 y4,四个角点按顺序排列。加载时要把角点转成(x, y, θ, w, h):
import numpy as np def corners_to_grasp(corners): """corners: [8,] 四个角点 x1 y1 x2 y2 x3 y3 x4 y4""" pts = corners.reshape(4, 2).astype(np.float32) # 中心点 cx, cy = pts.mean(axis=0) # 宽度:短边,高度:长边 edge1 = np.linalg.norm(pts[1] - pts[0]) edge2 = np.linalg.norm(pts[2] - pts[1]) w, h = min(edge1, edge2), max(edge1, edge2) # 角度:取短边方向 if edge1 < edge2: dx, dy = pts[1] - pts[0] else: dx, dy = pts[2] - pts[1] theta = np.arctan2(dy, dx) # 归一化到 [-pi/2, pi/2) if theta >= np.pi / 2: theta -= np.pi elif theta < -np.pi / 2: theta += np.pi return cx, cy, theta, w, h逻辑说明:抓取矩形的短边是夹爪张开方向,长边是指板深度。角度取短边方向,因为夹爪是沿短边闭合的。归一化到[-π/2, π/2)是为了消除方向歧义——夹爪从左边闭合和从右边闭合物理上等价。
参数说明:w是夹爪张开宽度,h是指板深度。Cornell 数据集里w范围大概 10 到 150 像素,h范围 10 到 200 像素。如果你的夹爪物理宽度有限制,比如最大只能张 80mm,那标注时要把w超过阈值的抓取过滤掉,否则网络会学出一堆物理上执行不了的抓取。
3.2 把标注转成训练标签:角度分桶与正样本生成
网络输出的是离散置信度图,所以要把连续的(x, y, θ, w)转成离散标签。做法是:对每个标注抓取,找到它中心点对应的特征图位置,算出角度档和宽度档,在那个位置打上正样本标签,其余位置是负样本。
def encode_grasp(cx, cy, theta, w, n_angles=12, n_widths=5, img_size=224, feat_stride=32, width_min=20, width_max=100): """把单个抓取编码成特征图上的类别索引""" feat_size = img_size // feat_stride # 中心点映射到特征图坐标 fx = int(cx / feat_stride) fy = int(cy / feat_stride) if not (0 <= fx < feat_size and 0 <= fy < feat_size): return None # 角度分桶:[-pi/2, pi/2) 分成 n_angles 份 angle_norm = (theta + np.pi / 2) / np.pi # [0, 1) angle_idx = int(angle_norm * n_angles) % n_angles # 宽度分桶 if w < width_min or w > width_max: return None width_norm = (w - width_min) / (width_max - width_min) width_idx = int(width_norm * n_widths) width_idx = min(width_idx, n_widths - 1) # 类别索引 = angle_idx * n_widths + width_idx cls = angle_idx * n_widths + width_idx return fy, fx, cls逻辑说明:feat_stride=32是因为 ResNet-50 主干下采样 32 倍。angle_norm把[-π/2, π/2)映射到[0, 1),再乘n_angles取整。宽度同理。最后类别索引是二维的(angle, width)展平成一维。
参数说明:width_min和width_max要根据你的夹爪实际张开范围设。比如夹爪最大张 100mm,相机标定后 1 像素对应 1mm,那width_max=100。设小了会丢正样本,设大了会有无效抓取。img_size=224是输入网络的分辨率,Cornell 原图是 640×480,要 resize 到 224×224,标注坐标也要同步缩放。
3.3 数据增强:旋转、缩放、颜色抖动哪些能用哪些不能
抓取检测的数据增强有讲究。旋转增强要谨慎——把图旋转 90°,抓取角度也跟着转 90°,但角度归一化到[-π/2, π/2)后可能变成另一个等价角度,标签编码容易出错。我一般只做小角度旋转(±15°),并且旋转后重新算角度。缩放和颜色抖动可以放心用,缩放时宽度标签要同步缩放。
import cv2 import random def augment(image, grasps, max_angle=15): """image: HxWx3, grasps: list of (cx, cy, theta, w, h)""" h, w = image.shape[:2] # 随机小角度旋转 angle = random.uniform(-max_angle, max_angle) M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) image = cv2.warpAffine(image, M, (w, h)) new_grasps = [] for cx, cy, theta, gw, gh in grasps: # 旋转中心点 pt = M @ np.array([cx, cy, 1.0]) ncx, ncy = pt[0], pt[1] # 旋转角度(注意 cv2 旋转方向与数学坐标系相反) ntheta = theta - np.deg2rad(angle) # 归一化 while ntheta >= np.pi / 2: ntheta -= np.pi while ntheta < -np.pi / 2: ntheta += np.pi new_grasps.append((ncx, ncy, ntheta, gw, gh)) # 颜色抖动 if random.random() < 0.5: image = image.astype(np.float32) image *= random.uniform(0.8, 1.2) image = np.clip(image, 0, 255).astype(np.uint8) return image, new_grasps逻辑说明:cv2.getRotationMatrix2D生成旋转矩阵,warpAffine做实际旋转。中心点用矩阵乘法变换,角度直接减去旋转角。注意 cv2 的旋转正方向是逆时针,而图像坐标系 y 轴向下,所以角度符号要反过来。
参数说明:max_angle=15是经验值,再大标签容易出错。颜色抖动系数 0.8 到 1.2 模拟光照变化,别超过这个范围,否则颜色失真太严重网络学不到真实特征。
4. 训练、推理与 ROS 对接:把模型塞进机械臂控制回路
4.1 训练循环与关键超参
训练 GRCNN 的循环不复杂,但有几个超参必须调对。下面是一个最小训练循环:
import torch from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, device, n_angles=12, n_widths=5): model.train() total_loss = 0 for images, targets in loader: images = images.to(device) targets = targets.to(device) # [B, H, W],-1 表示忽略 pred = model(images) # [B, n_angles, n_widths, H, W] B, A, Wd, H, W = pred.shape pred = pred.reshape(B, A * Wd, H, W) loss = focal_loss(pred, targets) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) # 超参配置 config = { "lr": 1e-4, # 主干用预训练,学习率要小 "batch_size": 8, # 显存 8G 能跑 8,12G 能跑 16 "epochs": 50, # Cornell 数据集 50 轮足够收敛 "weight_decay": 1e-4, # 防过拟合 "lr_step": 20, # 每 20 轮降一次学习率 "lr_gamma": 0.1, }逻辑说明:pred从[B, A, Wd, H, W]reshape 成[B, A*Wd, H, W]是为了和 focal loss 的输入格式对齐。targets里-1的像素被 focal loss 忽略,通常是图像边缘或者标注不确定的区域。
参数说明:lr=1e-4是因为主干有预训练权重,学习率大了会破坏预训练特征。batch_size=8是 8G 显存的保守值,显存够可以加到 16。epochs=50是针对 Cornell 885 张图的经验值,数据量大了要相应增加。lr_step=20配合lr_gamma=0.1是阶梯下降,比余弦退火更稳。
4.2 推理后处理:从置信度图到可执行抓取
网络输出的是置信度图,要转成机械臂能执行的抓取位姿,需要做几步后处理:取全局最大值、非极大值抑制、坐标反算。
def decode_grasp(score, img_size=224, feat_stride=32, n_angles=12, n_widths=5, width_min=20, width_max=100): """score: [n_angles, n_widths, H, W] numpy""" A, Wd, H, W = score.shape flat = score.reshape(-1) idx = flat.argmax() a_idx = idx // (Wd * H * W) rem = idx % (Wd * H * W) w_idx = rem // (H * W) rem = rem % (H * W) fy = rem // W fx = rem % W conf = flat[idx] # 反算原图坐标 cx = (fx + 0.5) * feat_stride cy = (fy + 0.5) * feat_stride # 反算角度 theta = (a_idx + 0.5) / n_angles * np.pi - np.pi / 2 # 反算宽度 gw = width_min + (w_idx + 0.5) / n_widths * (width_max - width_min) return cx, cy, theta, gw, conf逻辑说明:argmax拿到展平后的索引,逐层反算回(a_idx, w_idx, fy, fx)。坐标加 0.5 是因为特征图一个像素对应原图feat_stride个像素,取中心。角度和宽度取桶中心值。
参数说明:conf是置信度,实际部署时设个阈值比如 0.5,低于阈值的抓取不执行。feat_stride=32要和训练时一致,不一致坐标全错。
4.3 和 MoveIt2 对接:把抓取位姿发给机械臂
推理出(cx, cy, theta, gw)后,要转成机械臂能懂的位姿。假设相机已经手眼标定好,图像坐标到机械臂基座坐标的变换矩阵是T_cam2base,抓取位姿转换如下:
import numpy as np from geometry_msgs.msg import Pose def grasp_to_pose(cx, cy, theta, gw, depth, T_cam2base, camera_matrix): """把图像抓取转成机械臂基座下的 Pose""" fx = camera_matrix[0, 0] fy = camera_matrix[1, 1] ppx = camera_matrix[0, 2] ppy = camera_matrix[1, 2] # 像素坐标转相机坐标(假设深度已知) X = (cx - ppx) * depth / fx Y = (cy - ppy) * depth / fy Z = depth # 相机坐标转基座坐标 pt_cam = np.array([X, Y, Z, 1.0]) pt_base = T_cam2base @ pt_cam # 构造 Pose pose = Pose() pose.position.x = pt_base[0] pose.position.y = pt_base[1] pose.position.z = pt_base[2] # 姿态:夹爪垂直向下,绕 Z 轴转 theta # 这里用四元数表示,具体值取决于你的夹爪坐标系定义 import tf_transformations q = tf_transformations.quaternion_from_euler(np.pi, 0, theta) pose.orientation.x = q[0] pose.orientation.y = q[1] pose.orientation.z = q[2] pose.orientation.w = q[3] return pose逻辑说明:先用相机内参把像素坐标转成相机坐标系下的三维点,再用外参矩阵T_cam2base转到基座坐标系。姿态部分,夹爪垂直向下对应绕 X 轴转 180°,再绕 Z 轴转theta。
参数说明:depth是抓取点的深度值,可以从深度相机拿,也可以用固定工作平面高度代替。T_cam2base是手眼标定结果,标定误差直接决定抓取精度,这个矩阵不准后面全白搭。camera_matrix是相机内参,fx, fy是焦距,ppx, ppy是主点。
5. 避坑与排查:那些让抓取成功率腰斩的细节
5.1 抓取点全挤在图像中心
现象:推理出来的抓取矩形全部集中在图像中心区域,边缘的物体一个都抓不到。
原因:训练时正样本只在标注位置,但负样本是全图均匀采的,网络学到「中心区域先验」——因为 Cornell 数据集里物体大多在图像中心。这是数据集偏差,不是网络问题。
解决:训练时对负样本做难例挖掘,或者对图像做随机裁剪,让物体出现在不同位置。更直接的办法是在 focal loss 里对边缘区域的负样本降权,强制网络关注全图。
5.2 角度预测总是差 90 度
现象:抓取位置对,但夹爪方向总是差 90°,夹爪指板方向对着物体短边。
原因:角度归一化时符号搞反了。arctan2返回的是[-π, π],归一化到[-π/2, π/2)时如果减 π 的方向搞错,角度会差 90°。
解决:检查corners_to_grasp里短边方向的判断。打印几个标注的角度值,和可视化结果对照。另外注意 cv2 旋转和数学坐标系旋转方向相反,数据增强时角度符号要对应。
5.3 训练 loss 降不下去
现象:训练几十轮,focal loss 一直在 0.5 附近震荡,不下降。
原因:大概率是标签编码错了。比如feat_stride和主干实际下采样倍数不一致,或者角度分桶时angle_norm算错,导致正样本打在了错误通道。
解决:写个可视化脚本,把编码后的标签图叠在原图上,看正样本位置对不对。再检查主干输出特征图的尺寸,feat_size = img_size // feat_stride要等于实际特征图边长。
5.4 仿真里能抓,真机抓空
现象:Gazebo 仿真里成功率 90%,搬到真机上掉到 40%。
原因:仿真里深度是完美的,真机深度图有噪声和缺失。另外手眼标定误差在仿真里不存在,真机上几毫米误差就导致夹空。
解决:真机部署前重新做手眼标定,用棋盘格或者 ArUco 码,标定误差控制在 2mm 以内。深度图做滤波,缺失区域用邻域中值填充。抓取时加个下压动作,让夹爪先接触物体再闭合,容忍几毫米误差。
5.5 推理速度跟不上节拍
现象:模型推理一张图要 200ms,机械臂节拍要求 100ms 以内。
原因:ResNet-50 主干在 CPU 上跑就是慢,换 GPU 能到 20ms 但工控机不一定有 GPU。
解决:换轻量主干,MobileNetV3 或者 ShuffleNetV2,精度掉 2 到 3 个点但速度翻倍。或者用 TensorRT 量化,FP16 精度下速度能到 10ms 以内。再不行就降输入分辨率,224 降到 160,速度提升明显但小物体抓取会变差。
6. 进阶技巧:用深度图补平面抓取的短板
平面抓取最大的短板是不知道物体高度,夹爪下压深度全靠猜。一个实用技巧是把深度图作为额外通道输入网络,让网络自己学高度信息。具体做法是把 RGB 三通道和深度一通道拼成四通道输入,主干第一层卷积改成in_channels=4。深度值归一化到[0, 1],缺失区域填 0。
另一个技巧是抓取后验证。机械臂执行抓取后,用夹爪的力反馈或者电流判断是否夹到物体。如果没夹到,把这次失败的抓取作为负样本回传,在线微调网络。这个闭环能让成功率在几天内从 70% 爬到 85% 以上,比离线调参快得多。
验证方法上,我一般用分层测试:先在 Cornell 测试集上跑,看 AP 指标;再在仿真里跑 100 次抓取,看成功率;最后真机跑 50 次,记录失败案例。三层都过了才敢上产线。仿真和真机的差距主要来自标定误差和深度噪声,这两个不解决,仿真指标再高都是虚的。
最后说个血泪教训:别一上来就追求端到端。我见过太多团队想用一个大网络直接从 RGB 出关节角,结果调了半年连抓取点都定不准。GRCNN 这种「先出抓取矩形,再逆解关节角」的分步方案,每一步都可调试、可替换,出了问题能定位到具体环节。机械臂抓取是个系统工程,网络只是其中一环,标定、控制、夹爪选型哪个掉链子都白搭。希望帮到你。
本文还有配套的精品资源,点击获取