很多人第一次用 OpenCV 处理图片十个里有八个死在同一个地方cv2.imread()读进来的图片用plt.imshow()一显示颜色全变了红蓝颠倒。这个坑几乎每个初学者都要踩一遍我也是从一脸懵的状态过来的。其实 OpenCV 图片处理常见操作本身并不难难的是你并不知道每个函数背后到底做了什么不知道它的默认行为、参数含义和适用边界。这篇内容我按照自己平时实际处理图片的路径来组织从图像的读写显示讲起到颜色空间转换、几何变换、滤波去噪、阈值与形态学最后用边缘检测和轮廓提取把前面的操作串成一个完整流程。适合刚入门 OpenCV 的读者也适合写过一些代码但总感觉哪些地方没吃透的朋友。每个部分我都会讲清楚“为什么这么做”而不只是贴一段能跑的代码。1. 图像读写显示从 BGR 这个万年深坑说起1.1 一张图被 OpenCV 读进来之后到底发生了什么OpenCV 在imread()内部用的是BGR 通道顺序而不是我们习惯的 RGB。早期的 OpenCV 底层基于 C 语言实现当时很多视频编解码库和硬件接口输出的就是 BGR 顺序这个历史习惯一直延续到今天。你用cv2.imread()读取一张彩色照片返回的 numpy 数组有三个通道顺序是 B、G、R。如果不做任何转换直接用 matplotlib 显示红蓝两个通道互换画面会变成青蓝色调人脸看起来像刚从冰柜里拿出来的。处理方式有两种。第一种是用cv2.cvtColor()显式转换import cv2 import matplotlib.pyplot as plt img cv2.imread(photo.jpg) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) plt.imshow(img_rgb) plt.axis(off) plt.show()第二种是用 matplotlib 的插件式转换简单粗暴plt.imshow(img[:, :, ::-1])这个切片操作就是把 BGR 翻转为 RGB代码量最少。我个人更推荐第一种因为cvtColor表达的意图更明确别人看代码时知道你是有意做了转换而不是凑巧把颜色弄对了。另一个高频坑是中文路径。cv2.imread()在 Windows 上遇到中文路径会直接返回None而且不报错后面所有操作全部崩溃。不要浪费时间想为什么图片读不进来直接用cv2.imdecode()配合np.fromfile()解决import numpy as np img cv2.imdecode(np.fromfile(测试图片.jpg, dtypenp.uint8), cv2.IMREAD_COLOR)我自己做项目时凡是路径可能含中文的场景一律用这个组合。保存图片也一样cv2.imwrite()处理中文路径同样会失败需要用cv2.imencode()配合tofile()cv2.imencode(.jpg, img)[1].tofile(保存结果.jpg)这两个写法你背下来就行中文路径问题从此不会再找你麻烦。1.2 读取参数与显示窗口的一些细节imread()的第二个参数容易被忽略常见的取值有cv2.IMREAD_COLOR默认彩色图、cv2.IMREAD_GRAYSCALE灰度图、cv2.IMREAD_UNCHANGED保留 alpha 通道。如果你要处理透明 PNG 的 alpha 通道一定要用IMREAD_UNCHANGED否则透明部分会变成黑色底。我最初做贴图合成时怎么贴都有一块黑底就是这个原因。显示窗口方面cv2.imshow()配合cv2.waitKey(0)是标准组合。waitKey的参数单位是毫秒传入 0 表示无限等待按键如果你不调用waitKey窗口会直接无响应这是 OpenCV 的高层 GUI 机制决定的不是你的代码卡死了。还有一个小点waitKey()返回的是按键对应的 ASCII 码想通过按键退出可以这么写cv2.imshow(window, img) while True: key cv2.waitKey(1) 0xFF if key ord(q): break cv2.destroyAllWindows() 0xFF是为了截取低 8 位在 Windows 上不加有时会出问题。这个写法我基本每次都会用到。1.3 保存图片时的质量和压缩参数imwrite()保存 JPG 时可以控制质量范围是 0 到 100默认是 95。如果你要做批量压缩可以这样指定cv2.imwrite(compressed.jpg, img, [cv2.IMWRITE_JPEG_QUALITY, 80])PNG 则是无损压缩可以控制压缩级别cv2.imwrite(compressed.png, img, [cv2.IMWRITE_PNG_COMPRESSION, 9])JPG 的质量参数越低文件越小伪影越明显PNG 的压缩级别只影响编码耗时和文件体积不影响画质。这个区别在做图片处理项目时经常用到建议记住。2. 颜色空间转换为什么你不能直接靠 RGB 找红色2.1 BGR、HSV、灰度之间的转换逻辑如果你要在一张图中找出所有红色区域直接写if B 100 and R 100 and G 80这种规则基本是自找麻烦。原因很简单RGB 三个通道和亮度是耦合的光照稍微一变同一个红色的 R、G、B 值就全变了阈值很难卡住。正确的做法是把图像转到 HSV 颜色空间再做范围筛选。HSV 把色相Hue、饱和度Saturation、明度Value拆成三个独立维度其中 H 代表颜色本身S 代表色彩浓度V 代表亮度。你要找红色只需要锁定 H 在红色范围内S 和 V 给一个宽松的区间就行光照变化对 H 的影响远小于对 RGB 的影响。上代码import cv2 import numpy as np img cv2.imread(scene.jpg) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 红色在 OpenCV 的 HSV 中范围是 0-10 和 156-180 两端 lower_red1 np.array([0, 100, 100]) upper_red1 np.array([10, 255, 255]) lower_red2 np.array([156, 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 cv2.bitwise_or(mask1, mask2) result cv2.bitwise_and(img, img, maskmask)这里有个反直觉的地方OpenCV 里 H 分量范围是 0 到 179不是 360。很多教程直接用 0-360 的范围去套结果什么都匹配不到。原因是 OpenCV 为了把色相塞进一个字节uint8把 0-360 等比例压缩到了 0-179。红色在色相环上跨越 0 度和 360 度的交界所以必须同时匹配两端这就是代码里为什么要有两个红色范围再加bitwise_or。2.2 HSV 颜色提取的完整套路颜色提取不只是生成一个 mask 就结束了后面还有个关键操作去噪和填充空洞。噪声会让 mask 上出现一些细碎的白色点空洞则是因为颜色不均匀导致目标区域内部出现黑色。通常我习惯先做一次中值滤波或形态学操作让 mask 更干净mask cv2.medianBlur(mask, 5) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))然后可以用cv2.bitwise_and把原图里被 mask 覆盖的部分提取出来。如果你想进一步统计目标面积、找出目标位置就进入后面要讲的轮廓提取环节。HSV 提取颜色这个套路在做色块识别、肤色检测、交通标志识别时非常常用值得好好掌握。2.3 通道拆分与合并的实际用途cv2.split()和cv2.merge()虽然是基础操作但用好了能解决不少实际问题。比如你想单独增强蓝色通道的亮度或者做 R、G、B 通道互换产生特殊效果b, g, r cv2.split(img) merged cv2.merge([r, g, b]) # 通道顺序调换性能上有个提醒split()对每个通道做一次拷贝比较耗内存。如果只是需要对某个通道做操作直接切片更高效b img[:, :, 0] img[:, :, 0] 255 # 直接把蓝色通道全设为 255通道拆分在图像融合、直方图均衡单通道处理后再合并中经常会用到。比如做灰度图的伪彩色渲染就是先创建一张三通道的黑色画布再把灰度值放进某一个通道最后做合并。3. 几何变换缩放、旋转、平移背后的投影数学3.1 插值方法的选择为什么缩小时别用 INTER_CUBICcv2.resize()是 OpenCV 里最简单的操作之一但它背后的插值方法选择是有讲究的。插值本质上是目标图像每个像素要映射回原图的位置而映射后的坐标通常不是整数需要根据周围像素估算出一个值。OpenCV 提供了几种插值方式插值方法特点适用场景INTER_NEAREST最近邻插值速度快易出现锯齿像素风格处理、缩略图预览INTER_LINEAR双线性插值速度适中效果平滑常规缩放的首选INTER_CUBIC双三次插值更平滑但计算量大放大图像对质量要求高的场景INTER_AREA区域插值基于像素面积的重采样缩小图像的推荐选择关键点在于缩小图像时INTER_AREA 效果最好INTER_CUBIC 反而容易产生摩尔纹和伪影。因为缩小意味着多个原图像素要合并成一个目标像素INTER_AREA 根据面积权重平均了所有参与映射的像素信息保留更合理而 INTER_CUBIC 是从周围四个像素做三次插值像素太多时可能造成高频噪声。我自己实际测试中照片缩小到一半以下尺寸时INTER_AREA 和 INTER_CUBIC 的差距肉眼可见前者干净很多。resized cv2.resize(img, (640, 480), interpolationcv2.INTER_AREA) # 或者按比例缩放 height, width img.shape[:2] resized cv2.resize(img, (width // 2, height // 2), interpolationcv2.INTER_AREA)3.2 旋转与平移一个仿射变换矩阵搞定缩放、旋转、平移这三类操作在 OpenCV 里都能统一用仿射变换来表达。仿射变换是线性变换加平移的组合它能保持直线和平行关系不会把图像扭曲成不规则的形状。平移的实现方式是构建一个 2x3 的变换矩阵M np.float32([[1, 0, dx], [0, 1, dy]]) shifted cv2.warpAffine(img, M, (width, height))旋转稍微复杂一点需要先指定旋转中心、旋转角度和缩放比例M_rotate cv2.getRotationMatrix2D((center_x, center_y), angle, scale) rotated cv2.warpAffine(img, M_rotate, (width, height))getRotationMatrix2D生成的矩阵里包含了旋转和缩放旋转角度逆时针为正。这里有一个很多人踩过的坑旋转 90 度或 270 度时图像宽高会交换如果你继续用原图宽高边缘会被裁掉。正确做法是把输出尺寸的宽高互换height, width img.shape[:2] M_90 cv2.getRotationMatrix2D((width // 2, height // 2), 90, 1.0) rotated_90 cv2.warpAffine(img, M_90, (height, width))另外旋转后图像四个角容易变黑默认填充值是 0。如果旋转对象是一张截图黑边很丑这时可以指定边界模式让边缘像素扩展rotated cv2.warpAffine(img, M_rotate, (width, height), borderModecv2.BORDER_REPLICATE)BORDER_REPLICATE表示边缘像素向外复制黑边问题立刻解决。3.3 透视变换用 getPerspectiveTransform 做文档矫正仿射变换处理不了“近大远小”的透视效果。当你斜着拍一张纸纸上原本的矩形在图像里会变成一个梯形想把它矫正回矩形必须用透视变换。透视变换和仿射变换的区别在于仿射变换只做线性变换加平移透视变换允许视角变化变换矩阵是 3x3 的。OpenCV 处理透视变换需要你提供原始四个点坐标和目标四个点坐标src_points np.float32([[x1, y1], [x2, y2], [x3, y3], [x4, y4]]) dst_points np.float32([[0, 0], [width, 0], [width, height], [0, height]]) M_perspective cv2.getPerspectiveTransform(src_points, dst_points) corrected cv2.warpPerspective(img, M_perspective, (width, height))这个操作是很多文档扫描类应用的核心。实现完整流程需要先检测边缘后面会讲 Canny然后用霍夫变换找到四条边求交点得到四个角点坐标再传给getPerspectiveTransform。整个过程我调试过很多次最容易出错的地方是点坐标的对应顺序——源点和目标点必须一一对应顺序错了画面会翻转或者严重拉伸。4. 图像滤波与去噪均值模糊、高斯模糊、双边滤波怎么选4.1 三种模糊的卷积原理差异滤波在图像处理里就是用一个卷积核遍历整张图把每个像素的值替换成它周围像素的加权平均值。核越大每个像素参考的范围越广图像越模糊。基础操作里最常见的三种模糊方式均值模糊cv2.blur()卷积核内所有权重相等简单粗暴速度快但会把边缘也抹得比较狠。高斯模糊cv2.GaussianBlur()权重按高斯分布中心像素权重最大边缘保留比均值模糊好一些是使用频率最高的平滑操作。中值模糊cv2.medianBlur()取核内所有像素的中位数作为新值。它对椒盐噪声黑白点噪声有奇效因为孤立的白点或黑点在排序后会被周围的正常值“挤掉”。实际选型记住一个经验去高斯噪声用高斯模糊去椒盐噪声用中值模糊追求边缘保留用双边滤波。不要指望一种滤波解决所有问题。4.2 常用场景证件照磨皮与美颜的思路把滤波操作用在真实场景里最有意思的例子是磨皮。传统的高斯模糊会把皮肤细节和皱纹一起去掉虽然平滑但看起来像塑料。美颜软件的做法通常是先用双边滤波保留边缘只平滑平坦区域这样皮肤变细腻了轮廓和五官还是清晰的。smooth cv2.bilateralFilter(img, d9, sigmaColor75, sigmaSpace75)双边滤波和前面几种滤波最大的不同是它除了空间距离还考虑了像素值差异。卷积核里值差异越大的像素权重越低所以边缘像素值突变的位置能保留下来。参数sigmaColor控制颜色差异的敏感度sigmaSpace控制空间距离的影响范围值越大平滑力度越强。用双边滤波做磨皮效果已经很接近早期美颜处理的思路了。如果想进一步提亮肤色可以配合 HSV 空间把 V 通道稍微拉高或者用后面要讲到的直方图均衡来调整亮度分布。4.3 小细节核大小和标准差对效果的影响几个参数细节要注意。高斯模糊的核大小必须是正奇数比如 3、5、7原因是卷积核需要有一个中心锚点偶数核没有中心像素。如果你只传sigmaX不传核大小程序会根据标准差自动计算核大小反之如果你只传核大小不传标准差标准差也会被自动推导。官方文档里有个推荐公式核越大标准差也随之增大。gauss cv2.GaussianBlur(img, (0, 0), sigmaX2.0)我实际使用中更喜欢显式指定核大小和标准差避免不同版本 OpenCV 的默认行为差异导致效果不稳定。双边滤波的d参数是直径表示在多大范围内计算传 0 时会根据sigmaSpace自动计算。d越大计算越慢处理大图时速度可能会让你怀疑人生这时可以先把图缩小再滤波做完再放大回来视觉差距并不大。5. 阈值、二值化与形态学把图像变成“干净”的黑白5.1 全局阈值和大津法 Otsu 的问题阈值处理的目标是把灰度图变成二值图也就是每个像素要么是 0黑要么是 255白。最简单的方式是全局阈值_, binary cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)灰度值大于 127 的像素变成白色小于等于 127 的变成黑色。但问题是光照不均时全局阈值会废掉。左边亮右边暗的图同一个物体在亮处的灰度可能大于 127暗处小于 127用固定阈值一卡物体就被切得七零八落。大津法Otsu能自动寻找最佳全局阈值算法思想是遍历所有可能阈值找让前景和背景类间方差最大的那个值。它能改善一部分情况但本质上还是全局策略遇到光照梯度大的图仍然会被分崩离析_, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU)5.2 光照不均时用自适应阈值自适应阈值是局部策略图像被分成很多小块每个小块内部分别计算阈值小块里的像素再和自己的局部阈值比较。这样即使全图光照不均匀每个局部区域都能得到相对合理的分类结果。adaptive cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize11, C2 )blockSize是局部邻域的大小必须是奇数C是最终阈值的一个偏移量用于微调。我自己的经验是扫描文档里如果纸张本身有阴影脏污自适应阈值往往比所有全局阈值都靠谱。但自适应阈值也有代价它容易把比较均匀的区域拆出杂色纹理所以用完后通常要配合形态学开闭运算来清理。5.3 腐蚀、膨胀、开闭运算二值图的整形手术二值化之后图像里总会有一些断开的地方或者多余的小白点。腐蚀和膨胀这两个操作就是专门处理这类问题的腐蚀cv2.erode()对于每个像素如果核覆盖范围内有任何一个像素是 0这个像素就被置为 0。结果是白色区域缩小能去掉小的白点断开细的连接。膨胀cv2.dilate()如果核覆盖范围内有任何一个像素是 255当前像素就被置为 255。结果是白色区域扩大能填平小孔连接断裂的线条。腐蚀和膨胀组合起来就是更常用的形态学操作kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) # 开运算先腐蚀再膨胀去掉白色前景里的小噪点 opening cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 闭运算先膨胀再腐蚀填补前景内部的空洞 closing cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)getStructuringElement用来生成卷积核MORPH_RECT是矩形核也可以选MORPH_ELLIPSE椭圆核或MORPH_CROSS十字核。提取车牌字符时我常用矩形核做开运算去掉噪点做字符分割前又用闭运算把断裂的笔画连起来。结构元素的大小和形状直接影响形态学效果这个需要根据目标的实际情况调整没有固定答案。6. 边缘检测与轮廓提取把“常见操作”串成实战6.1 Canny 边缘检测的双阈值策略Canny 算子是目前最常用的边缘检测算法它的完整流程包括高斯模糊去噪、计算梯度幅值和方向、非极大值抑制、双阈值检测、滞后边界跟踪。其中双阈值是 Canny 最核心的机制高于高阈值的像素一定是边缘低于低阈值的一定不是夹在中间的像素只有与确定边缘相连才被保留。edges cv2.Canny(gray, threshold150, threshold2150)threshold1和threshold2就是低阈值和高阈值。经验法则是高阈值大约是低阈值的 2 到 3 倍。阈值设得低边缘细节多但噪声也多设得高边缘干净但容易丢失弱边缘。我自己通常先用 50 和 150 起步再根据图像内容调整。在 Canny 之前最好先做一次高斯模糊因为 Canny 对噪声非常敏感噪声会被当作梯度变化产生大量伪边缘。如果边缘检测完后出现密密麻麻的碎线大概率是原图噪声太多。6.2 轮廓查找findContours 的返回值陷阱findContours是 OpenCV 里坑比较多的函数最大的坑在版本差异。OpenCV 3 之前它返回三个值OpenCV 3 之后只返回两个值# OpenCV 3.x 及以上的正确写法 contours, hierarchy cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)第二个参数mode决定轮廓的层次关系RETR_EXTERNAL只取最外层轮廓RETR_LIST提取所有轮廓但不建立层次关系RETR_TREE保留完整的嵌套层级。做外接矩形检测时一般用RETR_EXTERNAL就够了一是速度快二是避免把内圈的轮廓重复识别出来。第三个参数method决定轮廓点的存储方式CHAIN_APPROX_SIMPLE会压缩水平、垂直和对角方向的冗余点只保留端点和拐角CHAIN_APPROX_NONE保存所有轮廓点。大部分场景用CHAIN_APPROX_SIMPLE就够了内存占用和计算量都小很多。6.3 实战用轮廓筛选识别图像中的目标区域掌握了轮廓提取就能做点实际的事了。这里用“找出图像中最大的矩形区域并矫正”为例把前面所有操作串起来。完整流程分五步灰度化、去噪、边缘检测或二值化、轮廓查找、轮廓筛选。import cv2 import numpy as np # 1. 读图并预处理 img cv2.imread(document.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (5, 5), 0) # 2. 边缘检测 edges cv2.Canny(gray, 50, 150) # 3. 形态学闭运算把边缘连起来 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) edges cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) # 4. 查找外轮廓 contours, hierarchy cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 5. 遍历轮廓筛掉面积太小或形状不规则的 doc_contour None max_area 0 for cnt in contours: area cv2.contourArea(cnt) if area 1000: continue # 用多边形近似轮廓减少边缘锯齿影响 epsilon 0.02 * cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, epsilon, True) if len(approx) ! 4: continue if area max_area: max_area area doc_contour approx # 6. 如果找到了四边形的轮廓做透视矫正 if doc_contour is not None: pts doc_contour.reshape(4, 2).astype(np.float32) # 按左上、右上、右下、左下排序 pts_sorted pts[np.argsort(pts[:, 0])] # 先按 x 排序 left pts_sorted[:2] right pts_sorted[2:] left left[np.argsort(left[:, 1])] right right[np.argsort(right[:, 1])] src np.vstack([left[0], right[0], right[1], left[1]]).astype(np.float32) width_max int(max(np.linalg.norm(src[1] - src[0]), np.linalg.norm(src[2] - src[3]))) height_max int(max(np.linalg.norm(src[3] - src[0]), np.linalg.norm(src[2] - src[1]))) dst np.float32([[0, 0], [width_max, 0], [width_max, height_max], [0, height_max]]) M cv2.getPerspectiveTransform(src, dst) result cv2.warpPerspective(img, M, (width_max, height_max)) cv2.imwrite(corrected.jpg, result)这段代码里几个细节值得说明。approxPolyDP的意义是把轮廓逼近成更简单的多边形因为轮廓边缘可能带小锯齿直接判断len(cnt)会得到几十上百个点逼近后的多边形顶点数才是真正的几何形状指标。面积筛选area 1000是为了排除那些检测到的小噪点轮廓阈值依据实际情况调整。排序点的逻辑比较粗暴但能处理大多数“轻微倾斜”的场景如果旋转角度特别大最好按质心和角度做更严格的排序。透视矫正用到的getPerspectiveTransform就是前面 3.3 节介绍过的内容到这里你会发现前面所有基础操作最终都能组合成一个完整的应用。写在最后几个必须记住的经验教训我现在再做 OpenCV 图片处理基本已经形成了一套条件反射。第一所有图像处理流程的第一步先确认通道顺序和数据类型。BGR 还是 RGB、uint8 还是 float、范围是 0-255 还是 0-1这三件事搞错任何一个后面全部白做而且排查起来很浪费时间。第二处理大图时先缩小再处理做完再放大回来很多算法的耗时和图像尺寸呈现非线性关系双边滤波和高斯模糊在大尺寸上尤其明显。第三不要迷信某个参数的“推荐值”光照、噪声、目标大小不同同一组参数的效果可能天差地别调参的时候单独写一个脚本可视化中间结果比盲猜效率高得多。OpenCV 图片处理的常见操作说白了就这些读写、颜色转换、几何变换、滤波、阈值、形态学、边缘检测、轮廓提取。每个单独看都不难难的是把它们组合成一条连贯的流水线。我提到的那几个坑——中文路径、BGR 顺序、旋转尺寸交换、findContours 返回值——都是自己一次次踩出来的希望这篇内容能帮你少走一点弯路。