☰
PyTorch keypoint_rcnn 训练自建数据集:从标注到推理全流程
2026/10/13 4:42:06 网站建设 项目流程

简介:这份资源面向深度学习与计算机视觉方向的学习者和开发者,聚焦如何用PyTorch中的Keypoint R-CNN在自建数据集上训练关键点检测模型,可应用于姿态估计、人脸特征点定位等场景。压缩包共116个文件,约8.55MB,包含8个py脚本与2个ipynb笔记本用于模型搭建和训练流程演示,34个txt与31个json承担标注与配置数据,39个jpg提供图像样本,另有md说明与gitmodules子模块配置,整体结构便于按数据准备、模型配置、训练评估的脉络查阅。资源已有166人学习下载,内容覆盖从数据组织、预处理增强到模型头部调整、损失组合、训练监控与ONNX导出的完整链路,读者可据此复现关键点检测训练流程,理解自建数据集标注格式与调参排错思路,适合具备一定PyTorch基础、希望动手实践关键点检测项目的中高级开发者参考。

1. 用 keypoint_rcnn 训练自建数据集:从标注到推理的完整路径

手里有一批自己拍的图片,想标出每张图里目标的几个关键点,比如零件的定位孔、手势的指尖、动物的关节,然后训练一个能自动预测这些点坐标的模型——这件事用 PyTorch 里的keypoint_rcnn就能干,而且不用从零搭网络。它本质上是把目标检测框架 Faster R-CNN 的头部换成了关键点回归分支:先框出目标,再在框内预测每个关键点的热力图,最后取峰值坐标。相比直接回归坐标,热力图方式对遮挡和小目标更稳,这也是它比一堆自写回归网络更值得用的原因。

适合谁?手头有几十到几百张标注图、想快速跑通关键点检测闭环的开发者;或者已经用过 YOLO 做检测、想扩展到关键点任务的熟手。整条链路是:标注工具出 COCO 格式 → 自定义 Dataset 读图读标注 → 改keypoint_rcnn的头部通道数 → 训练调参 → 推理可视化。下面按这个顺序拆开讲,参数和坑都会落到具体位置。

2. 数据标注与 COCO 格式:自建数据集的第一道坎

2.1 为什么关键点任务优先选 COCO 标注格式

关键点检测的标注比纯检测多一层结构:每张图除了bbox,还要有keypoints数组,长度是3 * K(K 是关键点个数),每三个数是一组(x, y, v),v=0表示未标注,v=1表示标注但不可见,v=2表示可见。keypoint_rcnn官方实现直接吃 COCO 格式,省掉自己写解析器的功夫。

常见做法是用 labelme 或 CVAT 标点,再转 COCO。labelme 导出的是它自己的 JSON,需要写脚本转。转换时最容易翻车的地方是v值:labelme 不区分可见性,转出来全是 1,如果图里有遮挡点,模型会学到错误信号。我的习惯是标注时对遮挡点直接不标,转换脚本里把缺失点填(0,0,0),让损失函数忽略它们。

COCO 的目录结构建议这样组织,annotations放单个 JSON,图片按训练验证分文件夹:

dataset/ annotations/ instances_train.json instances_val.json images/ train/ val/

JSON 顶层是images、annotations、categories三个数组。categories里每个类要有keypoints字段和skeleton字段,后者定义点之间的连线,只影响可视化,不影响训练,但漏了某些版本会报错。

2.2 从 labelme 到 COCO 的转换脚本

下面这个脚本把 labelme 的多个 JSON 合成一个 COCO JSON,关键点是处理v值和bbox计算:

