ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

目标检测训练加速:按比例压缩图片与txt标签处理实践

目标检测训练加速:按比例压缩图片与txt标签处理实践

做目标检测的朋友大概都有过这种体验:数据明明不算多,几万张图,训练却慢得要命,一个 epoch 跑几十分钟,GPU 占用率上不去,风扇呼呼转但算力就吃不满。打开监控一看,瓶颈根本不在显卡,而在数据加载那一段——每张图都是 4000×3000 的相机原图,解一次 JPEG 就要几十毫秒,一个 batch 里几十张图,CPU 光解码就累趴下了。我就是这么被折磨了两个项目以后,才狠下心来把整个数据集按比例压一遍,同时把对应的 txt 标签文件一起处理掉。今天就把这套“按比例压缩图片及对应 txt 标签”的完整做法摊开讲:为什么这么压、比例怎么算、标签到底要不要改、脚本怎么写、压完怎么验证精度没掉。不管你是刚跑通第一个检测 Demo 的新手,还是已经在调大规模训练的老手,这里面的几个关键结论(尤其是归一化坐标那条)都能省你不少试错时间。

1. 压缩这件事,压的到底是什么

1.1 训练慢,先分清是算力慢还是喂数据慢

动手之前一定要先定位瓶颈,不然压了半天发现是显卡本身跑不动,白费劲。判断方法很土但很准:跑几个 epoch,盯着 GPU 利用率和显存占用。如果 GPU 利用率长期在 40%~70% 之间来回跳,显存占用远没到上限,那大概率是数据供给跟不上,也就是常说的输入瓶颈;如果 GPU 利用率稳定在 95% 以上,显存吃满,那就是算力或 batch 尺寸的问题,压缩图片只能间接帮到你(省显存从而能加大 batch)。

数据供给慢的根源通常是三个:磁盘 IO、图像解码、预处理增强。其中图像解码的耗时基本和像素总数成正比,一张 1200 万像素的图解码加缩放,跟一张 60 万像素的图完全不是一个量级。更麻烦的是很多训练框架默认的加载策略是先读原图再在内存里缩放到网络输入尺寸,这意味着你付了解码超大图的全额成本,最后却只用到了缩放到 640 之后的那一小块信息——典型的“用大炮打蚊子”。把原图提前压到接近训练分辨率的尺寸,等于把这笔成本从“每个 epoch 都付”变成“一次性付清”,这就是整套方案最核心的经济学逻辑。

顺带说一句,压缩还顺带解决了两个隐性痛点:一是磁盘占用,几万张原图动辄几百 GB,备份、迁移、同步都难受;二是内存缓存,压缩后整个数据集有可能塞进内存,后面开启内存缓存就变成了纯 CPU 计算,速度提升会更夸张。

1.2 图片和标签之间那条看不见的绳子

YOLO 系列使用的标签格式非常简洁,每行一个目标:类别编号 x中心 y中心 宽度 高度,后四个数都是归一化过的浮点数,取值范围 0~1。关键在于,这个归一化是相对当前图片的宽和高来算的。也就是说,标签文件本身并不记录任何绝对像素坐标,它只记录“这个框在图片里占的位置比例”。

这个设计带来了一个极其重要的后果:只要你的缩放操作是宽高等比例的,那么任何一个框在图片中的相对位置和相对大小都不会改变,标签文件里的四个数字一个都不用动。举个具体例子,一张 2000×1500 的图里有个框,中心在 (1000, 750),宽高 400×300。归一化后是0.5 0.5 0.2 0.2。把图片等比缩到 1000×750,框的中心变成 (500, 375),宽高 200×150,再归一化——还是0.5 0.5 0.2 0.2。数学上完全恒等。

很多人一听到“压缩后要同步处理标签”就以为要重新算一遍坐标,于是写了一堆复杂的换算代码,反而因为浮点处理和取整引入了误差,把原本干净的标签搞脏了。理解这条“看不见的绳子”,你就知道绝大多数情况下最安全的做法是:先确认自己用的是等比缩放,然后别动标签。只有在裁剪、补边、旋转这类改变了画面构成的操心里,才需要真正重写标签。

1.3 哪些数据集适合压,哪些纯粹是自找麻烦

不是所有数据都值得压。我的经验判断标准大致是这样:

数据特征建议理由
长边在 2000 像素以上,目标尺寸普遍偏大强烈建议压解码成本高,信息冗余极大
长边已经接近训练分辨率(如 800~1300)可压可不压收益有限,压过头反而伤精度
小目标密集(目标像素面积普遍小于 32×32)谨慎,压缩比要小缩小后小目标可能只剩几个像素,直接丢失特征
原图带大量细纹理(遥感、医学、工业缺陷)谨慎,优先用高质量参数高频信息在缩放中损失最快
图片数量少但每张巨大建议压单张解码成本占比更高
需要保留原始数据做高分辨率推理只压训练副本别动原始库,留一份完整备份

我自己踩过一次坑:有一批电线巡检的图,目标是很细的螺栓和销钉,我图省事直接把长边从 4000 压到 800,结果训练集里接近三分之一的目标框在缩放后不足 3 个像素,模型学了个寂寞,mAP 掉了十几个点。后来改成压到 1600,再配合过滤掉缩放后确实过小的框,才恢复正常。所以压缩比不是越小越好,它是一次信息量和效率的交换,你得知道自己在拿什么换。

2. 方案设计:比例怎么定,坐标怎么处理

2.1 缩放比例的计算:从 imgsz 倒推目标边长

比例怎么定,别凭感觉。我的做法是从训练的输入尺寸倒推。假设训练时imgsz=640,那么模型实际“看到”的信息被限制在 640 这个尺度上。你喂给它 4000 像素的原图,它也是先缩放再送进网络,多出来的细节在训练阶段根本用不上。

合理的经验区间是:目标长边 ≈ 训练 imgsz 的 1.5 到 2.5 倍。取下限 1.5 倍是为了给数据增强留余量——随机缩放、随机裁剪、马赛克拼接这些操作会从原图里抠出局部区域再放大,如果原图和训练尺寸一样大,增强后的画面会明显发虚。取上限 2.5 倍是收益递减的分界线,再大就基本只是在喂解码器了。具体到这个例子:

  • imgsz=640,目标长边取 640×2 = 1280,比较稳妥
  • imgsz=960,目标长边取 1600~1920
  • imgsz=1280,目标长边取 1920~2560

按照这个思路,一张 4032×3024 的手机照片,长边压到 1280,缩放比例是 1280/4032 ≈ 0.3175,输出尺寸约 1280×960。像素总数从 1219 万降到 123 万,只有原来的 1/10。解码耗时在我这边环境里大概从 45ms 降到 8ms 左右,一个 batch 64 张的话,单次数据加载能省将近 2.4 秒,一轮一千个 batch 就是四十分钟的差距,非常可观。

如果你的数据里目标尺度差异很大,别用一个全局统一的长边,可以按图分组处理。比如把包含小目标的图单独挑出来给一个更宽松的压缩比,其余图压得更狠。这需要先跑一遍统计脚本,把每张图的框像素尺寸分布拉出来看,多花半小时,收益很大。

2.2 等比缩放、直接 resize、裁剪三者的取舍

三种方式看着都是“把图变小”,但对标签的影响完全不同,选错了后面全是麻烦。

直接 resize 到固定尺寸(比如全部拉成 640×640),会破坏宽高比,把圆形拉成椭圆,把行人拉得又扁又胖。这种形变对模型来说是纯噪声,而且更麻烦的是——它其实也不改变归一化标签,因为归一化是分别对宽和高做的。所以很多人以为直接 resize 后标签要改,其实不用。但我不推荐这种方式,形变带来的精度损失通常比你省下的那点处理时间更值钱。

裁剪(从原图中间或按目标分布裁一块出来),这个必须重写标签。裁剪相当于换了一个坐标系原点,所有框的像素坐标平移,还可能有一部分框被裁到画面外需要截断或丢弃。裁剪适合“目标超大但背景无用”的场景,比如只想训练画面中心的物体。

等比缩放(保持宽高比,整图缩小)是我最推荐的默认方案。它不改变画面构成,不丢任何目标,标签零改动,唯一的代价是输出尺寸不统一(有的 1280×960,有的 1280×720)。但这根本不是问题,因为训练框架本身会做 letterbox 补齐,统一尺寸这件事交给训练管线去做就行,没必要在数据落盘阶段就强行统一。

