ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python图像处理库宽高顺序避坑指南:OpenCV、Pillow、PyTorch等形状参数详解

Python图像处理库宽高顺序避坑指南:OpenCV、Pillow、PyTorch等形状参数详解

1. 一个让无数人栽跟头的“小问题”

如果你写过图像处理相关的代码,大概率经历过这样的场景:用 OpenCV 读了一张图,打印img.shape,得到(1080, 1920, 3),然后你理所当然地认为这张图是 1080 宽、1920 高。结果用 Pillow 打开同一张图,img.size返回(1920, 1080),你开始怀疑人生。更别提后面还有 NumPy、PyTorch、TensorFlow、matplotlib 各自为政,有的先宽后高,有的先高后宽,有的干脆把通道数也塞进来搅局。

这个问题看起来微不足道,但它是图像处理领域最经典的“低级错误制造机”。我见过太多项目因为宽高顺序搞反,导致图像被拉伸变形、标注框错位、模型输入尺寸对不上,排查半天最后发现就是一行 shape 参数写反了。更麻烦的是,这类 bug 往往不会报错,程序照常运行,只是结果悄悄错了,等你发现的时候可能已经浪费了大量训练时间。

这篇文章就是要把这件事彻底讲清楚。我会把 Python 生态里最常用的几个图像处理库——OpenCV、Pillow、NumPy、PyTorch、TensorFlow/Keras、matplotlib、scikit-image——它们的形状相关参数顺序逐一拆解,给出记忆方法、转换技巧和避坑经验。无论你是刚入门的新手,还是写过几年代码但每次遇到都要查文档的老手,这篇内容都值得收藏备查。

2. 为什么会出现宽高顺序的混乱

2.1 两种坐标系传统的根源

要理解为什么会有这种混乱,得从两个不同的传统说起。

数学和图像处理传统:在经典的图像处理教材和大多数图像编辑软件里,图像的坐标原点在左上角,用(x, y)表示,其中 x 是水平方向(列),y 是垂直方向(行)。图像的“宽度”对应 x 方向,“高度”对应 y 方向。所以描述一张图时习惯说“宽 × 高”,比如 1920×1080。

矩阵和数组传统:在 NumPy 这类数值计算库里,多维数组的索引顺序是(行, 列),也就是先访问第几行,再访问第几列。对于图像来说,行对应高度方向,列对应宽度方向。所以array.shape返回的是(高, 宽)或者(高, 宽, 通道)。

这两个传统本身都没问题,问题在于不同的库选择了不同的传统,而用户往往不自觉地混用。

2.2 各库的设计哲学差异

OpenCV 诞生于 C++ 时代,它的cv::Mat内部按行存储,rows对应高度,cols对应宽度,所以 Python 绑定的img.shape返回(height, width, channels)。这是矩阵传统的延续。

Pillow 是 Python 原生的图像库,它的设计更贴近“图像”这个概念而不是“矩阵”,所以Image.size返回(width, height),符合人类描述图像的习惯。

NumPy 本身不专门针对图像,它处理的是通用多维数组,所以shape返回的是各维度的大小,对于图像数组来说就是(height, width, channels)。

PyTorch 和 TensorFlow 作为深度学习框架,它们的张量形状约定又不一样。PyTorch 的图像张量通常是(N, C, H, W),即批次、通道、高、宽。TensorFlow/Keras 则常用(N, H, W, C)。这两个框架在数据加载、模型输入、可视化等环节都有各自的约定。

matplotlib 的imshow接受数组时按(rows, cols)或(rows, cols, channels)解释,也就是(height, width, channels),但它的figure尺寸参数figsize又是(width, height)。

scikit-image 作为科学计算风格的图像库,它的图像数组也是(height, width, channels)或(height, width)。

2.3 为什么这个问题如此容易出错

