资讯动态

KeypointNet复现:点云关键点检测与位姿估计实战

发布时间:2026/10/13 13:45:03 来源:尧图企业网站定制
简介KeypointNet是CVPR 2020提出的三维关键点检测数据集及配套代码面向三维视觉、点云处理与目标识别等方向的研究者与开发者。数据集基于ShapeNet模型的人工标注覆盖飞机、汽车、椅子等16个对象类别共含83,231个关键点与8,329个三维模型同时提供带注释的JSON数据与2048点采样点云。这份压缩包共92个文件主要包括28个Python训练/测试脚本、YAML环境配置与Shell部署脚本、CUDA/C实现的底层算子另附PCD点云示例、OBJ模型、JSON标注样例、可视化图像、Markdown说明与数据集划分文件整体仅1.54MB便于快速搭建开发环境。目前已有552人浏览学习。借助配置好的模型与工具脚本用户可复现论文实验、训练自定义关键点检测模型并通过可视化与指标模块评估效果是入门三维关键点检测的实用性很强的资料。1. 为什么 KeypointNet 值得复现一个能预测“缺失关键点”的 point clouds 语义检测器关键点检测在 point-clouds 数据上有一个很尴尬的现状ISS、Harris3D、SIFT 这类经典算法找的是几何显著点换个视角、换个密度同一把椅子检测出的“角点”位置能差出一大截而 keypoint detection 在下游配准和位姿估计里要的是跨实例、跨视角都稳定的语义关键点。KeypointNet 解决的就是这个问题——它把关键点当成类别固有属性来学每个类别学一组固定数量的关键点模板输入单个无组织点云输出每个点属于哪个关键点或背景。最反直觉的一点是即使某个关键点周围根本没有点自遮挡或传感器盲区网络照样能根据类别先验把它的位置预测出来而不是像几何方法那样直接放弃。它适合做物体位姿估计、形状对应、点云配准的从业者不适合只想在局部找几何角点的场景。这份资源对应完整可跑的 Python 实现下面按我实际复现的顺序拆。2. 先把数据集整明白ShapeNet 类别的关键点标注与加载流程2.1 为什么关键点标注要绕道合成渲染我第一次拿到这个项目时第一反应是“关键点直接用人在点云上点不就完了吗”。实际做了一轮就发现不行真实 LiDAR 点云密度前密后疏自遮挡导致同一语义位置在不同帧里有没有点完全看运气让三个人标同一把椅子的“扶手端点”能标出三种结果——定义本身都不稳定更别说训练。常见做法是绕道合成渲染。把每个 CAD 模型放到虚拟相机前渲染出深度图深度图上的每个有效像素就是一个朝外的表面点在深度图上做热图投票取局部极大值得到的是稳定的候选关键点。然后再做类别内聚类对齐同一个类别里不同实例的候选关键点数量不一样用谱聚类把它们对应到类别索引上这样“椅子的 0 号关键点”在椅子和另一把椅子上语义一致。这套流程有几个隐含参数复现时要留意渲染分辨率常见 224×224视角采样 24 个均匀分布在球面上的角度每个类别默认 10 个关键点也有扩展版本用 16 或 24但 10 个是原版默认值聚类对齐完成后标签是“每个点属于第几个关键点”0 号保留给背景。这里有个容易被忽略的点有些关键点在几何上并不显著比如桌面中心纯几何算法基本测不到但语义上它很稳定。这就是为什么这类资源必须用标签数据训练而不是在推理时去重新找特征点。2.2 数据文件长什么样加载、可视化和坐标约定解压数据之后每个实例通常是一个 .npz 文件包含点坐标、法线和逐点标签。我先写一段最小加载脚本确认数据没坏再谈训练import numpy as np import open3d as o3d data np.load(train_data.npz, allow_pickleTrue) points data[points] # (N, 3) normals data[normals] # (N, 3) labels data[keypoint_label] # (N,)0 表示背景1~K 表示关键点类别 category data[category_id] # 标量该实例属于哪个类别 print(points.shape, points.dtype, labels.max(), category) pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(points) pcd.normals o3d.utility.Vector3dVector(normals) # 把关键点标成红色背景保持灰色 colors np.zeros_like(points) colors[labels 0] [1.0, 0.0, 0.0] pcd.colors o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd])这段代码做的事情很直白np.load 把全部字段读进内存然后用 open3d 把关键点刷红可视化。逻辑上要注意 labels 的 0 是背景而不是“第 0 号关键点”很多人第一次看数据时把背景也算成关键点去查漏。参数上N 在不同实例间不固定有些是 1024有些是 4096这取决于下采样配置如果你的机器内存吃紧建议训练前统一 FPS最远点采样到 1024后面特征扩散矩阵的显存占用会小一个量级。2.3 归一化与数据增强哪些变换能碰哪些不能碰点云关键点检测最玄学的部分之一就是数据预处理。先说归一化。我一般用最大半径归一化也就是把所有点除以到原点最远距离保证整个物体落在单位球内。这里不要用 z-score 归一化原因是测试时点云往往是不完整视角均值会被缺失部分拉偏而最大半径对缺失相对稳定。def normalize(points): max_radius np.linalg.norm(points, axis1).max() return points / (max_radius 1e-6) def augment_rotation(points, normals): # 随机四元数旋转避免欧拉角万向锁问题 q np.random.randn(4) q q / np.linalg.norm(q) # 四元数转旋转矩阵常见实现可以直接用 scipy.spatial.transform.Rotation from scipy.spatial.transform import Rotation R Rotation.from_quat(q).as_matrix() return points R.T, normals R.T def augment_dropout(points, labels, drop_ratio0.2): keep np.random.rand(points.shape[0]) drop_ratio return points[keep], labels[keep]先说旋转增强沿三个轴各转随机角度是最直觉的写法但我习惯用随机四元数避免万向锁导致旋转分布不均匀。dropout 增强模拟的是传感器遮挡和自遮挡实际上就是随机删掉 20%~30% 的点删的时候标签要同步删这个不难但容易漏漏了会让网络在这些点上硬学背景特征。需要特别注意的是这个场景里禁用的变换有两个——随机平移和随机缩放。原因很直接关键点定义在物体局部坐标系里平移会让“扶手端点在哪”这个问题失去锚点缩放则直接破坏关键点之间的语义距离关系。训练时加了平移缩放损失是降了评估基本就是翻车现场。3. 网络结构拆开看backbone 选型、特征扩散与 Sinkhorn 约束3.1 主特征提取PointNet 够用PointNet 是后补第一眼看到这个项目的 backbone 是 PointNet 而不是 PointNet很多人会觉得“太老了”。实际上原版这么选是刻意的语义关键点需要的是类别级全局先验——无论输入是完整 CAD 模型还是残缺扫描网络都要知道“这是一把椅子椅子有 10 个固定关键点”。PointNet 的全局 maxpool 恰好提供这种置换不变的整体描述把全局特征拼回每个点相当于每个点都在“知道自己身处哪类物体”的前提下做局部判断。PointNet 的局部特征更强但在关键点这种强语义任务上并没有拉开质的差距反而训练速度慢、显存占用高。我一般先复现 PointNet 版本跑通全流程再把 backbone 调成 DGCNN 试试上限这是性价比最高的路径。import torch import torch.nn as nn class PointNetBackbone(nn.Module): def __init__(self, feat_dim80): super().__init__() self.mlp1 nn.Sequential( nn.Conv1d(3, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), ) self.mlp2 nn.Sequential( nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), nn.ReLU(), ) self.fc nn.Conv1d(1024, feat_dim, 1) def forward(self, x): # x: (B, 3, N) f self.mlp1(x) # (B, 128, N) g self.mlp2(f).max(dim-1).values # (B, 1024) g g.unsqueeze(-1).expand_as(f) # 全局特征拼回每个点 return self.fc(torch.cat([f, g], dim1)) # (B, feat_dim, N)这块代码的核心逻辑是“局部特征 全局特征”拼接。参数上值得注意两点feat_dim 默认 80太大会让后续特征扩散矩阵的参数量膨胀中间层从 64 直接跳到 128 再跳到 1024 是 PointNet 惯用配置不要自己去调窄——窄了全局描述能力立刻下降后果是类别先验变弱缺失关键点就预测不动了。3.2 特征扩散模块让逐点特征在空间里互相“通气”backbone 出来的逐点特征还带着很强的局部独立性椅背上的点和扶手上的点互不知道对方。特征扩散做的事情就是让特征在空间里迭代混合位置越近的点特征越趋近一致。这相当于在特征空间里做多轮平滑让一个语义关键点的“证据”能传播到它附近所有点上。def feature_diffusion(feats, pts, T5, sigmaNone): feats: (B, C, N)逐点特征 pts: (B, N, 3)归一化后的坐标 d2 torch.cdist(pts, pts) ** 2 # (B, N, N)点间距离平方 if sigma is None: # 常见做法取每个点到第 24 近邻距离的中位数作为 sigma 基准 knn_d torch.topk(d2, k24, largestFalse).values[:, :, -1] sigma 2.0 * knn_d.median(dim1).values.detach() K torch.exp(-d2 / (sigma.unsqueeze(-1) ** 2)) # 距离核矩阵 K K / K.sum(dim-1, keepdimTrue) # 行归一化 feats feats.transpose(1, 2) # (B, N, C) for _ in range(T): feats torch.bmm(K, feats) # 每轮做一次加权混合 return feats.transpose(1, 2)逻辑不复杂先算点与点之间的高斯距离核越近权重越大行归一化后形成一个转移矩阵然后把这个矩阵反复作用到特征上。参数上迭代次数 T 我一般取 3~5太小特征没“通气”太大特征被过度平滑所有点的特征趋同关键点类别直接糊掉。sigma 是距离核的带宽固定值不好使因为不同物体尺寸不同我习惯用每个样本第 24 近邻距离的中位数来自适应。这里有一个实打实的工程坑N1024 时这个矩阵是 1024×1024显存还能扛N 提到 4096 后直接变成 4096×4096一个 batch 就爆显存。常见做法是用 kNN 稀疏化这个核矩阵只保留每个点最近的 24 个邻居其余置零。训练时用稠密核完全没问题但部署到大规模点云时一定要换稀疏版本这是我从显存爆炸里换来的经验。3.3 Sinkhorn 匹配把关键点数量约束成类别先验分类头输出的是每个点对 K1 个类别K 个关键点 背景的 logits直接 argmax 就能得到逐点标签。但这样得到的关键点数量在每个实例上是随意的——这一把椅子可能预测出 13 个“扶手端点”下一把椅子只剩 6 个。为了让预测在实例间保持一致项目里引入 Sinkhorn 迭代做矩阵约束。def sinkhorn(logits, eps0.05, iters50): logits: (B, N, K)去掉背景类后的关键点分数 对每个点的概率分布做行列归一化使每个关键点列的总和接近类别先验 m torch.exp(torch.clamp(logits / eps, max10.0)) m m / (m.sum(dim1, keepdimTrue) 1e-6) # 行归一化每个点加起来为 1 for _ in range(iters): m m / (m.sum(dim2, keepdimTrue) 1e-6) # 列归一化 m m / (m.sum(dim1, keepdimTrue) 1e-6) # 行归一化 return m这个迭代做的是交替行列归一化收敛后每个关键点列的总和会被拉向一致也就是“每类关键点数量在实例间尽量一样”。eps 是温度参数越小约束越硬但梯度越容易消失我实测 0.05 是一个比较稳的起点。两个注意点一是 logits 必须做 clamp否则 eps 取小时 exp 直接溢出成 inf二是迭代次数 50 足够再往上收益很小只是白耗时间。需要提醒的是Sinkhorn 在训练初期很“平”所有概率都趋近均匀分布梯度非常弱。所以不要一上来就完全依赖 Sinkhorn 的约束必须配合逐点的分类损失一起训练等分类头大致工作后再让 Sinkhorn 发挥作用。后面第四章会讲到具体怎么在训练循环里配权重。4. 训练执行与调参清单环境、损失权重与曲线判读4.1 环境准备与文件组织环境上没有特别偏门的依赖。Python 3.7 以上PyTorch 1.7 以上就能跑open3d 只用在可视化训练主循环其实用不到。我习惯项目结构这样放keypointnet/ config.py # 类别数、每类关键点数、路径、batch 等全部参数 dataset.py # 读取 npz做归一化和增强 model.py # backbone 特征扩散 分类头 loss.py # CE Sinkhorn 一致性损失 train.py # 训练主循环 evaluate.py # 评估 PKr 指标 data/ train/ # 训练集 npz按类别分子目录 val/第一次跑通建议先用 CPU 跑一个 epoch数据集小的话几分钟就完。这一步不是为了训练而是验证数据维度、标签范围和网络 forward 是否对齐。最常见的错误是标签最大值等于 K但分类头输出了 K1 类维度差一位直到 loss 计算时报错才暴露。config.py 里有几个参数是每次换数据集必调的类别数默认 16 类、每类关键点数默认 10、输入点数量默认 1024、归一化半径。这些参数改错一个训练曲线异常都找不到原因。4.2 损失函数与训练循环损失分成两块逐点分类的交叉熵损失加上 Sinkhorn 的一致性约束。分类损失负责让每个点判断自己属于哪个关键点或背景Sinkhorn 损失负责让关键点数量在实例之间稳定。import torch import torch.nn as nn import torch.optim as optim class KeypointLoss(nn.Module): def __init__(self, num_keypoints10, sinkhorn_weight1.0): super().__init__() self.num_keypoints num_keypoints self.sinkhorn_weight sinkhorn_weight # 背景点数量远多于关键点必须压背景权重 class_weights torch.ones(num_keypoints 1) class_weights[0] 0.2 self.ce nn.CrossEntropyLoss(weightclass_weights) def forward(self, logits, labels): # logits: (B, N, K1)labels: (B, N) loss_cls self.ce(logits.permute(0, 2, 1), labels) key_logits logits[:, :, 1:] # 去掉背景 m sinkhorn(key_logits) # 先验每类关键点数量应该接近 N/K在归一化点云上 prior m.sum(dim1).mean(dim1) / self.num_keypoints loss_sink ((m.sum(dim1) - prior.unsqueeze(-1)) ** 2).mean() return loss_cls self.sinkhorn_weight * loss_sink, loss_cls, loss_sink配置上优化器用 Adam初始学习率 1e-3batch size 16~32。学习率策略我常用 60 个 epoch 后降到 1e-4或者直接用余弦退火到 1e-5。训练循环本身是标准写法model.train() for epoch in range(epochs): for pts, labels in loader: pts, labels pts.cuda(), labels.cuda() feats model(pts) logits cls_head(feats) total_loss, loss_cls, loss_sink criterion(logits, labels) optimizer.zero_grad() total_loss.backward() optimizer.step() # 每 5 个 epoch 在验证集上看一次 PKr这里有一个从实际训练里总结出来的经验前 10 个 epoch 建议把 sinkhorn_weight 设成 0只让分类头先学出大致的关键点分布10 个 epoch 之后再把它从 0.1 逐步升到 1.0。如果一开始就带 Sinkhorn分类损失会被矩阵归一化干扰曲线表现为两个 loss 都不降卡在一个平台期很久。4.3 训练曲线怎么看三个信号对应三个问题训练过程里我主要看三个信号每个信号对应一个具体问题第一CE loss 稳步下降但最终停在 0.3 以上降不下去。这说明背景类权重和关键点类权重失衡——背景点占绝大多数权重压到 0.2 通常足够但如果某个类别在数据里特别稀少关键点权重还要再往上提。第二Sinkhorn loss 下降很快但验证准确率很低。这说明 Sinkhorn 权重过大输出被强行平均化关键点之间的区分度被抹掉了。把 sinkhorn_weight 调回 0.3 以下通常会恢复。第三CE loss 正常下降Sinkhorn loss 却震荡不降。先验数量计算方式有问题——我这里用的是“每个类别关键点总数平均分配”的先验如果你的数据集里各类别点云密度差异很大这个先验就不成立。这时可以把先验改成训练集统计出的每个类别实际关键点数量中位数会稳很多。5. 推理评估与避坑记录指标、多视角投票和五条血泪经验5.1 评估指标怎么算位置误差而不是分类误差训练时看的是逐点分类准确率但评估一个关键点检测器好不好真正该看的是预测关键点位置和标注关键点位置的距离误差。逐点分类准确率有迷惑性大部分点本来就是背景模型把所有点都预测成背景就能拿 90% 准确率完全没意义。def evaluate_pk(model, loader, radius0.02): hits, total 0, 0 model.eval() for pts, labels in loader: pts pts.cuda() with torch.no_grad(): logits model(pts) # (B, N, K1) probs torch.softmax(logits, dim-1).cpu().numpy() for b in range(pts.shape[0]): pts_b pts[b].cpu().numpy() # (N, 3) labels_b labels[b].numpy() # (N,) for k in range(probs.shape[-1] - 1): mask_pred probs[b][:, k 1] 0.5 if not mask_pred.any(): continue # 概率加权中心作为预测关键点位置 center (pts_b[mask_pred] * probs[b][mask_pred, k 1:k 2]).sum(0) / probs[b][mask_pred, k 1].sum() mask_gt labels_b k 1 if not mask_gt.any(): continue dist np.linalg.norm(pts_b[mask_gt] - center, axis1).min() hits dist radius total 1 return hits / max(total, 1)我一般不用逐点 argmax 来定位关键点而是取概率大于 0.5 的所有点的概率加权中心。这样抗噪性好很多单个点预测错不会把中心拉飞。radius 的选取上归一化之后点云在单位球内我常用 0.02也就是半径的 2%如果物体本身直径差异大可以用 5% 物体直径作为自适应阈值。total 是“GT 中实际出现的关键点类别数量”有些实例因为遮挡缺了某个关键点的扫描这些缺失样本应该从分母里排除否则指标会被拉低得很冤枉。5.2 多视角投票推理把单帧预测稳住的常见做法训练数据里每个实例通常包含多个渲染视角的观测但推理时默认是单帧输入。单帧预测的问题是某个关键点在这个视角下完全没有前景点网络再强也只能靠先验硬猜位置方差变大。我在做位姿估计时会加一个多视角投票绕竖直轴每 45° 转一次得到 8 帧预测把每帧预测出的关键点中心变换回物体坐标系然后做聚类取簇中心。def predict_multiview(model, points, azimuthsrange(0, 360, 45)): centers {k: [] for k in range(num_keypoints)} for az in azimuths: R rotation_matrix_z(np.radians(az)) pts_r points R.T logits model(pts_r) probs torch.softmax(logits, dim-1) for k in range(num_keypoints): mask probs[:, k 1] 0.5 if mask.any(): center_r (pts_r[mask] * probs[mask, k 1:k 2]).sum(0) / probs[mask, k 1].sum() centers[k].append(center_r R) # 映射回原坐标系 # 对每个关键点取均值或聚类中心 return {k: np.mean(v, axis0) for k, v in centers.items() if v}这个做法的代价是推理耗时乘 8收益是位置方差显著下降。做配准和位姿估计时值得但如果只是做形状对应这种离线任务不一定要开。5.3 避坑记录复现 KeypointNet 遇到的五个典型问题下面几条是从实际复现里攒下来的按“现象 → 原因 → 解决”写方便对号入座。现象一训练 loss 正常下降但验证 PKr 只有个位数。原因评估时用逐点 argmax 去定位关键点而 GT 标签是按“最近关键点”分配的边界点 argmax 会抖动位置中心自然偏。解决评估一律用概率加权中心并且和 GT 做最近邻匹配后再算距离不要直接按索引对齐。现象二换到样本数少的类别比如只有几十个实例的类别训练直接过拟合验证集 PKr 暴跌。原因类别不平衡网络把少数类别的关键点模板记住了而不是泛化出来。解决训练时按类别实例数的倒数做采样权重每个 epoch 强制每个类别出现次数接近同时对少数类别开更强的 dropout 增强。现象三N 提到 4096 后第一个 epoch 显存直接爆掉。原因特征扩散的稠密距离矩阵是 N×N4096 的平方矩阵即使一个样本也扛不住。解决先用 FPS 把输入降到 1024 做前向预测出关键点标签后再映射回原始分辨率或者把稠密核换成 kNN 稀疏邻接矩阵只保留每个点 24 个近邻。现象四训练时用最大半径归一化测试时直接照搬训练代码PKr 却全线下降。原因测试时点云是不完整视角最大半径比训练时的完整模型小很多归一化尺度不一致。解决归一化半径不做逐样本计算而是用训练集统计出的固定半径值比如 0.8所有样本统一除以这个常数测试时同样禁用梯度避免 batch 统计干扰。现象五Sinkhorn 迭代在训练中段出现 NaN。原因logits 数值变大后exp(logits / eps) 溢出。解决对 logits 做 clamp 到 [-10, 10]同时把 logits 先减去每行最大值再做缩放数值稳定很多。这个坑最容易在混合精度训练下触发开启 AMP 时建议把 Sinkhorn 里的 exp 用 float32 计算。6. 进阶玩法热力图后处理、跨类别微调与关键点引导配准单纯跑通训练和评估只是第一步这套资源真正值钱的是在下游任务里怎么用。我常用三个进阶技巧都建立在已训练模型的基础上。第一个技巧是热力图后处理代替直接取概率最大点。模型输出的逐点概率本质上是一张定义在点云上的热力图直接取最大值单点很脆稍微一抖动位置就偏。我一般会在预测概率上做一个简单的非极大值抑制先把所有点按概率排序然后从高到低选点保证选出来的点之间距离不小于一个阈值。def nms_keypoints(pts, probs, threshold0.02): order probs.argsort()[::-1] selected [] for i in order: if any(np.linalg.norm(pts[i] - pts[j]) threshold for j in selected): continue selected.append(i) return np.array(selected)threshold 设成 0.02 通常够用物体越大这个阈值要跟着放大拿 5% 物体直径做基准更稳。做完 NMS 再对每个簇做概率加权平均比直接 argmax 稳定得多。第二个技巧是跨类别迁移。每类 10 个关键点的设定意味着类别之间不能直接共享预测头但如果要新增一个类别不必从零训练。我一般固定 backbone 和特征扩散层的权重只训练新类别的分类头和 Sinkhorn 参数每个类别用 20~50 个实例就能把 PKr 拉到接近全量训练的水平。初始化时把新类别的关键点数先设成和它最相似的已有类别一致微调阶段再加到目标数量。这个策略在只有少量标注数据时很实用。第三个技巧是把预测关键点用到位姿估计上。与其用 ICP 全局搜索不如先用预测的关键点做对应关系求解 Kabsch 或 PnP 得到初始位姿再用 ICP 精配准收敛。实际操作中关键点引导的 ICP 比全局盲搜快一个量级迭代次数通常 50 次以内就收敛距离阈值设成 2 倍点间距。做完这些之后我的习惯是用两个固定测试样本做回归第一个是完整扫描第二个是故意挖掉 40% 区域的残缺扫描。完整扫描验证模型的基本能力残缺扫描验证类别先验有没有真正学进去。从那以后我每次换数据集第一件事都是先拿两三个实例做单样本前向加热力图可视化确认类别关键点数量和先验对了再开全量训练。这个多花半小时的检查帮我省掉的无效训练时间远比模型本身值钱。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