这里有个小细节值得提一下:等比缩放时两个方向的取整可能带来极微小的宽高比偏差。比如 1000×667 的图缩到长边 800,算出宽 800、高 533.6,取整成 534,实际比例是 800/534 ≈ 1.498,原图是 1.499。这个偏差在千分之一量级,对归一化坐标的影响完全可以忽略,不需要做任何修正。但如果你的流程里有“把归一化坐标转回像素再转回归一化”的中间步骤,这个微小偏差会被放大成 1 像素的偏移,框看起来就会有一点点歪——所以能不动标签就别动。

2.3 归一化坐标的“不变性”——为什么等比缩放可以不动标签

前面提过结论,这里把推导补完整,因为这是整个方案的基石,理解了它你才能放心地“什么都不做”。

设原图宽 W、高 H,某个框的中心像素坐标 (cx, cy),像素宽高 (bw, bh)。写入标签的值是:

x = cx / W, y = cy / H, w = bw / W, h = bh / H

等比缩放比例 r,新图宽 W' = rW,高 H' = rH。缩放后中心变成 (r·cx, r·cy),框宽高变成 (r·bw, r·bh)。重新归一化:

x' = r·cx / (rW) = cx / W = x y' = r·cy / (rH) = cy / H = y w' = r·bw / (rW) = bw / W = w h' = r·bh / (rH) = bh / H = h

r 被约掉了,四个值完全相等。这就是不变性的来源,也是为什么“压缩图片后同步处理标签”这件事,在等比缩放的语境下,真正的正确做法是一个字节都不改。

那为什么标题里还写着“及对应的 txt 标签”?因为实际工程里标签文件确实需要动,只不过动的不是坐标,而是另外三类情况:一是需要过滤缩放后变得过小的框,二是需要同步文件命名和后缀,三是如果顺手做了补边(padding)到正方形,那就得重算坐标。这三件事才是“同步处理标签”的真实含义。把这个理解清楚了,你的脚本会干净很多,也不容易引入人为误差。

2.4 什么时候必须重写标签(padding 与裁剪场景)

有些团队出于各种原因希望在落盘阶段就把所有图补齐成正方形,这时候标签必须改。推导也不复杂:先按长边缩放到边长 S,得到缩放后尺寸 (rw, rh),然后在画布上居中放置,左上角偏移 (ox, oy) = ((S-rw)/2, (S-rh)/2)。新的归一化坐标是:

x' = (x·W·r + ox) / S y' = (y·H·r + oy) / S w' = w·W·r / S h' = h·H·r / S

注意这里 x 和 y 的分子多了一个偏移量,而 w、h 没有,因为宽高不随平移变化。实现的时候,补齐区域的填充色建议用 (114, 114, 114) 这个中性灰,和主流训练管线的默认填充一致,避免训练阶段再做两种不同填充导致的分布不一致。

裁剪场景的公式类似,只是偏移量变成负的(因为原点移动了),而且需要处理框越界的情况:如果一个框裁剪后只剩很小一部分,比如只剩原面积的 20%,我建议直接丢弃这个框,因为残缺的框标注本身就不准确,留着反而教坏模型。判断阈值可以设成保留面积占比 0.25 以上,太小的一律丢。

3. 动手实现:批量压缩脚本逐段拆解

3.1 目录约定与任务清单生成

先说目录结构。检测数据集一般是这个形态:

dataset/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/

图片和标签靠同名不同后缀一一对应,images/train/a001.jpg对应labels/train/a001.txt。压缩脚本要做的事情就是保持这套结构,输出到另一个根目录,方便你回滚对比。

写脚本之前,强烈建议先生成一份任务清单做个统计,别直接开干。统计内容包括:待处理图片总数、总字节数、最大和最小尺寸、尺寸分布直方图、以及是否存在“有图无标签”或“有标签无图”的孤儿文件。我就遇到过一批数据里混进了十几张.JPG大写后缀的图,标签是小写.txt,训练脚本按同名匹配失败,那十几张图一直当成负样本在训,直到我统计才发现。

