我之前有带一个新的零基础朋友入门OpenCV,他第一天就问了我一个挺扎心的问题:网上教程那么多,为什么我复制别人的代码跑来跑去看不懂?问题是,大家往往只把代码贴出来,不讲每一行在干什么,更不聊为什么这么写。所以这个系列我一直坚持做“保姆级”,把每一步掰开揉碎讲清楚,哪怕你没写过几行Python,也能跟着跑通,还能知道自己在干嘛。
这节课是计算机视觉系列第一课的OpenCV语法部分第二节,我们继续在上节课的基础上,把图像处理里最常用的那些基础操作过一遍。包括图像的读取显示与保存的细节差异、像素矩阵的理解、颜色空间转换(尤其是BGR和HSV的互换)、图像之间的运算和遮罩思路,还有缩放旋转翻转这类几何变换。学完这一节,你应该能独立完成“把一张图片裁剪出来、换个颜色空间、和另一张图融合、再旋转一下保存成新文件”这样一整套基础流程。
如果你是走传统图像处理方向,或者在为后面学深度学习做铺垫,这一节的很多技巧在你调试数据、做预处理的时候都会反复用到。我尽量按照我实际使用的顺序来讲,遇到容易踩坑的地方,都会单独标出来提醒你。
1. 图像读写显示:每一个参数都有它的脾气
1.1 读取文件的三种路径写法
很多人第一个报错就是“图片读不出来”,而且多半不是因为图片不存在,而是路径写错了。OpenCV的imread支持相对路径、绝对路径和原始字符串,我见过最常见的报错场景是这样的:
import cv2 # 相对路径(一般默认你当前运行脚本的目录) img = cv2.imread('cat.jpg') # 绝对路径(Windows下反斜杠比较多,记得处理转义) img = cv2.imread('C:\\Users\\test\\Desktop\\cat.jpg') # 原始字符串(推荐,省心很多) img = cv2.imread(r'C:\Users\test\Desktop\cat.jpg')注意第三个写法开头的r,原始字符串会把\t、\n这类转义符当作普通字符处理,否则C:\Users\test里的\t会被认成制表符,路径就废了。
还有个更隐蔽的问题:imread读不回来的时候不会报错,只会给你一个None,如果你紧接着去操作它就会出各种莫名问题。所以稳妥的做法是在读取后加一句判断:
img = cv2.imread('cat.jpg') if img is None: print("图片读取失败,检查路径") else: print("读取成功,图片尺寸是", img.shape)我自己的习惯是这个判断从第一天用到现在都没去掉过,它省下的排查时间远比写它花掉的时间多。
1.2 imread 的 flag:灰度读还是彩色读?
cv2.imread有个默认参数,但你最好知道它在干什么:
# 默认彩色读取(实际上会转成BGR三通道) img_color = cv2.imread('cat.jpg', cv2.IMREAD_COLOR) # 直接以灰度模式读入,得到的是单通道 img_gray = cv2.imread('cat.jpg', cv2.IMREAD_GRAYSCALE) # 原样读取,包含透明度信息等 img_unchanged = cv2.imread('cat.jpg', cv2.IMREAD_UNCHANGED)在OpenCV里,“彩色图读取出来就是BGR,不是RGB”,这一点怎么强调都不过分。后面用matplotlib显示图片的时候就会遇到:matplotlib默认当RGB显示,你得先把BGR转成RGB,不然颜色会非常怪。这就是网上很多教程里出现cv2.cvtColor(img, cv2.COLOR_BGR2RGB)这一行的原因。灰度图就是单通道,shape只有两个数字:高和宽。
1.3 imshow、waitKey、destroyAllWindows 的固定三连
显示图像的代码对新手来说有三个“为什么”:
cv2.imshow('window', img) cv2.waitKey(0) cv2.destroyAllWindows()第一个问题:为什么写了imshow但不显示?因为OpenCV的窗口显示机制是,imshow把图像推到窗口缓冲区,然后必须用waitKey去刷新事件循环,图像才会真正出现在屏幕上。waitKey(0)的意思是无限等待键盘输入,参数如果传25,就是等25毫秒。
第二个问题:为什么窗口会“未响应”?如果你用的是waitKey(0),窗口会一直开着,正常。但如果你在脚本里没有调用waitKey就结束了程序,窗口就会卡住不动甚至直接闪退。
第三个问题:destroyAllWindows()是负责清理所有窗口的,不加它程序一般也能退出,但加上之后在循环里反复开窗口的场景下,能避免内存堆积和窗口残留。我在做批处理测试时习惯把这段写成函数:
def show_image(img, name='result'): cv2.imshow(name, img) cv2.waitKey(0) cv2.destroyAllWindows()1.4 保存图片时的质量参数
保存图片用cv2.imwrite,第一个参数是文件路径,第二个参数是图像数组。它是靠文件后缀判断格式的,.jpg就是JPEG,.png就是PNG。还可以用第三个参数控制质量:
# JPEG质量 0-100,95表示高质量 cv2.imwrite('out.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 95]) # PNG压缩级别 0-9,0表示不压缩(文件更大,速度更快) cv2.imwrite('out.png', img, [cv2.IMWRITE_PNG_COMPRESSION, 0])这里有个需要注意的细节:当你用灰度图保存时,就算后缀是.jpg,它保存出来也是单通道灰度JPEG;但如果你把一张三通道图保存成.png,Alpha通道信息默认不会带进去。如果你想带透明通道保存,得用IMREAD_UNCHANGED读入,并且确保数组是四通道的。
2. 图像本质是数组:理解 shape、像素坐标与通道顺序
2.1 shape 里的三个数字分别是什么
第一次看到img.shape的返回结果,很多人会愣一下,比如(450, 600, 3)。它对应的是:高是450像素,宽是600像素,通道数是3。注意顺序不是宽、高,而是高、宽,这个和很多人的直觉相反。
之所以这样,是因为图像在OpenCV里本质是一个NumPy数组,数组的形状永远是“先行后列”,而行对应图像的高度方向,列对应宽度方向。所以你要访问第100行第200列的那个像素,写的是img[100, 200],不是img[200, 100]。新手最容易在这里把x和y搞反,一旦搞反,裁剪出来的位置就完全不对。
print(img.shape) # (高, 宽, 通道数) print(img.dtype) # uint8,说明每个通道的取值范围是0~255 print(img.size) # 高*宽*通道数,也就是总像素点个数dtype一般是uint8,意味着图像每个通道的值都在0到255之间。有些操作做完后值会超过255,所以需要手动用np.clip或者cv2.normalize把数值拉回这个范围。
2.2 用NumPy切片裁剪ROI区域
图像裁剪在OpenCV里异常简单,因为就是数组切片:
# 假设img是一张600x600的图 # 我要裁剪出水平方向200到400、垂直方向150到350这个区域 roi = img[150:350, 200:400]这里提醒一下,切片操作是“左闭右开”,150:350取的是150到349行的数据,不会包含350行。之前有同学裁剪后尺寸和预期差了一个像素,对着代码看半天,其实就是这个原因。裁剪出来的roi是原图的一个视图,在多数情况下修改它会影响原图,如果需要独立副本,用roi.copy()。
除了固定数字切片,你还可以先获取鼠标事件,让用户框选区域,再把得到的坐标传给切片。这属于交互式ROI选择,后面在做标注工具的时候会特别常用。
2.3 像素访问与BGR通道顺序
想单独拿到某个像素的BGR值:
b, g, r = img[100, 200] # 依次是蓝、绿、红修改一个像素:
img[100, 200] = (255, 0, 0) # 偏蓝色如果你希望只修改某个通道,可以这样:
img[:, :, 0] = 0 # 把蓝色通道全部置0这里注意,img[:, :, 0]代表的是蓝色通道,不是红色。BGR的顺序是历史遗留问题,早期一些厂商选择了BGR作为相机输出的排列顺序,OpenCV沿用了这个约定,所以它读取彩色图时默认就是BGR,而不是更常见的RGB。你后面对接其他库,比如matplotlib、PIL、或者是深度学习框架预处理图像,经常需要做通道顺序转换,这就是统一用cvtColor的原因。
3. 颜色空间转换:灰度、HSV、RGB之间的切换
3.1 cvtColor 的转换码到底怎么选
cvtColor是OpenCV里使用频率极高的函数,基本上只要做颜色处理,就会用到它。常用的转换码:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)在OpenCV里,几乎所有的转换码都遵循“从哪转到哪”的命名规则。COLOR_BGR2GRAY就是从BGR转到灰度,COLOR_BGR2HSV就是从BGR转到HSV颜色空间。如果你的图像本身是RGB顺序,你需要先把RGB转BGR,再用COLOR_BGR2GRAY转灰度。你可以直接查OpenCV枚举名称来进行修改,避免搞混。
转灰度图的核心原理其实是对三个通道做加权求和,OpenCV采用的是类似Y = 0.299R + 0.587G + 0.114B的公式。所以它不是简单地把三个通道取平均,而是考虑了人眼对不同颜色的敏感度。绿色权重最高,蓝色最低,这和人眼的感知特性是一致的。
3.2 为什么调试时HSV比RGB方便
很多初学者不理解为什么做颜色识别时大家都先转HSV,而不是直接在RGB上设范围。举个实际例子:你肉眼看到的“红色”,在RGB空间里可能对应很多完全不连续的组合,比如(255,0,0)是红色,(255,100,100)是浅红,(200,0,50)是暗红。想在RGB空间用一个矩形框把它们都框住非常困难。
HSV把颜色拆成色相(H)、饱和度(S)、明度(V)三个维度。色相是一个角度,红色大致在0到10和170到180两个区间,绿色在35到85附近,蓝色在100到130附近。更关键的是,明度变化对H影响很小,所以光照变化比较大时,在HSV空间里定格H值范围往往比在RGB里稳定得多。
我当年做一个小项目,要从画面里识别红色小球。在RGB空间里换了个会议室灯光就全崩了,转成HSV之后,只需要固定H的范围,S和V适当放宽,识别结果立刻稳定很多。所以做颜色检测时,我的固定流程就是:
hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 红色的两个区间(不同光源下可能需要微调) lower_red1 = np.array([0, 100, 100]) upper_red1 = np.array([10, 255, 255]) lower_red2 = np.array([170, 100, 100]) upper_red2 = np.array([180, 255, 255])这里要注意H通道在OpenCV里的取值范围是0到179,不是0到360。原因是OpenCV用uint8保存H,一个字节最大255,放不下360,所以缩了一半。很多从其他资料里抄[0, 100, 100]到[10, 255, 255]都能用,但你如果看到一份把H写成0到360的代码,记得先除以2再使用。
3.3 一个直观的HSV调参工具
在调试颜色区间时,我强烈建议一开始先用一个滑动条工具把每帧的HSV阈值可视化出来,不要靠猜。你可以用cv2.createTrackbar来做简易调参面板:
def empty(a): pass cv2.namedWindow("TrackBars") cv2.resizeWindow("TrackBars", 640, 240) cv2.createTrackbar("Hue Min", "TrackBars", 0, 179, empty) cv2.createTrackbar("Hue Max", "TrackBars", 179, 179, empty) cv2.createTrackbar("Sat Min", "TrackBars", 0, 255, empty) cv2.createTrackbar("Sat Max", "TrackBars", 255, 255, empty) cv2.createTrackbar("Val Min", "TrackBars", 0, 255, empty) cv2.createTrackbar("Val Max", "TrackBars", 255, 255, empty) while True: h_min = cv2.getTrackbarPos("Hue Min", "TrackBars") h_max = cv2.getTrackbarPos("Hue Max", "TrackBars") s_min = cv2.getTrackbarPos("Sat Min", "TrackBars") s_max = cv2.getTrackbarPos("Sat Max", "TrackBars") v_min = cv2.getTrackbarPos("Val Min", "TrackBars") v_max = cv2.getTrackbarPos("Val Max", "TrackBars") lower = np.array([h_min, s_min, v_min]) upper = np.array([h_max, s_max, v_max]) mask = cv2.inRange(hsv_img, lower, upper) cv2.imshow("Mask", mask) if cv2.waitKey(1) & 0xFF == ord('q'): break这个工具是纯调试用的。你调出满意范围之后,再把数值固化到代码里。这比每次改代码重新跑要高效率很多。
4. 图像运算:加法、加权融合与位运算遮罩
4.1 普通相加和 cv2.add 是两码事
图像加法听起来简单,但有一个很关键的细节。如果用NumPy直接相加:
img1 = cv2.imread('a.jpg') img2 = cv2.imread('b.jpg') added_np = img1 + img2uint8溢出时不会报错,而是会回绕。比如200加80,正常应该是280,uint8装不下,结果就变成了24。这会导致一片区域变成奇怪的暗色,完全不是你想要的效果。
用cv2.add则不同,它会做饱和运算,超出255的直接按255截断:
added_cv = cv2.add(img1, img2)这样亮的地方会更亮,但不会突然变暗。类似地,cv2.subtract也会做饱和运算,小于0的直接按0处理。做图像处理时只要涉及像素加减,我一般会优先考虑OpenCV的饱和操作,而不是直接使用NumPy加号,除非你明确要做模运算效果。
4.2 addWeighted 做融合和渐变
有时候你需要的不只是单纯相加,而是让图像按不同权重混合,比如做一个渐变过渡的效果。这时用cv2.addWeighted:
# 公式: dst = src1 * alpha + src2 * beta + gamma blended = cv2.addWeighted(img1, 0.7, img2, 0.3, 0)这里alpha是图1的权重,beta是图2的权重,gamma是额外加的常数项,一般是0。两个权重相加最好等于1,否则整体会偏亮或偏暗。这个函数在图片加logo、做过渡动画、合成水印效果时经常会用到。
配合上一节讲的ROI裁剪,可以把融合做到“局部”:先抠出一个区域,把这个区域单独和另一张图做加权融合,再放回原图。这在做贴纸、加水印的时候非常实用。
4.3 位运算:用mask按位提取感兴趣区域
位运算在图像处理里是个大杀器,尤其配合cv2.inRange生成的掩膜,能达到“只保留我要的区域,其他全部置黑”的效果。
常用的几个操作:
# 按位与:两张图对应位置都是白色(255)的地方才保留 anded = cv2.bitwise_and(img1, img2) # 按位或:任一位置是白色就保留 ored = cv2.bitwise_or(img1, img2) # 按位取反:黑白反转 noted = cv2.bitwise_not(img)最经典的用法是:先用cv2.inRange做出一个掩膜mask,然后把这个mask当作遮罩去提取原图里符合条件的部分:
mask = cv2.inRange(hsv_img, lower, upper) result = cv2.bitwise_and(img, img, mask=mask)为什么是bitwise_and(img, img, mask=mask)?因为掩膜里白色部分保留,黑色部分去掉,和原图自己和自己按位与后,白色区域像素不变,黑色区域像素变成0,于是就能把背景清空,只留下感兴趣物体。
在此基础上做轮廓检测、面积过滤、凸包等处理,基本就是很多入门项目的标准流程了。初学者在学到这一步时一定要亲手试一下:用一张纯色背景的物体照片,做一次颜色提取,看看掩膜和结果长什么样,你才算真正理解“遮罩”这个概念。
5. 几何变换:缩放、旋转、翻转与平移
5.1 resize 的两种指定方式
缩放图像是最常见的预处理手段,cv2.resize有两种用法:
# 方式一:直接指定目标尺寸(宽, 高) resized = cv2.resize(img, (300, 200)) # 方式二:指定x和y方向的缩放倍数 resized_2x = cv2.resize(img, None, fx=2.0, fy=2.0)这里又有一个顺序坑:目标尺寸参数传的是(宽, 高),不是(高, 宽)。和shape返回的顺序刚好相反,我初见时至少搞反过两三次。如果你发现缩放后的图片尺寸和预期差很多,先检查是不是这里顺序写错了。
缩放的另一个隐藏参数是插值方法。默认的cv2.INTER_LINEAR基本够用,但在缩小图片时,用cv2.INTER_AREA效果更好,可以避免一些锯齿和摩尔纹。在做OCR预处理时,有些小字放大,用INTER_CUBIC反而比INTER_LINEAR锐利一些。具体还是要多试。
5.2 旋转:getRotationMatrix2D 与 warpAffine
旋转不是直接一个函数就能完成的,OpenCV的做法是先算出一个旋转矩阵,再做仿射变换:
# 获取旋转矩阵 # 参数依次是:旋转中心、旋转角度、缩放比例 matrix = cv2.getRotationMatrix2D((center_x, center_y), 45, 1.0) rotated = cv2.warpAffine(img, matrix, (width, height))旋转中心一般取图像中心(width//2, height//2)。角度正数是逆时针旋转,负数是顺时针。缩放比例设为1.0表示保持原始大小。如果你希望旋转后不被裁剪,可以适当扩大目标尺寸,比如把宽高都设成对角线的长度,这样四个角就不会被切掉。
这里有个我踩过很多次的坑:旋转后的图像边缘会变成黑色填充色,填充值默认是0。如果你不喜欢黑色,可以在warpAffine里传第四个参数:
rotated = cv2.warpAffine(img, matrix, (width, height), borderValue=(255, 255, 255))5.3 翻转与平移
翻转是最简单的:
# 0:垂直翻转(上下翻转) flip_updown = cv2.flip(img, 0) # 1:水平翻转(左右翻转) flip_leftright = cv2.flip(img, 1) # -1:两个方向都翻转(180度翻转) flip_both = cv2.flip(img, -1)平移也是通过仿射变换矩阵完成:
matrix_trans = np.float32([[1, 0, 100], [0, 1, 50]]) shifted = cv2.warpAffine(img, matrix_trans, (img.shape[1], img.shape[0]))矩阵中右上角的100表示向右平移100像素,右下角的50表示向下平移50像素。如果数值为负,方向就反过来。这个操作在数据增强里主要用于生成更多样的训练样本。
5.4 简单了解仿射变换与透视变换
旋转、平移、缩放都属于仿射变换,它们保持直线和平行线不变。如果你需要做文档矫正、名片识别、俯拍矫正这类操作,用的就是透视变换,OpenCV里对应cv2.getPerspectiveTransform和cv2.warpPerspective。
透视变换的记忆方法是:仿射用2x3矩阵,透视用3x3矩阵;仿射需要3对点,透视需要4对点。对于入门阶段,只要能看懂下面这段代码就够用了:
src_points = np.float32([[0, 0], [width, 0], [0, height], [width, height]]) dst_points = np.float32([[50, 30], [width-40, 20], [10, height-30], [width-20, height-10]]) matrix_perspective = cv2.getPerspectiveTransform(src_points, dst_points) result_perspective = cv2.warpPerspective(img, matrix_perspective, (width, height))你手机里文档扫描功能的“自动切边矫正”,核心逻辑就跟这段代码同源。
6. BGR vs RGB 的经典误区与常见报错清单
6.1 一张发蓝或者发橘的图片,多半是通道顺序问题
使用OpenCV和matplotlib联调时,BGR/RGB顺序是一个高频经典坑。如果你把OpenCV读入的图像直接扔给matplotlib显示,画面会发蓝偏色,因为matplotlib默认把三个通道解释为RGB,而OpenCV给的是BGR。解决办法已经前面讲过:
rgb_img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) plt.imshow(rgb_img)但如果你用cv2.imshow显示,就完全不需要转换,它自己按BGR来。所以一个常见的错误是:你先用cv2.imshow显示没问题,改成matplotlib显示就发蓝,以为是自己代码写错了,其实只是通道顺序不一致。另外在用深度学习框架做预处理时,通常也要先检查框架期望的通道顺序,有的框架是RGB,有的是BGR,最好封装一个统一的转换函数,避免每次复制代码时踩坑。
6.2 常见报错与解决办法
我在带新手的过程中总结了一份高频报错对照表,基本都是这两节课范围内最常出现的:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
Assertion failed没有更多信息 | 图片路径读成了None,后续函数收到None | 先判断img is None |
TypeError: Image data cannot be converted to float | 把uint8图直接当作浮点图显示 | 先归一化到0~1或转成uint8 |
ValueError: could not broadcast input array | 两张图尺寸或通道数不一致就做相加、融合 | 先resize成同尺寸,或统一通道 |
error: (-215:Assertion failed) | 很多OpenCV函数对空图像直接报错 | 检查路径和读取结果 |
TypeError: Expected cv::UMat for argument | 传入的不是图像数组,可能是返回值为None | 打印type(img)和img.shape定位 |
有一个调试技巧我想专门提醒:在写OpenCV代码时,逻辑每走几步就print(img.shape),可以用小尺寸图先跑逻辑再上大图。很多人报错后心态就崩了,其实从头把每一步的shape和dtype打印一遍,80%的问题都能自己定位。
6.3 实操练手建议:把这节课串成一个完整流程
这节课内容看起来零散,但它们的组合使用价值远大于单个函数。举个例子,你完全可以把它串成一个小任务:
img = cv2.imread('input.jpg') hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 提取蓝色区域并存成mask lower_blue = np.array([100, 150, 50]) upper_blue = np.array([130, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) # 用mask提取原图中的蓝色物体 blue_objects = cv2.bitwise_and(img, img, mask=mask) # 缩放并旋转 resized = cv2.resize(blue_objects, (400, 400)) matrix = cv2.getRotationMatrix2D((200, 200), 30, 1.0) rotated = cv2.warpAffine(resized, matrix, (400, 400)) cv2.imwrite('result.jpg', rotated, [cv2.IMWRITE_JPEG_QUALITY, 95]) print("处理完成")这个流程里面包含了读取、色彩转换、掩膜、位运算、缩放、旋转、保存。建议你自己找一个色彩鲜艳的物体拍张照片,然后照着这个逻辑试一遍。只要跑通一次完整的链路,再回头看每个单独的语法点,会觉得它们之间是有关系的,而不是零散记忆。
我的个人经验是,基础语法不用死记硬背,多用“组合拳”式的功能代码去练习,比如今天做一个颜色提取,明天做一个文档纠偏,把每个函数的使用场景串起来,很快就形成肌肉记忆了。后面我再更新这个系列的时候,会把轮廓检测、形状识别、图像金字塔这些稍微进阶的内容也逐步做成保姆级的讲解,用同样的方式把每一步都讲透。