本节摘要:图像在计算机里不是"图",是数组。本节从像素与通道讲起,说清灰度图(单通道 0–255)与彩色图(三通道 BGR)的存储结构、常见图像格式的取舍,以及 NumPy 视角下的 shape 与 dtype。掌握"图像即数组"这一思维模型,后面所有 OpenCV 函数都只是对数组做数学。
阅读完本节,你应当能够:
照本教程的惯例,先上能跑的。装好环境(下一节讲安装,这里假设你已经能 import cv2),读一张图并打印它的基本信息:
import cv2 img = cv2.imread('example.jpg') print(img.shape) # 例如 (480, 640, 3) print(img.dtype) # uint8 print(img[0, 0]) # 左上角像素的值,例如 [ 87 122 203]
三行输出就是本节的全部核心:shape 告诉你这是一个 480 行、640 列、每格 3 个数的三维数组;dtype 告诉你每个数是 0 到 255 的无符号整数;img[0, 0] 是左上角那个格子,里面装着三个数——蓝、绿、红的强度。
再进一步,把左上角 100×100 的区域强行涂成红色,看看会发生什么:
img[0:100, 0:100] = (0, 0, 255) # BGR:蓝0 绿0 红255 cv2.imshow('modified', img) cv2.waitKey(0) cv2.destroyAllWindows()
运行后左上角出现一块红色方块。注意赋值时三个数的顺序是 (0, 0, 255)——蓝色分量在前,红色分量在后。这个反直觉的顺序不是 Bug,是历史。1999 年 OpenCV 起步时, 相机厂商与 Windows 位图流行 BGR 字节序,这个约定就被沿用至今。
如果你想"亲眼看到数组",可以做一个小实验:用 NumPy 手工造一张图,不依赖任何文件。
import cv2 import numpy as np canvas = np.zeros((300, 400, 3), dtype=np.uint8) # 全黑画布 canvas[:, :] = (255, 200, 150) # 整体填充一种颜色 cv2.imshow('handmade', canvas) cv2.waitKey(0)
一张 300×400 的纯色"图片"就这么凭空造出来了。所谓图像,不过是一个形状规则的整数数组。
真实世界的画面是连续的,但计算机存不下连续的东西。相机感光元件把画面切成网格,每个格子测一个(或一组)亮度值——这个过程叫采样与量化。网格密不密就是分辨率,亮度分几个档就是位深。我们最常用的 8 位图,每个通道把亮度分成 256 档:0 是最暗,255 是最暗到最亮区间的另一头。
灰度图每个像素只有一个数。0 到 255 从黑渐变到白,中间是深浅不同的灰。很多经典算法(Sobel 边缘、Harris 角点、大多数阈值方法)只吃灰度图,不是它们挑剔,而是"亮度变化"这个信息在单通道里已经足够,多出来的颜色通道反而是计算负担。
彩色图每个像素有三个数。最常见的是 RGB 模型:红、绿、蓝三盏灯,不同强度叠加出千万种颜色。而 OpenCV 在内存里把这三个数的顺序排成 B-G-R,也就是说一个像素在内存里的三个字节,第一个是蓝色分量,第三个才是红色分量。这件事的影响贯穿全书:当你用 matplotlib 显示 OpenCV 读进来的图,红蓝会互换,人脸色发蓝、蓝天发红——因为 matplotlib 期待 RGB。解决办法是在显示前做一次通道转换:
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
除了 RGB 家族,后面还会遇到 HSV 空间(色调、饱和度、明度)。HSV 的好处是把"颜色"和"亮度"拆开,比如"提取画面里所有黄色区域",在 HSV 里按色调范围筛选比在 BGR 里稳定得多。第 2 章会专门用到它。
数组索引用的是 img[行, 列]。行从上往下数,列从左往右数,都从 0 开始。所以 img[10, 20] 是第 10 行、第 20 列——视觉上在画面偏上、偏左的位置。而很多函数(比如画矩形时的坐标参数)用的是"先 x 后 y"的惯例,即 (列, 行)。这两套习惯会在你写代码时反复打架,是新手错误的重灾区。
我建议记一个口诀:方括号里先行后列,函数参数里先 x 后 y。切片同理,img[100:200, 300:400] 取的是行 100 到 199、列 300 到 399 的一块矩形区域,OpenCV 术语叫 ROI(感兴趣区域)。
8 位无符号整数是图像世界的默认货币:0–255 恰好一个字节,存储省、传输快、绝大多数函数默认接受它。但有些操作会溢出——两张 uint8 图直接相加,250 加 10 结果是 4(绕回去了),不是 260。NumPy 的加法就是这么定义的,而 cv2.add 会把结果截断在 255。做算术运算前把图转成 np.float32 或 np.float64,算完再转回来,是更安全的习惯。第 2 章讲锐化、第 3 章讲 Harris 角点时,都会先做这个转换(Harris 的输入就要求 float32)。
下面的流程图概括了本节所有概念的关系:
格式不是小事,选错会悄悄毁掉你的精度。
| 格式 | 压缩方式 | 适合场景 | 不适合场景 |
|---|---|---|---|
| JPEG | 有损 | 照片、训练数据集、网络传输 | 二值图、需要精确像素值的中间结果 |
| PNG | 无损 | 中间结果保存、截图、含透明通道的图 | 超大照片库(体积大) |
| BMP | 不压缩 | 快速读写、教学演示 | 任何关心磁盘空间的场合 |
有损压缩的含义要体会一下:把一张图存成 JPEG 再读回来,像素值已经变了,质量参数越低变得越多。如果你在做阈值分割,存成 JPEG 的中间结果会引入灰度抖动,阈值边缘附近的像素来回跳,导致结果不稳定。我的原则是:原始素材可以 JPEG,中间结果一律 PNG。
一张 1080P 彩图约 600 万个字节(1920×1080×3),4K 图约 2400 万。逐像素的 Python 循环处理一张 4K 图可能要几秒钟,而 OpenCV 的内置函数(底层 C++ 且并行)只要几十毫秒。所以养成习惯:能用 OpenCV 向量化函数就不要写 Python 双重循环,慢的不是一个数量级。另外,很多算法只对灰度图有意义,进算法前先 cvtColor 降成单通道,等于直接砍掉三分之二的计算量。
⚠️ 常见坑:用 matplotlib 直接显示 OpenCV 读的图,颜色诡异。原因就是本节讲的 BGR 与 RGB 之差,转换一次即可。另一个坑是从网上下载的图片带第四个透明通道(PNG 的 alpha),
shape是(h, w, 4),直接喂给只接受 3 通道的函数会报错,读图时用cv2.IMREAD_COLOR强制转 3 通道,或先做通道裁剪。
把下面的实验做一遍,本节就真正内化了:
cv2.IMREAD_GRAYSCALE 与 cv2.IMREAD_COLOR 读同一张图,对比 shape 差异。np.arange 生成 0 到 255 的序列,广播到二维。第 4 个实验的答案大约长这样:
grad = np.tile(np.arange(256, dtype=np.uint8), (256, 1)) cv2.imshow('gradient', grad)
一个方向上的 256 级渐变就出来了。你会看到从黑到白的平滑过渡——这 256 个亮度档,就是后面所有灰度算法操作的原材料。
不是。COLOR_BGR2GRAY 用的是加权公式,绿色权重约 0.59、红色约 0.29、蓝色约 0.11——依据是人眼对绿色最敏感、对蓝色最迟钝。证据随手可验:拍一张蓝天,转灰度后天空明显偏暗;拍一片草地,转灰度后亮度接近原彩色图。这个加权还有个实际影响:纯蓝与纯绿在彩色图里"亮度相同",转灰度后差出好几档——做基于灰度的检测时,目标颜色会影响它的"灰度可见性",蓝色系目标天然吃亏。
同一张文件只有一个位深,但处理流程里可以升降。16 位图(医学影像、HDR 拼接的中间产物)每个通道 0–65535,动态范围更大;降到 8 位显示时要做拉伸或截断,直接除以 256 会把暗部细节压成一团。遇到 16 位数据先 print(img.dtype) 确认,再决定转换策略,是处理这类图的第一步。
有。灰度 1 通道、彩色 3 通道、带透明 4 通道是常见三档,但 OpenCV 的数组结构不限制通道数——HSV 也是 3 通道(含义不同),某些变换的中间结果会有几十个通道。判断一张图"是什么",shape 与产生它的代码比直觉可靠。
三个理由:亮度信息承载了绝大多数结构特征(边缘、角点、纹理都定义在灰度梯度上);单通道计算量只有三分之一;颜色受光照色温影响剧烈而灰度相对稳定。例外是"颜色本身是判别特征"的任务(成熟度分拣、标志识别),那就要留在彩色空间甚至转 HSV 处理(第 2 章第 4 节)。
最后做一次"概念对表",把本节术语与后面各章的用法提前挂钩,学后文时不至于回头翻。
数组的 shape 在第 2 章几何变换里决定输出画布尺寸,在视频里决定帧的一致性(第 5 章读写器要求帧尺寸恒定)。dtype 在 Harris 角点(必须 float32,第 3 章第 1 节)与图像算术(防 uint8 溢出)两处反复出现。BGR 顺序会在两处咬人:matplotlib 显示(本节讲过)与 DNN 推理的 swapRB 参数(第 4 章第 2 节,方向恰好反过来)。灰度转换的加权公式决定了"蓝色目标灰度可见性差",这条在第 2 章阈值分割的实战里会再次提醒。行列坐标的口诀则是第 2 章画框、取 ROI 的每日常识。
换句话说,本节没有一行"高级代码",但后面五章的每一个高级函数都在消费本节的概念。基础不牢的典型症状是:函数都会调,一出错就懵——因为不知道数据在函数之间流动时发生了什么形态变化。把 shape、dtype、通道序、坐标系这四件事变成条件反射,后面的学习曲线会平缓一半。
下一节我们把环境装起来——发行包怎么选、装完怎么验证,这些看似琐碎的事,恰恰是新手卡壳最多的地方。