1. 这不是“跑通一个Demo”,而是吃透Detectron2数据流的起点
你点开Detectron2官方文档,看到balloon示例时,第一反应可能是:“哦,又一个玩具数据集”。但如果你真把它当玩具,后面训练自己手里的工业缺陷图、医疗CT切片或农业无人机影像时,大概率会在DatasetMapper报错、ROIAlign梯度消失、或者mAP卡在0.1死活上不去——这些都不是模型的问题,而是你没真正理解Detectron2怎么“看”一张图、“读”一组标注、“喂”给网络的全过程。balloon这个例子,表面是教你怎么改几行代码,本质是一套完整的视觉感知系统数据契约(Data Contract)教学包:它用最简结构暴露了所有关键断点——从JSON文件字段命名规范,到BoxMode.XYWH_ABS和BoxMode.XYXY_ABS的毫米级差异,再到register_coco_instances背后注册表如何被DefaultTrainer动态调用。我带过三届实习生,几乎所有人第一次改自定义数据集都栽在metadata.thing_classes顺序和COCODataset中category_id映射不一致上,而balloon的class_names = ["BG", "balloon"]这行看似无害的代码,恰恰是唯一能让你提前踩坑、建立肌肉记忆的安全沙盒。它不追求SOTA性能,只确保你每一步操作都有明确的因果反馈:改一个字段,日志里立刻报KeyError: 'bbox';少注册一个元数据,Visualizer画框时直接IndexError。这种“错误即文档”的设计哲学,才是你后续处理上千张复杂标注时最可靠的导航仪。
2. 数据准备与格式转换:为什么必须亲手写脚本而不是用现成工具
2.1 Balloon数据集的原始结构解剖
官方提供的balloon数据集压缩包解压后是典型的三件套:train/、val/两个图像目录,外加一个via_region_data.json。别急着扔进register_coco_instances——这个JSON根本不是标准COCO格式。打开它,你会看到类似这样的结构:
{ "filename.jpg": { "filename": "filename.jpg", "size": 123456, "regions": [{ "shape_attributes": {"name": "polygon", "all_points_x": [10,20,30], "all_points_y": [15,25,35]}, "region_attributes": {"name": "balloon"} }], "file_attributes": {} } }注意三个致命细节:第一,regions数组里没有bbox字段,只有多边形顶点;第二,region_attributes.name是字符串,而COCO要求整数category_id;第三,size是文件字节大小,不是图像宽高。很多新手直接拿labelme2coco.py转,结果生成的COCO JSON里annotations[].bbox全是[0,0,0,0],因为工具默认只处理labelme的JSON,对VIA格式的all_points_x/y视而不见。我试过7个开源转换脚本,只有2个能正确提取polygon并拟合最小外接矩形(Bounding Box),其余要么报ValueError: x and y must have same first dimension,要么把气球轮廓当成噪声过滤掉。
2.2 手写转换脚本的核心逻辑与避坑点
下面这段Python代码是我压箱底的via2coco.py精简版,重点不在功能全,而在暴露所有决策点:
import json import cv2 import numpy as np from pathlib import Path def via2coco(via_json_path, image_dir, output_path): with open(via_json_path) as f: via_data = json.load(f) coco = { "images": [], "annotations": [], "categories": [{"id": 1, "name": "balloon", "supercategory": "object"}] } ann_id = 1 for img_name, img_info in via_data.items(): # 关键1:获取真实图像尺寸(不能信via里的size字段) img_path = Path(image_dir) / img_name if not img_path.exists(): continue h, w = cv2.imread(str(img_path)).shape[:2] # OpenCV读取,避免PIL通道顺序干扰 # 关键2:构建images条目,注意file_name必须是相对路径(Detectron2硬性要求) coco["images"].append({ "id": len(coco["images"]) + 1, "file_name": img_name, # 必须和磁盘文件名完全一致,区分大小写 "width": w, "height": h, "date_captured": "" }) # 关键3:遍历regions生成annotations for region in img_info["regions"]: # 提取多边形顶点 x_coords = region["shape_attributes"]["all_points_x"] y_coords = region["shape_attributes"]["all_points_y"] poly = np.array(list(zip(x_coords, y_coords)), dtype=np.int32) # 拟合最小外接矩形(非旋转矩形!) x, y, box_w, box_h = cv2.boundingRect(poly) # 返回XYWH格式 # 关键4:坐标合法性校验(90%的训练崩溃源于此) if x < 0 or y < 0 or (x + box_w) > w or (y + box_h) > h: print(f"Warning: bbox {x,y,box_w,box_h} out of bounds for {img_name}") continue # 跳过越界框,比让训练崩掉强十倍 # 关键5:生成segmentation字段(mask训练必需) # 将polygon转为COCO格式的RLE编码(此处简化为polygon列表) segmentation = [coord for point in zip(x_coords, y_coords) for coord in point] coco["annotations"].append({ "id": ann_id, "image_id": len(coco["images"]), # 注意:这里用当前images长度,因上面已append "category_id": 1, "bbox": [x, y, box_w, box_h], # XYWH_ABS模式 "area": int(cv2.contourArea(poly)), "segmentation": [segmentation], "iscrowd": 0 }) ann_id += 1 with open(output_path, "w") as f: json.dump(coco, f)提示:
cv2.boundingRect()返回的是[x, y, width, height],这正是Detectron2要求的BoxMode.XYWH_ABS。如果你用skimage.measure.regionprops,它返回的bbox是(min_row, min_col, max_row, max_col),直接塞进去会导致所有框向右下偏移——这是我在某次医疗影像项目中调试三天才发现的陷阱。
2.3 数据集划分的工程实践:验证集不是“随机抽10%”
balloon官方把train/val分开存放,但实际项目中你往往只有一个images/目录和一个大JSON。此时train_test_split不能简单按文件名哈希:
# 错误示范:按文件名末位数字分 train_files = [f for f in all_files if f[-5] in "012345678"] # 随机性太强,类别分布不均 # 正确做法:按图像中实例数量分层抽样 from sklearn.model_selection import train_test_split # 先统计每张图的实例数 img_instance_count = {} for ann in coco_data["annotations"]: img_id = ann["image_id"] img_instance_count[img_id] = img_instance_count.get(img_id, 0) + 1 # 构建分层标签:实例数<3为"稀疏",3-10为"中等",>10为"密集" stratify_labels = [] for img in coco_data["images"]: count = img_instance_count.get(img["id"], 0) if count < 3: stratify_labels.append("sparse") elif count <= 10: stratify_labels.append("medium") else: stratify_labels.append("dense") # 分层抽样保证各类别比例一致 train_img_ids, val_img_ids = train_test_split( [img["id"] for img in coco_data["images"]], test_size=0.2, stratify=stratify_labels, random_state=42 )注意:
stratify参数必须和train_test_split的输入序列严格对应。我曾因stratify_labels长度比image_ids少1个,导致ValueError: Found array with dim 3. Expected dim <= 2,调试时打印len(stratify_labels)和len(image_ids)应成为肌肉记忆。
3. Detectron2配置与训练流程:从config.yaml到训练中断恢复
3.1 配置文件的三层嵌套逻辑
Detectron2的配置不是扁平化的参数列表,而是三层继承体系:基础模型(如COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x.yaml)→ 任务适配(修改MODEL.ROI_HEADS.NUM_CLASSES)→ 数据定制(覆盖DATASETS.TRAIN)。很多人直接改mask_rcnn_R_50_FPN_1x.yaml,结果升级Detectron2版本后配置项失效。正确姿势是创建my_balloon_config.py:
from detectron2.config import get_cfg from detectron2 import model_zoo cfg = get_cfg() # 第一层:加载预训练模型配置(冻结权重,只微调head) cfg.merge_from_file(model_zoo.get_config_file("COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x.yaml")) cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url("COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x.yaml") # 第二层:适配任务(关键!必须和你的categories数量一致) cfg.MODEL.ROI_HEADS.NUM_CLASSES = 1 # balloon只有1类,不是2类(BG不算) # 第三层:绑定数据集(注意:字符串名必须和register时完全一致) cfg.DATASETS.TRAIN = ("balloon_train",) # 元组!单元素必须加逗号 cfg.DATASETS.TEST = ("balloon_val",) cfg.DATALOADER.NUM_WORKERS = 2 # 根据GPU显存调整,4G显存建议设为1 # 训练参数(不要盲目调大BATCH_SIZE) cfg.SOLVER.IMS_PER_BATCH = 2 # 总batch size = IMS_PER_BATCH * NUM_GPUS cfg.SOLVER.BASE_LR = 0.001 cfg.SOLVER.MAX_ITER = 10000 # 1x schedule是90k iter,这里按比例缩减 cfg.SOLVER.STEPS = (8000, 9000) # 学习率衰减点 cfg.MODEL.ROI_HEADS.BATCH_SIZE_PER_IMAGE = 128 # 每张图采样128个RoI,影响正负样本平衡关键原理:
NUM_CLASSES = 1意味着ROI_HEADS的分类头输出维度是1(不是2),损失函数计算时会自动忽略背景类。如果设成2,模型会强行学习区分“balloon”和“BG”,但你的标注里根本没有BG的gt_box,导致CrossEntropyLoss输入logits维度为2而targets全为0,最终梯度爆炸。这个细节在官方文档里藏在ROI_BOX_HEAD章节末尾,属于“不踩坑不知道”的硬核知识。
3.2 自定义数据集注册的隐式契约
register_coco_instances不是简单的路径绑定,它强制执行一套数据契约校验:
from detectron2.data import DatasetCatalog, MetadataCatalog from detectron2.data.datasets import register_coco_instances # 注册训练集 register_coco_instances( "balloon_train", # 数据集名称(必须全局唯一) {}, # metadata字典,可空,但推荐显式传入 "/path/to/balloon/train/coco_annotations.json", # 转换后的COCO JSON "/path/to/balloon/train/" # 图像根目录(必须包含所有file_name指向的图片) ) # 注册验证集(名称必须不同!) register_coco_instances( "balloon_val", {}, "/path/to/balloon/val/coco_annotations.json", "/path/to/balloon/val/" ) # 关键:手动设置Metadata(否则Visualizer会报错) MetadataCatalog.get("balloon_train").set( thing_classes=["balloon"], # 顺序必须和JSON中category_id严格对应 evaluator_type="coco" ) MetadataCatalog.get("balloon_val").set( thing_classes=["balloon"], evaluator_type="coco" )注意:
thing_classes是一个列表,索引i对应category_id = i+1(COCO规范中category_id从1开始)。所以["balloon"]意味着所有标注的category_id必须是1。如果JSON里误写成"category_id": 0,DatasetMapper在map_dict时会触发KeyError: 0,错误堆栈深达20层,最终定位到detectron2/data/detection_utils.py第156行——这就是为什么必须亲手写转换脚本,亲眼确认每个category_id的值。
3.3 训练中断恢复与Checkpoint管理
Detectron2默认每1000次迭代保存一次checkpoint,但生产环境需要更精细的控制:
# 在cfg配置中添加 cfg.OUTPUT_DIR = "./output/balloon_finetune" # 输出目录必须存在 cfg.SOLVER.CHECKPOINT_PERIOD = 500 # 每500次保存一次 cfg.TEST.EVAL_PERIOD = 1000 # 每1000次在val集上评估 # 启动训练(支持从中断处继续) from detectron2.engine import DefaultTrainer trainer = DefaultTrainer(cfg) trainer.resume_or_load(resume=True) # resume=True自动检测latest.pth trainer.train()但resume_or_load有个隐藏陷阱:它只恢复模型权重和优化器状态,不恢复学习率调度器(LR Scheduler)的step计数。这意味着如果你在第8000步中断,重启后lr_scheduler仍从第0步开始,导致学习率突变。解决方案是手动保存scheduler:
# 训练循环中(需重写Trainer) def run_step(self): super().run_step() # 手动保存scheduler状态 if self.iter % self.cfg.SOLVER.CHECKPOINT_PERIOD == 0: self.checkpointer.save(f"model_{self.iter}", **{"optimizer": self.optimizer, "scheduler": self.scheduler, "iteration": self.iter})不过对于balloon这种小数据集,更务实的做法是:训练前用cfg.SOLVER.MAX_ITER = 2000快速验证流程,确认loss下降、mAP>0.5后再跑全量。我见过太多人执着于“一次性跑完10k步”,结果第500步就发现loss_box_reg是nan,白白浪费GPU时间。
4. 模型推理与可视化:从预测结果到可交付报告
4.1 推理时的配置陷阱与预处理一致性
训练时用DefaultTrainer,推理时必须用DefaultPredictor,且配置必须完全一致:
from detectron2.engine import DefaultPredictor # 错误:用新cfg初始化predictor cfg_new = get_cfg() cfg_new.merge_from_file("my_config.yaml") predictor = DefaultPredictor(cfg_new) # 可能因cfg不一致导致输入尺寸错误 # 正确:复用训练时的cfg(确保TEST.AUG.MIN_SIZES等参数一致) predictor = DefaultPredictor(cfg) # cfg是训练时那个cfg对象关键参数TEST.AUG.MIN_SIZES控制测试时的多尺度推理。balloon默认是[800],意味着所有图像会被resize到短边800像素。如果你的气球图像原始尺寸是1920x1080,resize后变成1481x800,此时预测框坐标需反向映射回原图:
im = cv2.imread("./test.jpg") outputs = predictor(im) # outputs["instances"].pred_boxes.tensor 是resize后的坐标 # 需要手动映射回原图 h_orig, w_orig = im.shape[:2] h_resized, w_resized = outputs["instances"].image_size # 计算缩放因子 scale_x = w_orig / w_resized scale_y = h_orig / h_resized # 反向映射(Detectron2内部已做,但自定义后处理需知晓) pred_boxes = outputs["instances"].pred_boxes.tensor.cpu().numpy() pred_boxes[:, [0,2]] *= scale_x # x1,x2 pred_boxes[:, [1,3]] *= scale_y # y1,y2实测心得:
balloon数据集中的气球通常占据图像中心区域,MIN_SIZES=[800]足够。但换成工业缺陷检测,缺陷可能只有10x10像素,此时必须设TEST.AUG.MIN_SIZES=[1333]并启用TEST.AUG.MAX_SIZE=2000,否则小目标直接被resize抹掉。这个参数没有银弹,必须根据你的目标尺寸分布直方图来定。
4.2 可视化结果的工程化封装
Visualizer适合调试,但交付给客户需要结构化报告。我封装了一个BalloonResultExporter:
from detectron2.utils.visualizer import Visualizer from detectron2.data import MetadataCatalog import matplotlib.pyplot as plt class BalloonResultExporter: def __init__(self, cfg): self.metadata = MetadataCatalog.get(cfg.DATASETS.TEST[0]) def export_report(self, image_path, outputs, output_dir): im = cv2.imread(image_path) v = Visualizer(im[:, :, ::-1], self.metadata, scale=1.2) out = v.draw_instance_predictions(outputs["instances"].to("cpu")) # 添加统计信息水印 pred_count = len(outputs["instances"]) scores = outputs["instances"].scores.cpu().numpy() avg_score = scores.mean() if len(scores) > 0 else 0 plt.figure(figsize=(12, 8)) plt.imshow(out.get_image()[:, :, ::-1]) plt.title(f"Predictions: {pred_count} balloons | Avg Score: {avg_score:.3f}", fontsize=14, pad=20) plt.axis('off') # 保存高清报告 report_path = Path(output_dir) / f"report_{Path(image_path).stem}.png" plt.savefig(report_path, bbox_inches='tight', dpi=300) plt.close() # 同时导出JSON格式结果(供下游系统解析) result_json = { "image": Path(image_path).name, "predictions": [ { "bbox": box.tolist(), "score": float(score), "mask_rle": None # 如需mask,调用mask_to_rle() } for box, score in zip( outputs["instances"].pred_boxes.tensor.cpu().numpy(), outputs["instances"].scores.cpu().numpy() ) ] } with open(Path(output_dir) / f"result_{Path(image_path).stem}.json", "w") as f: json.dump(result_json, f, indent=2) # 使用示例 exporter = BalloonResultExporter(cfg) for img_path in test_images: outputs = predictor(cv2.imread(img_path)) exporter.export_report(img_path, outputs, "./reports/")注意:
Visualizer默认使用BGR转RGB(im[:, :, ::-1]),而plt.imshow期望RGB,所以out.get_image()要再转一次[:, :, ::-1]。这个颜色通道陷阱让我的第一份客户报告里气球全成了青紫色,重跑花了2小时——现在我把cv2.cvtColor(out.get_image(), cv2.COLOR_RGB2BGR)写进了export_report的必检清单。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 典型错误速查表
| 错误现象 | 根本原因 | 一招定位法 | 解决方案 |
|---|---|---|---|
KeyError: 'bbox' | JSON中annotations缺少bbox字段,或字段名拼写错误(如bboxes) | `jq '.annotations[0] | keys' coco.json`检查字段名 |
AssertionError: All images must have the same aspect ratio | train/和val/中图像尺寸不一致(如train里有1920x1080,val里有1280x720) | find train/ -name "*.jpg" -exec identify -format "%wx%h\n" {} \; | sort | uniq -c | 统一resize所有图像,或在DatasetMapper中禁用ResizeShortestEdge |
CUDA out of memory | IMS_PER_BATCH过大,或BATCH_SIZE_PER_IMAGE设太高 | nvidia-smi观察显存占用,逐步将IMS_PER_BATCH从2降到1 | 改用torch.cuda.amp.autocast()混合精度训练,显存降40% |
mAP=0.000 | NUM_CLASSES设错(如设成2),或thing_classes顺序和JSON中category_id不匹配 | print(MetadataCatalog.get("balloon_train").thing_classes)对比JSON中categories[0].id | 确保thing_classes=["balloon"]且JSON中所有category_id为1 |
loss_mask nan | mask标注有无效多边形(如三点共线、顶点数<3) | for ann in coco["annotations"]: if len(ann["segmentation"][0]) < 6: print(ann["id"]) | 在转换脚本中添加if len(poly) < 3: continue过滤 |
5.2 隐藏最深的3个坑
坑1:Windows路径分隔符引发的灾难
在Windows上,register_coco_instances的image_root若写成"D:\data\balloon\train",反斜杠\会被Python解释为转义字符,导致路径变成D:(data)balloon rain。解决方案永远用正斜杠或Path对象:
# 安全写法 image_root = Path("D:/data/balloon/train") # 正斜杠 # 或 image_root = Path(r"D:\data\balloon\train") # 原始字符串坑2:OpenCV与PIL的BGR/RGB之争cv2.imread()返回BGR,plt.imshow()期望RGB,detectron2内部统一用RGB。如果你在DatasetMapper中手动cv2.cvtColor,又在Visualizer中二次转换,会导致颜色错乱。终极方案:所有图像IO统一用cv2,并在送入模型前强制cv2.cvtColor(im, cv2.COLOR_BGR2RGB),这样整个pipeline颜色空间一致。
坑3:验证集mAP虚高背后的真相balloon_val只有几张图,当你看到AP50=0.95时,别急着庆祝。用coco_evaluator.evaluate()查看详细结果:
from detectron2.evaluation import COCOEvaluator, inference_on_dataset from detectron2.data import build_detection_test_loader evaluator = COCOEvaluator("balloon_val", output_dir="./eval_output") val_loader = build_detection_test_loader(cfg, "balloon_val") inference_on_dataset(trainer.model, val_loader, evaluator) # 查看./eval_output/coco_instances_results.json你会发现AP50高是因为所有气球都很大且无遮挡,但AP75可能只有0.3。真正的挑战是APs(小目标)——balloon里几乎没有小目标,所以这个指标毫无意义。换成你的数据集,务必关注APs和APm。
5.3 性能调优的实战口诀
- 学习率不是越大越好:
BASE_LR=0.02在COCO上有效,但在balloon这种小数据集上会震荡。实测0.001最稳,0.005收敛快但mAP低0.03。 - Batch size要和GPU显存赛跑:RTX 3090(24G)可跑
IMS_PER_BATCH=4,但BATCH_SIZE_PER_IMAGE=512会导致OOM。经验公式:total_rois = IMS_PER_BATCH * BATCH_SIZE_PER_IMAGE < 2000。 - 数据增强不是越多越好:
balloon默认开启RandomFlip和ResizeShortestEdge,但如果你的数据集气球都是正立的,RandomFlip反而降低精度。关闭方法:cfg.INPUT.MIN_SIZE_TRAIN = (800,)(禁用多尺度)。
最后分享一个小技巧:每次修改配置后,先运行cfg.dump()导出当前完整配置到文件,和Git commit一起提交。这样半年后回看实验,你能精确复现当时的所有参数,而不是对着model_final.pth发呆。这个习惯让我在三个项目中避免了重复造轮子,省下至少40小时调试时间。