import json, os, glob # 关键点名称顺序必须固定,训练和推理都依赖这个顺序 KEYPOINTS = ["p1", "p2", "p3", "p4"] SKELETON = [[0, 1], [1, 2], [2, 3]] def labelme_to_coco(json_dir, out_path, category_name="target"): images, annotations = [], [] ann_id = 1 for img_id, jf in enumerate(sorted(glob.glob(os.path.join(json_dir, "*.json")))): data = json.load(open(jf, encoding="utf-8")) h, w = data["imageHeight"], data["imageWidth"] images.append({"id": img_id, "file_name": os.path.basename(jf).replace(".json", ".jpg"), "width": w, "height": h}) # labelme 的 shapes 里可能混有矩形框和点,这里只取点 pts = {s["label"]: s["points"][0] for s in data["shapes"] if s["shape_type"] == "point"} kps = [] for name in KEYPOINTS: if name in pts: x, y = pts[name] kps.extend([x, y, 2]) # 2 表示可见 else: kps.extend([0, 0, 0]) # 缺失点,损失会忽略 xs = [kps[i] for i in range(0, len(kps), 3) if kps[i+2] > 0] ys = [kps[i+1] for i in range(0, len(kps), 3) if kps[i+2] > 0] if not xs: continue # 整张图没有有效点,跳过 x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys) # bbox 外扩 10 像素,给关键点回归留上下文 pad = 10 bbox = [max(0, x0-pad), max(0, y0-pad), min(w, x1+pad), min(h, y1+pad)] bbox = [bbox[0], bbox[1], bbox[2]-bbox[0], bbox[3]-bbox[1]] # COCO 是 xywh annotations.append({ "id": ann_id, "image_id": img_id, "category_id": 1, "bbox": bbox, "area": bbox[2]*bbox[3], "iscrowd": 0, "num_keypoints": sum(1 for i in range(0, len(kps), 3) if kps[i+2] > 0), "keypoints": kps }) ann_id += 1 coco = { "images": images, "annotations": annotations, "categories": [{"id": 1, "name": category_name, "keypoints": KEYPOINTS, "skeleton": SKELETON}] } json.dump(coco, open(out_path, "w", encoding="utf-8"), ensure_ascii=False) print(f"images={len(images)}, annotations={len(annotations)}") labelme_to_coco("raw_json", "dataset/annotations/instances_train.json")

逻辑说明:bbox用有效关键点的外接矩形再外扩,是因为keypoint_rcnn的 ROI 裁剪依赖 bbox,如果 bbox 贴得太紧,边缘关键点的热力图会被裁掉一半,训练时表现为边缘点永远学不准。pad=10是经验值,图分辨率大就调到 20。num_keypoints字段必须和实际v>0的数量一致,某些版本的 COCO 评估会校验它。

参数说明:KEYPOINTS顺序一旦定了就不能改,改顺序等于让模型重新学;category_id从 1 开始,0 在 COCO 里通常留给背景。

2.3 自定义 Dataset 与数据增强的边界

keypoint_rcnn期望target里包含boxes、labels、keypoints三个张量。keypoints形状是[N, K, 3],第三维是(x, y, v)。写 Dataset 时最容易忽略的是增强:随机裁剪、旋转会改变关键点坐标,必须同步变换,否则标签和图像对不上,模型直接学废。

我一般用torchvision的transforms只做颜色抖动和水平翻转,翻转时手动镜像关键点 x 坐标并交换左右对称点的顺序。如果关键点没有左右对称关系,翻转后 x 坐标用w - x即可。随机缩放可以做,但要把 bbox 和关键点一起缩放,且缩放后v=0的点保持(0,0,0)。