核心原因在于:这些库之间经常需要互相转换。你用 OpenCV 读图,用 Pillow 做增强,用 PyTorch 训练模型,用 matplotlib 可视化结果。每换一个库,形状顺序就可能变一次。而且很多转换代码是隐式的,比如np.array(pil_image)会把 Pillow 的(W, H)变成 NumPy 的(H, W, C),如果你没意识到这一点,后面所有基于索引的操作都可能出错。

更隐蔽的是,当图像是正方形时,宽高相等,顺序反了也看不出来。等到处理非正方形图像时才暴露问题,但那时候你可能已经写了上百行代码,排查成本极高。

3. 主流库的形状参数逐一拆解

3.1 OpenCV:坚定的“高宽通道”派

OpenCV 的 Python 接口中,cv2.imread()返回的是一个 NumPy 数组,形状为(height, width, channels)。灰度图则是(height, width)。

import cv2 img = cv2.imread("test.jpg") print(img.shape) # 例如 (1080, 1920, 3) h, w = img.shape[:2] print(f"高: {h}, 宽: {w}") # 高: 1080, 宽: 1920

OpenCV 中几乎所有涉及尺寸的参数都是(width, height)顺序,比如cv2.resize():

resized = cv2.resize(img, (640, 480)) # 注意这里是 (宽, 高)

这就产生了一个经典的矛盾:img.shape给你(高, 宽),但cv2.resize要你传(宽, 高)。我见过太多人写成cv2.resize(img, img.shape[:2]),结果图像被转置了。

记忆口诀:OpenCV 读进来是“高宽”,用出去是“宽高”。读和写方向相反,这是最容易踩的坑。

3.2 Pillow:人类友好的“宽高”派

Pillow 的Image对象用size属性返回(width, height):

from PIL import Image img = Image.open("test.jpg") print(img.size) # (1920, 1080) print(img.width, img.height) # 1920 1080

Pillow 的resize()方法也接受(width, height):

resized = img.resize((640, 480)) # (宽, 高)

Pillow 的设计是一致的:所有地方都是宽在前、高在后。这一点比 OpenCV 友好得多。

但要注意,当你把 Pillow 图像转成 NumPy 数组时,顺序会变:

import numpy as np arr = np.array(img) print(arr.shape) # (1080, 1920, 3) —— 变成了高宽通道

这个转换是隐式的,很多人在这里丢失了宽高意识。

3.3 NumPy:通用数组的“高宽通道”视角

NumPy 本身不定义图像,它只定义多维数组。对于图像数组,约定俗成的形状是(height, width, channels)或(height, width)。

import numpy as np # 创建一个 1080 高、1920 宽的彩色图像数组 arr = np.zeros((1080, 1920, 3), dtype=np.uint8) print(arr.shape) # (1080, 1920, 3)

NumPy 的索引也是arr[row, col],即arr[y, x]。所以在 NumPy 里做像素级操作时,要记住第一个索引是行(高),第二个是列(宽)。

# 访问第 100 行、第 200 列的像素 pixel = arr[100, 200] # 这是 (高=100, 宽=200) 的位置

3.4 PyTorch:通道前置的(N, C, H, W)

PyTorch 的图像张量约定是(batch, channels, height, width)。单张图像通常是(C, H, W)。

import torch # 单张 RGB 图像 tensor = torch.randn(3, 1080, 1920) # (C, H, W) print(tensor.shape) # torch.Size([3, 1080, 1920]) # 批次 batch = torch.randn(8, 3, 1080, 1920) # (N, C, H, W)

从 NumPy 的(H, W, C)转到 PyTorch 的(C, H, W)需要转置:

arr = np.zeros((1080, 1920, 3), dtype=np.uint8) tensor = torch.from_numpy(arr).permute(2, 0, 1) # (3, 1080, 1920)

PyTorch 的torchvision.transforms.Resize接受(height, width)或单个整数:

from torchvision import transforms transform = transforms.Resize((480, 640)) # (高, 宽)

注意这里又是(高, 宽),和 OpenCV 的resize相反。

