1. 为什么还要再写一篇OpenCV入门笔记
距离我上一次系统整理OpenCV的学习笔记已经过去挺长一段时间了。这几年里,OpenCV的版本从4.x一路迭代,Python生态也发生了不小的变化,当年很多"标准写法"现在回头看,要么已经过时,要么有更优雅的替代方案。更关键的是,我发现在实际带新人、做项目复盘的时候,大家卡住的地方往往不是那些高深的算法原理,而是一些特别具体、特别琐碎的"入门级"问题——比如图像读进来颜色不对、摄像头打不开、坐标点到底怎么算、轮廓找出来一堆乱七八糟的东西。
所以这篇更新篇不是把旧笔记重抄一遍,而是把我这几年在真实项目里反复用到、反复踩坑、反复给别人讲的那部分内容重新梳理出来。它面向的是刚接触计算机视觉的开发者、需要快速用OpenCV解决实际问题的工程师,以及那些学过一遍但总觉得"没学透"的朋友。核心关键词就几个:图像读写、颜色空间、几何变换、轮廓处理、视频流处理。这些是OpenCV的地基,地基不牢,后面做检测、做识别、做跟踪全是空中楼阁。
我写这篇东西的原则很简单:能跑通的代码才写进来,能解释清楚的原理才展开讲,踩过的坑一定标出来。你不需要有很深的数学背景,但需要有一点Python基础,剩下的交给我。
2. 环境搭建与版本选择的那些门道
2.1 安装方式怎么选才不折腾
OpenCV的Python安装看起来就一行命令的事,但里面的坑一点都不少。最主流的方式是pip安装官方预编译包:
pip install opencv-python但这里有个很多人不知道的细节:opencv-python和opencv-contrib-python是两个不同的包。前者只包含主模块,后者额外包含了contrib扩展模块,比如SIFT、SURF这类曾经被专利保护、后来进入主库又留在contrib里的算法,还有一些高级的特征匹配工具。如果你只是做基础的图像处理,opencv-python足够了;但如果你要做特征点检测、图像拼接、人脸识别这类稍微进阶的东西,直接上contrib版本更省事:
pip install opencv-contrib-python注意:这两个包不要同时安装,它们会互相覆盖,导致某些函数莫名其妙找不到。我见过不止一个新手因为同时装了这俩,然后
cv2.someFunction报AttributeError,排查半天。
还有一个headless版本opencv-python-headless,它去掉了GUI相关的功能(比如imshow、waitKey)。这个版本适合部署在服务器、Docker容器里,因为那些环境根本没有图形界面,装完整版反而会因为缺少系统库而报错。我自己的习惯是:本地开发用完整版,服务器部署用headless版。
2.2 版本差异带来的实际影响
OpenCV 4.x和3.x之间有一些不兼容的改动,最典型的就是cv2.findContours的返回值。在3.x里它返回三个值(image, contours, hierarchy),在4.x里只返回两个(contours, hierarchy)。很多网上的老教程还是三个值的写法,你直接抄过来就会报"too many values to unpack"。
# OpenCV 4.x 正确写法 contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # OpenCV 3.x 老写法(现在会报错) # image, contours, hierarchy = cv2.findContours(...)我建议在代码开头打印一下版本,方便排查:
import cv2 print(cv2.__version__)另外,从4.5.x开始,很多常量名也做了调整,比如cv2.CV_LOAD_IMAGE_COLOR这种老常量早就废弃了,现在统一用cv2.IMREAD_COLOR。这些细节看着小,但真到调试的时候能让你抓狂。
2.3 验证环境是否正常
装完之后别急着写业务代码,先跑一个最小验证:
import cv2 import numpy as np # 创建一个纯色图像测试基本功能 img = np.zeros((100, 100, 3), dtype=np.uint8) img[:] = (0, 255, 0) # BGR绿色 cv2.imwrite("test_green.png", img) # 读回来验证 loaded = cv2.imread("test_green.png") print("图像形状:", loaded.shape) print("像素值(BGR):", loaded[50, 50])如果输出形状是(100, 100, 3),像素值是[0, 255, 0],说明读写链路正常。这一步能帮你排除掉90%的环境问题。
3. 图像读写与颜色空间:新手最容易翻车的地方
3.1 imread和imwrite的隐藏陷阱
cv2.imread看起来简单,但它有两个特别容易忽略的行为。第一,读取失败不报错,返回None。你路径写错了、文件损坏了、格式不支持了,它都安安静静返回None,然后你后面所有操作全崩。所以我的习惯是每次读完都检查一下:
img = cv2.imread("photo.jpg") if img is None: raise FileNotFoundError("图像读取失败,检查路径和文件格式")第二,默认读取的是BGR顺序,不是RGB。这是OpenCV的历史遗留问题,因为早期它对接的是某些特定的相机和显示库。但你现在用matplotlib显示、用PIL处理、或者传给深度学习模型,它们默认都是RGB。颜色通道顺序搞反,红色变蓝色,人脸检测可能还能跑,但颜色相关的任务直接废掉。
import cv2 import matplotlib.pyplot as plt img_bgr = cv2.imread("photo.jpg") img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) # matplotlib显示必须用RGB plt.imshow(img_rgb) plt.show()imwrite也有讲究。它根据文件扩展名决定编码格式,但有些格式的参数需要显式指定。比如保存JPEG时控制质量:
cv2.imwrite("output.jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95])质量参数范围0到100,默认95。我实测下来,90到95之间肉眼几乎看不出差别,但文件体积能小不少。做Web应用的时候这个参数很关键。
3.2 颜色空间转换的实际用途
cvtColor是OpenCV里调用频率最高的函数之一,但很多人只知道BGR转RGB和转灰度,其实颜色空间的用途远不止这些。
灰度转换是最基础的,cv2.COLOR_BGR2GRAY。它的计算不是简单平均,而是加权:Gray = 0.299R + 0.587G + 0.114B。这个权重来自人眼对不同颜色的敏感度,绿色最敏感,蓝色最不敏感。理解这一点,你就能明白为什么有些彩色图转灰度后某些区域对比度特别低。
HSV空间在颜色分割里几乎是必备的。H是色调,S是饱和度,V是明度。它的好处是把颜色信息和亮度信息分开了,所以你在做颜色追踪的时候,光照变化对H通道影响相对小。比如追踪一个红色物体:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # 红色的H值在0-10和170-180两个区间(因为H是环形的) 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]) mask1 = cv2.inRange(hsv, lower_red1, upper_red1) mask2 = cv2.inRange(hsv, lower_red2, upper_red2) mask = mask1 + mask2提示:HSV的H范围在OpenCV里是0到179,不是0到360。这个和很多教材不一样,因为OpenCV为了用uint8存储把角度除以了2。新手经常在这里算错阈值。
LAB空间在颜色差异计算上更符合人眼感知。如果你要做"这两块颜色像不像"的判断,用LAB的欧氏距离比RGB靠谱得多。YCrCb空间则在肤色检测里用得比较多,因为肤色在Cr和Cb通道上聚集得比较集中。
3.3 图像基础操作:ROI、通道分离与合并
ROI(Region of Interest)提取是日常操作。OpenCV的图像就是numpy数组,所以切片语法直接可用:
# 提取左上角100x100的区域 roi = img[0:100, 0:100] # 注意:这是视图不是副本,修改roi会影响原图 # 要副本就用 .copy() roi_copy = img[0:100, 0:100].copy()这个"视图vs副本"的坑我踩过。当时想提取一块区域做处理,结果发现原图被改了,排查半天才反应过来numpy切片是引用。
通道分离用cv2.split,合并用cv2.merge:
b, g, r = cv2.split(img) # 只保留红色通道 red_only = cv2.merge([np.zeros_like(b), np.zeros_like(g), r])但split和merge比较耗时,如果只是要操作某个通道,直接用numpy索引更快:
img[:, :, 2] = 0 # 把红色通道全部置零(BGR里索引2是R)4. 几何变换:缩放、旋转、仿射与透视
4.1 缩放与插值算法的选择
cv2.resize是使用频率极高的函数,但插值参数interpolation很多人从来不设,直接用默认值。默认是INTER_LINEAR,双线性插值,适合大多数场景。但有两种情况需要改:
放大图像时,INTER_CUBIC(双三次插值)效果更好,边缘更平滑,代价是慢一些。INTER_LANCZOS4质量最高但最慢,一般只在最终输出时用。
缩小图像时,INTER_AREA是最佳选择,它基于像素区域关系重采样,能有效避免摩尔纹。我做过对比,把一张大图缩小到四分之一,用INTER_AREA和默认的INTER_LINEAR,前者明显更干净。
# 缩小用INTER_AREA small = cv2.resize(img, None, fx=0.25, fy=0.25, interpolation=cv2.INTER_AREA) # 放大用INTER_CUBIC large = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)注意:
resize的dsize参数是(width, height),不是(height, width)。这个顺序和shape是反的,我到现在偶尔还会写错。
4.2 旋转与仿射变换的矩阵推导
旋转不能直接调一个rotate函数就完事(虽然OpenCV确实有个cv2.rotate,但它只能90度倍数旋转)。任意角度旋转要用仿射变换:
def rotate_image(img, angle, center=None): h, w = img.shape[:2] if center is None: center = (w // 2, h // 2) # 获取旋转矩阵 M = cv2.getRotationMatrix2D(center, angle, 1.0) # 计算旋转后图像尺寸,避免被裁切 cos = abs(M[0, 0]) sin = abs(M[0, 1]) new_w = int(h * sin + w * cos) new_h = int(h * cos + w * sin) # 调整旋转矩阵的平移部分 M[0, 2] += new_w / 2 - center[0] M[1, 2] += new_h / 2 - center[1] return cv2.warpAffine(img, M, (new_w, new_h))这段代码的关键在于getRotationMatrix2D返回的是一个2x3矩阵,它把旋转和平移合在一起。如果你不调整平移部分,旋转后的图像四个角会被裁掉。这个"旋转后尺寸计算"是实际项目里必须处理的,网上很多示例都忽略了。
仿射变换更通用,你给三个源点和三个目标点,它就能算出变换矩阵:
pts_src = np.float32([[50, 50], [200, 50], [50, 200]]) pts_dst = np.float32([[10, 100], [200, 50], [100, 250]]) M = cv2.getAffineTransform(pts_src, pts_dst) result = cv2.warpAffine(img, M, (w, h))仿射变换保持平行线仍然平行,但不保持角度和长度。它适合做倾斜校正、简单的形变。
4.3 透视变换:文档扫描的核心
透视变换是仿射变换的超集,它连平行性都不保持了,但正因为如此,它能处理"从斜角度拍文档"这种场景。你需要四个源点和四个目标点:
pts_src = np.float32([[56, 65], [368, 52], [28, 387], [389, 390]]) pts_dst = np.float32([[0, 0], [300, 0], [0, 400], [300, 400]]) M = cv2.getPerspectiveTransform(pts_src, pts_dst) result = cv2.warpPerspective(img, M, (300, 400))做文档扫描的时候,通常先用边缘检测加轮廓查找找到文档的四个角,然后把这四个角映射到一个矩形上。这个流程我在好几个项目里用过,效果很稳。关键难点在于怎么稳定地找到那四个角,这个后面讲轮廓的时候会展开。
5. 图像滤波与边缘检测的实战取舍
5.1 滤波器的选择逻辑
滤波的目的无非两个:去噪或者增强某些特征。选择哪种滤波器,取决于你要去掉什么噪声、保留什么细节。
均值滤波cv2.blur最简单,就是邻域平均,但它会把边缘也一起模糊掉。高斯滤波cv2.GaussianBlur按距离加权,中心像素权重大,边缘保留得稍好,是最常用的预处理手段。中值滤波cv2.medianBlur对椒盐噪声特别有效,因为它取的是中位数,孤立的噪点会被直接干掉。
# 高斯滤波,核大小必须是奇数 blurred = cv2.GaussianBlur(img, (5, 5), 0) # 中值滤波,核大小也是奇数 median = cv2.medianBlur(img, 5)核大小的选择有个经验法则:核越大,去噪越强,但细节损失也越多。我一般从3或5开始试,效果不够再往上加。高斯滤波的sigma参数如果设为0,OpenCV会根据核大小自动计算,通常这样就行。
双边滤波cv2.bilateralFilter是个特殊的存在,它能在去噪的同时保留边缘,因为它同时考虑空间距离和像素值差异。代价是慢,非常慢。我一般只在最终输出质量要求高、且不追求实时性的时候用。
5.2 Canny边缘检测的参数调优
Canny是使用最广泛的边缘检测算法,但它有两个阈值参数,很多人不知道怎么调。原理是这样的:高于高阈值的点确定为边缘,低于低阈值的点确定不是边缘,介于两者之间的点,只有和确定边缘相连时才认为是边缘。
edges = cv2.Canny(img, 50, 150)低阈值和高阈值的比例经验上是1:2到1:3。我通常先设一个大概范围,然后看结果调整。如果边缘断断续续,说明低阈值太高了;如果噪点太多,说明低阈值太低了。
实操心得:在Canny之前先做一次高斯滤波,能显著减少噪点引起的假边缘。这个组合几乎是标配。
还有一个技巧:如果图像对比度低,可以先做直方图均衡化再Canny。cv2.equalizeHist对灰度图有效,彩色图要先转灰度或者用CLAHE(对比度受限自适应直方图均衡化)。
5.3 形态学操作的妙用
形态学操作(腐蚀、膨胀、开运算、闭运算)表面上是处理二值图像的形状,实际上在去噪、连接断裂、分离粘连方面特别好用。
腐蚀cv2.erode让白色区域缩小,膨胀cv2.dilate让白色区域扩大。开运算是先腐蚀后膨胀,用来去除小的白色噪点;闭运算是先膨胀后腐蚀,用来填充黑色小孔。
kernel = np.ones((5, 5), np.uint8) # 开运算:去白噪点 opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 闭运算:填黑孔 closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)核的形状也有讲究。np.ones是矩形核,cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5,5))是椭圆核,后者在处理圆形物体时更自然。我做过字符分割,用椭圆核做闭运算,笔画连接效果比矩形核好。
6. 轮廓处理:从找到到用好
6.1 findContours的正确打开方式
前面提过版本差异,这里再强调一次返回值。4.x里:
contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)RETR_EXTERNAL只找最外层轮廓,RETR_TREE找所有轮廓并建立树状关系。如果你只关心物体外边界,用EXTERNAL,速度快且结果干净。CHAIN_APPROX_SIMPLE会压缩水平、垂直、对角方向的冗余点,只保留端点,能大幅减少点数。
注意:
findContours会修改输入的图像。在4.x之前它会直接修改原图,4.x之后虽然不修改了,但为了保险,传二值图进去之前最好copy一份。
6.2 轮廓筛选的实用策略
找到轮廓只是第一步,实际项目里你往往要从几十上百个轮廓里挑出想要的。筛选维度主要有三个:面积、周长、形状。
valid_contours = [] for cnt in contours: area = cv2.contourArea(cnt) if area < 100 or area > 10000: continue perimeter = cv2.arcLength(cnt, True) # 计算圆形度:4*pi*area / perimeter^2,完美圆形为1 circularity = 4 * np.pi * area / (perimeter * perimeter) # 筛选接近圆形的轮廓 if circularity > 0.7: valid_contours.append(cnt)面积筛选能去掉噪点和小碎片,形状筛选能区分不同物体。比如做硬币检测,圆形度就很管用;做矩形检测,可以用多边形逼近后的顶点数来判断。
cv2.approxPolyDP做多边形逼近,epsilon参数控制逼近精度,一般是周长的1%到2%:
epsilon = 0.02 * cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, epsilon, True) if len(approx) == 4: # 四边形,可能是矩形或正方形 pass6.3 轮廓的绘制与标注
cv2.drawContours可以画轮廓,也可以填充轮廓:
# 画轮廓,-1表示画所有 cv2.drawContours(img, [cnt], -1, (0, 255, 0), 2) # 填充轮廓 cv2.drawContours(img, [cnt], -1, (0, 255, 0), -1)画外接矩形用cv2.boundingRect,画最小外接矩形用cv2.minAreaRect,画外接圆用cv2.minEnclosingCircle。这几个函数在做物体定位和尺寸测量时是标配。
x, y, w, h = cv2.boundingRect(cnt) cv2.rectangle(img, (x, y), (x+w, y+h), (255, 0, 0), 2) rect = cv2.minAreaRect(cnt) box = cv2.boxPoints(rect) box = np.int0(box) cv2.drawContours(img, [box], 0, (0, 0, 255), 2)minAreaRect返回的是中心点、宽高、旋转角度,boxPoints把它转成四个顶点。这个在做倾斜物体的定位时特别有用。
7. 视频流处理与摄像头操作
7.1 VideoCapture的打开与释放
视频处理的基本流程是:打开、循环读帧、处理、释放。
cap = cv2.VideoCapture(0) # 0是默认摄像头 if not cap.isOpened(): print("摄像头打开失败") exit() while True: ret, frame = cap.read() if not ret: break # 处理frame cv2.imshow("frame", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()cap.read()返回两个值:是否成功、帧数据。循环里一定要检查ret,因为视频结束或者摄像头断开时它会返回False,不检查就会对None做处理然后报错。
注意:
waitKey的参数是毫秒,1表示等待1毫秒。如果设成0,它会无限等待按键,视频就卡住了。做视频播放一般用1到30之间的值。
7.2 视频属性获取与设置
cap.get和cap.set可以读写视频属性,比如帧率、分辨率、总帧数:
fps = cap.get(cv2.CAP_PROP_FPS) width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) # 设置分辨率 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)但这里有个大坑:设置不一定生效。摄像头驱动可能不支持你设的分辨率,它会自动选一个最接近的。所以设置完之后要再get一次确认。我遇到过设1920x1080结果实际是640x480的情况,排查半天。
7.3 视频保存的编码器问题
保存视频用cv2.VideoWriter,但编码器选择经常出问题:
fourcc = cv2.VideoWriter_fourcc(*'mp4v') out = cv2.VideoWriter('output.mp4', fourcc, 30.0, (640, 480)) while True: ret, frame = cap.read() if not ret: break out.write(frame) out.release()mp4v在大多数平台能用,但有些系统上会报错。备选方案是XVID配.avi后缀,兼容性最好。如果要做Web播放,H264是最佳选择,但OpenCV默认不带H264编码器,需要额外配置。
实操心得:
VideoWriter的尺寸参数必须和实际写入的帧尺寸完全一致,否则写出来的视频是坏的。如果你在处理过程中resize了帧,记得同步改VideoWriter的尺寸。
8. 常见问题排查速查表
8.1 图像相关
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| imread返回None | 路径错误、文件损坏、格式不支持 | 检查路径,用绝对路径测试,确认文件能正常打开 |
| 颜色显示不对 | BGR/RGB顺序搞反 | 用cvtColor转换,或显示前手动调换通道 |
| 图像显示全黑或全白 | 数据类型不对、归一化问题 | 检查dtype,imshow要求uint8或float32在0-1之间 |
| resize后图像变形 | dsize顺序写反 | dsize是(width, height),和shape相反 |
8.2 视频相关
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 摄像头打不开 | 被其他程序占用、索引错误 | 关闭其他占用程序,尝试不同索引 |
| 读帧返回False | 视频结束、摄像头断开 | 检查ret,循环退出条件要处理 |
| 保存的视频无法播放 | 编码器不兼容、尺寸不匹配 | 换XVID+avi,确认尺寸一致 |
| 视频播放太快或太慢 | waitKey参数不对 | 根据FPS计算,一般1000/FPS |
8.3 轮廓与形态学
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| findContours报错 | 版本差异、输入不是二值图 | 确认版本,输入前做threshold |
| 轮廓太多太乱 | 噪点、阈值不当 | 先滤波,调整阈值,用面积筛选 |
| 轮廓断断续续 | 边缘检测阈值太高 | 降低Canny低阈值,或做闭运算连接 |
| 形态学操作无效 | 核太小、操作类型不对 | 增大核,确认开闭运算的选择 |
9. 几个让我印象深刻的踩坑记录
第一个坑是关于cv2.imshow的。有段时间我在服务器上跑代码,imshow一直报错,我以为是OpenCV装坏了,重装了好几次。后来才反应过来服务器根本没有图形界面,应该用headless版本,或者干脆把结果保存成图片再看。这个坑的本质是没搞清楚运行环境。
第二个坑是坐标顺序。OpenCV里点坐标是(x, y),但numpy数组索引是[row, col]也就是(y, x)。我在做ROI提取和画图之间切换的时候,经常把这两个搞混。后来我养成了一个习惯:凡是涉及坐标的地方,变量名都带上x和y,比如center_x、center_y,这样不容易错。
第三个坑是数据类型。OpenCV很多函数对输入类型有要求,比如cv2.threshold要求输入是单通道uint8。我有次传了个float32的图进去,结果阈值处理完全不对,但又不报错。后来养成习惯,关键步骤前打印img.dtype和img.shape,能省很多调试时间。
第四个坑是关于性能的。我做过一个实时处理的项目,一开始每帧都做完整的预处理,帧率只有十几。后来发现有些操作可以隔帧做,有些可以降分辨率做,优化之后跑到三十多帧。OpenCV的函数大多很快,但积少成多,实时项目里每一毫秒都要抠。
10. 给不同阶段学习者的建议
如果你是纯新手,我建议先把图像读写、颜色转换、resize、画图这几个基础操作练熟,用它们做点小东西,比如给图片加水印、批量改尺寸、做个简单的颜色筛选器。这些不需要理解复杂原理,但能帮你熟悉API和numpy操作。
如果你已经能跑通基础流程,下一步重点攻轮廓处理和形态学。这两个是传统计算机视觉的核心工具,很多工业检测、尺寸测量、缺陷识别的项目,本质上就是轮廓加形态学的组合。把findContours的各种参数、approxPolyDP的精度控制、形态学核的选择练透,你会发现能解决一大类实际问题。
如果你要做视频和实时应用,那性能优化就是必修课。学会用cv2.getTickCount测函数耗时,学会用ROI减少处理面积,学会在合适的地方降采样。这些技巧在文档里不会重点讲,但实际项目里决定成败。
最后说一句,OpenCV的官方文档其实写得不错,但它是按函数组织的,不适合系统学习。我的经验是:先跟着一个完整的小项目走一遍,遇到不懂的函数再回去查文档,这样学得最快。纯看文档容易迷失在参数海洋里,动手做才是正道。