import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms.functional as F class KeypointDataset(Dataset): def __init__(self, coco_json, img_root, flip=False): self.coco = json.load(open(coco_json, encoding="utf-8")) self.img_root = img_root self.flip = flip self.img_map = {im["id"]: im for im in self.coco["images"]} # 按 image_id 聚合标注 self.ann_map = {} for a in self.coco["annotations"]: self.ann_map.setdefault(a["image_id"], []).append(a) def __len__(self): return len(self.coco["images"]) def __getitem__(self, idx): img_info = self.coco["images"][idx] img = Image.open(os.path.join(self.img_root, img_info["file_name"])).convert("RGB") w, h = img.size anns = self.ann_map.get(img_info["id"], []) boxes, labels, kps = [], [], [] for a in anns: x, y, bw, bh = a["bbox"] boxes.append([x, y, x+bw, y+bh]) labels.append(a["category_id"]) k = torch.tensor(a["keypoints"], dtype=torch.float32).view(-1, 3) kps.append(k) target = { "boxes": torch.tensor(boxes, dtype=torch.float32), "labels": torch.tensor(labels, dtype=torch.int64), "keypoints": torch.stack(kps) if kps else torch.zeros((0, 4, 3)) } img = F.to_tensor(img) if self.flip and torch.rand(1) < 0.5: img = torch.flip(img, dims=[2]) target["boxes"][:, [0, 2]] = w - target["boxes"][:, [2, 0]] target["keypoints"][:, :, 0] = w - target["keypoints"][:, :, 0] # 注意:有左右对称点时要在这里交换顺序 return img, target

逻辑说明:boxes用xyxy格式,这是keypoint_rcnn内部期望的;keypoints的v值原样保留,损失函数会根据v>0决定是否计入。翻转时boxes的 x 坐标交换要写成[:, [0,2]] = w - [:, [2,0]],直接减会搞反左右边界。

参数说明:flip概率 0.5 是常规值;如果关键点有强左右语义(比如左手右手),翻转后必须交换对应点的索引,否则标签语义错乱,这个坑很隐蔽,训练 loss 会正常下降但验证时左右点互换。

3. 改造 keypoint_rcnn 头部:通道数与关键点数的对应

3.1 模型结构里哪些层和关键点数强相关

keypoint_rcnn的头部由两部分组成:keypoint_head是一个反卷积加卷积的堆叠,输出通道数等于关键点数 K;box_head负责分类和 bbox 回归。用torchvision.models.detection.keypointrcnn_resnet50_fpn时,通过num_keypoints参数控制 K,但预训练权重是按 COCO 的 17 个点训的,直接改 K 会导致头部权重形状不匹配。

常见做法是加载预训练权重时用strict=False,让头部随机初始化,骨干网络继续用预训练特征。这样小数据集也能收敛,因为骨干已经会提特征了。如果数据集和 COCO 差异极大(比如医学影像),骨干也可以解冻微调,但学习率要调小。

import torchvision from torchvision.models.detection.keypointrcnn_resnet50_fpn import KeypointRCNN def build_model(num_keypoints, num_classes=2, pretrained=True): model = torchvision.models.detection.keypointrcnn_resnet50_fpn( pretrained=pretrained, # 骨干用 COCO 预训练 num_classes=num_classes, # 背景 + 1 类目标 num_keypoints=num_keypoints, # 你的 K min_size=800, max_size=1333 # 输入短边缩放范围 ) return model model = build_model(num_keypoints=4) # 检查头部输出通道 print(model.roi_heads.keypoint_predictor.kps_score_lowres.out_channels) # 应为 4

逻辑说明:num_classes包含背景,所以单类目标是 2。min_size/max_size控制图像缩放,小目标多就把min_size调大,但显存会涨。kps_score_lowres是最终输出热力图的层,它的out_channels必须等于 K,打印出来核对一下,改错了训练时会在 loss 计算处报形状错误。

参数说明:pretrained=True时头部会被替换,strict=False加载是内部行为;如果自己写加载逻辑,记得过滤掉keypoint_head和keypoint_predictor的键。

3.2 损失函数构成与训练时的关注指标

keypoint_rcnn的损失由四块组成:loss_objectness、loss_rpn_box_reg、loss_classifier、loss_box_reg、loss_keypoint。前四块是检测部分的,最后一块是关键点热力图交叉熵。训练时如果loss_keypoint不下降,先看检测部分是否正常——如果 bbox 都框不准,关键点无从谈起。

我一般先冻结关键点头部,只训检测部分几个 epoch,等loss_classifier稳定后再解冻一起训。这样能避免早期关键点损失把骨干带偏。监控时重点看loss_keypoint和验证集上的 OKS(Object Keypoint Similarity),OKS 是 COCO 的关键点评估指标,比单纯看 loss 靠谱。

import torch.optim as optim params = [p for p in model.parameters() if p.requires_grad] optimizer = optim.SGD(params, lr=0.005, momentum=0.9, weight_decay=0.0005) lr_scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1) model.train() for epoch in range(10): for images, targets in dataloader: images = [img for img in images] targets = [{k: v for k, v in t.items()} for t in targets] loss_dict = model(images, targets) losses = sum(loss for loss in loss_dict.values()) optimizer.zero_grad() losses.backward() optimizer.step() lr_scheduler.step() print(f"epoch {epoch}, keypoint_loss={loss_dict['loss_keypoint'].item():.4f}")

