ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JPG批量转NPY:数据预处理提速与训练缓存实战指南

JPG批量转NPY:数据预处理提速与训练缓存实战指南

把文件夹里的jpg图片批量转成对应的.npy格式文件,听起来就是个不起眼的小脚本,但我做视觉相关的数据处理时,这套转换永远是第一个写、也是最常被复用的一段代码。早几年我偷懒,训练脚本里直接读原始jpg,几百张图测着玩没感觉;后来真上了几万张的数据集,每次训练启动前光图片解码和预处理就要磨二十多分钟,实在忍不了,才把这一步抽出来固化成了.npy缓存。做完之后,同样的预处理工作变成了几秒钟的事,整个实验迭代节奏完全不一样了。这篇文章就把整套批量转换的思路、代码和坑完整摊开,讲清楚为什么转、怎么转、转完怎么验证,适合准备视觉数据集、做模型复现、或者搭训练管道的朋友参考。

1. 为什么要把jpg批量转成.npy:先算清这笔账

1.1 解码开销是数据准备里最容易被忽略的硬成本

很多人觉得,图片读进来不就是一个数组吗?反正cv2.imread一句就搞定了,何必多存一份.npy。问题恰恰出在这句"一句就搞定"上。jpg是压缩格式,磁盘上的一张224×224的图片可能只有二三十KB,但解码成RGB像素数组之后是224×224×3=150528个字节。每次imread都要走一遍熵解码、逆离散余弦变换、色彩空间转换,这些计算开销是纯CPU密集型的,读一万张就是重复一万遍。

.npy文件则完全不一样。它存的本来就是内存中数组的原始字节,头部只有一小段描述shape和dtype的元数据,np.load几乎等于直接做磁盘到内存的拷贝,中间没有任何解压和重算的过程。我实测的数据大致是:批量读一万张224×224的jpg,用cv2.imread解码加resize,单线程大约200秒;换成np.load读提前转好的.npy,同样一万张只需要15秒左右。差了一个数量级,不是因为哪份代码写得更"聪明",而是单纯省掉了整个解码环节。

1.2 什么场景值得做,什么场景没必要折腾

先说适合的场景。最典型的一种:你有一批固定的数据集,后面要对多个模型做对比实验,或者反复调参。这时候图片预处理逻辑——统一尺寸、转RGB、归一化——只做一次,存成.npy,之后每次实验加载都是纯I/O操作,不用再重复解码。第二种场景是数据需要喂给对输入格式有硬性要求的模型,比如固定224×224、固定float32范围,把预处理结果缓存下来,能省掉训练代码里一堆冗余的if-else判断。

不推荐折腾的情况也有:数据集只有几百张,一次脚本跑完就丢,转换的时间可能比直接读还慢;或者你的管道里每一步都在做随机裁剪、随机翻转这类在线增强,那缓存的意义主要落在"基础图"而非"增强后的图"。即便在这种场景下,我依然会把resize和通道统一先做了缓存,增强只在缓存图的基础上做,速度提升依然明显。

2. 动手前的准备:依赖选型和图片摸底

2.1 OpenCV还是Pillow:我的默认答案

转换第一步要选读图库。市面上常用的是OpenCV和Pillow两家,我默认用OpenCV,因为它解码速度快、resize方便,而且imread遇到损坏文件不会抛异常打断脚本,而是返回None,批量任务里这个行为对容错很友好。Pillow也不是不行,甚至在某些格式兼容性上更强,但需要多写几行。

对比项OpenCV (cv2)Pillow
读取效率更高一般
返回通道顺序BGRRGB
resize等几何变换内置且快可用但稍慢
EXIF方向处理默认忽略需配合exif_transpose
坏文件行为返回None抛异常

环境依赖其实就两个核心库加一个辅助库:numpy、opencv-python、tqdm(纯粹为了进度条)。安装命令很简单:

pip install numpy opencv-python tqdm

2.2 转换之前,先花两分钟摸清文件夹的底

拿到一个图片文件夹,别急着写转换脚本。先做一次摸底,看看里面到底有什么。我用的是这样一段快速侦察代码:

from pathlib import Path import cv2 src = Path("./images") jpg_list = list(src.glob("*.jpg")) print(f"jpg数量: {len(jpg_list)}") for p in jpg_list[:5]: img = cv2.imread(str(p)) if img is None: print(p.name, "读不出来") else: print(p.name, "shape:", img.shape, "dtype:", img.dtype)

