简介:这是一份面向计算机视觉算法学习者与目标检测开发者的 YOLOv5 OBB 旋转框训练示例工程,重点解决倾斜、非矩形目标的方向检测问题,可用于车辆、遥感、航拍等场景的实践入门与二次开发。压缩包共 583 个文件、约 445.58MB,包含 166 个 xml 标注、95 个 txt 说明与标签、93 个 png 及 23 个 jpg 图像、71 个 py 脚本、38 个 yaml 配置、6 个 pt 权重,另有 cpp、cu、onnx 等部署相关文件,覆盖数据处理、训练评估、推理导出等环节。已有 1822 人学习下载。读者可据此了解旋转框标注转换、OBB 模型配置、角度与位置损失计算、训练指标可视化及 ONNX/TensorRT 部署思路,并借助现成脚本与配置快速复现训练流程,适合作为旋转目标检测项目的参考模板。
1. 为什么旋转框值得单独开一份 demo:轴对齐框在密集场景里到底差在哪
一艘斜着靠泊的船,在正射影像里被轴对齐框裹成一个又大又空的矩形,两个相邻泊位一挤,NMS 直接把其中一个筛掉;一排斜停的车,轴对齐框重叠率超过 0.7,后处理阶段候选框相互冲突。旋转框要解决的就是这件事——框能贴着目标转,交并比算得准,密集场景不再互相挤压。
这份 YOLOv5-OBB 训练 demo 做的就是给普通 YOLOv5 的检测头扩出一个角度通道,配上八点标注转换、旋转 IoU 和角度损失,让整条链路从数据到推理能跑通。它不是一个 pip 装完就能用的插件,需要改几行头文件里的通道数、编译一个 C++ 扩展。适合已经跑过 YOLOv5 常规训练、手里有 DOTA 格式标注、想在遥感或工业图像上试旋转框的从业者。新手照着步骤走也能复现,但要有第一次跑不通的心理准备。
2. 环境与数据格式:把八点标注喂进 YOLOv5-OBB
这条链路第一步会卡在两件事上:扩展编译不过、角度转换写错。前者报错信息藏在 nvcc 输出里,后者不报错但训练 loss 会一直抖。下面按“环境锁版本 → 标注转换 → 目录与 yaml”三步走。
2.1 环境里那三个必须锁死的版本
旋转框的 IoU 和多边形 NMS 是靠 C++/CUDA 扩展算的,这套扩展对 PyTorch 版本和 CUDA 工具链比较敏感。我一般先确认三件事:nvidia-smi里的驱动版本、nvcc --version里的编译器版本、以及 PyTorch 编译时用的 CUDA 版本三者要能对上。最常见的问题是nvcc是 11.7 而 torch 装的是 cu118 包,编译阶段不报错,import时抛 undefined symbol。
# 1. 建环境,Python 版本别追新,3.8 兼容性最好 conda create -n yolo_obb python=3.8 -y conda activate yolo_obb # 2. 装 torch,cu117 要和机器上的 nvcc 对齐 conda install pytorch==1.13.1 torchvision==0.14.1 pytorch-cuda=11.7 \ -c pytorch -c nvidia -y # 3. numpy 必须压在 1.23.x:1.24 起移除了 np.float / np.int 这批别名 pip install numpy==1.23.5 opencv-python pyyaml tqdm matplotlib scipy shapely # 4. 编译旋转 IoU 与多边形 NMS 扩展 cd /path/to/yolov5_obb sh make.shmake.sh里通常编译两个模块:bbox_ext(CPU 上的旋转框 IoU)和nms_ext(旋转 NMS)。编译完成后,进 Python 里分别import bbox_ext和import nms_ext试一下,能导入才算过。参数上真正要盯的是 numpy:旧脚本里大量np.float、np.int、np.bool的写法在 1.24 之后会直接抛AttributeError,这个问题跟 OBB 本身无关,但会让人误以为是扩展没编好,白白排查半天。
提示:
make.sh失败时先看最后 20 行,nvcc 的报错在中间,error:前面那行才是真正的问题文件。
2.2 八点标注转 (cx, cy, w, h, θ):转换脚本与边界处理
DOTA 格式的标注是八个坐标加一个类别名:x1 y1 x2 y2 x3 y3 x4 y4 class_name,四个点按顺时针或逆时针排。OBB 训练需要的是五参数(cx, cy, w, h, θ),中间这步转换做错,后面全白搭。两个关键约定必须提前定死:θ 的周期是 π 而不是 2π(框转 180° 完全重合),以及 w 定义为长边、h 为短边,用来消除 180° 二义性。
import numpy as np def poly2rbox(points, eps=1e-6): """八点标注 -> (cx, cy, w, h, theta),像素坐标 theta 范围 [-pi/2, pi/2),w 恒为长边 """ pts = np.asarray(points, dtype=np.float64).reshape(4, 2) # 1. 四条边向量,最长边决定 theta 的方向 nxt = np.roll(pts, -1, axis=0) vec = nxt - pts lens = np.linalg.norm(vec, axis=1) k = int(np.argmax(lens)) w = lens[k] h = lens[(k + 1) % 4] # 相邻边当短边 if w < eps or h < eps: # 退化框直接丢弃 return None theta = float(np.arctan2(vec[k, 1], vec[k, 0])) # 2. 收进 [-pi/2, pi/2),这一步删掉就会出现角度跳变 if theta >= np.pi / 2: theta -= np.pi elif theta < -np.pi / 2: theta += np.pi # 3. 中心用四点均值,比外接矩形中心更稳 cx, cy = pts[:, 0].mean(), pts[:, 1].mean() return cx, cy, w, h, theta def convert_txt(src, dst): """单个标注文件转换,返回被丢弃的行数""" bad = 0 with open(src) as f, open(dst, "w") as g: for line in f: parts = line.split() if len(parts) < 9: bad += 1 continue r = poly2rbox(list(map(float, parts[:8]))) if r is None: bad += 1 continue g.write("%s %.6f %.6f %.6f %.6f %.6f\n" % (parts[8], *r)) return badpoly2rbox里三处逻辑值得单独说。第一步取最长边,是因为八点标注的起始点不固定,同一目标不同标注员可能从不同角点开始写,用最长边定方向能把结果拉到同一约定上。第二步的角度收敛是必须的:不做这个归一化,θ 会在 ±π 附近来回跳,回归头学到的是一条断裂的函数,loss 看起来在降但框永远对不上。第三步用四点均值而不是外接矩形中心,对透视变形更鲁棒。
convert_txt的返回值别浪费,跑完整个数据集后统计一下丢弃行数。正常数据集的丢弃率应该在千分之一以内,如果超过 1%,多半是八点顺序被打乱成了“Z”字形,面积算出来是自交的,得回去查标注工具。
2.3 数据集目录、yaml 与标签缓存
转换完之后,目录结构要按 YOLO 的约定摆:图片和同名 txt 放一起,或者图片一个目录、标签一个目录。OBB 分支一般要求标签文件和图片同名(0001.jpg对应0001.txt),txt 里每行是class cx cy w h theta的归一化值——注意坐标要除以图片宽高,θ 不归一化。
# data/dota_obb.yaml train: /data/dota/images/train val: /data/dota/images/val nc: 15 names: ['plane', 'ship', 'storage-tank', 'baseball-diamond', 'tennis-court', 'basketball-court', 'ground-track-field', 'harbor', 'bridge', 'large-vehicle', 'small-vehicle', 'helicopter', 'roundabout', 'soccer-ball-field', 'swimming-pool']归一化的那段代码容易被写错,尤其是 θ 千万不能除:
import cv2 img = cv2.imread("0001.jpg") H, W = img.shape[:2] with open("0001.txt") as f, open("0001_norm.txt", "w") as g: for line in f: c, cx, cy, w, h, t = line.split() # 只有长度类参数归一化,角度保持弧度 g.write("%s %.6f %.6f %.6f %.6f %.6f\n" % ( c, float(cx) / W, float(cy) / H, float(w) / W, float(h) / H, float(t)))首次训练前,框架通常会在data/下生成一个.cache标签缓存文件。有个反复有人踩的点:改完标签没删缓存,训练读的还是旧值,看日志里Scanning一行会显示“cached”,这时候删掉*.cache再跑才生效。图片尺寸也别混用,一个数据集里 800×800 和 1024×1024 混着放,dataloader 会按最长边统一 resize,小图上的目标被放得很大,anchor 匹配会偏。
3. 角度回归与损失设计:OBB 头到底改在哪
头部的改动量其实很小,两处:输出通道多一个,匹配阶段把 IoU 换成旋转的。真正决定成败的是角度怎么表示,这一步选错,后面调多少个学习率都没用。
3.1 从 Detect 头多出一个通道说起
普通 YOLOv5 每个 anchor 的输出是nc + 5(类别 + xywh + obj),三个 anchor 拼起来是na * (nc + 5)。OBB 版本变成na * (nc + 6),多出来的那一位就是 θ。以 nc=15、na=3 为例,通道数从 60 变成 63。
# models/common.py 里 Detect 类的改动点(示意) class Detect(nn.Module): def __init__(self, nc=80, anchors=(), ch=()): super().__init__() self.nc = nc self.no = nc + 5 + 1 # 关键:+1 给 theta self.na = len(anchors[0]) // 2 self.m = nn.ModuleList( nn.Conv2d(x, self.no * self.na, 1) for x in ch) def forward(self, x): # 推理阶段的解码里,theta 从第 5 位取 # xy = sigmoid(xy) * 2 - 0.5 + grid # wh = (sigmoid(wh) * 2) ** 2 * anchor # theta = (sigmoid(theta) - 0.5) * pi # 映射到 [-pi/2, pi/2) ...θ 的解码方式有两种常见写法:一种用sigmoid映射到[-π/2, π/2),另一种是直接线性输出不做激活、靠损失约束范围。前者收敛快但边界处的梯度会被压缩,后者自由度高但容易出现异常值。我在小数据集上更倾向sigmoid加周期归一化,稳一些。
anchor 匹配也要跟着改。原生 YOLOv5 用外接矩形的宽高比做筛选,OBB 得算旋转框之间的 IoU。这一步如果直接用 Python 循环会非常慢,所以才有bbox_ext那个 C++ 扩展。折中做法是先按外接矩形粗筛,再对候选算旋转 IoU,速度能提上去一个量级。
3.2 角度周期性与三种编码方式的选择
θ 的周期是 π,这是所有麻烦的根源。直接回归一个[-π/2, π/2)的连续值,当目标角度接近边界时,真值从 1.55 跳到 -1.55,模型看到的是一个巨大的跳变,梯度爆炸、loss 抖成锯齿。三种主流解法各有取舍:
| 编码方式 | 输出维度 | 边界问题 | 精度 | 适用场景 |
|---|---|---|---|---|
| 直接回归 θ | 1 | 严重 | 中 | 角度分布集中、框接近水平 |
| sin/cos 双通道 | 2 | 无 | 高 | 通用,推荐首选 |
| CSL 圆周平滑标签 | n_bin(如 180) | 无 | 高 | 高精度需求、显存充足 |
| KLD 高斯建模 | 5(高斯参数) | 无 | 高 | 长宽比极大的目标 |
sin/cos 编码的思路是把角度拆成两个连续量,sin(2θ)和cos(2θ),因为周期是 π 所以乘 2。这样边界处两个分量都是连续变化的,模型不会看到跳变。代价是多一个输出通道,解码时用atan2还原。
import torch def sincos_encode(theta): """theta: [..., 1] 弧度 -> [..., 2]""" return torch.cat([torch.sin(2 * theta), torch.cos(2 * theta)], dim=-1) def sincos_decode(sc): """还原角度,落在 [-pi/2, pi/2)""" return 0.5 * torch.atan2(sc[..., :1], sc[..., 1:]) def sincos_loss(pred_sc, gt_theta): """1 - cos(2Δθ),取值范围 [0, 2],对边界连续""" gt = sincos_encode(gt_theta) return (1.0 - (pred_sc * gt).sum(dim=-1)).mean()sincos_loss用1 - cos(2Δθ)而不是 L1 或 L2,原因是它对角度差的响应是平滑的:差 0 时损失 0,差 π/2 时损失 2,差 π 时又回到 0——正好符合“转 180° 框重合”的物理事实。用 L1 的话差 π 会给出最大惩罚,跟实际语义矛盾。
CSL 的做法是把角度离散成 180 个 bin,真值不放在单个 bin 上,而是给邻近 bin 一个高斯窗。好处是分类头天然处理周期性,坏处是显存占用多、推理慢。小数据集上我一般不折腾 CSL,sin/cos 就够。
3.3 损失组合:旋转 IoU + 角度损失的超参怎么给
总损失还是熟悉的四项,只是框回归那项换成了旋转版本:
loss = λ_box * loss_rot_iou + λ_obj * loss_obj + λ_cls * loss_cls + λ_ang * loss_angleloss_rot_iou用旋转框的 IoU 算 CIoU,loss_angle用上面的 sincos 形式。λ 的给法有讲究:λ_ang太小角度学不动,太大又会挤压框回归。我一般在0.5 ~ 2.0之间取,长宽比大于 5 的目标(船、桥、飞机)往上调,接近正方形的目标(储罐、球场)往下调。
# data/hyp.scratch.obb.yaml 里和角度相关的几项 box: 0.05 # 框回归权重,OBB 下通常比默认 0.05 略低 cls: 0.5 obj: 1.0 angle: 1.0 # 角度损失权重,从 0.5 起调试 angle_mode: sincos # 编码方式,与模型头保持一致调参顺序上,我习惯先固定angle: 0跑 10 个 epoch,确认框的宽高和中心能正常收敛(此时等价于轴对齐检测),再把angle打开从 0.5 起试。这样做的好处是把“框没学会”和“角度没学会”两个问题拆开,不然 loss 一起抖的时候根本分不清是哪一项在作乱。
4. 训练配置与命令:一次能收敛的参数组合
配置文件和命令本身不难,难的是显存、图像尺寸、batch 三者的配比,以及数据增强里那几个跟角度强耦合的开关。这一章给一套在小规模数据集上验证过的组合,再解释每项为什么这么给。
4.1 模型 yaml 与数据 yaml 的逐项拆解
模型 yaml 只改一个地方:nc。其余 depth_multiple、width_multiple、anchors 保持原样。anchors 要不要重聚类?如果目标尺度和 COCO 差得远(比如遥感里小目标占绝大多数),建议用 k-means 在训练集上重聚一次,但别一上来就动,先用默认值跑通再优化。
# models/yolov5s_obb.yaml nc: 15 # 与 data yaml 里的 nc 严格一致 depth_multiple: 0.33 width_multiple: 0.50 anchors: - [10,13, 16,30, 33,23] # P3/8,小目标层 - [30,61, 62,45, 59,119] # P4/16 - [116,90, 156,198, 373,326] # P5/32,大目标层数据 yaml 里train和val写绝对路径最省事,相对路径在不同执行目录下容易找不到。nc和names的条数必须严格对齐,差一个是常见的手误,报错信息还很不直观(会在算类别 loss 时抛越界)。
训练前跑一遍标签自检,比事后 debug 划算得多:
import glob, os import numpy as np bad = [] for p in glob.glob("/data/dota/labels/train/*.txt"): for i, line in enumerate(open(p)): v = line.split() if len(v) != 6: bad.append((p, i, "字段数不对")) continue x, y, w, h, t = map(float, v[1:]) if not (0 <= x <= 1 and 0 <= y <= 1 and 0 < w <= 1 and 0 < h <= 1): bad.append((p, i, "归一化越界")) if not (-np.pi / 2 <= t < np.pi / 2): bad.append((p, i, "角度超范围")) print(f"异常行 {len(bad)} 条") for b in bad[:10]: print(b)这段自检覆盖了三类最高频的错误:字段数(少写或多了分隔符)、归一化越界(忘了除宽高,或者用错了宽高)、角度超范围(转换脚本没做收敛)。十行代码能省掉半天排查。
4.2 训练命令、显存与 batch 的换算
python train.py \ --cfg models/yolov5s_obb.yaml \ --data data/dota_obb.yaml \ --hyp data/hyp.scratch.obb.yaml \ --weights yolov5s.pt \ --img-size 1024 \ --batch-size 8 \ --epochs 100 \ --device 0 \ --workers 4 \ --project runs/obb --name exp1关键参数逐个说。--img-size 1024是因为遥感影像里的小目标在 640 下几乎消失,1024 是精度和显存的折中点;如果显卡只有 8G,降到 800 或者把 batch 降到 4。--weights yolov5s.pt用 COCO 预训练权重,即使类别差异大也能加速收敛,但要注意加载时会因为输出通道数对不上而跳过检测头,日志里出现 “Transferred xxx items” 是正常的。
--batch-size和显存的关系大致是:img-size 1024、yolov5s、单卡 11G 显存下 batch=8 能吃满。想再往上加就开--amp混合精度,省 30% 左右显存,代价是个别情况下 loss 会在训练后期出现 NaN,遇到就把 amp 关掉。
--workers和 CPU 核数相关,一般给 4 到 8。给太高反而慢,因为每个 worker 都要加载图片并做几何变换,IO 打满之后瓶颈不在 CPU。用 SSD 的话可以开到 8,机械硬盘给 2 到 4。
训练日志里我最先看的是三行:labels统计(看目标数是否合理)、g和img_size(确认没有自动改尺寸)、以及前 3 个 epoch 的box_loss。OBB 的box_loss比普通检测要高,因为旋转 IoU 的计算对位置更敏感,这是正常现象。
4.3 数据增强开关:Mosaic 与旋转增强的取舍
增强是 OBB 最容易出问题的地方——每做一次几何变换,标签里的 θ 都要跟着变。框架内置的 Mosaic、RandomPerspective 都做了旋转框适配,但flipud、fliplr、degrees这几个参数需要人确认。
# utils/datasets.py 里增强部分的等价逻辑,用来对照标签变换 def flip_theta(theta, direction): """direction: 'h' 水平翻转 / 'v' 垂直翻转""" # 水平或垂直翻转后,角度符号都取反,再收敛回 [-pi/2, pi/2) theta = -theta if theta < -np.pi / 2: theta += np.pi elif theta >= np.pi / 2: theta -= np.pi return theta def transpose_theta(theta): """转置变换:θ -> π/2 - θ""" t = np.pi / 2 - theta if t >= np.pi / 2: t -= np.pi return t这三个变换写错的后果很隐蔽:loss 会降,但预测框的角度系统性偏一个方向,验证集 mAP 卡在半路上不去。判断方法是拿几张验证图可视化,如果所有框都朝同一个方向偏,基本就是翻转时角度没跟着变。
degrees参数控制随机旋转角度范围,默认 0 表示不旋转。遥感数据建议给到 90 或 180,因为目标本身朝向任意,模型不会因为旋转而不合理。但注意 90° 的整数倍旋转后,θ 只需要加一个固定偏移,不需要重采样,速度快;非整数倍旋转要做插值,图会有模糊。
Mosaic 在 OBB 上我一般关掉或降低概率(mosaic: 0.3)。原因很实在:四张图拼一张之后,每张图里的目标都被缩小,而旋转框的小目标对角度误差极其敏感,一个 16×16 的框角度差 5° 就已经算错了。小目标多的数据集上,mosaic: 0加上scale: 0.5的组合往往比默认配置效果好。
5. 避坑排查:五个把 demo 跑崩的现场
下面这五条都是我在不同数据集上真实撞过的,按“现象 → 原因 → 解决”记,能对上号就直接跳过。
5.1 标签与数据侧的两处翻车
现象一:训练能启动,但labels统计里目标数只有标注文件的十分之一,且box_loss一直是 nan。
原因基本锁定在角度转换上。八点标注的四个点如果不是顺序给出(比如标注工具导出的顺序是左上、右下、右上、左下),poly2rbox里算出来的边向量是交叉的,h可能算出 0 或负数,归一化之后进入模型就是一个非法框,IoU 计算里出现除零。
解决分两步。先加凸性校验:用鞋带公式算四边形面积,如果面积接近 0 或者与四点外接矩形面积比小于 0.1,直接丢弃这一行。再检查点序:取四个点的质心,算每个点相对质心的极角,按极角排序就能把顺序强行拉成顺时针。这两步加进转换脚本,丢弃率会从异常降回千分之一级别。
现象二:换了新数据集,训练集 loss 正常下降,验证集 mAP 一直是 0。
查了半天发现是验证集的标签没做归一化,还是像素坐标。框架在--img-size缩放时只对图片做 resize,不会去动标签,于是所有验证框的坐标都超出 [0,1],评估阶段一律被过滤掉。
解决是训练前对 train 和 val 各跑一遍 2.3 节那段自检脚本,别只查 train。另外补一句,验证集的类别名要和names列表顺序一致,顺序错了不报错,但 mAP 会掉得很诡异——某一类特别高、另一类完全为 0,看到这种分布就该去核对顺序。
5.2 训练与损失侧的两处翻车
现象三:loss 曲线整体在降,但每隔十几个 epoch 就出现一次尖峰,之后慢慢恢复,反复循环。
这是角度在边界处跳变的典型表现。检查hyp里用的编码方式:如果是直接回归 θ,而theta的解码又是线性输出没做约束,模型在拟合接近 ±π/2 的目标时就会反复横跳。
解决是把编码换成 sincos,或者保留直接回归但在 loss 里加一项周期一致性惩罚:把预测值和真值都映射到[-π/2, π/2)再算差,差值超过 π/2 时取补角。实测换成 sincos 之后尖峰消失,收敛轮数还能少两三个。顺带提一句,学习率别给太大,OBB 的角度分支比框分支敏感,warmup 阶段lr0的 1/10 起步比较稳。
现象四:sh make.sh报nvcc fatal: Unsupported gpu architecture 'compute_86'。
原因很直接:本机 nvcc 版本太老,不认识新显卡的算力代号。或者反过来,torch 是 cu118 而 nvcc 是 11.3,两边编译目标对不上,即使编过了运行时也会崩。
解决是先对齐版本,python -c "import torch;print(torch.version.cuda)"和nvcc --version输出的版本号主次版本要一致。实在没法重装 CUDA 的话,去make.sh里把-gencode那几行的算力代号改成本机显卡支持的(30 系是 86,40 系是 89,20 系是 75),编不过的架构直接删掉。改完记得rm -rf build清掉中间产物再编。
5.3 评估与导出侧的一处翻车
现象五:训练日志里各项指标都好看,跑test.py得到的 mAP 只有十几个点,把conf-thres降到 0.01 才勉强及格。
两个原因叠在一起。一是评估时的 NMS 阈值沿用了普通检测的 0.45,而旋转框在密集场景下的重叠率天然偏高,0.45 会误杀大量正确框;二是旋转 NMS 没启用,用的还是轴对齐 NMS,斜框被按外接矩形压制。
解决是把nms-iou调到 0.3 到 0.4 之间试,同时确认多边形 NMS 的开关是打开的。密集小目标(车辆、船只)建议直接上 0.3,稀疏大目标(球场、桥梁)可以给 0.5。另外评估的提交格式也要对:八点坐标、类别名在前、置信度在第二位,少一个逗号都会让评估脚本静默跳过整张图。
6. 推理验证与进阶:用可视化和旋转 NMS 自检
训练完拿到best.pt,第一件事不是看 mAP,是把预测框画出来看。数字会骗人,画出来的框不会。
import cv2 import numpy as np def draw_obb(img, boxes, color=(0, 255, 0), thickness=2): """boxes: [(cx, cy, w, h, theta, score), ...],像素坐标""" for cx, cy, w, h, theta, score in boxes: # cv2.boxPoints 的角度单位是度,且方向与数学坐标系相反 rect = ((cx, cy), (w, h), theta * 180.0 / np.pi) pts = cv2.boxPoints(rect).astype(np.int32) cv2.polylines(img, [pts], True, color, thickness) cv2.putText(img, f"{score:.2f}", (int(cx), int(cy)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) return img这段代码有个必须注意的点:OpenCV 的boxPoints接收的角度是度,而且旋转方向跟数学上的逆时针相反。如果你的 θ 是逆时针为正,画出来会发现框整体镜像了 90°,不是模型的问题,是可视化的问题。判断方法很简单:拿一张验证集里已知角度的图,手工算一个框的四个角点,跟画出来的对比。
推理阶段的参数比训练阶段更影响最终效果,下面这几个值我一般先扫一遍再定:
| 参数 | 默认 | 密集小目标 | 稀疏大目标 | 说明 |
|---|---|---|---|---|
| conf-thres | 0.25 | 0.15 | 0.25 | 置信度阈值 |
| nms-iou | 0.45 | 0.30 | 0.50 | 旋转 NMS 阈值 |
| max-det | 1000 | 2000 | 500 | 单图最多保留框数 |
| agnostic | False | False | True | 是否跨类别 NMS |
小目标多的时候max-det一定要放大,默认 1000 在一张 1024 的遥感图上很容易被占满,后面的框全被截断,表现为“某一片区域完全没检出”。这个坑我踩过不止一次,前面调了半天阈值,最后发现是max-det卡住了。
再进一步的做法是切片推理:把 1024×1024 的图切成 4 张 512 的重叠子图分别推理,再把结果映射回原图做一次全局 NMS。小目标的像素数翻倍之后,召回率通常能涨 5 到 10 个点。代价是推理时间变成 4 倍,适合离线批处理场景。
验证这块,我习惯在每次改完标注或增强之后,固定抽 20 张验证图,把真值和预测叠在一起画:真值用绿色、预测用红色,两个框角度偏差超过 10° 的直接标出来。这比看 mAP 直观得多,能立刻分辨是“框位置偏了”还是“角度学反了”。
从那以后我每次动标签转换或者增强参数,都会先跑这 20 张的可视化对比,确认角度没有系统性偏移,再去看训练指标——毕竟 loss 曲线给不了你“框歪了”这种信息。希望帮到你。
本文还有配套的精品资源,点击获取