
1. 从像素到黑白二值化的本质与应用场景1.1 二值化到底把图像变成了什么一上来先别急着敲代码先把“二值化”这三个字拆开。二值化本质上是对灰度图像做一次像素级分类每个像素点的值只要大于某个阈值就归到一类小于等于阈值就归到另一类在OpenCV里通常把这两类输出为255和0也就是我们常说的白和黑。生活化一点理解灰度图是一张带明暗层次的炭笔画二值化就是把它转成一张只有黑色剪影和白色背景的版画所有中间调都被丢掉。丢掉中间调听起来像是损失信息但对很多视觉任务来说这是故意为之。因为后续算法往往不关心“这块灰到底是深灰还是浅灰”只关心“这里有没有内容”。比如OCR需要知道哪些像素组成字符工业检测需要知道哪个区域出现了缺陷二维码识别需要区分黑白模块。把图片简化为两类后计算量更小特征更清晰后续分割、识别、轮廓提取都会好做很多。二值化几乎是所有图像处理教程里的第一课不是因为简单而是因为“阈值怎么定”这个问题几乎贯穿整个视觉项目。同一张图换个光照条件固定阈值就失效换一个传感器同样的参数输出完全不同。理解了二值化你就理解了图像处理里最朴素也是最核心的“信息取舍”。1.2 二值化之前预处理是绕不开的一个常见的错误是拿到BGR彩色图直接调threshold。OpenCV里threshold要求输入单通道灰度图所以第一步永远是转灰度。OpenCV灰度化用的是加权公式权重大概是R 0.299、G 0.587、B 0.114不是简单取平均因为人眼对绿色最敏感对蓝色最不敏感。直接用平均也能算出灰度图但效果往往偏暗细节会丢。不过只做灰度化还不够。二值化对噪声非常敏感图像上的小脏点、传感器噪声、压缩块边缘都可能被阈值误判成前景。常规做法是先做一步去噪常见的是高斯模糊或中值滤波。高斯模糊适合抑制随机噪声中值滤波对付椒盐噪声和细小颗粒效果更好代价是笔画边缘可能被磨掉。到底选哪个取决于你的图是文档扫描件还是工业相机照片不要无脑套用。还有一类预处理是光照校正。手机拍的文档经常出现一侧亮一侧暗全局阈值根本没法照顾每个区域。这时常见的思路是先用大核模糊估计背景亮度再把灰度图与背景做一次差分或者直接改用后面的自适应阈值。总的原则是二值化不是独立的一步而是一条流水线的“分水岭”前面预处理做到位后面阈值才选得准。2. 阈值怎么选固定阈值、大津法与自适应阈值2.1 固定阈值最直观但最容易翻车固定阈值是入门第一个方法代码最简单import cv2 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, thresh cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)这段代码的意思是原图里每个像素如果大于127就设成255小于等于127就设成0。ret会返回实际使用的阈值所以即使代码里写死127也可以从ret里看到实际值。固定阈值什么时候好用当图像背景干净、光照均匀、前景和背景对比度很高的时候它最稳。比如实验室里拍的纯黑背景上的白色标定板或者扫描仪扫描的文档这种图用固定阈值完全没问题。更合理的选法是看灰度直方图如果图像有明显的两个峰一个峰是背景一个峰是前景把阈值设在两峰之间的谷底效果最好。通常这个谷底不一定是127可能是100也可能是160。但固定阈值的问题也很致命它假设整张图的亮度条件一致。一旦图像出现阴影、渐变背景、不同批次亮度不一致同一个阈值就会在这里把背景错认成前景在那里又把前景漏掉。我处理过一张白纸上黑字的照片右侧窗户光很亮左侧有一块阴影用固定127试出来左侧大面积变成黑色块右侧文字又偏淡。这种场景就不是阈值调到多少的问题了而是这个方案本身不对需要换下面的方法。2.2 大津法让算法自己找全局阈值大津法的想法很简单把所有可能阈值从0到255都试一遍每个阈值都把像素分成两类。如果这两类的类间方差越大说明前景和背景被分得越开找到能让类间方差最大的那个灰度值就是最佳阈值。类间方差的直觉可以这样理解假设一个班的学生按身高分两组如果两组平均身高差异很大而且人数比例也接近说明分组分得很彻底如果两组平均身高几乎一样那这个分组就很失败。大津法就是把像素灰度当作身高找一个分组方式让两组之间差异最大。公式层面的核心是前景占比乘背景占比再乘两类平均灰度之差的平方也就是w0 * w1 * (μ0 - μ1)^2。OpenCV里一行就能用ret, thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU)注意这里阈值参数写0没有关系大津法会自动算ret返回的就是自动算出来的阈值。大津法最大的优点是省心大部分光照均匀的场景都能自动找到合理的全局阈值不需要人工试参。但它也有前提图像直方图最好呈现双峰分布。如果背景纹理复杂、目标占比过大或过小、光照不均导致直方图只有一个大平坡甚至多个峰大津法就容易跑偏。比如一片深色背景上放一个很小的白色物体背景像素占比太高算法会把绝大多数深色区域当成一类阈值也会跟着偏移。此时不要迷信大津法先看清楚你的图像是不是真双峰。2.3 自适应阈值局部算阈值专治光照不均自适应阈值不再全局固定一个值而是对每个像素单独算一个阈值。基本做法是把图像划分成一个个局部窗口对每个窗口计算一个参考值也就是窗口内像素的平均值或高斯加权平均值再用这个参考值减去一个常数C作为当前像素的判决阈值。左侧阴影区域的局部参考值低阈值也低右侧高亮区域的参考值高阈值也高于是光照影响被抵消了一部分。这个思路非常实用。手机拍摄的文档、执照翻拍、户外指示牌基本都带有不均匀光照全局阈值很难一次成功自适应阈值往往是最适合的起点。OpenCV里的写法是thresh cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 15, 10 )参数里blockSize15表示局部窗口是15x15必须是奇数C10表示参考值再减10。自适应阈值的好处是能保留很多细节尤其适合背景亮度缓慢变化的图像坏处是如果窗口太小或者C太小纸张纹理、水印、污渍这些低对比度区域也会被当成前景导致结果很脏。而且它比大津法慢因为要对每个像素做局部计算。但总体来说在文档图像和自然场景文本类任务里自适应阈值是出镜率最高的方案。3. OpenCV二值化实现函数细节与完整流程3.1 threshold的五种模式别再只知道BINARYcv2.threshold不只支持“大于阈值设白否则设黑”它一共支持五种模式用一张表说清楚模式像素 阈值时像素 阈值时THRESH_BINARY设为maxval常见255设为0THRESH_BINARY_INV设为0设为maxvalTHRESH_TRUNC截断为阈值保持原值THRESH_TOZERO保持原值设为0THRESH_TOZERO_INV设为0保持原值真正意义上的二值化只有前两种。TRUNC和TOZERO保留了一部分灰度信息更多用在图像增强、后处理场景里但面试和项目里经常被拿来做原理考察所以值得记住。实际使用到底用BINARY还是BINARY_INV取决于你希望前景是黑还是白。早期很多教材习惯输出黑字白底也就是字符是0背景是255但OpenCV的形态学操作默认把白色当作前景来处理膨胀、腐蚀、开闭运算也都是作用在白色区域上的。所以如果你后面要做连通域分析和形态学清理通常更建议输出白字黑底也就是用THRESH_BINARY_INV把文字变成255。这个选择看似小却能省掉后面一大堆反色问题。顺便提醒一句maxval参数不一定是255。如果你需要的是一个0和1的mask可以用maxval1得到0和1的数组。但0和1的图像直接显示时几乎全黑所以调试时先存255版本真正交给模型或算法时再按需转换。3.2 自适应阈值的blockSize和C到底怎么调自适应阈值最重要的两个参数是blockSize和C这两个值直接影响能不能打好局部阈值这张牌。blockSize是局部窗口的边长必须是正奇数。窗口越小局部性越强对光照变化的响应越快但也越容易被噪声和小纹理干扰窗口越大计算出的参考值越接近全局光照校正能力会变弱但结果更平滑。对常见文档照片我一般从11、15、21这几个值开始试。如果图像分辨率很高字符笔画在图像里占了很多像素要同步调大。C是一个偏置量它决定前景的激活门槛。参考值减掉C之后只有比这个结果更亮的像素才可能是前景。C越小二值化越敏感容易把轻微噪声、纸张纤维也拉进来C越大越能抑制低对比度噪声但笔画也可能因此断掉。常见区间是5到15但不要只看理论最实用的办法是把两个参数组合跑一遍for bs in [11, 15, 21]: for c in [5, 10, 15]: th cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, bs, c ) cv2.imwrite(fadaptive_bs{bs}_c{c}.png, th)跑完之后用看图软件快速翻一遍挑一张笔画完整、背景干净的结果再回到参数去看规律。对比几组之后你会发现字符出现断裂优先把C调小或把blockSize调小背景出现成片噪点优先把C调大或把blockSize调大。先调一个参数别两个一起乱动否则永远不知道是哪个参数起了作用。3.3 一个从彩图到干净二值图的完整流程结合前面所有内容我以一个手机拍摄的文档为例写一条最常用的预处理流水线。假设原始图片是doc.jpg画面里文字是深色纸张是浅色但纸张上有一部分阴影。import cv2 import numpy as np img cv2.imread(doc.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 第一步去噪。文档照片用高斯模糊就够核不要太大。 blurred cv2.GaussianBlur(gray, (3, 3), 0) # 第二步自适应阈值让阴影区域也有自己的局部阈值。 # 原图是深色文字、浅色纸张用BINARY_INV把文字变成白色前景。 thresh cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 10 ) # 第三步形态学清理。此时文字是白色前景背景是黑色。 kernel np.ones((3, 3), np.uint8) clean cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) clean cv2.morphologyEx(clean, cv2.MORPH_CLOSE, kernel) cv2.imwrite(doc_clean.png, clean)解释一下三个关键步骤为什么这么排。高斯模糊的核用3x3因为文档字符通常很细核太大容易把笔画抹成一团自适应阈值放在模糊之后因为噪声被压掉了局部阈值才不会被个别亮点带偏形态学先开运算再闭运算是因为开运算能去掉独立的小噪点闭运算能连接因为阈值偏保守而产生的小断裂。这里特别提醒如果你用THRESH_BINARY而不是BINARY_INV得到的会是黑字白底形态学默认把白色背景当前景开闭运算会作用在背景上结果和预期完全相反。所以代码里的这一步一定要根据文字颜色判断清楚不要照抄。4. 二值化翻车现场常见问题与排查技巧4.1 光照不均二值化结果东一块西一块现象是整张二值图被割成“正常区”和“黑块区”黑块往往正好落在阴影里。原因是全局阈值没办法适应局部亮度变化。解决思路第一选择是adaptiveThreshold它会为每个局部区域单独定阈值。同时把blockSize稍微调大一些避免局部窗口内只有文字没有背景算出来的参考值失真。如果阴影区域明暗对比实在太强自适应阈值也压不住试过比较有效的办法是先用一个比较大的高斯核对灰度图做模糊比如31x31或51x51得到一张近似背景亮度的图然后用原图减去背景亮度图把光照变化削平再做自适应阈值。这个操作不复杂效果往往立竿见影算是二值化前的小小光照校正。4.2 二值化后全是细碎噪点背景像撒了一把盐当图像是低质量扫描件或者手机夜晚拍摄时二值化结果容易出现密密麻麻的小白点。原因通常是原图噪声没有被提前滤掉或者阈值定得太低导致一些本来很暗的噪声也被划成前景。处理顺序很重要先在灰度图上做中值滤波中值滤波对椒盐噪声和孤立点非常有效再考虑用大津法代替固定阈值让阈值靠近直方图谷底最后才用一次开运算把独立小点干掉。但如果噪点非常多不要直接用大核开运算否则文字也会被削薄。正确的做法是把去噪和阈值调整放在前面形态学只是最后一道清扫。4.3 文字断裂成一截一截或者笔画粘成一条字符断裂通常有两个原因一是自适应阈值里的C设得太大导致笔画中灰度略低的部分被判成背景二是局部窗口太大参考值被周围亮背景拉高深色笔画边缘被误杀。解决办法是把C调小同时把blockSize调小比如从15调到11。如果断裂已经发生可以用闭运算修复先膨胀再腐蚀把邻近断点连起来。笔画粘连则往往反过来是阈值偏低或噪声太多导致笔画边缘不干净。处理时可以先做一次轻微腐蚀把黏连部分断开再考虑开运算。要注意形态学操作的核别一上来就定5x5、7x7对细字文档来说3x3才是安全的起点。膨胀腐蚀都是对白色前景操作的所以确保你的二值化输出里文字是白色否则先做一次bitwise_not。4.4 反色问题为什么二值图和预想完全反了不少初学者会把“前景是文字”和“像素值为255”看成必然关系其实threshold做的是“大于阈值设白”和前景定义没有必然联系。深色文字配浅色背景用THRESH_BINARY得到的是黑字白底想让文字变白色必须用THRESH_BINARY_INV。如果已经得到反色的图不需要重新跑一遍直接dst cv2.bitwise_not(thresh)就能翻转。判断需不需要反色最简单的标准是看下一步操作。常见的OCR引擎黑字白底和黑底白字通常都能处理但用OpenCV做轮廓提取、面积过滤、形态学操作时白色就是默认前景此时把文字处理成白色会顺手很多。我有一个习惯每个二值化项目的第一行就明确写下我的前景是0还是255后面所有形态学、轮廓筛选都围绕这个定义展开避免反复踩反色坑。4.5 常见问题速查表症状可能原因优先处理方式大面积黑块或亮块光照不均但用了固定阈值换大津法或自适应阈值先做光照校正背景杂质多原图噪声没处理C太小中值滤波或高斯模糊调大C开运算笔画断裂C太大或blockSize太大调小C和blockSize闭运算笔画粘连噪声多或阈值偏低去噪、调高阈值轻微腐蚀前景背景反了不了解255和前景的关系用BINARY_INV或bitwise_not翻转文字在二值化后明显变粗高斯核太大或C太小减小模糊核调大C后处理别过度膨胀这张表是我平时排查问题最先看的东西。遇到二值化效果不对先定位症状属于哪一类再动参数不要一上来把所有参数都改一遍。5. 调参经验我踩过几次坑之后养成的小习惯5.1 拿到图先看直方图再决定用哪种阈值很多人习惯直接丢进threshold默认值127然后调半天。我的建议是反过来先看灰度直方图再选方法。用OpenCV算直方图很简单hist cv2.calcHist([gray], [0], None, [256], [0, 256])如果直方图是明显的双峰固定阈值或大津法会很好用阈值就在谷底附近如果是单峰或者拖尾严重说明图像背景或光照本身就不统一老老实实走自适应阈值路线如果前景在整幅图里占比特别小大津法很容易被背景主导这时反而手动看几个点的灰度值更有把握。直方图并不会直接给你答案但它能帮你排除掉一批错误方案少走很多弯路。5.2 批量图片处理时别只用一套固定参数处理一批来源相同的图像时固定参数的确省事但只要设备、分辨率、光线条件变一个同一套参数就会翻车。我现在的做法是先取两到三张典型图试出基线参数再批量跑大津法或自适应阈值作为兜底同时把关键中间图保存下来做抽检。分辨率变化对自适应阈值影响很大。同样的文档从低分辨率变成高分辨率字符宽度近似变大一截blockSize也要跟着放大不然二值化结果全是断裂笔画。如果要从头估算起始参数可以按图像宽度推算一个大致范围再上下浮动试验。不要怕多试批量处理前花十分钟做参数扫描比跑完几千张再返工快得多。二值化调试最大的成本从来不是算力而是返工。5.3 二值化只是中间产物后面还有连通域和轮廓分析很多项目里二值化只是第一道门槛真正筛选目标靠的是轮廓分析。与其把每个像素都调得完美不如让二值化结果保持“有点杂质但不漏目标”然后靠轮廓面积、长宽比、填充率这些特征把关。比如背景里残留几点噪声后续代码加一个连通域分析面积小于阈值的区域直接忽略比反复调阈值快得多也更稳定。我常用的思路是“先粗后精”先用相对激进的自适应参数保证目标不丢再通过形态学和小面积过滤把噪声清掉如果反过来先追求背景绝对干净很容易误伤真正的前景。图像处理里很少有参数能一劳永逸二值化更是如此。做到后面你会发现真正难的不是把图变成黑白而是知道哪些该白、哪些该黑以及什么时候该停下来接受一个够用但不完美的结果。