1. 图像格式互转这件事,值得单独聊聊
写图像相关的代码,绕不开这三样东西:numpy、OpenCV(cv2)、PIL(Pillow)。做深度学习预处理、写点图像处理脚本、给数据集做清洗,天天在这三者之间来回倒腾。很多人卡在最开始的一步:用PIL打开图片,想用OpenCV处理,结果报错;或者用OpenCV读一张图,转成PIL保存,发现颜色偏了。搞来搞去,就是这三者的图像数据格式没对齐,转换方式也没吃透。
这篇文章就把Numpy、OpenCV、PIL三者的图像数据格式摸清楚,把各种转换路径一条一条写出来,附带代码、踩坑点、以及我的个人经验。适合谁看?刚接触图像处理的同学,被cv2.imread和PIL.Image.open折腾得头疼的开发者,以及写了很久代码但没认真梳理过转换细节的朋友。
2. 三种格式的本质差异,先闹明白才能不踩坑
2.1 Numpy数组图像:本质是一堆数字
用Numpy表示图像,就是把图像看成多维数组。常见的是三维数组,形状为(H, W, C),其中H是高(height),W是宽(width),C是通道数(channel),彩色图像一般是3(RGB或BGR),灰度图是二维数组(H, W),或者也可以让它保持三维(H, W, 1)但一般不这么干。
你可能会问,为什么图像能直接用Numpy表示?因为图像本质就是像素值矩阵,每一个点的颜色就是该位置通道的数值组合。shape决定尺寸,dtype决定精度,Numpy本身不关心这堆数字到底是图片还是别的数据。这一点很关键,因为它意味着转换的底层逻辑其实就一件事:把图像数据重新组织成目标库期望的数组排列。
2.2 OpenCV的图像:就是Numpy数组换了件马甲
OpenCV读图用cv2.imread,返回的类型是numpy.ndarray。这一点很多人会忽略,以为OpenCV有自己的图像对象。它没有。cv2.imread之后拿到的就是Numpy数组。但注意,通道顺序是BGR而不是RGB,dtype通常是uint8,三条通道存在第三个维度上。
正因为通道顺序是BGR,直接用PIL或者matplotlib显示cv2.imread读进来的图,颜色一定会错乱——红色变成蓝色,整个画面发青。这不是bug,是历史遗留设计。早年间OpenCV选用BGR作为默认通道顺序,于是这个反直觉的选择传承了下来。你不需要去评价它合理不合理,记住这个feature,写代码时主动修正就行。
2.3 PIL图像:有自己的对象,但能透出像素数据
PIL(Pillow)的图像类型是PIL.Image.Image,它不是一个纯数组结构,而是一个包含图片元数据、调色板、模式等信息的对象。它的一个特点是通道顺序是标准的RGB,这一点符合直觉,也让它和OpenCV的BGR天然无法直接兼容。
PIL本身也提供访问像素的接口,比如getpixel或者加载后转成Numpy再操作。但真实项目中,大家普遍的做法是先把PIL对象转成Numpy数组,再做后续处理,因为直接逐像素访问PIL效率不高。反过来,Numpy数组转PIL也非常容易,Image.fromarray一行完成。
2.4 三者之间的坐标系与通道顺序对照
| 库 | 数据类型 | 默认通道顺序 | 灰度图表示 | 常用读取方式 |
|---|---|---|---|---|
| Numpy | ndarray | 不关心,由数据决定 | (H, W) | 自己生成或来自其他库 |
| OpenCV | numpy.ndarray | BGR | (H, W) | cv2.imread |
| PIL/Pillow | PIL.Image.Image | RGB | 模式L | Image.open |
这张表看起来简单,但暗含了大多数转换问题的根源。通道顺序不一致是最大坑,其次是数据类型、尺寸、模式差异。下面具体讲转换方法,把每一条路径都写清楚。
3. 六条转换路径,按需取用
3.1 Numpy数组转PIL Image
把Numpy数组变成PIL图片,核心API就一个:
from PIL import Image import numpy as np # 假设img_arr是形状为 (H, W, 3) 的uint8数组,通道顺序为RGB pil_img = Image.fromarray(img_arr)这个方法看起来简单,但有两个关键点必须说透。
第一,dtype必须是无符号8位整数(uint8)。PIL的图像按模式区分,每通道8位的图像是常用的模式,对应像素值0到255。如果你的数组是float32或者int,fromarray会报错或者生成不可预料的图像。错误信息大致是“cannot convert float NaN to integer”之类,原因就是数据类型不匹配。
第二,通道顺序必须是RGB。前面说过,PIL默认认为第三维度的顺序是R、G、B。如果你把一个BGR数组直接传给fromarray,得到图像的红色和蓝色通道会对调,人眼看上去蓝得不正常。
所以稳妥做法是先保证数组的通道顺序正确。如果你是从OpenCV得到的Numpy数组,先转换通道顺序再给PIL:
import cv2 from PIL import Image import numpy as np img_bgr = cv2.imread('example.jpg') img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(img_rgb)如果手头是灰度图,二维数组直接转换即可:
gray_arr = np.array(pil_gray_img) # shape (H, W) pil_gray = Image.fromarray(gray_arr, mode='L')这里mode='L'表示灰度模式。如果忘记指定模式,PIL会自动判断,但最好显式声明,尤其当你的数组值范围是0到255而形状是二维时,不加模式有时会被识别为调色板模式。
3.2 Numpy数组转OpenCV图像
Numpy数组转OpenCV图像,本质上就是让OpenCV接收一个Numpy数组,因为OpenCV的图像对象本身是numpy.ndarray。常见做法分两步:先确认数组的dtype和形状,再决定要不要转通道。
如果数组本身就是uint8、形状为(H, W, 3),并且顺序为BGR,那可以直接认为它已经是OpenCV可用的图像,不需要额外转换:
import cv2 # img_arr 是 BGR 顺序的 uint8 数组 cv2.imwrite('output.jpg', img_arr)但绝大多数情况下,Numpy数组的通道顺序是从PIL或者matplotlib那边来的,是RGB。此时你要先转成BGR再交给OpenCV:
img_rgb = np.array(pil_img) # 从PIL得到的RGB数组 img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR) cv2.imwrite('output.jpg', img_bgr)通道顺序是唯一的隐形杀手。另一个容易忽略的点是,dtype必须是uint8。许多人把图像数据归一化到0到1范围的float32,然后直接想保存成图片,结果OpenCV报错或者保存出全黑图像。正确的做法是在保存前反归一化回0到255,并转换dtype:
img_float = img_arr.astype(np.float32) / 255.0 # 处理后 img_uint8 = (img_float * 255).astype(np.uint8) cv2.imwrite('output.jpg', img_uint8)顺便提一句,cv2.imwrite对图片保存格式由扩展名决定,写.jpg就是高清压缩方案,写.png是无损保存。想让图片体积小的,可以调cv2.IMWRITE_JPEG_QUALITY参数,取值为0到100:
cv2.imwrite('compressed.jpg', img_bgr, [cv2.IMWRITE_JPEG_QUALITY, 85])3.3 PIL Image转Numpy数组
PIL转Numpy是转换链条的起始点,也是很多人第一次接触图像数据时会用到的入口。最简单的写法:
import numpy as np from PIL import Image pil_img = Image.open('example.jpg') img_arr = np.array(pil_img)有一个细节要说明:np.array(pil_img)的返回数据类型和形状会根据PIL图像模式变化。RGB模式的图返回的是(H, W, 3)的uint8数组;灰度模式返回的是(H, W)的uint8数组;支持透明通道的RGBA模式返回的是(H, W, 4),最后一个维度是Alpha。所以不要假设数组一定是三维,在写下游代码之前,建议打印shape和dtype确认一下。
另外还有一个小坑:np.array(pil_img)到底是复制还是引用?答案是它通常会产生一个副本。PIL图像对象背后的像素数据和这个Numpy数组不共享内存,修改数组不会直接影响原PIL图。但如果数据量特别大,这个复制行为会带来额外内存开销。如果内存吃紧,可以用img_arr = np.asarray(pil_img),它尽量不复制数据,但实际效果受PIL内部实现细节影响,不能依赖。我自己的做法是:小图直接用np.array,大图或者批量处理时预分配好存储,避免反复转换带来的性能损耗。
3.4 PIL Image转OpenCV图像
这条路径最常用,但也最容易被坑。PIL的图像是RGB,OpenCV要的图像是BGR,不能直接转,必须经过Numpy做中间桥梁:
import cv2 import numpy as np from PIL import Image pil_img = Image.open('example.jpg') img_rgb = np.array(pil_img) img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)这三行代码实际上完成了两件事:先把PIL对象变成Numpy数组,再用cv2.cvtColor交换红蓝通道。三步看起来啰嗦,但每一步都不可省略。如果你忘记最后一步,直接cv2.imshow显示,画面颜色会明显失真。
需要注意批量处理时候的代码复用。可以把这段封装成一个函数:
def pil_to_cv2(pil_img): return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)以后处理批量图片,一行调用就行。这个封装也方便你在通道顺序策略变化时统一修改,不至于在项目里翻来覆去地找转换代码。
3.5 OpenCV图像转Numpy数组
严格来说,OpenCV图像本身已经是Numpy数组,所以“转成Numpy数组”不需要任何代码。你可以直接对cv2.imread的返回值调用任何Numpy操作:
import cv2 img = cv2.imread('example.jpg') # 已经是numpy.ndarray print(type(img)) # <class 'numpy.ndarray'> print(img.shape) # (H, W, 3) print(img.dtype) # uint8有人会问,那为什么还要单独说这条转换路径?因为实际任务中,OpenCV的BGR通道顺序到了Numpy环境里,容易和别的库产生冲突。你如果把cv2.imread的结果直接传给一个期望RGB输入的算法库,颜色就会出错。所以“OpenCV图像转Numpy数组”这个命题的正确解读是:转成符合目标需求的Numpy数组,核心是通道顺序调整。
如果需要RGB顺序的数组:
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)如果需要灰度数组:
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)这两种很常见,参与深度学习训练时,预处理管道中一般用OpenCV读取图像、转灰度或缩放,然后交给模型。
3.6 OpenCV图像转PIL Image
OpenCV转PIL同样需要通道顺序转换,因为没有哪个正派的库会直接接受BGR通道顺序的PIL图像。正确路径是:
import cv2 from PIL import Image img_bgr = cv2.imread('example.jpg') img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(img_rgb)这里Image.fromarray接收的数组是RGB顺序的uint8数组,才能得到正确的PIL图像。转换完成后,PIL对象可以用于缩略图、模拟用户下载逻辑、和matplotlib配合显示等场景。
有一个实际项目里常见的问题:显示图像时有些人喜欢用matplotlib,直接把cv2.imread的结果传给plt.imshow,结果颜色发青。原因一模一样:plt.imshow默认认为传入数组是RGB。解决办法要么是转成RGB再显示,要么显式告诉plt.imshow通道顺序:
import matplotlib.pyplot as plt plt.imshow(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))不过这只是显示问题,和保存文件不同,处理思路本质是一样的。
3.7 所有转换路径汇总表
为了方便快速查阅,我把六条路径汇总成一张表,建议收藏备用:
| 转换目标 | 代码路径 | 注意点 |
|---|---|---|
| Numpy → PIL | Image.fromarray(arr) | arr必须是uint8,通道顺序为RGB |
| Numpy → OpenCV | 若需BGR则cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) | 确认arr的dtype是否uint8 |
| PIL → Numpy | np.array(pil_img) | 新图像是复制数据,改数组不影响原图 |
| PIL → OpenCV | np.array(pil_img)+cv2.cvtColor(..., COLOR_RGB2BGR) | 两步不可省,别忘通道顺序 |
| OpenCV → Numpy | 无需转换,已是ndarray | 调整通道顺序由后续需求决定 |
| OpenCV → PIL | cv2.cvtColor(img, COLOR_BGR2RGB)+Image.fromarray(...) | 先转RGB,再创建PIL图 |
这张表只看的话很简单,但代码里的坑往往藏在那些“看似理所当然”的步骤里。
4. 转换中的隐藏细节与底层逻辑
4.1 不同模式带来的维度变化
PIL图像有各种模式,常见的是RGB、RGBA、L(灰度)、P(调色板)、CMYK、I;16等。P模式非常容易踩坑,因为np.array(pil_img)后拿到的可能不是原始的RGB像素值,而是调色板的索引值,直接做颜色处理和保存都会出错。经验是,碰到P模式图像先转成RGB:
pil_img = Image.open('example.png').convert('RGB')再看RGBA模式。这种图有四个通道,多出来的Alpha通道表示透明度。如果你转成Numpy数组后直接保存成JPG,JPG不支持透明度,要么保存失败,要么输出奇怪的结果。正确的策略是确定你后面的处理是否需要透明度,如果需要就用PNG格式保存,不需要就删除Alpha通道:
img_rgba = np.array(pil_rgba) img_rgb = img_rgba[:, :, :3] # 去掉透明度通道还有灰度模式L,转数组后shape是二维的(H, W)。这会导致一些依赖三维数组的代码报错,比如卷积操作里预定义输入维度是3。处理方式是用np.expand_dims(gray, axis=-1)增加一个维度,让它变成(H, W, 1)。
4.2 数据类型的连锁影响
uint8是使用最多的图像数据格式,范围0到255,好处是内存占用小、符合各图像库的默认要求。但在深度学习、图像处理算法中经常需要浮点数参与计算,因此我们会把uint8转成float32,然后归一化到0到1范围。
这个转换本身很简单,arr.astype(np.float32),接着除以255即可。但反着来的时候很容易出问题:
# 从0-1浮点数组转回整数图像 img_back = (float_arr * 255).astype(np.uint8)如果不做舍入而是直接强制类型转换,1.99会变成1而非2,累积多次转换误差在视觉上可能不明显,但对精度敏感的任务是有害的。稳妥做法是先np.round,再astype:
img_back = np.round(float_arr * 255).astype(np.uint8)还有一个容易被忽略的边界问题:float_arr里面如果存在NaN或者大于1、小于0的值,转换之后表现不可控。所以做归一化处理时,保证输入数据正常,最好先np.clip(arr, 0.0, 1.0)这类的截断操作。
4.3 不同库的显示与保存行为差异
OpenCV的cv2.imshow显示BGR图不要求转换,但cv2.imwrite保存时也是按BGR解释图像。也就是说,带着RGB数组去调用cv2.imwrite一定出错,这是通道顺序问题引发的经典案例。
PIL的save方法要求图像对象是RGB等正确的模式,如果你直接从BGR数组构造PIL图像,保存的图片红蓝通道会交换。这在纯白纯黑图像上不会有明显差异,但一旦图像里有鲜艳色彩,一眼就能看出来。
matplotlib和PIL使用一致的RGB约定,所以plt.imshow显示PIL转Numpy的数组是正常的,显示OpenCV直接读入的数组则颜色错乱。这里做一个很实用的弥补技巧:没有RGB图时,可以用cv2.cvtColor转一下再显示,也可以在plt.imshow的API里直接通过扩展参数指定通道顺序,具体参考所用库版本的支持范围。
5. 实操常见问题与排查经验
5.1 显示图像颜色异常
这是最常见的现象,几乎全是因为通道顺序问题。比如用cv2.imread读图,然后用matplotlib显示,结果人脸直接变成了蓝紫色调。
排查流程:
- 打印数组
shape,确认通道数。 - 把形状中第三个维度拆出来,分别看三个通道的均值。如果第一通道均值小于第三通道,那个异常的画面可能来自BGR数据。
- 用
cv2.cvtColor转换后再显示,对比效果。
这个过程中不要靠肉眼瞎猜,直接用统计量确认通道顺序最可靠。
5.2 转换报错:数组元素类型不支持
用Image.fromarray(arr)报错,或者显示“unknown binary mode”之类的提示,多半是dtype问题。快速修复方案是:
arr = arr.astype(np.uint8)如果是浮点数,先确认数值范围,再做clip和转换。这里补充一个容易忽略的问题:bool数组不能直接转为PIL图像,需要先转成uint8再乘以255。
5.3 保存图片后部分区域发黑或出现噪点
常见原因有两种:一是数组里有NaN或Inf,OpenCV的保存函数遇到这些值会直接崩坏或者生成黑块;二是数值范围不对,比如原本0到1的浮点数据没放大,直接保存。
排查方案是保存前检查数据:
import numpy as np print(np.isnan(arr).sum()) # 看看有没有NaN print(np.isinf(arr).sum()) # 看看有没有无穷大 print(arr.min(), arr.max()) # 看数值范围确认没问题再保存。如果是一个预处理管道,建议在管道最后加一次数值校验,能省掉大半的诡异bug。
5.4 批量转换时性能差,内存爆掉
一次处理几千张图时,每张图都无脑np.array和cv2.cvtColor,性能开销会被放大。我建议的做法是:
第一,预先分配一个Numpy数组保存所有转换结果,避免循环里反复生成临时对象。
第二,如果图像尺寸固定,可以一次性申请一个(N, H, W, C)的大数组,统一填充。
第三,批量处理时注意PIL的读取本身较慢,换成OpenCV读取往往更快。如果必须用PIL读取的文件,可以读取后马上转Numpy,后续所有操作都在数组上进行。
下面是一段批量转换的示例思路:
import cv2 import numpy as np file_list = ['img1.jpg', 'img2.jpg', 'img3.jpg'] N = len(file_list) # 假设所有图尺寸一样 H, W = 1080, 1920 batch = np.zeros((N, H, W, 3), dtype=np.uint8) for i, fname in enumerate(file_list): img = cv2.imread(fname) # BGR # 如果需要RGB,这里就cvtColor batch[i] = img这样批量数据一次成型,后面不管是写文件还是送模型都很方便。
5.5 PIL和OpenCV混合使用时,RGB与BGR经常反过来
这个错误我自己也犯过几次。典型流程是:用PIL打开图,转成Numpy数组,然后调用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)做转换,结果颜色更乱了。原因很简单——你的数组本来就是RGB,却用BGR转RGB的宏去转换,等于又对调了一次。
正确的规律是:Numpy数组的通道顺序是哪一边,交换宏就选哪一边。具体说:
- 如果数组是PIL给的RGB,想交给OpenCV,用
cv2.COLOR_RGB2BGR。 - 如果数组是OpenCV给的BGR,想交给PIL,用
cv2.COLOR_BGR2RGB。
这个顺序很容易记反。我建议写代码时把通道顺序打印出来确认,或者用一两个像素点直观验证:
pixel = img[0, 0, :] print('RGB' if pixel[0] < pixel[2] else 'BGR', pixel)写多了就有直觉,但刚开始不要省这一步。
6. 什么时候该用哪个库,以及实战建议
6.1 三个库各有所长,别只用一个
图像处理开发中,三个库的职责边界其实是清晰的。
PIL轻量好用,在图像读写、格式转换、缩略图生成、简单滤镜方面占优势。它的Image.open对格式支持非常广泛,尤其适合做图片浏览、缩略图这类功能。
OpenCV是重型工具,内置各种几何变换、色彩空间转换、滤波器、特征检测等算法,性能好、生态大。它的cv2.cvtColor、cv2.resize、cv2.warpAffine在传统视觉方案里几乎不可替代。
Numpy则是数据操作的底层语言。图像在它手里变成一个数组,你可以自由做矩阵运算、切片、聚合统计、批量处理等。模型预处理和后处理、数据增强的很多逻辑都落在Numpy层。
三个一起用,才是工程上的正常姿势。PIL管输入、Numpy管数据变换、OpenCV管复杂算法处理,串起来就是一个标准图像处理管线。
6.2 推荐的图像处理管线
下面是我个人常用的一套流程,你可以拿去改造:
- 读取图片用PIL或OpenCV都可以,但尽量统一。如果读取后要交给深度学习框架,建议用PIL读然后转RGB数组,因为大部分框架默认RGB。
- 数据预处理在Numpy上完成,归一化、裁剪、flip这类操作全部用数组操作。
- 需要传统视觉算法时就转成OpenCV格式,调
cvtColor等C++加速的接口。 - 输出保存时,按照目标格式规定好通道顺序。PNG选PIL或OpenCV都行,JPG想要压缩参数控制就用OpenCV。
这样做的好处是代码结构清晰,转换函数集中管理,后续维护不用到处找图像数据到底是多少维、什么通道顺序。
6.3 架构层面的一点忠告
项目早期就建一个img_utils.py或者transforms.py,把PIL转OpenCV、OpenCV转PIL、Numpy转PIL等函数全部汇总。不要图省事,在多个文件里来回写转换代码。没有统一封装的情况下,每个新人闯入项目第一件事就是重新踩一遍通道顺序的坑,然后留下好几套标准不一的转换逻辑。
我的做法是在工具模块里提供显式命名的函数,比如:
def pil2bgr(pil_img): return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR) def pil2rgb(pil_img): return np.array(pil_img) def cv2_to_pil(img_bgr): return Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))命名里直接写清返回的是RGB还是BGR,调用处一目了然。这样处理完后,很多因为格式假设不一致导致的bug会被消灭在源头。写代码时多花两分钟,调试时能省两小时,这笔账怎么算都划算。
7. 经验和最后的压箱底小建议
图像格式转换看起来是个不起眼的小问题,但项目做得越久,越明白这里的坑有多深。我自己在实际操作中的体会是:所有诡异的颜色错乱、类型报错、黑图问题,几乎都能追溯到某一个“理所当然”的假设——假设PIL返回的是BGR、假设np.array是复制、假设OpenCV的数组就是RGB。打破这些假设,打印shape、dtype、通道顺序,问题就解决了一半。
最后分享一个小技巧:如果你经常在多库之间倒腾图像,可以写一个调试专用的辅助函数,把关键信息一次打印出来:
def debug_img_info(name, img): if hasattr(img, 'shape'): print(f"{name}: shape={img.shape}, dtype={img.dtype}, " f"min={img.min()}, max={img.max()}, " f"channel0_mean={img[:,:,0].mean():.2f}, " f"channel2_mean={img[:,:,2].mean():.2f}") else: print(f"{name}: PIL mode={img.mode}, size={img.size}")遇到拿不准的情况,调一下这个函数,立刻知道图像是什么组织形态。久而久之,你对Numpy、OpenCV、PIL三者之间的转换逻辑就会有肌肉记忆,写出来的代码干净、不出错、效率也高。