ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

OpenCV图像处理基础:灰度化与二值化核心原理详解

OpenCV图像处理基础:灰度化与二值化核心原理详解 1. 灰度化处理为什么要先把图像变“灰”做图像处理的朋友应该都有体会打开一张彩色图片后第一步往往不是急着去检测边缘、识别轮廓而是先把它转成灰度图。原因很简单灰度图只有亮度信息没有颜色信息计算量小、处理速度快而且很多后续算法本身就是基于亮度梯度来工作的。1.1 灰度图的本质单通道亮度矩阵先明确一个概念。彩色图像在OpenCV中默认是BGR三通道每个像素由三个值组成分别代表蓝、绿、红三个分量的亮度。灰度图像则只有一个通道每个像素只有一个值范围是0到2550表示纯黑255表示纯白中间值是不同程度的灰。打个比方彩色图像是一张三层叠在一起的透明胶片每层负责一种颜色灰度图像就是一张普通的黑白照片只有明暗变化。做图像处理时大多数算法关心的是“亮度怎么变化”而不是“颜色是什么”所以灰度化就成了最常用的预处理手段。1.2 灰度化常用的三种计算方法灰度化的核心问题是怎么把三个通道的值合并成一个值。常见的做法有三种最大值法取三个通道中的最大值作为灰度值。这种方法会让图像整体偏亮适合保留高光细节。平均值法取三个通道的平均值。计算简单但得到的结果在视觉上会偏暗因为人眼对绿色更敏感而平均值没有体现这一点。加权平均法给三个通道分配不同的权重绿色最高、红色次之、蓝色最低。OpenCV的cvtColor函数使用的就是加权平均权重系数是B: 0.114G: 0.587R: 0.299。为什么绿色权重最高因为人眼对绿色光谱区域最敏感对蓝色最不敏感。加权平均法得到的灰度图最接近人眼观察到的亮度感所以也是实际工程中使用最多的方案。import cv2 import numpy as np # 读取彩色图像 img cv2.imread(example.jpg) # 方法一使用OpenCV内置函数采用加权平均法 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 方法二手动实现加权平均 weighted_gray np.uint8(0.114 * img[:, :, 0] 0.587 * img[:, :, 1] 0.299 * img[:, :, 2]) # 方法三平均值法 avg_gray np.mean(img, axis2).astype(np.uint8) # 方法四最大值法 max_gray np.max(img, axis2).astype(np.uint8)这里需要提醒一下使用cvtColor时输入图像必须是uint8类型否则会报错。另外如果输入图像是从imread读取的默认就是BGR顺序转换时要用COLOR_BGR2GRAY不要用COLOR_RGB2GRAY否则通道顺序不对得到的结果会偏色。1.3 为什么推荐用cvtColor而不是手动计算有人可能会问既然手动计算也能实现灰度化为什么还要用OpenCV自带的函数第一个原因是性能。cvtColor底层是经过优化的C实现处理大尺寸图像时速度优势非常明显。我自己在测试1920x1080的图像时cvtColor大概需要几毫秒而用Python的numpy手动计算要慢很多尤其是用循环逐像素操作时差距可以达到几十倍。第二个原因是代码的可读性和可维护性。cvtColor一行代码意图明确别人看代码时一眼就知道你在做灰度化。手动计算的代码还要解释权重系数是什么意思增加了沟通成本。2. cv2.threshold二值化函数详解如果说灰度化是把彩色图变成黑白灰的过渡图那二值化就是把灰度图变成只有黑和白两种值的图。二值化的结果是“非黑即白”0表示黑255表示白中间值全部被替换掉。这个操作在做文档扫描、字符识别、缺陷检测等场景中非常常用。2.1 threshold函数的参数解析cv2.threshold是OpenCV中最基础、最常用的二值化函数。完整签名如下retval, dst cv2.threshold(src, thresh, maxval, type)四个参数的含义分别是src输入图像必须是单通道灰度图可以是8位或32位浮点型。thresh阈值一个具体的数值用来做像素值的分界。maxval最大值当像素值超过阈值时会被设置为这个值。通常取255。type阈值类型决定具体的二值化规则。函数返回两个值retval是实际使用的阈值在使用OTSU或三角法时这个值会被自动计算出来不是传入的threshdst是二值化后的输出图像。2.2 五种阈值类型的数学原理与适用场景这五种类型是面试和实践中都会碰到的基础知识点我把它们的规则和适用场景整理出来了类型规则T为阈值M为maxval适用场景THRESH_BINARY大于T的设为M否则设为0最常用白色前景黑色背景THRESH_BINARY_INV大于T的设为0否则设为M黑色前景白色背景THRESH_TRUNC大于T的设为T否则保持不变抑制高亮保留亮度层次THRESH_TOZERO大于T的不变否则设为0去除暗部噪声保留亮部细节THRESH_TOZERO_INV大于T的设为0否则保持不变去除亮部干扰保留暗部细节这里用一个例子来说明更直观。假设有一个像素值数组[10, 80, 120, 180, 240]阈值设为150maxval设为255THRESH_BINARY[0, 0, 0, 255, 255]THRESH_BINARY_INV[255, 255, 255, 0, 0]THRESH_TRUNC[10, 80, 120, 150, 150]THRESH_TOZERO[0, 0, 0, 180, 240]THRESH_TOZERO_INV[10, 80, 120, 0, 0]如果你做的是“把目标区域提取成白色”这类任务那就用THRESH_BINARY如果想把背景变成白色而目标变成黑色用THRESH_BINARY_INV。比如做OCR文字识别时白底黑字的图片用THRESH_BINARY_INV黑底白字的图片用THRESH_BINARY。2.3 固定阈值和自适应阈值的取舍cv2.threshold使用的是全局固定阈值也就是说整张图片都用同一个数字做分界。这对于光线均匀、前景背景对比明显的图像非常有效缺点是遇到光照不均匀的情况就会翻车。举一个我自己踩过的坑拍摄一张印在塑料瓶上的二维码由于瓶子是圆柱面光照在两侧和中间差别很大中间亮、两边暗。用固定阈值二值化后中间部分正常两侧的二维码线条几乎全断了。后来换成了cv2.adaptiveThreshold自适应阈值效果立刻就好了。自适应阈值会根据每个像素周围一个小区域的亮度情况计算出局部阈值来做判断。在处理光照不均匀的图像时优先考虑自适应阈值# 自适应阈值blockSize必须是奇数 adaptive_thresh cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2 )但自适应阈值的缺点是速度比全局阈值慢而且参数blockSize、C值需要按经验调整。如果图像质量比较好用全局阈值就够了不必为了“先进”而牺牲效率。3. 实操从读取图像到完成二值化的完整流程这一节我直接从实际项目出发完整演示一遍从读取图像到灰度化、再到二值化、最后显示对比结果的整套流程并解释每个步骤的细节和意图。3.1 基础案例固定阈值二值化先跑一个最基础的版本代码不超过20行import cv2 import matplotlib.pyplot as plt # 1. 读取图像 img cv2.imread(document.jpg) # 2. 灰度化 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 3. 固定阈值二值化 ret, binary cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) # 4. 输出阈值并显示结果 print(f使用的阈值: {ret}) plt.figure(figsize(10, 5)) plt.subplot(1, 3, 1), plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) plt.title(Original), plt.axis(off) plt.subplot(1, 3, 2), plt.imshow(gray, cmapgray) plt.title(Gray), plt.axis(off) plt.subplot(1, 3, 3), plt.imshow(binary, cmapgray) plt.title(Binary (threshold127)), plt.axis(off) plt.show()这里有几个细节要说一下第一plt.imshow显示图像时彩色图像需要把BGR转换成RGB否则显示出来的颜色会偏蓝偏暗。灰度图和二值图直接用cmapgray不转换也没关系。第二阈值127是一种“拍脑袋”的取值方式。它是255的一半听起来合理实际上未必好用。很多实际图像的前景和背景亮度分布并不均匀127不一定是两者的分界点。更好的做法是用OTSU自动计算阈值。第三cv2.threshold返回的第一个值ret在固定阈值模式下就等于我们传入的thresh在OTSU模式下才是自动计算出来的最佳阈值。打印ret可以帮助我们了解自动阈值具体选在了哪个亮度级别。3.2 进阶OTSU自动阈值与大津法原理OTSU大津法是一种自动确定最佳阈值的算法核心思想是找到一个阈值使得用这个阈值把图像分成前景和背景两类后两类之间的类间方差最大。类间方差越大说明两类像素的亮度差异越明显分割效果也就越好。在OpenCV中使用OTSU非常简单只需要在type参数上加上cv2.THRESH_OTSU# OTSU自动阈值二值化 ret_otsu, binary_otsu cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) print(fOTSU自动计算的最优阈值: {ret_otsu})注意这里的thresh参数传的是0实际传入什么值都会被忽略因为OTSU会自己遍历所有可能的阈值0到255来寻找最优解。大津法处理双峰明显的图像效果很好所谓双峰明显就是图像直方图上有两个明显的峰一个对应背景一个对应前景。但遇到直方图是单峰或者多峰的情况OTSU的效果就不太稳定了。比如图像中前景和背景面积差距极大或者有强烈噪声干扰时OTSU选出的阈值可能会偏向面积更大的那类。我在实际项目中用OTSU的经验是先看一眼灰度直方图再决定用不用你。如果直方图是明显的双峰直接上OTSU省心省力如果直方图一团糟那就先做滤波高斯模糊、中值滤波预处理再做阈值或者干脆改用自适应阈值。# 查看灰度直方图辅助判断是否适合用OTSU import matplotlib.pyplot as plt plt.hist(gray.ravel(), bins256, range[0, 256]) plt.title(Gray Histogram) plt.xlabel(Pixel Value) plt.ylabel(Frequency) plt.show()3.3 案例车牌字符分割前的二值化处理二值化最典型的应用之一是车牌字符分割。这里结合车牌识别场景来说明实际操作思路。拿到一张车辆照片车牌区域在图像中亮度较高、颜色通常是蓝色底白字。第一步先把图像转成灰度图然后做OTSU二值化。由于车牌字符是白色背景是蓝色灰度化后白色字符的灰度值通常高于蓝色背景所以用THRESH_BINARY即可让字符变成白色、背景变成黑色。import cv2 import numpy as np # 读取车辆图像 car_img cv2.imread(car.jpg) # 裁剪出车牌区域实际项目中往往用轮廓检测或目标检测先定位 plate_area car_img[300:360, 120:420] # 灰度化 plate_gray cv2.cvtColor(plate_area, cv2.COLOR_BGR2GRAY) # 高斯模糊去除噪点 plate_blur cv2.GaussianBlur(plate_gray, (3, 3), 0) # OTSU二值化 ret, plate_binary cv2.threshold(plate_blur, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 找到字符轮廓 contours, hierarchy cv2.findContours(plate_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)这个流程里的几个关键操作值得展开说一下高斯模糊在二值化之前先做模糊是为了去除图像中的细粒度噪声。这些噪声在二值化后可能变成小白点干扰后续的轮廓提取和字符分割。但是模糊核不能太大我用的是3x3再大就容易把字符边缘也糊掉了。找轮廓参数选择RETR_EXTERNAL只提取最外层轮廓能过滤掉字符内部的“孔洞”轮廓比如数字8内部的空心部分对于字符分割来说非常合适。CHAIN_APPROX_SIMPLE是压缩轮廓点减少内存占用和计算量。3.4 阈值类型组合技巧type参数支持按位或操作组合使用最常见的就是THRESH_BINARY THRESH_OTSU。具体来说# OTSU ret1, dst1 cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 三角法TRIANGLE适用于直方图单峰的情况 ret2, dst2 cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_TRIANGLE)很多初学者不知道THRESH_TRIANGLE的存在。三角法的原理是连接直方图最高峰和最远端的点然后计算直方图上每个点到这条连线的垂直距离距离最大的那个点对应的灰度值就是阈值。它适用于直方图只有一个明显峰的图像比如细胞显微图像背景暗、目标亮且目标面积远小于背景。遇到这类图像三角法往往比OTSU更稳。4. 实际应用中的常见问题与排查技巧前面讲完了原理和基础流程这一节我把实际工程中经常遇到的问题整理成速查表并分享一些排错思路。4.1 常见问题速查表问题现象可能原因解决办法cv2.error: src is not a valid array输入图像为空检查文件路径是否正确用cv2.imread后判断img是否为Nonethreshold报错src data type 21 is not supported输入不是单通道灰度图先调用cvtColor(gray)转换或检查图像是否读成了RGBA格式二值化后图像全黑或全白阈值设置不当使用OTSU自动选阈值或者打印灰度直方图分析亮度分布结果中出现大量白色噪点图像噪声多先做高斯滤波或中值滤波再进行二值化字符边缘不连续有断线阈值过高或光照不均降低阈值或改用自适应阈值imshow显示图像全白像素值类型不是uint8检查输出图像的数据类型必要时用astype(np.uint8)转换4.2 遇到全黑或全白图像时的排查思路这是最常碰到的问题。新手好不容易写完代码一看显示结果要么全黑要么全白第一反应往往是怀疑自己的阈值写错了。但实际上问题可能出在更早的环节。我之前就遇到过一种情况用智能手机拍摄的图片直接读进来imread返回的图像是正常的但二值化后全黑。后来检查才发现拍摄的图像是RGBA四通道格式带了透明通道而我没有注意到这一点直接在天真地取img[:, :, 0]当作一个通道的灰度值来处理结果取到的是透明通道那个通道的值几乎全是0二值化自然全黑。排查方法很简单读入图像后立刻打印img.shape如果看到第三维是4说明是RGBA格式需要先用cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)转成三通道再做灰度化。另一个常见原因是输入图像的像素值范围不是0到255而是0到1。有些时候用cv2.imread正常但图像经过某种归一化处理后变成了float64类型且值在0到1之间。这时候直接用cv2.threshold虽然不报错但阈值255就永远不可能被达到结果必然全黑。排查时打印gray.dtype和gray.max()就能判断。4.3 光照不均场景下的处理经验固定阈值最大的敌人就是光照不均。在实际的工业视觉项目中几乎不存在完美均匀的光照环境。我的处理策略通常是这样的第一步先尝试用GaussianBlur模糊原图然后把原图和模糊图相减得到一个去除背景光照的差值图再用固定阈值处理这个差值图。这种操作在OpenCV里属于形态学里的顶帽操作Top-hat可以用cv2.morphologyEx一步完成# 顶帽操作原图 - 开运算图用于提取亮背景下的暗细节 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) tophat cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel) # 对顶帽结果二值化 ret, binary cv2.threshold(tophat, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU)如果顶帽操作还不够就直接上adaptiveThreshold。这个函数我在前面已经示例过唯一的难点是参数调整。blockSize一般取11到31之间的奇数C值是常量用于决定判定时是否偏向某一侧。我自己debug时习惯先用一个小图快速试参数确定合适的blockSize和C值之后再应用到整批图像上。4.4 关于data type21报错的特别提醒这是使用cv2.threshold时非常高频率出现的报错信息类似这样cv2.error: OpenCV(4.5.5) :-1: error: (-5:Bad argument) in function threshold Overload resolution failed: - src data type 21 is not supporteddata type 21对应的是CV_32F也就是说输入的图像是32位浮点类型。cv2.threshold默认支持CV_8U8位无符号整数和CV_32F两种类型但实际上很多OpenCV版本对CV_32F只支持特定的阈值类型混用时会直接报错。解决办法有两个第一种是把图像转换成uint8类型gray_uint8 np.uint8(gray)第二种是把图像缩放到0到255的范围再转换# 如果图像是浮点型且在0到1之间 gray_scaled np.uint8(gray * 255)我建议把类型检查写成一个固定的习惯动作。在处理图像前先确认三件事图像是否为空、通道数是否正确、数据类型是否是uint8。这三步确认完90%以上的基础报错都能避开。4.5 轮廓提取之前为什么要先做二值化很多做OpenCV入门的朋友都会有这个疑问灰度图也是单通道为什么不能直接用来找轮廓非要多此一举做二值化原因在于cv2.findContours提取的是“具有相同像素值”的连通区域边界。灰度图中像素值千变万化相邻像素之间灰度差异很小边界模糊不清提取出来的轮廓会有很多噪声和断裂。二值化等于把灰度图的256个等级强制压缩成两个等级这样前景和背景的边界变得非常清晰轮廓算法才能稳定地找到目标区域的边界。当然如果你处理的图像是灰度渐变非常平滑的比如指纹图像直接做二值化会丢失很多细节信息这时候用Canny边缘检测而不是二值化来做轮廓提取的前置步骤效果会更理想。5. 灰度化与二值化的进阶扩展方向基础功能掌握之后这个专题还能往后延伸很多方向这里简单整理几个值得深入的方向供参考。方向一彩色图像直接二值化很多初学者不知道cv2.threshold也支持彩色图像直接输入它会分别在每个通道上独立执行阈值操作返回的dst与输入通道数一致。但这种做法实际价值不大因为各通道独立阈值后再合并颜色信息被割裂了处理结果往往不如先转灰度再做单通道二值化稳定。除非你明确知道自己的颜色通道分离后仍然具有一致的亮度特征否则不推荐。方向二自适应阈值和大津法的组合使用自适应阈值处理光照不均匀的优势明显但它没有“全局最优”的概念。如果用自适应阈值处理完图像后继续对结果做一个OTSU全局二值化往往可以把自适应阈值留下的灰色过渡区域进一步压缩让结果更干净。这个组合方案在文档拍照矫正里效果不错。方向三阈值处理在图像分割中的定位从整个图像处理流程来看灰度化和二值化都只是预处理手段真正的目标往往是后续的分割、检测和识别。理解了这一点你在做二值化时就不会盲目追求“越干净越好”而是会根据后续算法的需求来调节处理的粒度。比如后面要做字符识别就不要把字符边缘弄得过于细腻否则识别器反而会因为噪声过多而识别失败如果后面要做大区域轮廓提取就可以适当模糊用更大范围的连通域来过滤小瑕疵。6. 写在最后的几个实操建议做图像处理这么多年灰度化和二值化看起来是最基础的内容但越是基础的东西越值得花时间训练手感。我个人的经验是拿到一张图片后不要急着写代码先用可视化工具比如matplotlib画直方图观察亮度分布再决定用固定阈值还是OTSU、要不要加滤波、是全局阈值还是自适应阈值。这个“先观察、后动手”的习惯能省下大量debug时间。另外一点关于代码里的阈值数值不要硬编码。即使你通过观察确定了一个当前图片效果很好的阈值也建议尽量用OTSU或三角法自动计算。因为你今天处理的图片和明天处理的图片光照环境可能完全不一样硬编码的阈值会让程序变得脆弱。最后再分享一个小技巧调试二值化效果时别只盯着最终结果图看把中间过程的灰度图、直方图、二值化结果图都打印出来对比着看。很多时候一眼就能看出问题出在灰度化阶段还是阈值选择阶段比反复改参数瞎猜要高效得多。这些基础功扎实了后面学轮廓检测、边缘检测、图像分割都会轻松很多。
返回列表