这一步往往会有惊喜:你以为全是RGB三通道图,结果混进来几张灰度图;你以为尺寸都是统一的,结果有横版有竖版。这些信息直接决定转换时要不要统一到RGB、要不要统一resize。我在实际项目中就遇到过文件夹里混着几千张RGBA的png和jpg的情况,如果摸底时没发现,后面一堆图片的通道数对不上,踩坑成本很高。

3. 单张图片转换的完整链路:从解码到落盘

3.1 每一步代码在做什么,为什么要这么做

单张转换的核心逻辑就五步,把它吃透了,批量只是加一层循环而已。

import cv2 import numpy as np img = cv2.imread("images/img_001.jpg") # 1. 解码成ndarray img = cv2.resize(img, (224, 224), interpolation=cv2.INTER_AREA) # 2. 统一尺寸 img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 3. 调通道顺序 arr = img.astype(np.float32) / 255.0 # 4. 归一化到[0,1] np.save("npy_data/img_001.npy", arr) # 5. 落盘

第一步,imread返回的是一个H×W×C的uint8数组,H是高、W是宽、C是通道数。注意它返回的顺序是BGR,不是RGB,这个坑后面专门讲。第二步,统一尺寸为什么用INTER_AREA?因为缩小图片时,区域插值会做像素平均,抗锯齿效果好,不容易出现纹理失真;如果是放大图片,用INTER_LINEAR更顺滑。第三步,如果你希望下游拿到的数据是RGB标准顺序,这里就必须显式转换。第四步开始涉及一个关键决策——到底要不要归一化,下面单独说。第五步,np.save会自动补.npy后缀,它是numpy自带的二进制格式,跨平台、跨版本兼容性都很好。

3.2 dtype与归一化的取舍:别在不该内存翻倍的地方翻倍

这一步是整个转换里最容易被忽略、也最容易埋雷的决策点。jpg解码出来是uint8,取值范围0到255;归一化后会变成float32,取值0到1。两者文件大小差了整整4倍:一张224×224×3的uint8数组约147KB,转成float32直接变成588KB。

我的建议是:如果你只是把.npy当作中间缓存,后面训练代码里本来就要做归一化,那缓存就保留uint8,训练时一行.astype(np.float32) / 255.0就完事了。文件更小,加载更快,肉眼验证也方便。如果你用的预训练模型要求输入就是float32的[0,1]或者[-1,1],那就在转换阶段归一化好,免得每次训练都重复算。关键原则是:这个决策一旦定下来,就把它写进一个meta文件记下来,而不是靠记忆。

4. 批量脚本设计:目录遍历、自然排序与命名一一对应

4.1 目录遍历没有你想的那么简单

批量转换第一步是拿到所有jpg的路径列表。最简单的写法是list(Path("./images").glob("*.jpg")),但直接排序会有个经典问题:字符串排序是字典序,img10.jpg会排在img2.jpg前面,顺序完全乱掉。如果你的图片名不是固定长度补零格式,这个顺序混乱会直接影响后续的数据对齐。

解决办法是自然排序,把文件名里的数字部分按数值比较:

import re def natural_key(path): return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", path.stem)]

这个函数会把"img_2"拆成["img_", 2],把"img_10"拆成["img_", 10],排序时数字按大小排,结果就是img_2、img_10而不是img_10、img_2。批量处理前先这样排一遍,能保证转换顺序、后续加载顺序、标签生成顺序全部一致。

4.2 主循环脚本:稳定、可续传、有报错日志

"对应的.npy"是这个项目的核心要求——每张img_001.jpg都生成一个img_001.npy,命名一一对应,方便追溯。目录结构上我习惯打成这样:

images/ img_001.jpg img_002.jpg ... npy_data/ img_001.npy img_002.npy ...

完整的批量脚本我一般这么写:

