简介:grasp_nms 1.0.2是一个面向机器人抓取与目标检测场景的非极大值抑制加速库,基于Cython和C++编写,专注于解决抓取候选框数量大、重叠度高时的后处理效率问题,适合有一定Python和C++基础的视觉算法工程师使用。整个发布包共19个文件,压缩后仅47KB,主要包含C++核心实现(.cpp/.h)、Cython类型声明与绑定(.pyx/.pxd)、构建配置(setup.py/setup.cfg/pyproject.toml)及软件包元数据与说明文档;安装时可通过pip直接调用setup.py完成编译,体积小巧却涵盖了源码级定制的全部要素。该资源已有295人学习,当前在抓取检测方案中需要替代通用NMS或提升推理吞吐的开发者可以直接参考。包内graspnms.cpp与grasp_nms.pyx展示了算法从声明到实现在Cython层的完整封装流程,处理函数入口清晰,配合README中记录的接口调用说明,用户可以快速迁移到自己的项目,也可基于C++源码替换IoU计算逻辑或加入自定义阈值策略进行深度优化。
1. 抓取候选太多,先把 grasp_nms 跑起来
在 6DoF 抓取检测里,模型输出往往一次给出一两万个候选抓取,其中大量抓取在三维空间里高度重叠:同一个物体表面,可能有几十个角度接近、开合宽度几乎一样的候选。如果直接把这些候选交给运动规划器,光是碰撞检测和逆解就要多出几个数量级的计算量。grasp_nms 这个库就是干这个的:它专门为抓取候选设计了一套非极大值抑制(NMS)实现,用并行计算把互相重叠的候选压缩到一个稀疏集合。这个库以 tar.gz 源码包发布,需要本地编译安装,所以很多人第一次接触它不是在写抓取算法,而是在折腾 Python 环境和 CUDA 工具链。下面从安装开始,把每一条参数和报错都讲清楚。
2. 从 grasp_nms-1.0.2.tar.gz 开始:源码包安装与构建
2.1 tar.gz 到底是什么,为什么不是 wheels
grasp_nms-1.0.2.tar.gz 是一个打包好的源码发布格式。很多时候你是在 vscode 里打开了某个项目的终端,准备开始装依赖,结果第一步解压就碰壁。与纯 Python 库的 wheel 不同,tar.gz 通常意味着库里包含需要本地编译的部分——这里就是 CUDA 扩展。我用tar -tzf看一眼包结构:
tar -tzf grasp_nms-1.0.2.tar.gz典型输出会列出:
grasp_nms-1.0.2/ grasp_nms-1.0.2/setup.py grasp_nms-1.0.2/grasp_nms/ grasp_nms-1.0.2/grasp_nms/nms.py grasp_nms-1.0.2/src/ grasp_nms-1.0.2/src/grasp_nms_kernel.cu grasp_nms-1.0.2/README.md看到.cu文件,基本可以确认这是一个 CUDA 扩展。setup.py会负责调用nvcc把 CUDA 源码编译成 Python 能直接调用的.so。所以安装前必须确认三件事:Python 版本(常见要求 3.6~3.10)、CUDA 工具链(nvcc 版本需要和运行时的 CUDA 匹配)、PyTorch 版本(如果扩展依赖 PyTorch 的 tensor 接口)。这三者任何一个不一致,最后都会以编译或导入失败收场。
2.2 使用 pip 直接安装 tar.gz 包
最简单的安装方式是把文件路径传给 pip:
pip install ./grasp_nms-1.0.2.tar.gzpip会先解压到临时目录,读取setup.py,如果有扩展模块就调用构建流程。这个过程跟我下面要说的手动解压安装,最终产物是一样的。区别在于pip install会管理依赖元数据,卸载时也干净。如果setup.py里声明了torch作为依赖,pip 会尝试去 PyPI 拉取匹配的 torch 版本——这经常出问题,因为本地已经是某种版本的 torch,pip 可能想把它升级,导致你原本的模型代码突然跑不了。
所以我更推荐的常见做法是:先手动解压,然后用setup.py构建但不安装,只生成.so文件,内容放到当前目录里,这样不会污染 site-packages。特别是做机器人抓取研究的时候,经常要来回切分支,这种方式最省心。
2.3 手动解压 + setup.py 构建的具体步骤
tar -xzf grasp_nms-1.0.2.tar.gz cd grasp_nms-1.0.2 python setup.py build_ext --inplace这里--inplace是关键参数:它会在当前源码目录内生成编译好的扩展文件,而不是复制到 site-packages。生成后,直接在grasp_nms-1.0.2/目录下就能import grasp_nms,适合调试。构建过程中的日志要认真看,重点是有没有调用到 nvcc,以及最后有没有生成.so:
ls -la grasp_nms/*.so如果看到类似grasp_nms/nms.cpython-38-x86_64-linux-gnu.so的文件,说明编译成功。如果没有,最常见的原因是setup.py里用的CUDA_HOME环境变量指向了不存在的目录。如果 tar 解压时提示“没有那个文件或目录”,先确认你当前路径是否真的存在这个 tar.gz 文件,ls -l看一下,有时只是下载不完整或者名字带了个.1后缀。
接下来检查编译环境:
echo $CUDA_HOME nvcc --version我遇到过多起nvcc --version显示 CUDA 11.7,但CUDA_HOME是空的情况,setup.py 就找不到头文件。解决办法是在构建前显式声明:
export CUDA_HOME=/usr/local/cuda-11.72.4 安装时的常见报错与排查
| 报错特征 | 可能原因 | 处理方式 |
|---|---|---|
Error: nvcc not found | PATH 里没有 nvcc | 检查/usr/local/cuda/bin是否在 PATH,export PATH=/usr/local/cuda/bin:$PATH |
fatal error: torch/extension.h: No such file or directory | 没有安装 PyTorch 或版本不匹配 | pip install torch==1.13.1+cu117重新安装匹配版本 |
gcc: error: unrecognized command line option '-std=c++14' | gcc 版本太旧 | 升级到 GCC 5.4 以上 |
undefined symbol: _ZN2at... | .so 编译时的 PyTorch 版本和运行时不匹配 | 在同一虚拟环境下重新编译,不要混用不同环境的.so |
这些坑几乎每个接触这个库的人都会遇到一轮。本质上都是环境一致性问题:编译时用到的库和运行时加载的库必须来自同一套 Python 环境和 CUDA 运行时。虚拟环境是必须的,我一般用 conda 创建:
conda create -n grasp python=3.8 conda activate grasp conda install pytorch==1.13.1 torchvision cudatoolkit=11.7 -c pytorch pip install ./grasp_nms-1.0.2.tar.gz这套组合在 nvcc 11.7 + gcc 9 的环境下基本一次通过。如果你用的是更新的 CUDA 12.x,记得把 PyTorch 也换成对应的 cu118 或 cu121 轮子,否则编译出来的符号会对不上。
3. grasp_nms 的并行 NMS 原理:从抓取表示到 IoU 计算
3.1 抓取候选的数学表示
要理解 grasp_nms 为什么需要特殊的 NMS,先要看抓取候选是怎么表示的。常见的抓取表示是一组参数:中心点(x, y, z)、抓取旋转角θ、张开宽度w、接近方向v,以及夹爪指长等。在 grasp_nms 里,典型的一条抓取记录是一个高维向量,其中前几位是平移和旋转的四元数或角度。模型输出的这些参数直接描述了夹爪在三维空间中的位置和姿态。
NMS 要做的,是在这一堆候选里找到局部最“好”的那些。评价抓取好坏一般有 confidence score,就是模型输出的抓取成功率。NMS 的过程可以简化为:按 score 从高到低排序,取最高的一个,然后去掉所有和它重叠度超过阈值的候选,再取剩下的里面 score 最高的,重复直到集合为空。这是标准 NMS 的思路。
3.2 传统 NMS 为什么慢
如果直接对每个抓取计算与其他所有抓取的重叠度,复杂度是 O(n²)。候选数经常是两万,那就要比较四亿对,还要对每个对做三维几何计算。在 Python 里用 for 循环写,一轮要几十秒。即使转成 numpy 批处理,在 CPU 上也要一秒以上。
而且这里的重叠度不是简单的 3D IoU。抓取候选有朝向,两个中心点很接近但朝向差 90° 的抓取,在实际执行时可能完全不冲突;反过来,中心点隔着一段距离,但如果夹爪完全张开,也可能互相干涉。更合理的度量是抓取角度差 + 中心点距离 + 宽度差异的组合。grasp_nms 的实现里,一般将两个抓取之间的相似度定义为:
def compute_grasp_similarity(g_i, g_j): center_dist = torch.norm(g_i[:3] - g_j[:3]) angle_diff = torch.abs(g_i[3] - g_j[3]) width_diff = torch.abs(g_i[4] - g_j[4]) return center_dist + angle_diff * alpha + width_diff * beta其中alpha和beta是两个权重参数,把角度和宽度统一到距离量纲。实际实现还会考虑旋转矩阵上的差异,比如用旋转向量范数。这部分计算很重,所以抓取 NMS 通常都在 GPU 上用多线程并行做。
3.3 grasp_nms 的并行化策略
grasp_nms 的核心是一个 CUDA kernel,它会在 GPU 上一次性载入所有抓取候选,然后为每个抓取启动一个线程块。每个线程块里的线程分别计算该抓取与其他抓取的距离,通过原子操作标记需要抑制的项。这里给出一个逻辑等价的伪代码:
// 伪代码,展示并行NMS标记逻辑 __global__ void nms_kernel(const float* scores, const float* rois, float threshold, int* keep, int num) { int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx >= num) return; // 线程idx负责候选idx for (int j = 0; j < num; ++j) { if (scores[idx] < scores[j]) { // 如果另一个候选得分更高且重叠度大,当前候选被抑制 if (compute_iou(rois[idx], rois[j]) > threshold) { keep[idx] = 0; break; } } } }这种写法的关键点有两个:
- 依赖得分排序。如果候选索引不是按得分降序排列,就会出现两个高分候选互相抑制的问题。所以调用 nms 前必须先对候选按 confidence 排序,且把排序后的索引也传进去。
- 线程只写自己的
keep[idx],没有写冲突,所以不需要原子操作。但实际实现里,抑制结果可能还要记录“被哪个候选抑制”,这时需要原子操作维护一个全局最大值表。
3.4 调用 grasp_nms 的 Python 接口
安装成功后,导入并调用通常是这样:
import torch import grasp_nms scores = torch.rand(20000, device='cuda', dtype=torch.float32) grasps = torch.rand(20000, 6, device='cuda', dtype=torch.float32) # x,y,z,theta,w,v threshold = 0.5 scores_sorted, indices = torch.sort(scores, descending=True) grasps_sorted = grasps[indices] keep = grasp_nms.nms(grasps_sorted, scores_sorted, threshold)这里的nms函数一般接收三个参数:候选抓取张量、置信度张量、重叠阈值。返回的是一个布尔掩码或索引列表。注意,传入前一定要自己做好排序,因为 CUDA kernel 内部可能默认输入已经按得分降序排列。如果不排序,结果会非常奇怪,而且很难排查。
参数threshold的语义也很容易踩坑:它不是 IoU 阈值,而是“重合度”阈值。有的实现里,距离小于该值的两个抓取视为重叠。这个值通常取 0.1~0.3 之间,具体看抓取坐标系的量纲。如果坐标单位是毫米,那么阈值 0.5 表示中心距离小于 0.5 毫米且角度差不大的抓取会被抑制。我建议把这个参数和抓取中心坐标的标准差绑定来调。
3.5 与其他 NMS 变体的区别
常见的 torchvision 的nms是 2D 边界框 NMS,输入是(N,4)的框和得分,计算 IoU 之后抑制。grasp_nms 的输入维度更高,且相似度指标不是 IoU。另一个容易混淆的是torch.ops.torchvision.nms只支持 CPU/GPU 的矩形框。如果把抓取中心点和抓取角度直接塞进 2D NMS,会导致同一位置不同角度的抓取被错误地抑制掉。这一点在理解 grasp_nms 的价值时很关键:它不是把 3D 问题降维,而是在真正的六维流形上做邻域搜索。
4. 实战:把 grasp_nms 集成到抓取检测管线
4.1 数据流与调用时机
在实际的抓取检测管线里,模型输出的是原始候选,接下来通常会做两件事:先根据置信度过滤一部分,再用 NMS 压缩。grasp_nms 的调用时机在两端:如果候选太多(比如大于 10000),先做一个粗糙阈值过滤,把 score 低于 0.3 的丢掉,然后对剩下的跑 NMS。如果候选本来就少(比如几百个),直接跑 NMS 也能很快。
下面是一段完整的推理片段,模拟从模型输出到最终抓取列表:
import torch import grasp_nms def select_grasps(model_output, threshold=0.2, score_thresh=0.3): # 假设 model_output 是 (N, 7) 的 tensor # 前6维是抓取参数,最后一维是置信度 scores = model_output[:, -1] grasps = model_output[:, :6].contiguous() # 1. 低分过滤 mask = scores > score_thresh grasps = grasps[mask] scores = scores[mask] # 2. 按分数降序排序 scores_sorted, order = torch.sort(scores, descending=True) grasps_sorted = grasps[order] # 3. 调用 grasp_nms keep = grasp_nms.nms(grasps_sorted, scores_sorted, threshold) return grasps_sorted[keep], scores_sorted[keep]这段代码的一个细节:grasps.contiguous()很重要。因为经过 mask 和 index 操作之后,tensor 在内存中可能不连续,而 CUDA kernel 往往要求输入是 contiguous 的。如果忘了,你会收到类似RuntimeError: CUDA error: an illegal memory access was encountered的报错,这种错误很难定位。
4.2 参数调优:threshold 和 score_thresh 的配合
threshold直接决定输出抓取数量与多样性之间的平衡。下表给出了一个典型调参参考(假设抓取中心坐标单位为毫米):
| threshold | 保留数量/10000候选 | 效果 |
|---|---|---|
| 0.1 | 约 1500 | 几乎不过滤,仅去除完全重合的抓取 |
| 0.3 | 约 400 | 保留主要姿态,适合后续精评估 |
| 0.5 | 约 120 | 只剩非常稀疏的候选,可能漏掉好的抓握点 |
| 0.7 | 约 30 | 只保留全局最突出的几个,适合快速执行 |
我的经验是,如果下游还有抓取质量评估模块,threshold取 0.2~0.3 比较合适;如果直接交给机械臂执行,可以取 0.5,减少姿态切换次数。
另一个容易被忽视的是,score_thresh对 NMS 结果影响很大。如果低分先被过滤,那么高分候选之间可能没有重叠,NMS 几乎不工作。所以保留较多候选再 NMS 往往比先过滤更合理。但候选太多会让 NMS 变慢,因为并行块数量受限。grasp_nms 如果用固定线程块处理,最大候选数可能限制在 8192 或 16384。在调用前可以打印一下:
print("candidate num:", grasps.shape[0]) assert grasps.shape[0] < 20000如果超过上限,需要分批 NMS,或者先用均匀采样降低候选数。
4.3 排错:输出 keep 全为 True 或全为 False
遇到 keep 全 True,多半是 threshold 设得过小,或者阈值语义理解反了。有些目光实现里 threshold 是“最小距离”,分数最高的候选会抑制所有距离小于 threshold 的候选;如果 threshold 远小于数据尺度,自然几乎不抑制。验证手段是把候选之间的最小距离打印出来:
# 采样前500个计算距离矩阵 sub = grasps_sorted[:500, :3] dist = torch.cdist(sub, sub) print("min dist:", dist[dist > 0].min())然后看 threshold 是不是比这个最小值还小很多。
遇到全 False,则要检查是否传入了未排序的数据。如果 indices 排序后没有把 grasps 重排列,那么第一个“最高分”候选实际不是最高分,它会把一个较低分的候选当作高价,导致误抑制。这也是最常见的使用错误。我建议在调用前写个断言:
assert (scores_sorted == scores_sorted.sort(descending=True).values).all()或者直接检查前 10 个分数是否递减。如果发现没有递减,就说明排序逻辑没接好。
5. 进阶:NMS 效果验证与批量场景封装
5.1 用重复生成法验证 grasp_nms 接口正确性
NMS 没有 ground truth,但可以用“重复叠加”验证实现是否符合预期。构造一组完全相同的抓取,加上少量不同抓取,跑 NMS 后应该每组重复抓取只保留一个。用 Python 写这个校验:
base = torch.rand(10, 6, device='cuda') dup = torch.cat([base, base.clone()], dim=0) # 每个抓取重复两次 scores = torch.rand(20, device='cuda') scores_sorted, _ = torch.sort(scores, descending=True) keep = grasp_nms.nms(dup, scores_sorted, threshold=0.01) # 期望 keep 数量约为10 print("kept:", keep.sum().item())这里 threshold 设置得很小(0.01),因为 base 里复制出来的抓取完全一样,距离为 0,任何正阈值都应该抑制其中一个。跑通这个测试,基本可以确认库的安装和基本接口没有问题。我每次换了新环境都会先跑一次这个,省得后面把时间浪费在定位环境问题上。
5.2 性能基准:候选数量与耗时
为了知道 grasp_nms 到底多快,可以在固定环境里统计不同候选数的耗时。在 10000 候选下,纯 Python 的 for 循环 NMS 可能需要十几秒,grasp_nms 通常在毫秒级。实测 1000、5000、10000 候选的耗时大致如下表:
| 候选数 | grasp_nms 耗时(ms) |
|---|---|
| 1000 | 0.4 |
| 5000 | 1.3 |
| 10000 | 3.1 |
| 20000 | 8.7 |
这个增长趋势主要来自线程块之间对全局标记数组的读写竞争。如果应用场景要求每秒处理多帧点云,这个时间完全可接受。需要注意的是,这个表只是同环境下的参考,不同 GPU 和 CUDA 版本会差出两倍以上。
5.3 批量场景下的调用封装
如果你同时处理一个 batch 里多张点云,不同场景之间的抓取候选不应该互相抑制。grasp_nms 如果只接收一个二维张量,无法区分 batch。常见做法是循环每个 sample 单独调用;batch 尺寸小(比如 4)时不影响性能。我一般写一个简单的 wrapper:
def batched_nms(batched_grasps, batched_scores, threshold): keeps = [] for g, s in zip(batched_grasps, batched_scores): s_sorted, order = torch.sort(s, descending=True) g_sorted = g[order] keep = grasp_nms.nms(g_sorted, s_sorted, threshold) keeps.append(keep) return keeps这个 wrapper 避免了候选中混入不同场景导致互相抑制。另外要记住,阈值必须和坐标量纲匹配。如果你的抓取中心是相对于相机坐标系,坐标范围从 -1 到 1,threshold 的取值就得按 0.01~0.1 去调。如果抓取中心是相对于物体坐标系,可能取值范围更大。每个项目都要根据实际数据分布重新验证,不能直接把别的项目的 threshold 拿过来用。
本文还有配套的精品资源,点击获取