逻辑说明:model在train()模式下返回 loss 字典,eval()模式下返回预测结果,别搞混。学习率 0.005 是检测任务的常用起点,小数据集可以降到 0.001。StepLR每 3 个 epoch 降一次,防止后期震荡。

参数说明:weight_decay0.0005 是标准值;如果过拟合严重,加到 0.001。batch size 受显存限制,keypoint_rcnn输入分辨率高,单卡 batch 2 到 4 比较现实,太小可以用梯度累积。

4. 训练避坑:自建数据集上最容易翻车的五个点

4.1 关键点全标成 v=2 导致遮挡点学不准

现象:训练 loss 正常下降,但推理时被遮挡的关键点位置乱飘,OKS 明显低于可见点。原因:标注时把所有点都标成v=2,模型被迫对遮挡点也输出确定坐标,学到的是噪声。解决:标注阶段对遮挡点要么不标(v=0),要么标v=1,让损失函数对它们降权。COCO 的 OKS 评估本身会对v=0的点跳过,所以不影响指标。

4.2 bbox 贴太紧导致边缘点热力图被裁

现象:位于目标边缘的关键点(比如轮廓角点)始终学不准,其他点正常。原因:ROI Align 按 bbox 裁剪特征,bbox 如果刚好卡在关键点上,该点的热力图目标落在裁剪边界外,监督信号丢失。解决:转换脚本里给 bbox 外扩 10 到 20 像素,或者训练时对 bbox 做随机抖动。这个坑很隐蔽,因为 loss 不会报错,只是某些点精度上不去。

4.3 图像增强没同步变换关键点

现象:训练 loss 震荡大,验证集精度远低于训练集,可视化发现预测点整体偏移。原因:做了随机裁剪或旋转,但关键点坐标没跟着变,标签和图像错位。解决:所有几何增强必须同步作用于关键点,翻转时注意左右对称点的索引交换。颜色增强不影响坐标,可以放心用。

4.4 学习率过大导致关键点损失爆炸

现象:训练几个 step 后loss_keypoint变成 NaN。原因:关键点热力图用的是交叉熵,输出未归一化时数值范围大,学习率过高容易梯度爆炸。解决:把学习率降到 0.001 甚至 0.0005,或者在关键点头部前加梯度裁剪。torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)是后悔药级别的保险。

4.5 验证集评估时忘了切 eval 模式

现象:验证时 loss 正常但预测结果乱七八糟,或者报错说返回类型不对。原因:model.eval()没调用,模型还在 train 模式,返回的是 loss 字典而不是预测框。解决:验证和推理前必须model.eval(),并用torch.no_grad()包住,否则显存会持续累积。

5. 推理与可视化:把预测点画回原图

5.1 单张图推理的完整流程

推理时模型返回每个目标的boxes、labels、scores、keypoints。keypoints形状是[N, K, 3],第三维是(x, y, v),这里的v是预测的置信度,不是标注的可见性。取点时用v过滤低置信度点,阈值一般设 0.5。

