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, 宽: 1920OpenCV 中几乎所有涉及尺寸的参数都是(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 1080Pillow 的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) | 打印 shape | np.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 错误,看似简单,但几乎每个人都会遇到。关键不是记住所有规则,而是建立一套自己的检查机制,让错误在发生之前就被拦截。希望这篇内容能帮你少走一些弯路。