import re from pathlib import Path import cv2 import numpy as np from tqdm import tqdm SRC_DIR = Path("./images") DST_DIR = Path("./npy_data") DST_DIR.mkdir(parents=True, exist_ok=True) TARGET_SIZE = (224, 224) USE_RGB = True KEEP_UINT8 = True # False则转float32并归一化 def natural_key(p: Path): return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", p.stem)] def convert_one(path: Path): try: img = cv2.imread(str(path)) if img is None: raise ValueError("imread返回None,文件可能损坏") img = cv2.resize(img, TARGET_SIZE, interpolation=cv2.INTER_AREA) if USE_RGB: img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) if not KEEP_UINT8: img = img.astype(np.float32) / 255.0 out_path = DST_DIR / f"{path.stem}.npy" np.save(out_path, img) return path.name, "ok" except Exception as e: return path.name, str(e) paths = sorted(SRC_DIR.glob("*.jpg"), key=natural_key) errors = [] for p in tqdm(paths, desc="转换中"): name, status = convert_one(p) if status != "ok": errors.append((name, status)) print(f"完成 {len(paths) - len(errors)}/{len(paths)} 张") if errors: print("失败列表(最多显示20条):") for name, reason in errors[:20]: print(f" {name}: {reason}")

这个脚本有几点是实际跑出来的经验:每个文件都套了try/except,单个文件失败不会中断整批任务,最后统一打印失败清单,方便回头手动处理;tqdm进度条在几万张图时非常重要,至少你知道它在动、还差多少;转换函数独立成convert_one,为后面的并行改造留了余地。

5. 上万张图片时的内存与性能策略

5.1 逐张保存还是合并成一个大数组

前面讲的是每张jpg生成一个.npy,这也是标题里"对应的"三个字决定的方向。但实际工程里你还会面临另一个选择:要不要把所有图片合并成一个大的.npy数组,比如dataset.npy加上labels.npy。

先算内存账。一万张224×224×3的uint8图片,合并成一个数组是10000×224×224×3=1505280000字节,约1.4GB;如果转成float32,直接翻倍到5.6GB。这个数据在单机训练场景下能接受,但如果你有十万张甚至百万张图,合并成单文件就不现实了。

逐张保存的优势是灵活:想随机采样、想按标签批量加载、想用生成器逐个读入,都很自然。缺点是会产生大量小文件,机械硬盘上读写会慢,固态硬盘问题不大。合并成大数组的优势是加载快、内存布局紧凑,但前提是所有图片尺寸必须一致,否则没法stack。另外,如果你确实需要超大数组做随机访问,可以考虑np.memmap,它把磁盘上的大数组映射成内存地址空间,按需读取,不用一次性全部装进内存。

5.2 并行处理与断点续传

批量转换的瓶颈在解码,解码是CPU密集型,而且每张图之间互不影响,天然适合多进程。改造很简单,把主循环换成ProcessPoolExecutor:

from concurrent.futures import ProcessPoolExecutor import os with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool: results = list(pool.map(convert_one, paths, chunksize=100))

我的经验是进程数不要贪多,CPU核心数就够了,解码本身已经能打满单核;chunksize设成100,减少进程间通信调度开销。四核机器实测能到三倍多加速,八核能到五倍左右,收益非常明显。

断点续传是我在转换大文件夹时一定会加的小功能:循环里先判断目标.npy是否已存在,存在就直接跳过。这样万一中间断电、机器重启、或者数据源目录加了新图,重新跑一遍脚本就行,不会重复转换已有文件:

for p in tqdm(paths, desc="转换中"): if (DST_DIR / f"{p.stem}.npy").exists(): continue name, status = convert_one(p) ...

6. 三个实测最容易翻车的细节

6.1 通道顺序错位:红蓝互换的诡异画面

BGR和RGB这个问题,字面上谁都懂,实际操作时几乎每个人都至少翻车一次。症状就是:转换结果里天空变成了橙色,人的脸变成蓝紫色,整体色调诡异。原因很简单,cv2.imread读出来的是BGR顺序,如果你直接把这个数组交给按RGB训练的模型,或者直接用matplotlib的imshow显示,前两个通道的反转会被完全暴露。

我在早期一次项目里就因此浪费过半天时间:模型在验证集上表现异常,排查了很久才发现是缓存数据里通道顺序错了,模型看到的所有图都是红蓝颠倒的。从那以后,我在转换脚本里固定写一行cvtColor(img, cv2.COLOR_BGR2RGB),并且在输出目录放一个README.txt,里面记录通道顺序、尺寸、dtype这些元信息。这个习惯后来帮我省了无数次沟通成本,别人拿到这批数据也不会猜来猜去。

