简介:这是一套基于深度学习的6自由度抓取姿态预测项目,聚焦机器人视觉抓取中的位姿估计问题,适合机器人操作、自动化与计算机视觉方向的研究者学习,也可作为机械臂抓取算法的参考实现。压缩包共54个文件,以16个Python源码(含模型定义、训练与评估脚本)、12个npy抓取姿态数据、7个STL与2个OBJ三维物体模型为主,另有YAML配置文件、README与多份许可证文件,整体约9.83MB,目录结构清晰,便于按模块阅读和复用。目前已有629人浏览学习。资源不仅包含GraspNet网络架构、数据读取与物体渲染工具,还提供panda/yumi机械臂抓取点模型、均匀四元数采样数据以及训练配置模板,能帮助读者完整了解从目标检测、抓取采样到姿态评估的整个流程,为二次开发或复现实验提供了可直接运行的脚本与配置。
1. 六自由度抓取为什么值得做:从2D抓取到6dof-graspnet的跳跃
机械臂抓取这件事,很多团队一开始用的是2D抓取:相机拍一张图,算法在图像平面上找目标,手臂伸过去只做平移和绕z轴的旋转。可一旦物体斜靠在货架上,2D方案就开始翻车,手爪角度差几度就会把目标推倒。6dof-graspnet这一类基于深度学习的六自由度抓取网络,把问题重定义为点云到抓取位姿的直接映射:位置3个自由度,旋转3个自由度,全部端到端输出,还带上质量评分。适合读这篇文章的是做机器人视觉抓取、上下料、分拣的工程师,需要把视觉引导抓取真正装到机械臂上。下面按表示、推理、训练、避坑这条线把整个方案拆开讲,文章里会用到的代码都是这套方案里最常见的实现方式,你可以直接照着改。
2. 网络在学什么:抓取表示、生成器与评估器的配合
打开6dof-graspnet的源码包,第一眼看到的往往不是训练脚本,而是一堆表示抓取的数学工具。我带新人看这类项目,第一件事就是让他们把抓取表示的代码逐行读一遍,没读懂之前不碰网络结构。原因是,同样的点云输入、同样的损失函数,抓取表示不同,训练出来的模型行为会差很多。
2.1 抓取位姿的数学表示:从4x4齐次矩阵开始
机械臂的末端工具坐标系是一个4x4齐次矩阵,抓取网络最终输出也必须落到这个矩阵上。常见的输出规范包括位置、接近方向、夹爪张开方向和宽度。这里最关键的是方向向量的单位化与正交化,因为机械臂运动学求解器要求旋转矩阵是正交矩阵,差一点点逆运动学就会算出奇怪的关节角。
我在工程里一般先把抓取参数组装成齐次矩阵,再送去可视化或下发到机械臂。这个组装函数看起来简单,但坐标系方向一旦弄反,后面所有验证都会在视觉上被误导。
import numpy as np def build_grasp_matrix(center, approach, width, finger_depth=0.02): """ 由抓取参数构造4x4齐次变换矩阵。 参数说明: center: 抓取中心点位置,形状 (3,),单位米 approach: 夹爪接近方向单位向量,形状 (3,) width: 夹爪开度,单位米(本函数不直接用,但调用方需保持一致) finger_depth: 抓取深度,抓取中心到指尖的距离,单位米 返回: T: 4x4齐次矩阵,列向量依次对应夹爪坐标系的三个轴 binormal: 夹爪平面内的方向向量,用于调试可视化 normal: 夹爪张开方向,宽度测量的基准轴 """ approach = np.asarray(approach, dtype=np.float64) approach = approach / np.linalg.norm(approach) # 选一个与 approach 不平行的临时向量做叉积,避免零向量 tmp = np.array([0.0, 0.0, 1.0]) if abs(np.dot(approach, tmp)) > 0.9: tmp = np.array([1.0, 0.0, 0.0]) binormal = np.cross(approach, tmp) binormal = binormal / np.linalg.norm(binormal) normal = np.cross(binormal, approach) T = np.eye(4) T[:3, 0] = binormal T[:3, 1] = normal T[:3, 2] = approach T[:3, 3] = np.asarray(center, dtype=np.float64) return T, binormal, normal这段代码先把approach向量归一化,再用临时向量做叉积得到第一个基向量binormal,最后用binormal和approach叉积得到normal。这样三个轴严格正交,approach方向就是手爪伸向物体的方向。这里的finger_depth参数暂时没有参与矩阵构建,但它决定预抓取位置的计算:把夹爪模型沿approach反方向平移finger_depth,才能真正让指尖对齐到抓取中心,后面真机调试时会反复用到。
提示:第一次看这个项目时,建议抓一个公开数据集的场景,把生成的抓取矩阵可视化到点云里,再对照机械臂末端工具坐标系的方向定义检查一遍。坐标系问题在可视化阶段就能暴露,别等上了真机才查。
2.2 CVAE:为什么用条件变分自编码器而不是直接回归
同一个物体,比如一个马克杯,可以从侧面抓、从上方抓、从杯把方向抓,结果都是稳定可行的。如果直接用回归网络去学这几种抓取的平均值,出来的位姿往往是所有方式都不挨边的中间状态。这就是6dof-graspnet选择条件变分自编码器(CVAE)而不是普通回归网络的原因:抓取本身是多模态的,网络不能只给一个答案,而要能生成一批有差异且可用的候选。
条件变分自编码器的逻辑分两个阶段。训练阶段,编码器同时读入点云特征和真实抓取,把抓取信息压缩成潜在变量z的均值和对数方差,然后从z采样,让解码器从(z, 点云特征)重建出抓取。这相当于告诉网络:在给定这个物体的点云时,合法的抓取大致落在哪些区域。推理阶段,编码器退场,解码器直接从随机采样的z生成不同的抓取。
class GraspCVAE(nn.Module): """ 6dof-graspnet生成器部分的简化示意。 输入条件特征(点云编码),输出抓取参数。 latent_dim 常用2~8:太小生成结果单一,太大会让重建损失偏高。 """ def __init__(self, pc_feat_dim=256, latent_dim=4, grasp_dim=9): super().__init__() # 点云编码器通常复用 PointNet++ 的 set abstraction 模块 self.point_encoder = PointNetPPEncoder(out_dim=pc_feat_dim) # 训练阶段用到的编码分支:由点云特征+真实抓取推断z分布 self.encoder_mu = nn.Linear(pc_feat_dim + grasp_dim, latent_dim) self.encoder_logvar = nn.Linear(pc_feat_dim + grasp_dim, latent_dim) # 解码器:从点云特征和潜在变量生成抓取 self.decoder = nn.Sequential( nn.Linear(pc_feat_dim + latent_dim, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, grasp_dim), ) def reparameterize(self, mu, logvar): std = torch.exp(0.5 * logvar) eps = torch.randn_like(std) return mu + eps * std def forward(self, pc_feat, grasp_gt=None, z=None): if self.training: enc_input = torch.cat([pc_feat, grasp_gt], dim=-1) mu = self.encoder_mu(enc_input) logvar = self.encoder_logvar(enc_input) z = self.reparameterize(mu, logvar) dec_input = torch.cat([pc_feat, z], dim=-1) return self.decoder(dec_input), mu, logvar else: dec_input = torch.cat([pc_feat, z], dim=-1) return self.decoder(dec_input)这里的latent_dim是控制生成多样性的开关。设置太小,生成的抓取会集中在一个狭窄区域,面对不同高度、不同遮挡的物体时适应能力差;设置太大,解码器会倾向于把z里的噪声直接映射到无意义位姿。训练时重参数化操作是必须的,它让随机采样这个不可导过程变得可反向传播。
2.3 推理时的采样-打分循环
模型训练完,推理阶段不是直接输出一个位姿,而是采样一大批候选再排序。采样数量太少,可能漏掉高得分抓取;采样太多,机械臂每来一个物体要等几百毫秒。常见的项目里,采样数设在200到500之间,处理后取分数最高的前几个。
def inference_pipeline(model, evaluator, pc_tensor, num_samples=300, top_k=10): """ 采样 num_samples 个候选抓取,用评估器打分后返回 top_k 个。 """ pc_feat = model.point_encoder(pc_tensor) # 只编码一次,节省开销 score_list, grasp_list = [], [] for _ in range(num_samples): z = torch.randn(model.latent_dim).to(pc_tensor.device) grasp = model.decoder(torch.cat([pc_feat, z], dim=-1)) # 这里把 grasp 通过 2.1 的矩阵处理再送入评估器,避免维度错乱 quality = evaluator(pc_feat, grasp) score_list.append(quality.item()) grasp_list.append(grasp.detach().cpu().numpy()) order = np.argsort(score_list)[::-1][:top_k] return [grasp_list[i] for i in order], [score_list[i] for i in order]这个循环每次前向只有一个样本,300次前向在GPU上会慢得明显。想提速的常见做法是先把z放进一个batch张量,一次性前向得到300个抓取,再做排序。排序后的高分抓取之间往往很相似,实际使用时通常还会加一个去重逻辑:两个抓取中心距离小于某个阈值且旋转角度差小于阈值时只保留分数高的那个,这样机械臂拿到的候选才有区分度。
3. 环境搭建与最小推理:把6dof-graspnet跑起来的关键三步
很多人卡在第一步的深度学习环境配置上,代码还没跑起来就放弃了。这类项目本质上是一个典型的深度学习实战项目案例,环境问题解决后,真正的难点才会浮出水面。
3.1 依赖安装与CUDA版本匹配
6dof-graspnet依赖PyTorch、open3d、numpy,同时点云特征部分会用到PointNet++的自定义算子,需要本地编译。整个环境搭建最让人头疼的就是CUDA版本匹配。老版本代码多半是按PyTorch 1.x时代写的,conda新建环境后先装PyTorch,再编译自定义算子,顺序不能乱。
conda create -n graspnet python=3.8 conda activate graspnet # 先确认 torch 能正常调用 GPU python -c "import torch; print(torch.__version__, torch.cuda.is_available())" # 安装常用依赖 pip install numpy open3d scipy # 编译项目里的自定义算子(PointNet++ 等) python setup.py build_ext --inplacepython=3.8是目前跑这类老代码最不容易出意外的版本。Python 3.10以上版本会遇到setup.py里distutils的兼容性问题,报错信息五花八门。build_ext --inplace会把编译好的.so文件放在源码目录下面,改完C++源码必须重新执行这个命令,否则训练时会出现undefined symbol这种怪错误。
3.2 从点云到抓取候选的最小推理代码
环境通了之后,先别急着训练,把推理脚本跑通是最有价值的动作。推理逻辑非常直接:读点云,降采样到固定点数,中心化和归一化,然后送进生成器和评估器。
import open3d as o3d import numpy as np import torch def run_grasp_inference(model, evaluator, ply_path, device): """ 读取PLY格式点云,输出抓取位姿。 ply_path: 点云文件路径 device: torch.device,如 cuda:0 """ pcd = o3d.io.read_point_cloud(ply_path) pts = np.asarray(pcd.points, dtype=np.float32) # 降到2048点:不足则重复采样,超过则随机抽 if len(pts) > 2048: idx = np.random.choice(len(pts), 2048, replace=False) pts = pts[idx] else: pad_idx = np.random.choice(len(pts), 2048 - len(pts), replace=True) pts = np.concatenate([pts, pts[pad_idx]], axis=0) # 中心化 + 按最大范数归一化 centroid = np.mean(pts, axis=0) pts = pts - centroid scale = np.max(np.linalg.norm(pts, axis=1)) pts = pts / scale # 组batch送入网络 pc_tensor = torch.from_numpy(pts).float().unsqueeze(0).to(device) with torch.no_grad(): grasps, scores = inference_pipeline( model, evaluator, pc_tensor, num_samples=300, top_k=5 ) # 把归一化坐标还原回原尺度 for g in grasps: g[:3] = g[:3] * scale + centroid return grasps, scores这段代码有三个容易踩的细节。第一,输入点云必须和训练时的坐标系完全一致,左手系右手系混用会直接让抓取方向全部反转;第二,点数不足时重复采样会让网络看到很多重复点,质量分数会虚高,所以点数不足时更推荐先做上采样或者干脆报错提醒;第三,归一化的尺度要和训练代码保持一致,训练用包围盒直径归一化,你这里用最大范数,输出位姿就会差出量级。
3.3 结果可视化:抓到没抓到,点云里一眼看出来
推理结果不可视化,等于没做。把夹爪模型按预测的4x4矩阵摆进点云场景,立刻能看到抓取位姿的合理性,这一步能过滤掉大部分明显错误。
def draw_grasps(pcd, grasps, scores, size=0.05): """ 用 Open3D 展示点云和抓取位姿坐标架。 """ vis = o3d.visualization.Visualizer() vis.create_window(window_name="grasp result") vis.add_geometry(pcd) for g, s in zip(grasps, scores): axes = o3d.geometry.TriangleMesh.create_coordinate_frame(size=size) axes.transform(g) vis.add_geometry(axes) print(f"grasp score={s:.3f}") vis.run() vis.destroy_window()create_coordinate_frame返回的坐标架,红色是x轴,绿色是y轴,蓝色是z轴。工程里最好把坐标架的尺寸设成和机械臂末端工具一样大小,比如你的夹爪长度为0.08米,坐标架就设0.08。比场景小太多会被点云盖住,比夹爪大多了又会误导你对抓取位置的判断。
提示:可视化时把多个候选用不同颜色区分,或者逐个显示。全部叠在一起时,高分候选会把低分候选压住,看不出分布规律。
4. 数据管道:从深度相机原始流到训练标签
6dof-graspnet能跑通,一半功夫在数据。很多团队会在这一步偷懒,直接拿别人的模型和数据集去真机测试,结果针对自己场景的物体和光照条件效果很差。自建数据管道是绕不开的环节。
4.1 公开数据集的标签结构
先看公开数据集里究竟有什么。拿最常被引用的GraspNet-1Billion这类数据来说,它的场景目录下通常包含深度图、相机内参、物体位姿,以及逐物体实例的抓取标注。抓取标签一般包含接触点、接近方向、夹爪宽度和是否可行的类别标签。这些信息最后会组织成训练时用的点云与位姿对。
训练6Dof-GraspNet时,你要关心的不是图像或者物体分类,而是点云与抓取标注的对应关系。下载数据集时,很多人没有注意到场景里存在多个相机视角,同一个场景有几十帧不同视角的观测。数据量并不是越大越好,覆盖相机高度、遮挡程度的变化才是关键。
4.2 用深度图生成点云的预处理脚本
自己的现场没有带标注的数据,第一步只能从深度相机原始流出发。深度图加内参就可以得到点云,这一步的代码很短,但单位问题特别容易踩。
def depth_to_points(depth, K, depth_unit='mm'): """ 深度图转点云。 参数说明: depth: HxW 单通道深度图 K: 3x3 相机内参矩阵 depth_unit: 深度图单位,'mm' 或 'm' 返回: points: Nx3 点云,单位米 """ h, w = depth.shape u, v = np.meshgrid(np.arange(w), np.arange(h)) if depth_unit == 'mm': z = depth.astype(np.float32) / 1000.0 else: z = depth.astype(np.float32) x = (u - K[0, 2]) * z / K[0, 0] y = (v - K[1, 2]) * z / K[1, 1] points = np.stack([x, y, z], axis=-1).reshape(-1, 3) # 去掉无效深度和离群点 mask = (z.reshape(-1) > 0) & (z.reshape(-1) < 3.0) return points[mask]真机上常见的坑是深度图在近处和远处都有噪点,特别是近距离反光物体表面会出现飞点。只按z值范围过滤远远不够,更实用的做法是先做中值滤波,再用统计滤波去掉孤立点。如果相机本身输出的已经是单位米的深度图,用mm去换算会让整个点云缩小1000倍,抓取位置全落到原点附近,这是新手最容易翻车的点。
4.3 用自己的数据微调:最少采多少样才不算玄学
自己采数据的问题在于,每采集一个场景就要做一次抓取标注。完全不标注直接训练是不可能的,损失函数里根本没有真值可以用。常见做法分两种:一是用仿真器批量生成训练数据,导入物体网格模型,渲染不同视角点云,通过运动学计算自动生成可行抓取;二是对少量真实场景做程序化标注,人为标出可行的抓取中心、方向和宽度。
起步阶段,我个人会用仿真数据先跑通训练流程,再用少量真实数据做微调。仿真的点云分布和真实深度相机之间有差距,所以微调数据的数量一般不要低于真实场景的十分之一,否则微调等于没做。如果发现模型在真实数据上分数虚高但真机成功率很低,优先检查标注时的夹爪宽度和接近方向是否按真实机械臂的物理尺寸生成,而不是怀疑网络结构。
5. 训练避坑:损失不降、抓取全偏的5个直接原因
这个章节把我在训练6dof-graspnet时遇到过的真实问题写出来。每一条都是先现象、后原因、再解决办法,你可以对照自己的训练日志排查。
5.1 坐标系约定不一致,训练流程却在跑
现象:训练损失能正常下降,推理可视化里抓取位姿却整体偏离物体表面,有的朝向桌面,有的指向相机方向。
原因:代码里训练数据的抓取矩阵应用了两次坐标变换,或者点云预处理阶段把坐标系从右手系翻转成了左手系,而标签没有跟着翻转。这类问题在纯数值训练里不报错,但可视化一出来就露馅。
解决:把训练读入的数据和标签做一个固定抽查,用主程序里的矩阵把抓取画回到点云里,人工看一眼是否对齐。这个检查脚本放在训练脚本里,每次启动训练前自动跑一帧,能尽早拦截坐标系问题。
5.2 夹爪宽度和深度单位对不上
现象:网络输出的夹爪宽度要么过大要么过小,比如细长螺丝刀被预测出0.1米的夹爪开度;真机上夹爪还没到目标位置就提前闭合。
原因:数据集中夹爪宽度标注单位不一致,有的用毫米,有的用米。模型本身没有物理常识,它只会模仿训练数据的分布,单位一混,输出就乱。
解决:在数据加载类里统一转成米,并在训练前对宽度标签做一次统计,打印mean、max、min。如果发现宽度分布跨度超过两个数量级,优先怀疑单位问题,而不是网络问题。
5.3 点云降采样把薄片物体采没了
现象:模型对薄板、手机这类扁平物体几乎不给高分抓取,对所有候选都打出低质量分数。
原因:训练前把点云降采样到固定点数时,薄片物体的点数本就少,再被均匀采样一次后只剩零星几点,PointNet++提取不到有效几何特征。
解决:采样策略不要用全局随机降采样,改成先做体素降采样保留密度信息,再按空间分布均匀化。另外在数据增强里加入随机裁剪和点云补全,让网络见过部分缺失的输入,推理时遇到被遮挡的薄物体才不至于完全懵掉。
5.4 KL散度权重过大,生成结果变成千篇一律
现象:训练损失里KL散度那一项下降得特别快,但生成的抓取几乎都在同一个位置,采样300次得到的结果差异很小。
原因:KL散度权重设置太大,变分自编码器退化成普通自编码器,潜在变量z的分布被压到接近零均值零方差,采样出来的z都差不多,多样性自然没了。
解决:把KL散度的权重从默认值调小,比如训到某一步再用warm-up让权重慢慢加上去,而不是一开始就满权重。经验做法是前10个epoch权重从0线性增加到目标值,让解码器先把重建能力学好,再逐步加入分布约束。
5.5 评估指标选错:只盯成功率却忽略接触约束
现象:评估脚本显示抓取成功率很高,但真机重复测试时有一半机会把物体碰倒。
原因:成功率指标只看夹爪最终是否抓住物体,没检查抓取位姿是否和物体表面存在干涉。一个抓取位姿如果深入物体内部太多,视觉上得分很高,真机执行时会把物体刨飞。
解决:评估时加两个硬约束——抓取中心到物体表面的距离必须小于夹爪指尖长度,接近方向与物体表面法线的夹角不能超过一定阈值。这两个约束在推理后处理里做掉,而不是只靠网络输出的分数。
6. 把抓取位姿接到真实机械臂:坐标变换与真机验证
模型在离线数据和可视化里表现正常,只说明深度学习那部分做完了,真正的工作从机械臂执行才刚开始。把相机坐标系下的抓取位姿变成机械臂基座坐标系下的末端位姿,需要经过手眼标定得到的变换矩阵。常见的链路是:相机检测到物体,得到相机系下的抓取矩阵T_cam,然后左乘手眼矩阵T_base_cam得到基座系下的抓取矩阵T_base,再送给运动学求解器。
def camera_to_robot(T_grasp_cam, T_base_cam): """ 把相机系下的抓取矩阵变换到机械臂基座系。 T_grasp_cam: 相机坐标系下的4x4抓取位姿 T_base_cam: 手眼标定得到的相机在基座系中的位姿 """ T_grasp_base = T_base_cam @ T_grasp_cam return T_grasp_base这行矩阵乘法看起来简单,但实际项目里最容易出错的是手眼标定矩阵本身的坐标系定义。有的标定工具输出的是相机到基座的变换,有的输出基座到相机的变换,方向反了机械臂会朝着反方向猛冲。我第一次接真机时就在这里栽过,吓得后来每次上电前都用一支笔尖在空间里指定三个点,先手动验证变换方向再放开速度。
真机验证我有一个固定习惯:先用最低速度、最大预留距离跑一次预到达动作,让机械臂运动到抓取点上方10厘米停住,确认位姿方向没有明显碰撞风险,再完全下爪。这个习惯救了我很多次,尤其是夹爪朝向反了的情况,在距离物体还有10厘米时就能发现。
另一个实用技巧是记录每一次抓取的完整上下文:点云文件、预测位姿、实际执行结果(成功/偏移/碰撞),按时间戳存成一个JSON。不要相信自己的记忆,翻车之后没有回放数据等于白翻。这条链路要做到稳定,恐怕比训练模型还费时间,但做完之后整个抓取系统的可调试性会提升一大截。
跑6dof-graspnet这类深度学习抓取项目,真正值钱的不是把模型训练出来,而是把从点云到机械臂抓取这整条链路里的每一个坐标系、单位和约束都钉死。这也是我踩过多次之后养成的习惯,希望帮到你。
本文还有配套的精品资源,点击获取