import cv2 import numpy as np model.eval() img = cv2.imread("test.jpg") rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) tensor = torch.from_numpy(rgb).permute(2, 0, 1).float() / 255.0 with torch.no_grad(): pred = model([tensor.to(device)])[0] # 取分数最高的目标 if len(pred["scores"]) > 0: best = pred["scores"].argmax() kps = pred["keypoints"][best].cpu().numpy() # [K, 3] for x, y, v in kps: if v > 0.5: cv2.circle(img, (int(x), int(y)), 3, (0, 255, 0), -1) # 按 skeleton 连线 for i, j in SKELETON: if kps[i][2] > 0.5 and kps[j][2] > 0.5: cv2.line(img, (int(kps[i][0]), int(kps[i][1])), (int(kps[j][0]), int(kps[j][1])), (255, 0, 0), 2) cv2.imwrite("out.jpg", img)

逻辑说明:输入张量要归一化到[0,1],这是预训练模型的期望。pred["keypoints"]里的坐标是原图尺度,因为模型内部会做缩放还原,不用手动映射。连线时两端点都要过置信度阈值,否则会出现半截线。

参数说明:置信度阈值 0.5 是起点,误检多就调高到 0.7,漏检多就降到 0.3。cv2.circle的半径按图分辨率调,大图用 5。

5.2 用 OKS 量化评估而不是只看可视化

可视化只能看个大概,真正判断模型好坏要用 OKS。OKS 的计算需要每类关键点的标准差,COCO 给了一套默认值,自建数据集如果没有,可以用关键点标注的方差近似。评估脚本可以基于pycocotools改,把keypoints结果按 COCO 格式写回 JSON 再调COCOeval。

一个容易忽略的点:OKS 对 bbox 尺度敏感,如果预测框比真实框大很多,OKS 会被拉低。所以检测部分的精度直接影响关键点指标,调参时不能只盯loss_keypoint。

6. 小数据集上的进阶技巧:从能跑到能用

自建数据集通常只有几十到几百张图,直接训keypoint_rcnn很容易过拟合。我踩过的坑是:训练集 OKS 0.85,验证集只有 0.4,可视化一看验证图的点全挤在目标中心。后来靠三招把验证 OKS 拉到 0.7 以上。

第一招是冻结骨干的前两层。keypoint_rcnn的 backbone 是 ResNet50+FPN,浅层学的是边缘纹理,这些特征通用性强,冻结它们能减少在小数据上的过拟合。代码上遍历model.backbone.body.layer1和layer2的参数设requires_grad=False,只训后面层和头部。代价是收敛慢一点,但验证集更稳。

第二招是关键点热力图的高斯核尺寸要和目标尺度匹配。默认实现里高斯核标准差是按 bbox 面积算的,小目标上核太大,热力图糊成一片,峰值定位不准。如果目标普遍偏小,可以在配置里把keypoint_head的kps_score_lowres上采样倍率调小,或者自己改损失里的 sigma。这个改动需要动源码,但效果明显。

第三招是测试时增强(TTA)。推理时把图水平翻转一次,两次预测的关键点取平均,x 坐标翻转回来。这个技巧对对称目标特别有效,OKS 通常能涨 2 到 3 个点。代价是推理时间翻倍,看场景取舍。

# 测试时水平翻转增强 def predict_tta(model, tensor, w): with torch.no_grad(): p1 = model([tensor])[0] p2 = model([torch.flip(tensor, dims=[2])])[0] # p2 的 x 坐标翻转回原图 p2["keypoints"][..., 0] = w - p2["keypoints"][..., 0] # 简单平均,实际可按 score 加权 merged = (p1["keypoints"] + p2["keypoints"]) / 2 return merged

逻辑说明:翻转后预测的 x 坐标要映射回原图坐标系,w - x是水平镜像公式。两次预测的目标顺序可能不一致,严格做法是按 bbox IoU 匹配后再平均,简单场景下取分数最高的目标平均也够用。

参数说明:TTA 只建议在推理阶段用,训练阶段用会拖慢且收益小。如果关键点有强左右语义,翻转后要交换对应点索引再平均,否则左右点会被平均到中间。

最后说个习惯:每次改完数据或模型,先拿 5 张训练图过一遍推理,确认能复现标注点,再跑全量训练。这个检查花两分钟,能省掉几小时的无效训练。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询