1. 训练效率卡在哪:先看清楚瓶颈的构成
我最初接手一个目标检测训练任务时,数据集是从几个不同来源拼凑起来的,图片尺寸从 640×480 到 4000×3000 都有,标注文件是标准 YOLO 格式的 txt,每行五个值:类别序号、中心点 x、中心点 y、宽、高,后四个都是归一化到 0~1 的小数。这批数据喂进训练脚本之后,第一个 epoch 的时间长得离谱,GPU 利用率却只有三四成,数据加载那条流水线天天打满。
很多人一遇到训练慢,第一反应是换显卡、加 batch、开混合精度、调 dataloader 的 worker 数。这些手段当然有用,但它们解决的是“算得快不快”和“喂得饱不饱”的问题。而我当时遇到的是更底层的一件事:数据本身的像素规模远远超过了任务需要。一张 4000×3000 的图,经过预处理缩放到 640×640 再送进网络,前面那几千万像素的读取、解码、缩放全是白干的活儿。真正被网络看到的只有 640×640 那一小块,其余部分在数据增强阶段就被扔掉了。
这就引出了本文要讲的核心操作:按比例压缩图片及对应的 txt 标签。注意这里的两个关键词,一个是“按比例”,一个是“对应的 txt 标签”。前者说的是缩放不能变形,长宽比要保住;后者说的是图片改了尺寸,标注坐标必须同步换算,否则标签和图像就对不上了,训练出来的模型会彻底跑偏。实际操作里,第二件事比第一件事更容易出错,也是我踩坑最多的地方。
先把这个操作能带来什么说清楚。假设原始数据集平均分辨率是 2000×1500,共 2 万张图。磁盘上占的空间大概在几十 GB 量级,每张图的解码和缩放开销在几十毫秒级。如果任务本身的目标(比如车辆、行人、工件缺陷)在缩放到 1024 长边之后依然清晰可辨,那么把这批数据统一压到长边 1024,磁盘占用能降到原来的四分之一左右,单张图解码加缩放的耗时也能降一个数量级。数据加载不再是瓶颈之后,GPU 才真正跑得起来,一个 epoch 的墙钟时间可能从两小时压到四十分钟。
但这里有个前提必须先讲明白:压缩不是无损的,检测精度会受影响。压缩到什么程度是安全的,取决于你任务里最小目标的像素尺寸。这一点后面会给出具体的估算方法。所以本文适合的人群是:手上有一批高分辨率标注数据、训练时数据加载明显拖后腿、并且愿意花半小时做一次数据体检再动手的人。如果你刚入门,连 YOLO 的 txt 标注格式都还没看明白,也建议先读下去,因为坐标换算这段是绕不过去的基础功。
1.1 为什么“按比例”这三个字不能省
我见过一些图省事的做法,直接resize到固定 640×640,不管原图什么长宽比。这样做在训练里其实也能跑,因为 YOLO 的预处理本身会做 letterbox 填充,但问题出在信息被非等比拉伸这件事上。一个正方形的目标被拉成扁的,模型学到的形状先验就是错的,推理时遇到正常比例的目标反而容易漏检。
更隐蔽的问题在标签上。有些同学觉得“反正 YOLO 用的是归一化坐标,图片 resize 了坐标不用改吧”。这个想法是错的。归一化坐标的基准是图片自身的宽和高,图片的宽高比变了,x 和 y 的缩放系数就不是同一个数,标签如果不跟着做非等比变换,框就会错位。而如果保持等比缩放,x 和 y 共用同一个缩放系数,换算只需要乘一个数,出错概率大幅下降。
所以“按比例”既是保证图像内容不失真的要求,也是让标签换算保持简单的工程选择。这就是为什么我在所有这类脚本里,第一件事都是算scale = target_long_side / max(w, h),然后宽高同时乘这个系数,绝不分别处理。
1.2 压缩带来的连锁收益
把这件事的收益拆开看,其实是三层。第一层是存储和 IO:文件小了,从磁盘读进内存的时间短了,如果数据放在机械盘或者网络存储上,这一层的收益会非常明显。第二层是解码和预处理:JPEG 解码的耗时和像素数基本成正比,缩放这一步同理,这部分开销原来能吃满好几个 CPU 核心。第三层是显存和 batch:如果你用的是动态尺寸训练或者多尺度训练,图片本身小了,同样的显存能塞下更大的 batch。
我在一个项目里实测过这三层的叠加效果:原数据平均 2400×1800,压到长边 1280 之后,数据加载线程的 CPU 占用从 8 个核心满载降到 2 个核心左右,单 epoch 时间从 105 分钟降到 38 分钟,而验证集上的 mAP 只掉了 0.4 个点。这 0.4 个点的代价换来的训练迭代速度提升,对需要反复调参的阶段来说是完全值得的。
注意:这里的 0.4 个点是针对这个特定数据集的结论,不能直接套用到你的任务上。掉多少点取决于你的最小目标尺寸和压缩比例,必须自己验证。
2. 动手前必须搞清楚的三个参数
在写任何脚本之前,有三件事要先定下来,它们直接决定了压缩是否安全、脚本怎么写。这三个参数分别是:目标长边尺寸、最小目标像素尺寸、以及缩放系数的取整方式。前两个决定了“压到多少”,第三个决定了“标签换算会不会引入额外误差”。
2.1 目标长边尺寸怎么定
最直接的办法是看你的训练输入尺寸。如果训练时统一缩放到 640×640,那么把数据压到长边 640 到 960 之间是合理的,压到 640 以下就纯属自损。留一点余量的原因是,训练时的 data augmentation 里常有随机裁剪、随机缩放,原图如果刚好只有 640,裁剪之后就只剩四五百像素,小目标直接糊掉。
如果训练用的是多尺度训练(YOLOv5/v8 里常见做法,每若干轮随机换一个输入尺寸),那就按你设定的最大输入尺寸来定,压到最大输入尺寸的 1.2 倍左右比较稳妥。
我用过一个粗略的对照表,实测下来比较靠谱:
| 训练输入尺寸 | 建议压缩目标长边 | 说明 |
|---|---|---|
| 416 | 640 | 留足增强余量 |
| 640 | 960 | 常用组合,性价比高 |
| 960 | 1280 | 中等目标为主时适用 |
| 1280 及以上 | 1600 | 小目标密集场景慎压 |
2.2 最小目标像素尺寸的估算
这是决定压缩上限的硬指标。做法很简单:从数据集里挑几十张有代表性的图,找出里面最小的那些标注框,算一下它们在原图上的像素宽高。一个目标如果压缩后长边只剩 8 个像素,那它基本就没法被检测出来了,因为 YOLO 的 stride 最小是 8,特征图上对应的响应区域太小。
具体算法:假设原图中最小目标的宽是w_min像素,原图长边是L_orig,你打算压到L_target,那么压缩后这个目标的宽度是w_min × (L_target / L_orig)。我的经验阈值是,压缩后最小目标短边不低于10~12 像素,低于这个数就别压那么狠,或者干脆把这类样本单独拎出来不压。
这里还有一个容易被忽略的点:如果你的数据集里本身就混着一批低分辨率的小图,压缩时不能一刀切。一张本来就只有 500×400 的图,你按长边 960 去“压缩”,实际是放大,白白增加体积还引入插值模糊。所以脚本里必须判断,只压不放。
2.3 缩放系数的取整策略
这个问题很多人不在意,但它直接影响标签精度。假设原图 1920×1080,目标长边 960,缩放系数是 0.5,那么新尺寸是 960×540,很干净。但如果原图是 1920×1079,系数 0.5,算出 539.5,你取 539 还是 540?
取整本身没问题,问题在于标签换算要用哪个系数。正确做法是:先用浮点系数算出新的整数宽高,然后用实际的 新宽/原宽 和 新高/原高 两个真实比例去换算标签,而不是用你最初那个理论系数。因为取整引入了微小偏差,如果标签按理论系数算,坐标就会和图像内容差那么零点几个像素,样本量大了之后这种系统性偏移会影响框的回归质量。
代码上就是两步:new_w = max(1, round(w * scale)),然后sx = new_w / w,sy = new_h / h,标签用sx、sy分别换算。这样图像和标签是严格对齐的。
提示:这一步多写两行代码,能省掉后面排查“框总是偏一点”的大量时间。我吃过这个亏,一开始用理论系数换算,训练集 loss 收敛得挺好,但可视化一看,框普遍往左上偏了一两个像素。
3. 核心实现:图片缩放与标签同步换算
先讲图片这边。缩放用什么插值算法,是个值得说道的选择。缩小图片时,cv2.INTER_AREA是效果最好的,它做的是区域平均,能有效抑制摩尔纹和锯齿;INTER_LINEAR快一些但缩小场景下容易产生伪影;INTER_CUBIC在缩小的时候反而可能过冲。所以我的默认选择是INTER_AREA,只在追求极致速度且压缩比例不大的时候才用INTER_LINEAR。
保存格式也要考虑。原图如果是 PNG,压缩后建议转成 JPEG,质量设 92 到 95,体积能降一大截,肉眼几乎看不出差别。但要注意,如果原图是带透明通道的 PNG,转 JPEG 前要先处理 alpha 通道,否则会报错或者颜色错乱。这个坑我在处理一批带透明背景的工业检测图时踩过。
3.1 标签换算的数学推导
YOLO 的 txt 每行长这样:class cx cy w h,其中 cx、cy、w、h 都是相对图片宽高的归一化值。设原图宽高为W0, H0,新图为W1, H1。由于是等比缩放,理论上W1/W0 ≈ H1/H0,但因为有取整,实际用sx = W1/W0、sy = H1/H0两个独立系数。
换算公式:
- 新的 cx = 原来的 cx(归一化坐标在等比缩放时保持不变)
- 新的 cy = 原来的 cy(同理)
- 新的 w = 原来的 w × (sx / sy)
- 新的 h = 原来的 h × (sy / sx)
等一下,这里要仔细想。归一化的 cx 是相对宽度,图片宽度变了,但 cx 表示的是“中心点位于宽度的百分之几”,这个比例在等比缩放中是不变的。所以 cx、cy 确实不用改。宽高就不同了:w 是相对图片宽度的比例,而图片宽度按 sx 变了,目标的绝对像素宽按 sx 变了,所以绝对宽度不变比例——也就是说,如果 sx 等于 sy,w 和 h 都不用改。
那什么时候要改?只有当 sx 和 sy 不相等时,也就是图像被非等比缩放时。而我们的前提是等比缩放,sx 和 sy 只在取整误差上有一点点差异,比如 0.5001 和 0.4998。这种情况下严格来说需要修正,但修正量极小。稳妥的做法是:统一用 sx 和 sy 的较小者或者平均值作为统一系数,或者干脆让新高度由新宽度等比算出,保证 sx 和 sy 完全一致。
我的实际做法是这样的:先定新宽度W1 = round(W0 * scale),再定新高度H1 = round(W0 * scale * H0 / W0),也就是H1 = round(W1 * H0 / W0)。这样做的好处是 sx 和 sy 的比值更接近 1。但即便如此,取整还是会让两者有细微差别,所以标签换算我仍然严格按sx = W1/W0、sy = H1/H0来算 w 和 h 的修正。
写清楚就是:
new_cx = cx new_cy = cy new_w = w * (sx / sy) new_h = h * (sy / sx)绝大多数情况下 sx ≈ sy,这个修正几乎为零。但如果你的脚本对某些图做了特殊处理导致比例偏差,这段修正就能救你。
3.2 一个可直接抄的脚本骨架
我把核心逻辑写成 Python,依赖只有 OpenCV 和标准库,读 YOLO txt、缩放、写回,全部在一个函数里完成。目录结构假设是images/和labels/平级存放,图片和 txt 同名不同后缀。
import os import cv2 import math def resize_one(img_path, txt_path, out_img_path, out_txt_path, target_long=960): img = cv2.imread(img_path) if img is None: return False, "read_fail" h0, w0 = img.shape[:2] long_side = max(w0, h0) if long_side <= target_long: # 只压不放 return False, "already_small" scale = target_long / long_side w1 = max(1, int(round(w0 * scale))) h1 = max(1, int(round(h0 * scale))) img_new = cv2.resize(img, (w1, h1), interpolation=cv2.INTER_AREA) sx = w1 / w0 sy = h1 / h0 lines_out = [] if os.path.exists(txt_path): with open(txt_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) < 5: continue cls_id = parts[0] cx, cy, bw, bh = map(float, parts[1:5]) ncx = cx ncy = cy nbw = bw * (sx / sy) nbh = bh * (sy / sx) # 边界裁剪,防止浮点误差越界 ncx = min(max(ncx, 0.0), 1.0) ncy = min(max(ncy, 0.0), 1.0) nbw = min(max(nbw, 1e-6), 1.0) nbh = min(max(nbh, 1e-6), 1.0) lines_out.append( f"{cls_id} {ncx:.6f} {ncy:.6f} {nbw:.6f} {nbh:.6f}" ) cv2.imwrite(out_img_path, img_new, [cv2.IMWRITE_JPEG_QUALITY, 95]) with open(out_txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines_out)) return True, "ok"这个骨架里有几处细节值得单独说。第一,if long_side <= target_long那个判断,是小图跳过,避免放大。第二,坐标保留 6 位小数,YOLO 系列读取时完全够用,保留太多位反而增大文件。第三,做了 0~1 的裁剪,因为浮点乘除之后偶尔会出现1.0000001这种值,某些框架会因此报错或丢弃该框。
3.3 批量处理的工程要点
单张能跑通之后,批量处理时有几个工程问题要处理。并行方面,图片解码和编码是 CPU 密集型的,用进程池比线程池效果好,因为 Python 的 GIL 会限制线程在纯计算上的并行。我一般开os.cpu_count() - 1个进程,留一个给系统。
断点续跑方面,两万张图跑到一半崩了,不能从头再来。做法是在输出目录里判断目标文件是否已存在且大小大于零,存在就跳过。这个逻辑比维护一个进度日志简单可靠得多。
日志和统计方面,至少要记录三类计数:成功压缩的张数、因为尺寸小跳过的张数、读取失败的张数。读取失败必须把路径打出来,我遇到过一批文件后缀是.jpg但实际内容是 PNG 的,OpenCV 能读但某些工具链会报错,这种异常必须暴露出来而不是静默跳过。
目录结构方面,强烈建议输出到新目录,不要在原始数据上原地覆盖。原始数据是宝贵的,压缩参数一旦选错,覆盖之后就回不去了。
注意:原地覆盖是这类脚本最容易犯的错,而且一旦执行很难挽回。我的习惯是输出目录名带上压缩参数,比如
images_960/,这样同时保留多套不同压缩比例的数据,方便做对比实验。
4. 校验与常见问题排查
脚本跑完不等于事情做完了。压缩之后必须做校验,否则很可能拿着一套错位的数据训练到天亮,最后发现框全歪了。校验分自动校验和人工抽查两层,两层都要做。
4.1 自动校验:用统计指标兜底
自动校验的核心思路是对比压缩前后的标注分布。因为等比缩放不改变归一化坐标,所以压缩前后整个数据集的 cx、cy、w、h 的均值和中位数应该几乎一致,只有取整误差级别的浮动。如果某一项的均值偏差超过 0.005,那基本可以断定换算逻辑有问题。
具体做法是遍历所有 txt,把四个值分别累加求平均,压缩前后各算一次。我还会额外统计一个指标:框的中心点是否落在图片范围内,以及框的宽高是否超过 1.0。这两个检查能抓住绝大多数越界问题。
另一个很有用的自动化检查是抽几张图把框画出来。写个十行的小脚本,用 OpenCV 在压缩后的图上按 txt 画矩形,肉眼看几张。这个步骤花不了一分钟,但能发现很多统计指标看不出来的问题,比如框整体偏移、框尺寸系统性偏大等。
4.2 快速对照表:我踩过的坑和排查路径
下面这张表是我在实际项目中遇到的问题整理,按出现频率排序:
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 框整体偏左上或偏右下 | 用了理论缩放系数而非实际新宽/原宽 | 打印一张图的 sx、sy 和理论 scale 对比 | 改用实际宽高比换算 |
| 框普遍变大或变小 | w、h 被错误地按单方向缩放 | 检查是否误写了bw * sx而非bw * (sx/sy) | 修正公式,等比时该项应为 1 |
| 部分图没有对应 txt | 图片和标签目录结构不一致,或后缀大小写不同 | 统计图片数百个标签数 | 统一后缀,建立匹配映射 |
| 训练时偶发坐标越界报错 | 浮点误差导致值略大于 1 | 扫描所有 txt 找 >1 的值 | 写入时做 0~1 裁剪 |
| 压缩后体积没怎么降 | 原图是 PNG,输出仍写 PNG | 看输出文件后缀和平均大小 | 改存 JPEG,质量 92~95 |
| 小目标检测精度明显下降 | 压缩比例过大 | 统计最小目标压缩后的像素尺寸 | 降低压缩比例或分类处理 |
| 输出成了放大的图 | 缺少只压不放的判断 | 检查是否有 new_w > w0 的情况 | 加尺寸判断直接跳过 |
| 处理速度很慢 | 用了线程池而非进程池 | 看 CPU 占用是否只有单核 | 改用多进程并行 |
表里前两条是最致命的,因为它们不会报错,模型照样训练,只是精度莫名其妙地差。我在一个项目里就因为第二条,白白训练了两轮才发现问题,回头一看是公式写错,w 和 h 的修正项搞反了。这种错误靠 loss 曲线是看不出来的,只能靠校验。
4.3 精度掉太多的补救思路
如果校验没问题,但压缩后的模型精度确实掉得不能接受,有几条路可以走。第一条是降低压缩比例,从长边 960 提到 1280,代价是 IO 收益缩水,但通常能挽回大部分精度。第二条是分类处理:统计每张图里最小目标的尺寸,对包含极小目标的图不压或者只轻压,其余正常压,这样能保住关键样本。第三条是在训练时针对性补强:把 mosaic、mixup 这些增强里的小目标处理调一调,或者把小目标样本复制加权。
我个人更倾向于第二条,因为它的收益是确定的、可控的,不需要反复试训练参数。实现上就是在脚本里多一个判断:读完 txt 之后,算出最小的绝对像素框,如果低于阈值,就用一个更保守的目标长边重新算一次。这段逻辑只多十几行代码,但能让压缩这件事在大多数数据集上都变得安全。
4.4 一个容易被忽略的场景:多套数据的一致性
如果你手上不止一批数据,比如训练集和验证集来自不同来源、分辨率分布不一样,压缩时一定要用同一套规则。具体说就是,目标长边要一致,只压不放的判断要一致,插值方式要一致。否则训练集和验证集的图像质量分布产生系统性差异,验证指标会失真。
我遇到过一次,训练集压到 960,验证集因为图本来就小没压,结果验证集上的指标虚高,部署到实际场景才发现性能掉了一截。原因就是验证集的图没经过压缩带来的轻微模糊,模型在“干净”的验证集上表现好,但实际输入的图会经过各种预处理,分布对不上。
提示:压缩脚本最好对训练集、验证集、测试集一次性跑完,并把参数写在脚本顶部而不是散落在各处,避免不同批次用了不同参数。
5. 实操记录:一次完整的压缩流程
把上面这些东西串起来,完整走一遍流程。假设数据集在dataset/下,包含images/train、images/val、labels/train、labels/val。
第一步,体检。先跑一个只统计不处理的脚本,输出:图片总数、分辨率分布(按长边分桶)、txt 总数、图片与 txt 的匹配率、最小目标框的像素尺寸分布。这一步的输出决定了目标长边怎么定。我那次体检发现,最小目标框的短边中位数是 22 像素,5% 分位数是 9 像素,原图长边中位数 2100。按压缩到 960 算,系数约 0.457,最小目标里那 5% 会掉到 4 像素左右,这些样本会受损。所以我最终定的是 1120,系数 0.533,5% 分位数掉到 4.8 像素,仍然偏小,于是又对这批小目标样本单独标记,训练时做了过采样补偿。
第二步,小样本试跑。挑 50 张图跑一遍,然后画框可视化,肉眼确认框贴合。这一步千万别省,我基本每次都靠这一步发现脚本里的低级错误。
第三步,全量跑。用进程池,输出到带参数名的新目录。跑的时候盯着日志里的失败计数,如果有读取失败,当场查原因。
第四步,校验。跑统计对比脚本,确认四个归一化量的均值偏差在阈值内,确认所有 txt 的值域合法。
第五步,训练验证。用同一份训练配置,分别在原始数据和压缩数据上跑,对比单 epoch 时间和验证集指标。这一步是最终裁决,其他都是过程指标。
这五步走完,才算把“压缩”这件事做扎实了。整个流程里,写脚本可能只占三成时间,其余七成都在体检和校验上。我的经验是,校验花的时间永远比写脚本值,因为脚本写错可以改,数据错了却可能污染好几轮实验的结论。
5.1 实测数据对比
那次项目的具体数字,我记了下来,放在这里做个参考:
| 指标 | 原始数据 | 压缩后(长边 1120) |
|---|---|---|
| 图片总数 | 21400 | 21400 |
| 磁盘占用 | 47 GB | 11 GB |
| 平均单图解码+缩放耗时 | 31 ms | 9 ms |
| dataloader 8 worker CPU 占用 | 约 780% | 约 210% |
| 单 epoch 训练时间 | 106 min | 41 min |
| 验证集 mAP@0.5 | 0.812 | 0.806 |
| 验证集 mAP@0.5:0.95 | 0.541 | 0.529 |
这组数字里,最值得看的是最后两行。mAP@0.5 只掉了 0.6 个点,mAP@0.5:0.95 掉了 1.2 个点,说明框的定位精度有轻微损失,但检出能力基本保住。对于需要快速迭代调参的阶段,这个交换是划算的;如果最终要交付高精度模型,可以在这个基础上再用原始数据做一轮微调,把精度找回来一部分。
5.2 关于“压缩后再微调”的做法
这个方法我用了好几次,效果稳定。思路是:先用压缩数据把模型训到收敛,快速完成结构选型、超参搜索这些需要大量试错的阶段;确定配置之后,再用原始高分辨率数据接着训若干轮。因为模型已经学得差不多了,微调轮数不用多,通常 10 到 20 个 epoch 就能把精度补回来,总时间仍然远少于全程用原始数据训练。
这个做法有个前提,压缩时的缩放方式要和你微调时的数据分布尽量一致。如果你压缩用INTER_AREA,微调用原始数据但训练时 resize 用INTER_LINEAR,两者还是有细微差异,问题不大,但最好知道这件事。
6. 最后分享几条实打实的经验
写了这么多,把最值得记住的几条单独拎出来,都是吃亏换来的。
只压不放这一条,加一个if就能避免,但不加的话,你会得到一批被插值放大、体积反而变大的图,白白浪费 IO。标签换算用实际宽高比,不用理论系数,多两行代码,省一周排查。永远输出到新目录,原始数据是最后的退路。校验要做统计对比加肉眼抽查,只看 loss 曲线发现不了标签错位。训练集验证集用同一套压缩规则,否则指标会骗你。
另外说一个偏门但实用的技巧:压缩脚本跑完之后,顺手生成一份resize_meta.json,把每张图的原尺寸、新尺寸、sx、sy 都记下来。后续如果要做 COCO 格式转换、要还原坐标、要分析某类目标在缩放后的尺寸分布,这份元数据能省大量重算。我一开始没存,后来为了做一个尺寸分布分析,又把两万张图重新读了一遍,纯属浪费。
还有一点,如果你的数据集里图片格式很杂(JPEG、PNG、BMP、WebP 混着),脚本里的后缀匹配要做成大小写不敏感,并且图片和 txt 的配对不要靠替换后缀字符串,而是取文件名主干去两个目录里找。字符串替换遇到a.b.jpg这种文件名就会出错,这种文件名在真实数据里是真的存在。
关于压缩到什么程度,我的个人体会是:以你能接受的最大精度损失为界,反过来推压缩比例。先做一轮压缩比例的小实验,比如 1280、1120、960 各跑一小部分数据训练几个 epoch,看验证指标的下降曲线,找到一个拐点。这个实验可能花两三个小时,但它能给你一个针对自己数据集的确定答案,比任何通用的经验值都可靠。