只要在 Python 里折腾过图像处理,早晚都会碰见这三种格式打架的情况:直接读进 Numpy 的数组、OpenCV 读出来的 BGR 图、PIL 打开的对象。尤其是当你既想用 OpenCV 做形态学处理,又想用 PIL 去生成缩略图,或者想拿 Matplotlib 显示一下中间结果的时候,格式之间的转换就成了绕不过去的第一步。这期就来把 Numpy、OpenCV 和 PIL.Image 三者之间的转换彻底讲清楚,包括常见的 BGR/RGB 通道顺序这个坑、dtype 和形状的隐性要求、以及哪些转换只是改了“看法”、哪些转换真的在“拷贝数据”。
这些内容是学习 OpenCV 的第一道门槛,也是用 Python 做任何视觉任务的基石。无论你是刚入门的初学者,还是已经实际做过几个项目的开发者,把这里的每个转换原理看清楚,能帮你少踩非常多隐形的雷。接下来我会从三者的底层数据关系讲起,再给出完整的转换清单、代码示例、常见错误的排查思路,最后附上我在实际项目中踩过的坑和总结出来的经验。
1. 先把底子打牢:Numpy、OpenCV 和 PIL 的本质关系
1.1 三种格式到底谁依赖谁
这个问题看着基础,但里面藏着一个关键认知:OpenCV 的图像对象和你用 Numpy 存的数组,本质上就是同一个东西。
OpenCV 的 cv2.Mat 在 Python 里已经直接映射成了 Numpy 的 ndarray。也就是说,你用cv2.imread()读一张图,拿到的对象类型就是numpy.ndarray,它的形状是(height, width, channels),所以 OpenCV 和 Numpy 之间是“零成本”的互操作关系。但 PIL 不一样,PIL.Image 是一个完整的对象,它内部有自己的一套数据管理方式,虽然底层最后还是像素值,但它必然有一个把像素数据“导入/导出”到 Numpy 数组的过程。
为了便于理解,可以想象成 OpenCV 直接生在 Numpy 的地基上,而 PIL 是一座独立的房子,想和 Numpy 数据互通,就必须经过“进出门口”的转换函数。
1.2 通道顺序:BGR 和 RGB 的爱恨纠葛
这个坑几乎是所有初学者都会踩的。OpenCV 读出来的图片通道顺序是 BGR,也就是蓝色、绿色、红色。而 PIL 读出来的顺序是 RGB,红色、绿色、蓝色。Numpy 数组本身是没有“通道顺序”这个概念的,它只是一堆数字,顺序完全取决于上面的图是怎么写进去的。
如果你把 OpenCV 的数组直接交给 PIL 去显示,或者反过来,图像看起来就会像是“通道被拆开又错位”了一样,整体颜色发蓝或发红,非常刺眼。所以凡是涉及 OpenCV 和 PIL 之间的转换,几乎总要加一步cv2.cvtColor(img, cv2.COLOR_BGR2RGB)或者cv2.COLOR_RGB2BGR。
注意:
cv2.cvtColor会生成一份新的数组,而不是原地修改,这在某些内存敏感的场景下值得留意,不过对于一般使用来说,多一份拷贝不算大事。
1.3 dtype 和形状的隐性约束
Numpy 作为中间桥梁,有两条硬性规则:
- 图像数组的 dtype 通常是
uint8,也就是 0 到 255 的整数。如果你的数组是 float 类型,OpenCV 很多函数会用 0.0 到 255.0 的范围来解读,而不是 0.0 到 1.0,这会让结果完全变样。 - PIL 的
fromarray对数组的形状和 dtype 要求比较严格:灰度图需要是(height, width),RGB 图需要是(height, width, 3),RGBA 图需要是(height, width, 4),并且 dtype 通常只能是uint8。不符合条件时,fromarray会直接报错或者生成格式不正确的图。
这两条规则虽然看着像常识,但真正在写转换函数时,很多人都会忽略,导致结果莫名其妙。
2. 核心转换全景:一个表看懂所有常见转换
在展示代码之前,我先把常用转换整理成一个速查表,操作过程中直接对着查就行,非常省心。
| 转换目标 | 操作方式 | 是否产生新数据 | 备注 |
|---|---|---|---|
| Numpy -> OpenCV | 直接用就行 | 否(共享内存) | 前提是数组形状和 dtype 合法 |
| OpenCV -> Numpy | 直接用就行 | 否(共享内存) | 它就是 ndarray |
| Numpy -> PIL | Image.fromarray(arr) | 是 | 通常需要先确认 dtype 是 uint8 |
| PIL -> Numpy | np.array(img) | 是 | 标准做法,会拷贝像素数据 |
| OpenCV -> PIL | Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) | 是 | 必须先转换通道顺序 |
| PIL -> OpenCV | cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) | 是 | 先转数组,再转通道 |
| PIL -> Numpy | np.asarray(img) | 否(某些情况) | 推荐np.array(img)更安全,避免视图的坑 |
这里特别说明一下“是否产生新数据”这一列。OpenCV 和 Numpy 之间共享内存,意味着你修改数组的像素值,再拿 OpenCV 去imshow,看到的会是修改后的内容,不需要任何额外赋值。这个特性有时候是非常方便的,但也会造成隐蔽的 bug,比如你以为操作的是副本,结果却改了原图。
3. 具体代码实现:每一条转换路径都给你写明白
3.1 OpenCV 转 Numpy:什么都不用做
这大概是最省心的一条路。直接看代码:
import cv2 import numpy as np img_cv = cv2.imread('example.jpg') print(type(img_cv)) # <class 'numpy.ndarray'> print(img_cv.shape) # (height, width, 3) print(img_cv.dtype) # uint8 # 直接当作 numpy 数组使用 arr = img_cv arr[0, 0] = [0, 0, 0] # 修改数组,就是修改了 img_cv print(img_cv[0, 0]) # [0 0 0],证明是同一块内存所有 OpenCV 返回的图像对象,包括cv2.imread()的结果、cv2.cvtColor()的结果、cv2.filter2D()等等,全部都是 ndarray。因此 OpenCV 与 Numpy 的转换其实不需要任何中间步骤,唯一的边界条件在于:你的数组至少得是二维,而且 dtype 和通道数要符合相关函数的要求。
3.2 Numpy 转 OpenCV:同样简单,但注意形状
Numpy 数组转成 OpenCV 可以直接传进cv2.imshow()或者各种处理函数,前提是这个数组本身符合图像的要求。
import numpy as np import cv2 # 生成一张 256x256 的纯蓝色图像(BGR) arr = np.zeros((256, 256, 3), dtype=np.uint8) arr[:, :] = (255, 0, 0) # 注意,OpenCV 中 (255, 0, 0) 表示蓝色 # 直接用于显示 cv2.imshow('blue image', arr) cv2.waitKey(0) cv2.destroyAllWindows()但有一个很关键的提示:如果你的数组维度是二维(比如灰度图),那你得考虑目标 API 是接受二维数组还是要求变成三维。很多 OpenCV 函数对二维数组也能处理,但像cv2.imshow对灰度图就是按照二维处理的,所以你不需要强行增加通道维度。然而有些深度学习框架或自定义函数会强依赖(h, w, 1)这种三维形式,这就得靠np.expand_dims(arr, axis=-1)来扩展维度。
3.3 Numpy 转 PIL.Image:注意 fromarray 的限制
Image.fromarray是 Numpy 转 PIL 的核心入口。下面是一个完整的示例:
import numpy as np from PIL import Image # 生成一个 200x200 的渐变图 x = np.linspace(0, 255, 200, dtype=np.uint8) arr = np.tile(x, (200, 1)) # 形状 (200, 200),灰度图 pil_img = Image.fromarray(arr, mode='L') pil_img.save('gradient.png') # 生成一张 RGB 随机图 arr_rgb = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) pil_rgb = Image.fromarray(arr_rgb, mode='RGB') pil_rgb.show()这里有几个细节值得展开:
mode='L'表示 8 位灰度模式,如果你想从灰度数组生成 RGB 图,那需要先把数组扩展成三维,再用mode='RGB'。强行把二维数组转成 RGB 是不行的。- 数组的 dtype 最好是
uint8。如果数组是float32,Image.fromarray有时能“摸黑”转换,但应用范围比较有限,很多情况下需要你手动astype(np.uint8)。经我实测,float32在 0~1 范围内的数组直接传给fromarray会生成一张全黑图,因为 PIL 把 0.x 当成了小数像素,几乎全等于 0。这个坑极其隐蔽。 - 数组的 C 连续性(内存连续)也是个潜在因素。Numpy 的切片操作有时会生成非连续的数组,
Image.fromarray在内部会自动强制复制,因此多数场景不会报错,但如果你频繁做这类转换,还是提前np.ascontiguousarray(arr)更利落。
3.4 PIL.Image 转 Numpy:用 np.array 而不是直接用手动遍历
PIL 转 Numpy 的标准写法是:
import numpy as np from PIL import Image pil_img = Image.open('example.jpg') arr = np.array(pil_img) print(arr.shape) # (height, width, 3) 或 (height, width) print(arr.dtype) # uint8对这幅数组的修改,不会影响原本的pil_img,因为它是一次拷贝。如果你出于节约内存的考虑想拿到一个“视图”,可以使用np.asarray(pil_img),但这样做风险很大。我曾做过实验:
arr_view = np.asarray(pil_img) arr_view[0, 0] = 255 pil_img.save('changed.png')在某些 PIL 版本和图像模式下,这个arr_view确实会直接修改原 PIL 对象内部的 buffer,导致原来的图片被改写;在另一些版本里则不会,行为取决于 PIL 是否返回了可写缓冲区。所以为了避免这种不确定性,推荐一律用np.array()。
如果你用np.array()得到了想要的数组,之后就随便切片、调节对比度、做数据增强,不必担心影响原图。
3.5 OpenCV 转 PIL.Image:通道互换是必须的
这大概是使用频率最高,也最容易出错的转换场景。如果你直接Image.fromarray(cv2_img),会得到一张红蓝通道互换的图片,看起来整个画面像被蒙了一层诡异的色调。
正确写法:
import cv2 import numpy as np 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) pil_img.show()这里的原理再深化一遍:cvtColor就是把每个像素的三个通道重新排列了一遍,并没有改变像素的个数和色彩信息,只是改变了“谁在哪个位置”。所以当你把 BGR 数据当成 RGB 给 PIL 时,PIL 会用红色通道读取原本的蓝色通道值,用蓝色通道读取原本的红色通道值,于是画面失真。
如果不想复制一份再转,也可以直接用np.flip(img_bgr, axis=2)做通道翻转,这个操作会生成视图而不是逐像素拷贝,速度快一点。但在可读性上不如cvtColor,而且有些老代码里还会有别的问题,所以我一般还是建议cvtColor。
3.6 PIL.Image 转 OpenCV:反过来同样要处理通道顺序
这条路线是上面路线的逆向走法:
import cv2 import numpy as np from PIL import Image pil_img = Image.open('example.jpg') arr_rgb = np.array(pil_img) # 先转成 numpy,rgb arr_bgr = cv2.cvtColor(arr_rgb, cv2.COLOR_RGB2BGR) # 现在所有 OpenCV 函数都能处理这个数组了 gray = cv2.cvtColor(arr_bgr, cv2.COLOR_BGR2GRAY)有些精简写法会先np.array(pil_img)[:, :, ::-1]来实现通道翻转,效果与cvtColor一样,但要注意[::-1]会创建一个数组视图,后续对它做通道变换时要小心是否产生不可预期的干扰。用cvtColor更稳,多产生一份拷贝在绝大多数场景下换来的都是省心和安全。
4. 再深入一步:转换背后的内存机制与性能细节
4.1 共享内存 vs 拷贝:什么时候必须小心
第一阶段已经提过,OpenCV 和 Numpy 共享内存。这意味着如果你从网络读入一个缓冲区,先转成 Numpy 数组,再直接丢给 OpenCV,这个过程里零拷贝,效率极高。
PIL 转 Numpy 是拷贝的,Numpy 转 PIL 也是拷贝的。对于中等尺寸的图(比如 1920x1080 的 BGR),一次转换大约要拷贝 6 MB 数据,在现代电脑上也就是几十毫秒的事,通常不是瓶颈。但如果你在一个实时视频循环里每帧都要做这个转换,60 fps 下每秒就是 600 MB 的内存搬运,这就可能增加 CPU 负载,甚至影响帧率。这时候优化思路通常是:减少不必要的 PIL 交互,直接在 Numpy/OpenCV 空间里完成所有处理,最后输出时才用 PIL。
4.2 连续内存的影响
Numpy 在处理图像时,很多底层算法假设数组是 C 连续(即按行优先的方式存储所有元素)的。如果用切片从原图上裁出一块 ROI,得到的往往是非连续数组。
import numpy as np large_arr = np.zeros((1920, 1080, 3), dtype=np.uint8) roi = large_arr[100:200, 200:300] # 这是一个 view,内存不是连续块 print(roi.flags['C_CONTIGUOUS']) # 可能是 False当你想把这种roi传给 PIL 的fromarray时,PIL 内部会强制先转成连续数组,所以最终结果没问题,但会额外引发一次内存拷贝。如果你在循环里反复做这种操作,最好先手动np.ascontiguousarray(roi),让代码意图更明确,也避免重复触发内部的隐式拷贝。
4.3 大量图像批量转换时的性能经验
我做数据集预处理时,需要把上千张图片从 PIL 形式转成 OpenCV 处理后再转回 PIL,一开始直接循环写,跑得很慢。后来做了两个优化:
- 一次性读成 Numpy 数组,分批处理,而不是一张张单独转换。
- 使用
np.ascontiguousarray()尽量保证输入是连续的。
批量预处理的伪代码如下:
from PIL import Image import numpy as np import cv2 paths = [...] # 几百个文件 arrays = [] for p in paths: img = Image.open(p).convert('RGB') arr = np.array(img) arr_bgr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) # 做一些 OpenCV 操作 processed = cv2.GaussianBlur(arr_bgr, (3, 3), 0) # 再存回 PIL pil_out = Image.fromarray(cv2.cvtColor(processed, cv2.COLOR_BGR2RGB)) arrays.append(np.array(pil_out))这种思路比反复调Image.open()然后在 PIL 和 OpenCV 之间来回跳要高效得多,而且代码更清晰。
5. 常见问题与排查技巧实录
5.1 转换后颜色不对:蓝红莫名互换
这种问题 90% 出在 OpenCV 与 PIL 直接互转但漏了cvtColor。排查方法也很简单,取图像里某个像素的 RGB/BGR 值,打印出来看看是符合预期。
最常见的错误写法:
# 错 pil_img = Image.fromarray(cv2_img) # 对 pil_img = Image.fromarray(cv2.cvtColor(cv2_img, cv2.COLOR_BGR2RGB))同理,PIL 转 OpenCV 也要先转成 BGR。
5.2 图片黑屏或全白:dtype 错了
一个典型的场景是,你从一个深度学习模型拿到float32的数组,范围在 0 到 1 之间,想保存成 PNG。如果直接Image.fromarray(arr_float),生成的图片基本是纯黑的。
import numpy as np from PIL import Image # 错误示例 arr_float = np.random.rand(100, 100, 3).astype(np.float32) img_wrong = Image.fromarray(arr_float) # 几乎全黑 # 正确示例 arr_uint8 = (arr_float * 255).astype(np.uint8) img_right = Image.fromarray(arr_uint8, mode='RGB')记住:PIL 的图像数据对 uint8 的依赖非常强,几乎所有常用模式都是基于 8 位整数的。要做归一化的话,要在进入 PIL 之前把数值乘到 0~255 区间,并转成 uint8。
5.3 保存时提示“cannot write mode P”之类
当你用Image.fromarray()从一个标记为调色板模式(P 模式)或带透明通道的数组转出来时,有时会出现保存格式不受支持的报错。我之前遇到过一个很恼火的情况:从某 PNG 抠图得到 RGBA 数组,然后想直接保存成 JPEG,结果报错,因为 JPEG 不支持透明通道。
处理办法:
img_rgba = Image.open('rgba.png') img_rgb = img_rgba.convert('RGB') img_rgb.save('new.jpg')或者如果你坚持要在 Numpy 里操作,就先把通道从 4 降到 3。
5.4 尺寸变了:所有转换都不会改变尺寸
如果你在转换过程中发现图像的宽、高发生了变化,那一定不是转换函数的问题,而是你用错了别的操作,比如某个 resize。Numpy 数组本身是基于行列组织的,从(h, w, c)的角度看,宽对应的是shape[1],高对应的是shape[0],这个顺序非常简单。但在 PIL 里,Image.size返回的是(width, height),也就是(w, h),和 Numpy 的 shape 顺序是反的。这个也会导致你在循环里处理时把宽高搞反,然后图像看起来像是旋转了 90 度。
arr = np.array(pil_img) h, w = arr.shape[:2] pil_w, pil_h = pil_img.size # 注意顺序区别 assert h == pil_h and w == pil_w5.5 用 Matplotlib 显示 OpenCV 图像出现红蓝色调
这个不算转换问题,但也经常被拿来一起问。如果你直接用plt.imshow(cv2_img),因为 Matplotlib 期望 RGB,而你的 OpenCV 数组是 BGR,所以颜色会错乱。解决办法:
plt.imshow(cv2.cvtColor(cv2_img, cv2.COLOR_BGR2RGB))或者直接plt.imshow(cv2_img[:, :, ::-1]),这两者都行。我在写快速调试代码时,通常直接用第二种,因为少打几个字。
5.6 批量转换中内存持续增长
当你循环里每次都np.array(pil_img)或者Image.fromarray(arr)时,如果保存了不必要的中间引用,Numpy 的分配器可能会让内存看起来涨得厉害。排查方法:
- 用
gc.collect()看是否能回收。 - 检查循环里是否把中间数组加入了一个 list 但忘了清理。
- 尽量使用局部变量,让函数作用域结束后自动释放。
一个常见的错误是,在循环外定义一个空的images = [],然后不断append转换后的结果,但如果后续不再需要所有图像,就要么限制长度,要么直接把处理逻辑放进循环里。
6. 实操经验与避坑心得
6.1 我的固定工具函数
在实际项目里,我习惯封装几个简单的工具函数,统一处理不同来源的图像格式:
import cv2 import numpy as np from PIL import Image def pil_to_cv(pil_img): return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR) def cv_to_pil(cv_img): return Image.fromarray(cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB)) def pil_to_np(pil_img, mode='RGB'): if pil_img.mode != mode: pil_img = pil_img.convert(mode) return np.array(pil_img) def np_to_pil(arr): if arr.dtype != np.uint8: arr = arr.astype(np.uint8) if arr.max() <= 255 else (arr * 255).astype(np.uint8) return Image.fromarray(arr)这几个函数短小简洁,能覆盖我 90% 的日常转换需求。你自己也可以在此基础上增加断言,比如检查数组形状是否为二维或三维,检查通道数是否为 1 / 3 / 4,这样能在开发阶段快速暴露错误。
6.2 在框架中如何选择读取库
你可能会问:既然 OpenCV 的imread和 PIL 的Image.open都能读图,我该用哪个?
我的习惯是:
- 如果要做图像处理算法,OpenCV 优先,强大且快。
- 如果要快速加载大量图片并做简单预处理,PIL 配合 Numpy 也很方便。
- 如果要用深度学习框架(如 PyTorch 的 Transform),PIL 更顺手,因为很多预置的数据增强都是基于 PIL 对象的。
但无论选择哪种,核心转换逻辑都是一模一样的。这也正是为什么把格式转换吃透之后,整个图像处理流程就不会再被“格式不对”卡住。
6.3 实战里最容易忽略的 dtype 陷阱
我在某次做机器学习数据集的探索性实验时,想把经过归一化的 float 数组保存成 PNG 当预览图,结果出来的图全是一团黑影。我排查了半天,才发现问题是np.clip(arr, 0, 255)之后数组还是 float 类型,像素值精度虽然没错,但 PIL 对它做了自己的解释,把大部分值都当成接近 0 来对待了。后来我把代码改成np.clip(arr, 0, 255).astype(np.uint8),图像立刻恢复正常。
这看似是个小坑,可一旦数据集规模大了,这种问题会浪费你大半天时间。我后来给这个逻辑加了一个“三步走”:先检查范围、再检查 dtype、最后检查连续性和通道数,流程化之后基本不会再犯错。
6.4 小心保存到文件时的模式问题
有一种场景是,图片经过一系列操作后得到的数组是三通道的,但原始图像其实是带透明通道的 RGBA。你直接用Image.fromarray(arr_rgb)保存成 PNG 会丢失 alpha 信息,在某些业务上可能没事,但如果你是在做表情包、贴纸这类透明素材的自动化处理,就会很难受。
处理方案:
arr_rgba = np.dstack([arr_rgb, alpha_channel]) # alpha_channel 是 (h, w) 的 numpy 数组 img_rgba = Image.fromarray(arr_rgba, mode='RGBA') img_rgba.save('output.png')注意np.dstack需要两个数组的宽高一致,alpha_channel 的值范围也要在 0~255 之间。
6.5 最后分享一个调试技巧
如果你搞不清楚一张图当前的颜色通道,可以直接取一个像素打印:
px = img_arr[100, 100] print(px) # [B, G, R] 还是 [R, G, B]拿一张纯红图像做测试最好使:OpenCV 读出来的纯红图,像素值会是[0, 0, 255],PIL 读出来的则是[255, 0, 0]。这么一验证,你就知道当前处理的是哪个空间了。
我在接手任何图像相关的老项目时,第一件事永远是确认项目中读图用的是哪个库、后续处理是否混用了另一个库的接口。一旦发现混用,立即以标准转换规范为基准去审查代码,通常能快速找到隐藏的颜色错乱 bug。
7. 扩展:其他常见图像库的格式接轨
经常会有人说,光会 Numpy、OpenCV、PIL 还不够,实际项目中还会遇到 Matplotlib、scikit-image、TorchVision 乃至一些硬件 SDK 输出的图像。其实这些东西的底层大多也是 Numpy 数组,因此只要你掌握了一致的转换思路,再对接任何新库都很容易。
比如 scikit-image 读取的图片是 RGB 数组,和 OpenCV 的区别就在通道顺序;Matplotlib 显示图片要求 RGB,所以传给plt.imshow之前,OpenCV 图像必须先做 BGR 转 RGB;PyTorch 的 Tensor 数据则经常是(C, H, W)布局,你从 Numpy 的(H, W, C)转到 Tensor 时需要用.permute(2, 0, 1)调整维度顺序,这些本质上都是同一个问题的变体。
所以说,Numpy 是图像数据的公共语言,OpenCV 和 PIL 只是上层接口。掌握了格式转换的核心逻辑后,任何新库对你来说都只是换了个“外壳”而已。
在我自己的项目里,最后形成的最有用的习惯是:所有图像在进入项目主流程时,统一转成 Numpy 数组并明确定义通道顺序;所有图像在输出到外部时,再按接收方的要求转换成 PIL 或 OpenCV 格式。这样一来,中间的每一步都是清晰的数组操作,不会再被各种库的隐式行为干扰。这也是这篇关于格式转换的总结最想传递给大家的核心思想。