3.5 TensorFlow/Keras:通道后置的(N, H, W, C)

TensorFlow 的默认图像张量格式是(batch, height, width, channels),简称 NHWC。

import tensorflow as tf # 单张图像 tensor = tf.random.normal((1080, 1920, 3)) # (H, W, C) # 批次 batch = tf.random.normal((8, 1080, 1920, 3)) # (N, H, W, C)

Keras 的ImageDataGenerator和tf.image.resize都遵循这个约定:

resized = tf.image.resize(tensor, (480, 640)) # (高, 宽)

TensorFlow 也有 NCHW 格式,但需要显式指定,默认是 NHWC。

3.6 matplotlib:显示用(H, W, C),画布用(W, H)

matplotlib 的imshow接受(H, W)或(H, W, C)的数组:

import matplotlib.pyplot as plt plt.imshow(arr) # arr 形状 (1080, 1920, 3) plt.show()

但figure的figsize参数是(width, height),单位是英寸:

fig = plt.figure(figsize=(12, 8)) # 宽 12 英寸,高 8 英寸

subplot的布局参数也是(行数, 列数),和图像形状无关。

3.7 scikit-image:科学风格的(H, W, C)

scikit-image 的图像数组格式和 NumPy 一致,(height, width, channels)或(height, width):

from skimage import io img = io.imread("test.jpg") print(img.shape) # (1080, 1920, 3)

它的resize函数接受(height, width):

from skimage.transform import resize resized = resize(img, (480, 640)) # (高, 宽)

3.8 汇总对照表

库/框架形状顺序典型属性/方法resize 参数顺序
OpenCV(H, W, C)img.shape(W, H)
Pillow(W, H)img.size(W, H)
NumPy(H, W, C)arr.shape无内置 resize
PyTorch(C, H, W) / (N, C, H, W)tensor.shape(H, W)
TensorFlow(H, W, C) / (N, H, W, C)tensor.shape(H, W)
matplotlib(H, W, C)imshow输入figsize 是 (W, H)
scikit-image(H, W, C)img.shape(H, W)

这张表建议截图保存,每次写代码前扫一眼,能省下大量调试时间。

4. 实战中的转换与避坑技巧

4.1 从 Pillow 到 NumPy 到 PyTorch 的完整链路

这是深度学习项目中最常见的链路,每一步都可能出问题。

from PIL import Image import numpy as np import torch # 第一步:Pillow 读图 pil_img = Image.open("test.jpg") print(f"Pillow: {pil_img.size}") # (1920, 1080) —— (W, H) # 第二步:转 NumPy np_img = np.array(pil_img) print(f"NumPy: {np_img.shape}") # (1080, 1920, 3) —— (H, W, C) # 第三步:转 PyTorch tensor = torch.from_numpy(np_img).permute(2, 0, 1) print(f"PyTorch: {tensor.shape}") # (3, 1080, 1920) —— (C, H, W) # 第四步:加批次维度 batch = tensor.unsqueeze(0) print(f"Batch: {batch.shape}") # (1, 3, 1080, 1920) —— (N, C, H, W)

每一步的顺序变化都要心里有数。我个人的习惯是在每个转换步骤后都打印一次 shape,确认无误再继续。

4.2 标注框坐标的宽高陷阱

目标检测任务中,标注框通常用(x, y, w, h)表示,其中 x、y 是左上角坐标,w、h 是宽和高。但有些数据集用(x1, y1, x2, y2)表示左上角和右下角。

当你把标注框画到图像上时,如果图像数组是(H, W, C),那么:

# 正确的画框方式 x, y, w, h = 100, 200, 300, 400 cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)

这里cv2.rectangle接受的是(x, y)坐标,不是(row, col)。虽然图像数组是(H, W, C),但画图函数的坐标参数是(x, y),即(宽方向, 高方向)。这个细节很容易搞混。

4.3 图像缩放时的尺寸计算

假设你要把一张 1920×1080 的图缩放到宽度为 640,保持宽高比:

original_w, original_h = 1920, 1080 target_w = 640 scale = target_w / original_w target_h = int(original_h * scale) # 360 # OpenCV 方式 resized = cv2.resize(img, (target_w, target_h)) # (640, 360) # Pillow 方式 resized = pil_img.resize((target_w, target_h)) # (640, 360) # PyTorch 方式 transform = transforms.Resize((target_h, target_w)) # (360, 640)

注意 OpenCV 和 Pillow 都是(宽, 高),PyTorch 是(高, 宽)。如果你用同一个变量target_size = (640, 360)传给这三个库,PyTorch 会把它解释成高 640、宽 360,结果完全错了。

4.4 模型输入尺寸的常见错误

深度学习模型通常要求固定输入尺寸,比如 224×224。但“224×224”到底是(高=224, 宽=224)还是(宽=224, 高=224)?对于正方形来说无所谓,但如果模型支持非正方形输入,比如 320×240,就要小心了。

PyTorch 的transforms.Resize((320, 240))表示高 320、宽 240。如果你想要宽 320、高 240,应该写transforms.Resize((240, 320))。

TensorFlow 的tf.image.resize(img, (320, 240))表示高 320、宽 240。

4.5 可视化时的形状检查

用 matplotlib 显示图像前,先检查数组形状:

import matplotlib.pyplot as plt import numpy as np def show_image(img_array): print(f"形状: {img_array.shape}") if img_array.ndim == 3 and img_array.shape[0] in [1, 3, 4]: # 可能是 (C, H, W),需要转成 (H, W, C) img_array = np.transpose(img_array, (1, 2, 0)) print(f"转置后: {img_array.shape}") plt.imshow(img_array) plt.axis("off") plt.show()

这个函数能自动处理(C, H, W)和(H, W, C)两种情况,避免显示时颜色错乱或图像旋转。

5. 常见问题与排查技巧实录

5.1 图像显示颜色不对或旋转了

现象:用 matplotlib 显示图像时,颜色发蓝或图像旋转了 90 度。

原因:数组形状是(C, H, W)而不是(H, W, C),matplotlib 把通道维度当成了高度或宽度。

解决:用np.transpose调整维度顺序:

# 从 (C, H, W) 转到 (H, W, C) img_display = np.transpose(img, (1, 2, 0)) plt.imshow(img_display)

5.2 图像被拉伸变形

现象:缩放后的图像看起来被压扁或拉长了。

原因:宽高参数传反了。比如原图是 1920×1080,你想缩放到 640×360,但传成了(360, 640)。

排查:打印原始尺寸和目标尺寸,确认比例一致:

print(f"原始: {original_w}×{original_h}, 比例: {original_w/original_h:.2f}") print(f"目标: {target_w}×{target_h}, 比例: {target_w/target_h:.2f}")

如果两个比例不一致,说明宽高传反了或者计算错了。

5.3 模型推理结果异常

现象:模型推理结果完全不对,但训练时正常。

原因:推理时的预处理和训练时不一致,尤其是 resize 的宽高顺序。

排查:检查训练和推理的预处理代码,确保Resize参数顺序一致。建议把预处理参数写成一个配置字典,训练和推理共用:

PREPROCESS_CONFIG = { "resize": (224, 224), # (高, 宽) "mean": [0.485, 0.456, 0.406], "std": [0.229, 0.224, 0.225], }

5.4 标注框错位

现象:画出来的标注框位置不对,偏上或偏左。

原因:标注框坐标是(x, y, w, h),但画框时用成了(row, col, h, w)。

解决:统一坐标系统。建议在代码中明确注释每个变量的含义:

# 标注框:x, y 是左上角坐标,w, h 是宽和高 x, y, w, h = 100, 200, 300, 400 # 画框时用 (x, y) 作为左上角,(x+w, y+h) 作为右下角 cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)

5.5 常见问题速查表

问题现象可能原因排查方法解决方案
颜色发蓝/发红通道顺序是 BGR 不是 RGB检查读图库OpenCV 读图后cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
图像旋转 90 度形状是 (C, H, W)打印 shapenp.transpose(img, (1, 2, 0))
图像拉伸变形宽高参数传反检查比例确认 resize 参数顺序
标注框错位坐标系统不一致打印坐标统一用 (x, y, w, h)
模型结果异常预处理不一致对比训练/推理代码共用预处理配置
图像显示全黑数据类型不对检查 dtype确保是 uint8 或 float 在 [0,1]
保存图像颜色不对保存时通道顺序错检查保存库OpenCV 保存前转 BGR

5.6 独家避坑技巧

技巧一:建立自己的“形状检查”函数。每次拿到一个新图像数组,先调用这个函数打印形状和推断的格式:

def inspect_image_shape(arr): """打印图像数组的形状信息,帮助判断格式""" print(f"形状: {arr.shape}, 维度: {arr.ndim}, 类型: {arr.dtype}") if arr.ndim == 3: if arr.shape[0] in [1, 3, 4]: print("推断: (C, H, W) 格式") elif arr.shape[2] in [1, 3, 4]: print("推断: (H, W, C) 格式") else: print("推断: 未知格式,请手动检查") elif arr.ndim == 2: print("推断: 灰度图 (H, W)") else: print("推断: 非标准图像数组")

技巧二:在变量命名中体现顺序。不要用size、shape这种模糊的名字,而是用hw、wh、chw、hwc等明确的后缀:

target_hw = (480, 640) # 高, 宽 target_wh = (640, 480) # 宽, 高

这样即使过了几个月回头看代码,也能立刻明白每个变量的含义。

技巧三:写单元测试验证形状。对于关键的转换函数,写一个简单的测试:

def test_pil_to_numpy(): from PIL import Image import numpy as np pil_img = Image.new("RGB", (100, 50)) # 宽 100, 高 50 np_img = np.array(pil_img) assert np_img.shape == (50, 100, 3), f"期望 (50, 100, 3),实际 {np_img.shape}" print("测试通过")

这个测试能确保你理解 Pillow 到 NumPy 的转换规则。

6. 我的个人经验与建议

写了这么多年图像处理代码,我最大的体会是:不要相信自己的记忆,要相信打印输出。每次涉及形状转换,不管多简单,都加一行print(shape)。这看起来笨,但能省下大量调试时间。

另一个习惯是:在项目初期就统一形状约定。比如我的项目里,所有内部处理的图像数组统一用(H, W, C),所有涉及宽高的参数统一用(width, height)命名。只有在调用特定库的函数时才做转换,并且转换代码集中在一个工具模块里,不散落在各处。

还有一点:文档比记忆可靠。每个库的形状约定都可能随版本变化,或者有例外情况。遇到不确定的时候,花 30 秒查一下官方文档,比花 30 分钟调试划算得多。

最后分享一个我常用的转换速查代码片段,放在项目的utils.py里,随时调用:

def hwc_to_chw(img): """(H, W, C) -> (C, H, W)""" return np.transpose(img, (2, 0, 1)) def chw_to_hwc(img): """(C, H, W) -> (H, W, C)""" return np.transpose(img, (1, 2, 0)) def wh_to_hw(size): """(W, H) -> (H, W)""" return (size[1], size[0]) def hw_to_wh(size): """(H, W) -> (W, H)""" return (size[1], size[0])

这几个函数虽然简单,但在实际项目中能避免大量低级错误。尤其是wh_to_hw和hw_to_wh,在 OpenCV 和 PyTorch 之间切换时特别有用。

图像处理领域的形状问题就像编程中的 off-by-one 错误,看似简单,但几乎每个人都会遇到。关键不是记住所有规则,而是建立一套自己的检查机制,让错误在发生之前就被拦截。希望这篇内容能帮你少走一些弯路。

RELATED READING

延伸阅读

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