
1. 数据处理前的整体设计与规划做深度学习或者计算机视觉相关项目时最让人头疼的往往不是模型结构怎么搭而是怎么把一堆乱糟糟的原始图片整理成可以直接训练的数据集。我见过不少刚入门的朋友下载了一个公开数据集或者自己从各个渠道收集了一批图片文件夹里可能是“IMG_20210715_123456.jpg”、“微信图片_20230501_abcdef.png”、“新建文档.jpg”这种千奇百怪的命名尺寸从几百乘几百到几千乘几千都有图片格式也是参差不齐这时候直接拿去训练要不就报错要不就是数据的分布乱得一塌糊涂。这篇博客要讲的就是一套我自己常用的图片预处理流水线。它把六个最常见的需求一次性串了起来批量重命名、修改格式、统一大小、保存到其他路径、灰度化、归一化最后还把处理后的BGR像素值写入txt文件。这套流程基本覆盖了做数据集前期百分之八十的脏活累活。为什么要把这些操作做成一条流水线而不是一个个单独的小脚本举个生活化的例子你去做体检量身高、称体重、抽血、做B超每一项单看都不难但你要是每一项都重新排队、重新挂号、重新填单子那一个早上就全耗在里面了。预处理也是一样一张图片从磁盘读进来要解码、要缩放、要改通道、要归一化、要保存如果每一步都重新读一次盘小数据集还无所谓到了上万张图那点I/O开销和时间成本完全是在浪费生命。所以我习惯的做法是读入一张图之后在内存里把这些操作全部做完最后一次性输出结果。腾讯这套流程适合谁大致分三类一是准备做分类、检测、分割任务但还没整理好数据集的深度学习入门者二是做传统图像处理项目的朋友需要把不同来源的图片统一标准三是有批量数据清洗需求的工程师比如要把一套旧系统的图片迁移到新项目里。全文核心代码基于Python 3和OpenCVNumpy辅助数值处理不会引入额外的大框架。2. 工程环境的准备与基础选型2.1 基础依赖安装与版本建议我用的环境是Python 3.8以上建议至少3.8太老的版本有些语法和库的支持会有坑。核心依赖其实就三个opencv-python、numpy、以及标准库里的os和glob。os和glob是Python自带的不需要额外安装真正要装的是前两个。pip install opencv-python numpy版本方面OpenCV装4.x系列就行太新的版本有时候对某些摄像头的驱动或者特殊格式支持会有变化但做纯文件图片处理影响不大。Numpy别装太老1.20以上基本都够用因为我们后面做归一化和数组reshape的操作都依赖numpy的新特性。装完之后建议快速验证一下环境import cv2 print(cv2.__version__)能正常打印输出版本号就说明装好了。2.2 目录结构设计与文件命名约定一个标准的预处理工程目录结构最好是清楚的我自己常用的划分方式如下image_preprocess/ ├── raw_images/ # 原始图片存放目录乱就乱吧反正只读 ├── processed_images/ # 处理后的图片输出目录 ├── output_txt/ # 存放BGR像素数据的txt输出目录 └── preprocess.py # 主处理脚本这里有个细节点输入目录和输出目录一定要分开。我见过有人图省事处理完直接覆盖原图结果跑完发现原始数据被破坏想回退或者重新调整参数都没办法了。数据预处理这条路上第一原则就是永远保留一份原始数据。哪怕你的脚本写得再完美指不定哪一天你发现归一化方式需要调整或者统一尺寸从224x224改成256x256如果原图没了你就得重新去搜集数据那是真正的灾难。至于文件命名约定我建议用统一的可排序前缀加索引编号比如cat_0001.jpg。为什么前缀要有意义因为后期你需要写个txt标签文件或者做一个类别映射有个可读的前缀会省很多事情。纯数字编号本身没问题但可读性差一个dog_0001.jpg和0001.jpg放在一起前者一看就知道是什么类别后者还得靠额外一个映射表去查。3. 核心预处理流水线的代码实现与拆解3.1 批量重命名的实现逻辑与遍历方式批量重命名这件事看起来最简单却有不少容易踩坑的地方尤其是文件名的生成规则。我推荐的遍历方式是用glob模块它可以一次性把所有符合条件的文件路径抓出来比os.listdir再逐个拼接更清爽。import glob import os def list_all_images(raw_dir): extensions [*.jpg, *.jpeg, *.png, *.bmp] all_files [] for ext in extensions: pattern os.path.join(raw_dir, **, ext) all_files.extend(glob.glob(pattern, recursiveTrue)) return all_files这里用了recursiveTrue意思是会递归查找子目录。为什么要递归因为很多数据集的文件夹是有层级结构的比如raw_images/folder1/img_0001.jpg如果你只搜第一层子文件夹里的图会全部漏掉。这是新手经常遇到的问题排查了很久就是找不到图片。重命名的核心逻辑是按文件名排序然后从1开始编号。为什么按文件名排序因为glob返回的文件顺序在不同操作系统上是不确定的Windows下和Linux下的顺序可能不一样为了保证可复现性手动排序是最稳妥的。all_files.sort() class_prefix flower rename_count 0 for idx, file_path in enumerate(all_files, start1): directory os.path.dirname(file_path) ext os.path.splitext(file_path)[1] # 保留原扩展名 new_name f{class_prefix}_{idx:04d}{ext} new_path os.path.join(directory, new_name) os.rename(file_path, new_path) rename_count 1 print(f重命名完成共处理 {rename_count} 个文件)注意{idx:04d}这个格式化意思是用4位数字不足补0比如第1个文件会变成flower_0001.jpg第9999个会变成flower_9999.jpg。为什么要补零因为如果位数不统一1、10、100、1000排序时会变成1、10、100、1000而不是1、2、3、4这会导致后面处理的时候文件顺序全乱了。3.2 图像格式转换与统一尺寸的多种方案格式转换和统一尺寸这两个操作一般都放在一起做。我用的方案是读入原图用OpenCV的imread读成numpy数组处理完之后再用imwrite按指定的扩展名保存。这里有个原理要说清楚OpenCV读图得到的是一个三维数组对于彩色图它的shape是(height, width, channels)值和“原文件格式是jpg还是png”已经没有关系了。换句话说尺寸调整和格式转换在内存里完成之后写文件时扩展名决定最终格式。target_size (224, 224) for path in all_files: img cv2.imread(path) if img is None: print(f警告无法读取文件 {path}跳过) continue resized_img cv2.resize(img, target_size, interpolationcv2.INTER_AREA)这里必须强调interpolation参数的选择。cv2.resize有几种插值算法INTER_NEAREST、INTER_LINEAR、INTER_CUBIC、INTER_AREA。其中INTER_AREA是最适合缩小图像的它对像素区域做平均缩小后图像更平滑、噪点更少。而如果是要放大图像INTER_LINEAR或INTER_CUBIC效果会更好。在我的场景里原始图片大都是几千乘几千的高分辨率图需要缩小到224x224所以INTER_AREA是正确选择。如果你做的是目标检测任务统一尺寸时还要注意一个问题直接resize会改变长宽比导致物体变形。这种情况下通常的做法是letterbox即先把图片等比缩放到能放进目标尺寸的范围内然后填充灰色边框到指定尺寸。这属于任务相关的特殊处理不在基础流水线里展开但大家心里要有个数。3.3 灰度化与归一化的数学原理灰度化本质上是把三维的彩色图降维成二维的灰度图。OpenCV中一行命令就能搞定gray_img cv2.cvtColor(resized_img, cv2.COLOR_BGR2GRAY)灰度化的原理是把BGR三个通道的值按一定权重加权求和OpenCV使用的权重是0.114B 0.587G 0.299*R。为什么绿通道的权重最高因为人眼对绿色最敏感用这个权重算出来的灰度图更符合人的视觉感受。这个细节很少有人注意到但它解释了为什么灰度图看起来像是“正常”的明暗图而不是单纯的每个通道平均出来的那种偏色的灰。归一化则分两种场景。一种是像素值归一到[0,1]区间直接除以255即可normalized_img gray_img.astype(np.float32) / 255.0另一种是标准化即减去均值再除以标准差让数据符合标准正态分布mean_value np.mean(normalized_img) std_value np.std(normalized_img) standardized_img (normalized_img - mean_value) / std_value我习惯的场景是第一种归一到[0,1]的浮点数。为什么因为在做深度学习预处理时大部分框架内部会有自己的归一化层你只需要把数据转成[0,1]浮点数模型的前处理已经标准化了。如果提前做得太过反而可能和框架内置的处理方式冲突。注意灰度图归一化之后如果你用cv2.imwrite去保存会遇到一个坑imwrite只接受0~255范围的uint8类型你把浮点的、0到1之间的数据显示出来会变成全黑或者全灰的画面。我的做法是保存时做一次映射np.clip(normalized_img * 255, 0, 255).astype(np.uint8)这样既能保留归一化后的数据用于后续写入txt也能正常存图用于人工检查。3.4 BGR数据写入txt文件的细节与reshape技巧标题里提到“将BGR写入txt”这里有两层意思需要拆解。第一种是直接把归一化后的一维像素数组写入txt用于某些传统机器学习模型或者不依赖图片文件的输入管线。第二种是保留BGR三维通道信息分别记录这在某些特殊场景也会用到。先看核心代码def save_bgr_to_txt(original_bgr_img, output_path): # original_bgr_img 是归一化前的原始BGR图shape: (H, W, 3) b, g, r cv2.split(original_bgr_img) with open(output_path, w, encodingutf-8) as f: for row in range(original_bgr_img.shape[0]): for col in range(original_bgr_img.shape[1]): b_val b[row, col] g_val g[row, col] r_val r[row, col] f.write(f{b_val} {g_val} {r_val}\n)这个嵌套循环写的txt每一行是像素坐标(row, col)处的BGR三个值空格隔开。另一种更高效的写法是把三维数组flatten成一维数组一行写完flattened original_bgr_img.reshape(-1, 3) np.savetxt(output_path, flattened, fmt%d, delimiter )这里有个关键区别要注意original_bgr_img是一个(H, W, 3)的数组reshape(-1, 3)之后每一行是那个像素的三通道值。但numpy的reshape到底是先遍历行还是先遍历列实际上是按内存顺序从左到右、从上到下也就是row-major顺序。这和你用嵌套循环先遍历row再遍历col的写入顺序是一致的。我还想提醒一点如果你要写入的是归一化后的数据那就不能用fmt%d了得用浮点格式比如fmt%.6f。这是一个常见的细节错误很多人写整数数据写习惯了换成浮点数据时忘了改fmt结果数据被截断成整数精度全没了。4. 完整流水线编排与执行优化4.1 把所有环节串成一个脚本的编排逻辑单个环节写完之后下一步就是把它们串成一条完整的流水线。核心逻辑不复杂但顺序有讲究。我建议的管道顺序是读入 - 重命名 - resize - 灰度化 - 归一化 - 保存图片 - 写入txt。一个容易忽略的问题如果你最终要写入txt的是BGR值那灰度化之后就不是BGR了数组变成了二维通道信息就没了。所以如果你两个都要那灰度化和归一化并不影响写txt因为写txt应该在灰度化和归一化之前从原始BGR图里取数据或者说你应该保留一份处理前的彩色图引用。我的做法是for idx, path in enumerate(all_files, start1): # 1. 读取原始图 color_img cv2.imread(path) if color_img is None: continue # 2. 缩放统一尺寸 resized_color cv2.resize(color_img, target_size, interpolationcv2.INTER_AREA) # 3. 从缩放后的彩图写入txt保留BGR信息 txt_output_path os.path.join(txt_dir, f{prefix}_{idx:04d}.txt) save_bgr_to_txt(resized_color, txt_output_path) # 4. 灰度化和归一化 gray_img cv2.cvtColor(resized_color, cv2.COLOR_BGR2GRAY) normalized_img gray_img.astype(np.float32) / 255.0 # 5. 保存处理后的灰度图 save_img np.clip(normalized_img * 255, 0, 255).astype(np.uint8) img_output_path os.path.join(output_dir, f{prefix}_{idx:04d}.jpg) cv2.imwrite(img_output_path, save_img)这里要特别强调第3步的位置写txt必须在灰度化之前完成。如果先灰度化BGR三个通道就没了你只能写入一个通道的灰度值那就不是你标题里说的“BGR写入txt”了。很多人第一次做的时候就在这个顺序上栽了跟头。4.2 大批量数据下的性能优化技巧批次处理一万张图片时你会发现一个很尴尬的问题裸写嵌套循环实在太慢了。当图像尺寸是224x224时每个像素都要进行一次文件写入操作即便有操作系统缓冲速度依然上不去。有个优化方案是把所有像素数据先累积到列表最后一次性写入def save_bgr_to_txt_fast(original_bgr_img, output_path): # 提前把数组转为列表 b original_bgr_img[:, :, 0].flatten() g original_bgr_img[:, :, 1].flatten() r original_bgr_img[:, :, 2].flatten() with open(output_path, w, encodingutf-8) as f: for i in range(len(b)): f.write(f{b[i]} {g[i]} {r[i]}\n)但实测下来真正更快的方案还是numpy的tofile配合格式化save_array original_bgr_img.reshape(-1, 3) np.savetxt(output_path, save_array, fmt%d, delimiter )savetxt是C语言底层实现的写入循环迭代在Python解释器层面少速度能快接近十倍。我处理一万张224x224的图片时用np.savetxt大概五到八分钟全部跑完如果手写嵌套循环可能要半小时以上。更极端的情况如果你的下游工具支持二进制格式建议直接用resized_color.tofile(output_path)把原始字节写进bin文件那速度是质的飞跃。txt的好处是可读性好缺点是占用空间大512x512的图一行三个数一个文件动辄几百KB。实际项目中要看下游模型吃什么格式有个权衡。4.3 多进程加速的改造方案当图片数量上万甚至十万级时单线程的for循环还是慢。这个阶段就要上多进程。Python的多线程受GIL限制在CPU密集型任务上帮不上什么忙所以要用multiprocessing或者concurrent.futures的ProcessPoolExecutor。一个简单的改造思路把单个图片的处理封装成函数然后映射到进程池。from concurrent.futures import ProcessPoolExecutor def process_one_image(args): idx, src_path, out_img_path, out_txt_path args color_img cv2.imread(src_path) if color_img is None: return False resized_color cv2.resize(color_img, (224, 224), interpolationcv2.INTER_AREA) np.savetxt(out_txt_path, resized_color.reshape(-1, 3), fmt%d, delimiter ) gray_img cv2.cvtColor(resized_color, cv2.COLOR_BGR2GRAY) norm_img gray_img.astype(np.float32) / 255.0 save_img np.clip(norm_img * 255, 0, 255).astype(np.uint8) cv2.imwrite(out_img_path, save_img) return True with ProcessPoolExecutor(max_workers8) as executor: tasks [] for idx, path in enumerate(all_files, start1): tasks.append((idx, path, os.path.join(img_dir, f{prefix}_{idx:04d}.jpg), os.path.join(txt_dir, f{prefix}_{idx:04d}.txt))) results list(executor.map(process_one_image, tasks))有几个注意点第一进程之间不能共享图片数据所以每个进程都要自己读图这是重复I/O但CPU并行带来的收益通常会超过I/O的损失第二max_workers设CPU核心数一半到四分之三比较合适开太满反而会频繁切换导致性能下降第三如果你是在Windows上跑必须把这段代码放在if __name__ __main__:的保护下面不然会无限递归创建子进程。这个坑我在Windows上踩过不止一次。5. 常见问题速查与实战排查5.1 中文路径与特殊字符导致的读图失败opencv的imread在某些版本下对中文路径支持非常差直接返回None。你是在Windows下开发的概率很大也极大概率文件夹叫“图片”、“数据集”之类的。这个问题有两个解法方案一临时切工作目录绕过去os.chdir(os.path.dirname(path)) img cv2.imread(os.path.basename(path)) os.chdir(origin_dir)方案二强烈推荐用numpy配合imdecodeimport numpy as np def cv2_imread_unicode(path): data np.fromfile(path, dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) return imgimdecode不走内部文件系统打开逻辑而是自己读字节再解码所以中文路径完全没问题。同理保存图片如果路径里有中文直接imwrite也会失败可以用imencode配合tofiledef cv2_imwrite_unicode(path, img): ext os.path.splitext(path)[1] result, encoded_img cv2.imencode(ext, img) if result: encoded_img.tofile(path)这是我强烈建议大家在代码里直接默认使用的读写函数别等出了问题再改。5.2 图片损坏或读取失败导致的流程中断大批量处理时几乎一定会碰到损坏的图片、拖拽下载时被截断的图、或者实际上是伪装成jpg的png文件。这类图片imread会返回None如果你的代码直接往下走做resize会直接抛空指针异常脚本中断。所以我编写的每个处理步骤都会先判断返回值img cv2.imread(path) if img is None: print(f[跳过] 无法读取: {path}) continue第二类经典问题是“伪jpg”文件扩展名是.jpg实际编码却是PNG。OpenCV的imread自动检测编码所以读入没问题但当你保存时如果指定的是.jpg扩展名部分版本会报编码错误。解决方案在保存时用imencode显式指定格式或者直接用cv2.imwrite但确认输出路径扩展名是.jpg且图片是三通道或单通道。如果碰到异常的最安全的做法是保存为png因为png是无损格式不怕反复压缩。5.3 txt文件占用空间过大与写入格式的取舍一张512x512的彩色图BGR三通道逐像素写入txt每一行是三个数字大概占用2到3MB。一万张就是20到30GB这还没算图片本身的空间。有些人的数据集原本才几个GB处理完整整翻了几倍。如何平衡三个方向供选。第一减少txt内容只写归一化后的数据用%.6f但还是会占空间因为浮点字符串长度更长。第二用二进制格式替代txt参考前面提到的tofile一张图可能才几百KB但可读性几乎为零只能靠下游代码解析。第三压缩txt存成zip如果只是存档不是直接读取这是个好方案。我实际项目中倾向于这样处理如果后续训练脚本是Python环境直接用np.savetxt生成txt因为读取简单如果是为了做嵌入式或C端的推理部署我会改成二进制bin文件。不要因为“txt看起来正规”而盲目选择一定要看下游系统的实际需求。5.4 内存溢出问题与分批次处理的策略一次性把所有图片都读进内存做处理数据集一大就容易爆内存。一张高清原图如果只有几百KB解码成numpy数组后就会膨胀到原来体积的好几倍因为解码后是原始像素数据比如BGR三通道2496x1664的图占用内存大约12MB一万张同时读进内存就是120GB根本不可能。所以我后面的代码里从来没有把全部图片一次性读入内存而是遍历路径、逐张处理、逐张释放。每次循环迭代结束上一张图的numpy数组就被垃圾回收了。如果你自己写代码时用了列表把所有图像append进去趁早改成逐张处理。有人会问numpy数组的释放是主动的吗在Python的CPython实现中主要是引用计数当一个numpy数组对象的引用计数归零时内存就被释放了。所以循环体内不要保留不必要的引用尤其不要顺手把图片存到一个全局列表中用于“以后可能有用”。这个“以后”到来之前你的内存已经爆了。6. 基于真实项目的实操记录与效果展示6.1 一万张混合格式图片的完整预处理实录我曾经在一个模拟项目中处理过一批这样的图片集文件夹里共有一万张图来源五花八门有手机拍的、有网页抓的、有扫描文档格式包括jpg、png、bmp甚至gif文件命名是完全无规律的英文和数字组合。需求是把这些图片统一重命名成face_0001.jpg的格式并输出对应的txt像素数据用于一个性别分类的小模型训练。整个流水线跑下来的时间消耗大致是这样的CPU为普通桌面级处理器8线程仅重命名部分不到1分钟重命名 resize 灰度化 保存灰度图大约3分半再加上逐像素写入BGR txt大约12分钟改成np.savetxt后大约7分钟进程数开8个总耗时约2分20秒。你们感受一下差距用对工具和用错工具效率能差出一个数量级。6.2 处理前后数据分布对比处理前的原始图片尺寸分布非常散从最小的128x96到最大的4928x3264长宽比也完全不一样有细长条、有正方、有横向宽幅。这类数据如果直接丢进网络很多框架会在内部隐式做尺寸调整但不同框架的策略不完全一样而且你无法控制细节。处理后的数据就干净多了一万张图全部是224x224通道统一变成灰度单通道像素值全部落在0到255的整数区间txt文件里的内容可以直接读入numpy做标准化。模型的训练稳定性明显比之前直接用乱尺寸数据要好损失曲线收敛更平滑没有因为个别超大尺寸图片拖慢训练batch的情况。6.3 处理脚本的可复用性设计这套脚本我写成函数后会存为一个模板后续不同项目只改三个参数输入目录、输出目录、类别前缀。核心处理函数不需要变。真的预处理代码最值钱的部分不是那几行cv2调用而是整个流程的顺序安排、异常处理、路径兼容这些细节。建议大家自己整理一个预处理模板类把读图、resize、保存、写txt分别做成独立方法而不是写完一次就扔。我还会在脚本开头加一个配置字典config { raw_dir: ./raw_images, img_save_dir: ./processed_images, txt_save_dir: ./output_txt, class_prefix: flower, target_size: (224, 224), is_gray: True, normalize: True, save_format: jpg, }之后换个项目只需要改这个字典代码部分完全不用动。7. 避坑指南与个人实战经验7.1 不要修改原始数据这句话我说过很多遍但是每次都有新朋友犯错。预处理脚本一定要把“读入原始目录、输出到新目录”作为铁律。原始数据就是你的唯一证据和退路你把唯一的一份原始数据覆盖了后续想调整任何参数都回不去了。我强烈建议把原始图片放到只读目录或者压缩备份一份尤其在批量处理几千张以上的场景。7.2 每次只改一个变量批量处理实验时如果你同时改了resize尺寸、归一化方式、插值算法、txt写入格式出问题了很难倒推是哪个改动导致的结果异常。我的习惯是一次只改一个配置项然后跑一个小样本比如100张图验证输出确认没问题再上全量。7.3 写一个可视化检查工具如果你在流水线跑完后不看看处理结果等到训练时发现数据有问题回过头来排查成本很高。我会随手写一个二三十行的检查脚本随机抽九张处理后的图片用matplotlib拼一个3x3的网格同时打印出对应txt的前几行内容人工过目一眼。这一个小习惯挽救了我不少次。import matplotlib.pyplot as plt import random sample_files random.sample(all_output_images, 9) fig, axes plt.subplots(3, 3, figsize(9, 9)) for ax, file_path in zip(axes.flatten(), sample_files): img cv2.imread(file_path, cv2.IMREAD_GRAYSCALE) ax.imshow(img, cmapgray) ax.axis(off) plt.show()7.4 后期扩展方向多线程读取、视频帧抽取与其他这套流水线的扩展性很好。比如标题里虽然写的是“图片预处理”但如果你录了一段视频想要做成数据集只需要把cv2.VideoCapture按帧抽取的代码接到预处理函数之前抽出帧之后直接走这条管道。再比如你要做数据增强在resize之后、保存之前插入随机旋转、随机裁剪、色彩抖动这些操作整个流水线的架构完全不用变。归一化之后的数值也可以根据需要转成其他格式比如输出为.npy或者直接输入到TensorFlow/PyTorch的dataset构造器。我个人理解的这套流程的核心价值在于它是“数据工程”里的一个标准工序工序定义清楚了换场景只是换参数。最后分享一个小技巧如果处理的图片量特别大而且你确认后续不会再改参数建议把中间过程的灰度图和彩色图选一种保存即可不必都存。txt数据其实在灰度图保存时已经有了冗余信息但标题需求里要求写入txt就按需求来。真正做项目时存档环节不必贪多够用就行。