6.2 EXIF方向被忽略:手机竖拍照片全躺平

另一个容易被忽略的坑是EXIF方向信息。手机拍的照片,尤其是竖拍的,会在文件头里写一个orientation标签,告诉显示软件"这张图要旋转90度才是正着看的"。然而cv2.imread默认完全无视这个标签,直接按文件里的原始像素读。结果就是:你转换出来的.npy里,所有竖拍照片都是横躺的。

我遇到过整批图片转完,训练出的模型对方向特别敏感,准确率始终上不去。排查后才发现问题不在模型,在数据方向全是乱的。解决办法是读图时主动处理EXIF,用Pillow会更顺:

from PIL import Image, ImageOps import numpy as np pil_img = ImageOps.exif_transpose(Image.open(str(path))) img = np.array(pil_img.convert("RGB")) # 这里拿到的是RGB顺序,不需要再cvtColor

如果你必须用OpenCV,可以先用Pillow处理完方向,再转成OpenCV能用的numpy数组。至少心里要有数:cv2不处理EXIF,方向问题得自己兜底。

6.3 文件名大小写、路径问题与坏图拦截

glob("*.jpg")只会匹配小写后缀,但现实中图片可能是.JPG、.jpeg甚至.JPEG。我的习惯是干脆匹配所有文件,再按后缀过滤:

paths = [p for p in src.iterdir() if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg"}]

路径方面,有中文目录名的场景下,尽量全程用pathlib的Path对象,不要手动拼字符串路径。Windows上还容易踩路径过长的问题,文件夹层级不要套太深,我用脚本处理前会把数据集拷到单独的、短的根目录下。坏图问题前面说过,imread返回None时直接跳过并记录路径;如果整批成功率低于98%,建议先排查源图是不是有系统性损坏。

7. 转换完成后的验证:别急着拿去训练

7.1 像素级对比抽检

转换完成不代表数据没问题。我的习惯是随机抽几张图,把原始jpg重新读一遍,和.npy内容做像素级对比。验证代码大致长这样:

import cv2 import numpy as np SRC_PATH = "images/img_001.jpg" DST_PATH = "npy_data/img_001.npy" TARGET_SIZE = (224, 224) src = cv2.imread(SRC_PATH) src_rgb = cv2.cvtColor(src, cv2.COLOR_BGR2RGB) src_rgb = cv2.resize(src_rgb, TARGET_SIZE, interpolation=cv2.INTER_AREA) data = np.load(DST_PATH) if data.dtype == np.float32: data_u8 = (data * 255).round().astype(np.uint8) else: data_u8 = data diff = np.abs(data_u8.astype(np.int16) - src_rgb.astype(np.int16)) print("shape:", data.shape, "dtype:", data.dtype) print("最大像素差:", diff.max(), "平均像素差:", diff.mean())

正常情况下,最大像素差应该是0,因为resize和cvtColor都是确定性操作。如果你看到最大像素差是几十甚至一百多,就去查前面6.1里说的通道顺序问题,或者dtype换算有没有出错。这里有个小技巧:astype(np.int16)是为了防止uint8相减时下溢成巨大的错误值,unsigned减unsigned在numpy里会绕回,不用int16中间类型很容易误判。

7.2 完整性与命名校验

像素验证之外,还得确认数量和命名一一对应。这个校验逻辑很简单,但没做过的同学下次一定用得上:

src_names = {p.stem for p in paths} dst_names = {p.stem for p in DST_DIR.glob("*.npy")} missing = src_names - dst_names extra = dst_names - src_names print("缺失:", missing if missing else "无") print("多余:", extra if extra else "无")

到这里,整套批量转换流程就闭环了:摸底、单张转换、批量处理、性能优化、踩坑规避、结果验证。我自己后来在这套脚本上加了一个小功能,转换完成后自动生成一个meta.txt,记录通道顺序、目标尺寸、dtype、图片数量和转换时间。就这一个文件,后面不管是谁接手这批数据、或者换了一台机器重新跑,都不用再对着代码猜配置。如果这套流程能帮你省下第一次那二十分钟的等待,我觉得这篇就值了。

RELATED READING

延伸阅读

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