import os from pathlib import Path from collections import Counter def scan(root): root = Path(root) imgs = [p for p in (root / "images").rglob("*") if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp", ".webp"}] stats = Counter() missing = [] for p in imgs: # 标签路径按 images -> labels 替换,后缀改 txt lp = Path(str(p).replace("/images/", "/labels/")).with_suffix(".txt") if not lp.exists(): missing.append(str(p)) stats[p.suffix.lower()] += 1 print("图片总数:", len(imgs)) print("后缀分布:", dict(stats)) print("缺标签的图:", len(missing)) return imgs, missing

这个小函数花不了几行代码,但能在动手之前把一半的脏数据问题暴露出来。

3.2 图片压缩的几个关键参数

图片处理我用 Pillow,够用且依赖少。核心参数只有四个,但每个都有讲究:

插值算法。缩小图像时最忌讳用双线性或最近邻,会产生明显的锯齿和摩尔纹。Pillow 的Image.LANCZOS在缩小时质量最好,OpenCV 的INTER_AREA效果接近且更快。别用INTER_NEAREST,那是给自己找麻烦。

EXIF 方向。手机拍摄的图经常带 Orientation 标记,表示“实际应该旋转 90 度显示”。如果你不处理,Pillow 打开时给你的是未旋转的原始像素,宽高是反的,标签坐标就会全部错位 90 度。这一条必须写在代码里,用ImageOps.exif_transpose一行解决,而且在 resize 之前调用。

颜色模式。四通道 RGBA 存成 JPEG 会直接报错,灰度图 L 模式存 JPEG 没问题但有些训练管线会报通道数异常。统一转成 RGB 最省心,代价是灰度图体积变大一点。还有一种情况是 CMYK 模式的印刷用图,转 RGB 的时候颜色会变,这个就得注意了——虽然检测任务对颜色的敏感度不高,但如果你做的是颜色相关的分类任务,就得单独处理。

质量参数。JPEG 的quality建议 85~90,subsampling=1(4:2:2 色度采样)能在几乎看不出损失的情况下省掉不少体积。实测长边 1280 的图,quality 88、subsampling 1 的情况下平均单张 60~90KB,相比原图通常能省 90% 以上。别为了省那点空间把 quality 压到 70 以下,色块噪声会污染特征的边缘,对小目标检测尤其不友好。

from PIL import Image, ImageOps def shrink_one(src, dst, max_side=1280, quality=88): with Image.open(src) as im: im = ImageOps.exif_transpose(im) # 先纠正方向 if im.mode not in ("RGB", "L"): im = im.convert("RGB") w, h = im.size scale = max_side / max(w, h) if scale >= 1.0: # 本来就不大,不放大 new_size = (w, h) else: new_size = (max(1, round(w * scale)), max(1, round(h * scale))) im = im.resize(new_size, Image.LANCZOS) dst.parent.mkdir(parents=True, exist_ok=True) im.save(dst, format="JPEG", quality=quality, subsampling=1, optimize=True, progressive=True) return new_size

注意scale >= 1.0时不做放大,这一点很重要——数据集里往往混着一些本来就很小的图,你按比例放大它们只会增加体积和模糊度,一点信息都不会多出来。

3.3 标签同步处理的两种策略与代码

标签处理分两条路走,看你的场景选。

策略 A:等比缩放场景,只做过滤和同步。坐标一个不动,但要做两件事:一是过滤缩放后小到没有意义的框,二是确保文件名和后缀跟图片一致。

过滤的判定要在像素层面算,不要用归一化值直接比,因为同样 0.005 的宽度,在 1280 宽的图上和 640 宽的图上完全是两回事。做法是用缩放后的新尺寸把归一化值还原成像素:

def filter_and_copy_label(src_txt, dst_txt, new_w, new_h, min_px=2, min_area_px=9): out_lines = [] if not src_txt.exists(): dst_txt.parent.mkdir(parents=True, exist_ok=True) dst_txt.write_text("") # 保留空标签,别漏掉负样本 return 0, 0 for line in src_txt.read_text().strip().splitlines(): parts = line.split() if len(parts) < 5: continue cls = parts[0] x, y, w, h = map(float, parts[1:5]) bw, bh = w * new_w, h * new_h if bw < min_px or bh < min_px or bw * bh < min_area_px: continue # 小到看不见,丢掉 # 顺手把越界的框夹回 [0,1],防止下一路由归一化误差导致报错 x = min(max(x, 0.0), 1.0) y = min(max(y, 0.0), 1.0) out_lines.append(f"{cls} {x:.6f} {y:.6f} {w:.6f} {h:.6f}") dst_txt.parent.mkdir(parents=True, exist_ok=True) dst_txt.write_text("\n".join(out_lines) + ("\n" if out_lines else "")) return len(out_lines), 0

这里的min_px=2、min_area_px=9是我的经验值,你可以按数据特点调。有两点容易忽略:一是空标签文件必须保留,因为“这张图里确实没有目标”是一个有效样本,如果文件缺失,某些训练管线会把它当数据错误跳过或者直接报错;二是坐标格式化到小数点后六位就够了,再多没有意义,还会让文件变大。

策略 B:补边到正方形场景,必须重算。这段逻辑在 2.4 节推导过,实现如下:

def letterbox_label(src_txt, dst_txt, w, h, S): scale = S / max(w, h) rw, rh = round(w * scale), round(h * scale) ox, oy = (S - rw) / 2, (S - rh) / 2 out = [] for line in src_txt.read_text().strip().splitlines(): parts = line.split() if len(parts) < 5: continue cls = parts[0] x, y, bw, bh = map(float, parts[1:5]) # 归一化 -> 像素 -> 缩放加偏移 -> 归一化 px, py = x * w * scale + ox, y * h * scale + oy pw, ph = bw * w * scale, bh * h * scale nx, ny = px / S, py / S nw, nh = pw / S, ph / S # 夹到边界 nx = min(max(nx, 0.0), 1.0) ny = min(max(ny, 0.0), 1.0) out.append(f"{cls} {nx:.6f} {ny:.6f} {nw:.6f} {nh:.6f}") dst_txt.parent.mkdir(parents=True, exist_ok=True) dst_txt.write_text("\n".join(out) + ("\n" if out else ""))

注意我这版为了代码简洁没有处理“框被边界截断后需要缩窄”的情况。严格来说,如果一个框的中心在画面内但边缘超出,裁剪后应该把宽高截到边界再重新算归一化值。实际数据里这种情况不多,但如果你做的是大幅裁剪,就必须补上这段,否则框会画出图外,训练时被静默忽略。

3.4 多进程、断点续跑与日志

数据集大起来,单进程压缩几万张图能跑到天荒地老。用multiprocessing.Pool开多进程是最简单的方式,进程数设成 CPU 核数或核数减一。这里有个必须注意的坑:Windows 上多进程是 spawn 模式,所有任务函数必须定义在模块顶层,并且入口要有if __name__ == "__main__":保护,否则会无限递归创建进程直到把机器拖死。Linux 下是 fork,没这个问题,所以很多脚本在本地跑得好好的,挪到别的环境就炸,原因就在这。

断点续跑靠一行判断就能实现:目标文件存在且非空就跳过。这在批量处理里非常实用,因为压缩过程可能因为一张损坏的图报错中断,你不想每次都从头再来。

import traceback from multiprocessing import Pool def process_one(args): src, dst_img, dst_lbl, max_side = args try: if dst_img.exists() and dst_img.stat().st_size > 0: return ("skip", str(src)) new_w, new_h = shrink_one(src, dst_img, max_side=max_side) src_lbl = Path(str(src).replace("/images/", "/labels/")).with_suffix(".txt") dst_lbl = dst_lbl_for(dst_img) filter_and_copy_label(src_lbl, dst_lbl, new_w, new_h) return ("ok", f"{src} -> {new_w}x{new_h}") except Exception as e: return ("fail", f"{src} | {e} | {traceback.format_exc()}") if __name__ == "__main__": tasks = build_tasks(...) # 生成 (src, dst_img, dst_lbl, max_side) 元组 with Pool(8) as pool: for status, msg in pool.imap_unordered(process_one, tasks, chunksize=16): log(status, msg)

日志不要只打印成功数量,失败记录的完整异常必须落盘。我在一批数据上遇到过几十张图报OSError: image file is truncated,原因是原图本身就是损坏的(传输中断造成的半张图)。这类文件你只能挑出来单独处理,要么丢弃要么重新下载,脚本里默默吞掉只会让最终数据集少一部分而你不知道。

4. 压完之后:训练侧的配套调整与效果验证

4.1 清缓存、改配置,别让旧的元数据坑你

数据换了,配置必须跟着换。第一件事是清理旧的缓存文件。很多训练管线会在数据集目录下生成标签缓存(形如train.cache、labels.cache或.npy文件),里面记录了上一次扫描得到的图片路径、尺寸和标签内容。如果不清掉,新数据可能不被识别,或者更糟——尺寸信息是旧的,导致标签匹配错位,训练 loss 一路飙高但你看不出原因。

find /path/to/new_dataset -name "*.cache" -delete find /path/to/new_dataset -name "*.npy" -delete

清完以后第一次启动训练会重新扫描一遍并生成新缓存,日志里会有一行提示扫描了多少张图、多少个标注框,务必核对这个数字和你脚本输出的统计是否一致,不一致就是路径或文件名出了问题。

第二件事是改数据配置文件里的路径指向新数据集,同时检查nc(类别数)和names是否和压缩前一致。我习惯在配置里加一行注释记下版本号,比如# v2-resized-1280,方便回溯是哪一版数据。

4.2 训练参数的联动调整

压缩之后有几个参数值得顺手改一下,能吃到额外收益:

数据加载进程数(workers)。这是个反直觉的点:图片变小、解码变快之后,单个 worker 的吞吐上去了,如果还保持原来那么高的 worker 数量,CPU 会在进程调度上白白内耗。我的经验是从原来的 8 降到 4 甚至 2,先试 4,看 GPU 利用率是否还能维持在高位。降下来省出的 CPU 还可以让给别的任务。

内存缓存。如果压缩后整个数据集的解码后大小能塞进内存,就打开内存缓存选项(常见的命名是cache='ram'或cache=True)。这一步的提速往往比压缩本身还明显,因为彻底消除了磁盘 IO 和解码开销,每个 epoch 都从同一份内存里取。要注意它吃的是系统内存不是显存,几万张 1280×960 的 RGB 图解码后约 3.5MB 一张,一万张就是 35GB,动手前先看看机器内存够不够。

矩形训练 / 非方形批处理。如果训练管线支持按 batch 内最长边动态组 batch(业内常见的叫法是矩形训练),那压缩后保留原始宽高比的优势就体现出来了——不用统一补成正方形,padding 区域少,无效计算也少。这个开关通常只是一个布尔参数,但对宽高比差异大的数据集(比如大量 16:9 的监控截图)收益相当可观。

批大小和输入尺寸。如果压缩后显存有富余,可以试着把 batch 提一档,或者把imgsz提一档。这两者怎么选取决于你的目标:提 batch 提升训练稳定性和吞吐,提 imgsz 提升小目标检测能力。我的默认选择是先提 batch,因为压缩的目的是提效率,imgsz 提升会吃掉一部分收益。

4.3 一次压缩前后的对照实验

光说不练没意义,我给一套对照实验的设计,你可以照着在自己数据上跑一遍。

控制变量:同一份原始数据、同一个模型结构、同一个随机种子、同样的 epoch 数(我一般用 30~50 个小 epoch 看趋势,不用跑满)、同样的增强配置。唯一变量是数据集版本(原始 vs 压缩到 1280 长边)。

记录四项指标:单 epoch 平均耗时、GPU 平均利用率、验证集在同样 imgsz 下的 mAP50、以及小目标子集的召回率。第三项和第四项最关键,因为压缩带来的唯一风险就是精度损失。

指标原始数据(长边 4032)压缩后(长边 1280)变化
数据集体积基准约 1/10大幅下降
单 epoch 耗时基准约 0.45 倍明显下降
GPU 平均利用率55% 左右88% 左右明显提升
mAP50(整体)基准基本持平,波动 ±0.3无显著差异
小目标召回基准视压缩比而定,压过头会明显下滑需重点观察

我这边几次实验的普遍结论是:长边压到 imgsz 的 2 倍左右,mAP 基本持平,速度提升 2 倍以上;压到 imgsz 的 1 倍,速度提升 3 倍多但小目标召回开始掉。所以那个“1.5 到 2.5 倍”的经验区间不是拍脑袋来的,是速度和精度的实际拐点。

还有一个容易被忽略的验证项:可视化抽查。从压缩后的验证集里随机抽 20 张,把标签框画到图上,肉眼过一遍。这一步能抓出自动化脚本抓不到的问题,比如框整体偏移几个像素、某些图的框跑到画面外面、或者方向转了 90 度。花五分钟看图,比调一整天参数有用。

5. 踩过的坑与排查手册

5.1 框整体偏移、框跑到图外

这是压缩后最高频的问题,八成出在坐标系理解上。如果所有框都朝同一个方向偏,先怀疑两件事:一是 EXIF 方向没处理,图片实际被旋转了 90 度而标签没跟着转;二是做了补边但只改了填充没改坐标,偏移量等于 padding 的一半。

排查方法很直接:挑一张图,手动算出标签对应的像素框位置,用图像工具画出来看。如果偏差是个固定值,就是平移问题(padding 忘记加偏移);如果偏差随位置变化,就是缩放问题(比例算错,通常是拿错了宽或高);如果偏差是整体旋转,就是方向问题。

还有一种隐蔽情况:某些图库里读出来的尺寸是(宽, 高),某些是(高, 宽),混用就会算反。统一约定用(w, h)顺序,并且在所有函数签名里写清楚参数名,能避免大部分低级错误。

5.2 小目标集体消失

压缩之后小目标检测性能断崖式下跌,通常有两个层次的原因。表层原因是过滤阈值设得太狠,2 像素以下的框都被删了,训练集里的小目标样本直接没了。深层原因是即使框没被删,缩放后目标只剩几个像素,卷积网络也提取不出有效特征了。

处理办法有三条:一是把压缩比放宽,从 imgsz 的 1.5 倍提到 2.5 倍;二是在过滤时区分对待,别把所有类别用同一套像素阈值,小类别单独给更宽松的门槛;三是考虑分桶处理,把小目标密集的图单独放在一个子集里,用更小的压缩比,训练时再混合采样。这套组合拳我在两个项目里用过,基本能救回大部分精度。

5.3 文件层面的低级错误

这类问题不涉及任何算法,但排查起来最耗时间,因为它们往往表现为“训练正常但结果不对”。常见的有:压缩后图片后缀从.png变成了.jpg,标签文件名没跟着改;大写后缀.JPG和小写.txt匹配不上;某些图的标签文件是空文件,被误判为数据错误;输出目录里残留了上一次运行的旧文件,造成图片和标签混批。

防御手段很简单:压缩完成后跑一遍一致性校验,遍历所有图片检查同名标签是否存在,遍历所有标签检查同名图片是否存在,两边数量必须相等,且每个标签文件里每行的第一个字段必须是合法的类别编号(在0到nc-1范围内)。这个校验脚本三十行,能挡住后面的无数麻烦。

5.4 问题速查表

现象最可能原因处理动作
训练 loss 变成 NaN标签里有 0 宽高或越界值过滤w<=0或h<=0,坐标夹到 [0,1]
框整体偏移固定像素padding 后没改坐标按公式重算,加上偏移量
框方向错乱、宽高互换EXIF 未处理resize 前调用 exif_transpose
图片数少于预期多进程异常被吞落盘完整异常日志,逐条排查
标签数和图片数对不上后缀不一致或路径替换出错跑一致性校验脚本
第一个 epoch 特别慢旧 cache 未清理删除*.cache/*.npy后重启
压缩后体积没降多少图片本身已压缩或 quality 设太高检查原始质量,quality 降到 85~88
训练报通道数错误RGBA 或 CMYK 未转 RGB保存前统一 convert 到 RGB
显存占用反而涨了开了内存缓存或 batch 提太大检查 cache 配置与 batch 设置
精度轻微下降但可接受正常的压缩信息损失压缩比放宽到 imgsz 的 2 倍以上

我在实际做这套流程时最大的体会是:压缩本身的技术难度几乎为零,难的是确认自己压得对不对。写出压缩脚本十分钟,做对照实验和可视化抽查可能要半天。但正是这半天的验证,决定了你是白干一场还是真的把训练效率提上去两倍。如果时间只够做一件事,我建议先做那个一致性校验加随机可视化抽查的脚本,把它当成流水线的固定一环,每次改数据都跑一遍——踩过几次坑之后,你会发现最省时间的从来不是更聪明的算法,而是更早发现错误的检查机制。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进