012、小目标标注总被忽略?SAHI 切片策略与超大图标注的工程化方案
从一次深夜调试说起
去年做遥感图像检测项目,客户给了一张 2 万像素 × 2 万像素的卫星图,里面密密麻麻的车辆、船只,最小的目标只有 8×8 像素。我直接扔进 YOLOv8 训练,mAP 0.5 勉强到 0.3,小目标 recall 几乎为零。更离谱的是,标注人员告诉我:“那些小点我根本没标,因为看不清,而且标注工具里缩放太麻烦。”
这不是个例。小目标标注被忽略,本质上是两个问题叠加:一是标注工具在大图上操作困难,二是模型在原始分辨率下对小目标感知力不足。SAHI(Slicing Aided Hyper Inference)切片策略,就是专门解决这个痛点的工程化方案。
为什么直接训练大图会失败?
先别急着上切片。你得理解为什么大图直接训练不行。
YOLO 系列在输入尺寸上有限制,通常 640×640 或 1280×1280。你把 2 万像素的图 resize 到 640,小目标直接变成 0.2 像素——模型根本看不见。有人会说“那我用大分辨率输入”,但显存扛不住,batch size 降到 1 都爆显存。
更隐蔽的问题是标注质量。标注员在超大图上标注,缩放比例失调,小目标很容易被漏标、错标。我见过一个项目,标注员把 10 像素的汽车标成了 5 像素的矩形框,因为缩放后鼠标点不准。这种标注噪声,比模型本身的误差更致命。
SAHI 切片策略:把大图拆成小图
SAHI 的核心思想很简单:训练和推理时,把大图切成若干小图,每个小图独立检测,最后合并结果。但这里有几个坑,我踩过之后才明白。
切片参数怎么设?
切片大小和重叠率是关键。切片太小,目标被切碎;切片太大,小目标依然不明显。
我的经验公式:切片大小 = 模型输入尺寸 × 1.5 到 2 倍。比如 YOLOv8 输入 640,切片用 960 或 1280。重叠率至少 30%,否则边界处的目标会被截断。遥感图像我常用 50% 重叠,因为目标分布密集。
代码实现时,注意边界处理。别这样写:
# 错误示范:直接整除切片,边界目标丢失foriinrange(0,img_height,slice_size):forjinrange(0,img_width,slice_size):slice_img=img[i:i+slice_size,j:j+slice_size]这里踩过坑:当图像尺寸不是切片大小的整数倍时,最后一行/列会丢失。正确做法是计算实际切片数量,确保覆盖全图:
# 正确做法:计算切片数量,处理边界num_slices_y=math.ceil((img_height-overlap)/(slice_size-overlap))num_slices_x=math.ceil((img_width-overlap)/(slice_size-overlap))foriinrange(num_slices_y):forjinrange(num_slices_x):y_start=i*(slice_size-overlap)x_start=j*(slice_size-overlap)# 确保不越界y_end=min(y_start+slice_size,img_height)x_end=min(x_start+slice_size,img_width)# 如果切片尺寸不足,用padding补全ify_end-y_start<slice_sizeorx_end-x_start<slice_size:# 这里用0填充或镜像填充,看场景pass标注怎么同步切片?
训练时,标注框也要跟着切片。SAHI 官方库提供了切片标注的功能,但有个坑:它默认只保留完全落在切片内的目标。对于跨切片的目标,直接丢弃会导致训练数据丢失。
我的做法是:保留与切片有交集的任何目标,但只取交集部分作为新标注。这样虽然会引入一些截断的目标,但总比丢掉好。推理时再用 NMS 合并。
# 保留跨切片目标,只取交集defslice_annotation(bbox,slice_bbox):x1=max(bbox[0],slice_bbox[0])y1=max(bbox[1],slice_bbox[1])x2=min(bbox[2],slice_bbox[2])y2=min(bbox[3],slice_bbox[3])ifx2>x1andy2>y1:# 转换到切片坐标系return[x1-slice_bbox[0],y1-slice_bbox[1],x2-slice_bbox[0],y2-slice_bbox[1]]returnNone超大图标注的工程化方案
切片解决了模型训练的问题,但标注环节才是真正的瓶颈。标注员面对 2 万像素的图,效率极低。我试过几种方案,最终选了一个折中。
方案一:先切片再标注
把大图切成 640×640 的小图,让标注员在小图上标注。优点是标注精度高,小目标不会被忽略。缺点是切片数量爆炸,2 万像素的图切成 640 大小,重叠 50%,能切出上千张小图。标注员要标注上千张图,重复劳动多。
方案二:标注大图,训练时切片
标注员在大图上标注,训练时程序自动切片。优点是标注工作量小,一张图标一次。缺点是大图上标注小目标容易漏,而且标注工具在大图上操作卡顿。
方案三:混合策略(推荐)
我最终用的是这个:先在大图上标注大目标(比如建筑、道路),然后自动切片,让标注员在小图上补充标注小目标(车辆、行人)。这样大目标不会漏,小目标也能精确标注。
具体流程:
- 标注员在大图上标注面积大于 1000 像素的目标(大目标)
- 程序自动将大图切成 640×640 小图,重叠 30%
- 标注员在小图上标注面积小于 1000 像素的目标(小目标)
- 程序合并标注结果,去重
去重逻辑要注意:同一个目标可能出现在多个切片中,用 IoU 阈值 0.5 合并。这里踩过坑:如果阈值设太低,会把相邻的不同目标合并成一个。
推理时的 SAHI 合并策略
训练完模型,推理时也要切片。SAHI 的推理流程:
- 将大图切成小图,每个小图独立推理
- 每个小图的检测结果转换回原图坐标系
- 用 NMS 合并重叠的检测框
NMS 的 IoU 阈值要调低,我通常用 0.3。因为同一个目标可能出现在多个切片中,检测框位置有偏移,阈值太高会保留重复框。
还有一个细节:切片边缘的目标检测置信度通常较低,因为目标被截断。我加了一个后处理:如果检测框距离切片边界小于 10 像素,且置信度低于 0.5,直接丢弃。这能减少大量假阳性。
# 边缘低置信度过滤deffilter_edge_boxes(boxes,scores,slice_bbox,margin=10):filtered_boxes,filtered_scores=[],[]forbox,scoreinzip(boxes,scores):x1,y1,x2,y2=box# 检查是否靠近切片边界if(x1-slice_bbox[0]<marginory1-slice_bbox[1]<marginorslice_bbox[2]-x2<marginorslice_bbox[3]-y2<margin):ifscore<0.5:continuefiltered_boxes.append(box)filtered_scores.append(score)returnfiltered_boxes,filtered_scores性能优化:别让切片拖慢推理
切片推理的最大问题是速度。一张大图切成 100 张小图,每张都要过模型,推理时间增加 100 倍。我试过几种优化:
批量推理:把切片组成 batch,一次推理多张。注意 batch size 不要太大,否则显存爆炸。我通常用 4 或 8。
跳过空白切片:如果切片内没有目标(比如全是天空或水面),直接跳过。可以用简单的像素方差判断,方差小于阈值就跳过。
多尺度切片:大目标用大切片,小目标用小切片。比如 1280 切片检测大目标,640 切片检测小目标,合并结果。这能减少切片数量。
ONNX 加速:把模型转 ONNX,推理速度提升 30% 以上。YOLOv8 官方支持导出 ONNX,一行代码搞定。
个人经验性建议
别迷信 SAHI 能解决所有小目标问题。它只是工程手段,模型本身对小目标的感知能力才是根本。如果目标只有 4×4 像素,切片也救不了。考虑超分辨率或特征金字塔改进。
标注质量比模型更重要。我见过太多项目花大量时间调模型,结果标注数据一塌糊涂。先花一周做标注质量检查,比调一周模型参数有效。
切片大小不是越大越好。有人觉得切片大能保留更多上下文,但小目标在切片内占比反而更小。我做过实验,640 切片比 1280 切片在小目标 recall 上高 5 个点。
重叠率 30% 是底线。低于 30%,边界目标丢失严重。高于 50%,计算量翻倍,收益递减。
标注工具选对能省一半时间。LabelImg 在大图上卡死,用 CVAT 或 Supervisely 的远程标注功能,支持大图缩放和切片标注。别省这个钱。
最后说一句:小目标检测没有银弹。SAHI 切片是工程上的妥协,它让不可能变成可能,但代价是计算量和标注工作量。如果你的项目对实时性要求高,切片推理可能不适用。这时候,考虑模型结构改进,比如 YOLOv8 的 P2 层或 YOLOv6 的 RepVGG 结构,才是正道。