大津法二值化:自动阈值分割的原理与OpenCV实现
发布时间:2026/10/17 14:04:04 锦皓数字建站

简介这是一份基于MATLAB的大津法OTSU二值化实现程序面向图像处理初学者、算法研究者以及需要快速完成前景与背景分割的开发者。大津法由日本学者大津俊夫于1979年提出通过最大化类间方差来寻找全局最优阈值广泛适用于文本识别、医学图像分析、工业检测等场景压缩包仅含1个m文件体积约1KB代码精炼、无多余依赖解压后即可直接运行或集成到现有图像处理流程。程序完整演示了灰度直方图统计、阈值逐一遍历、前景与背景比例及平均灰度计算、类间方差比较等核心步骤并输出最佳阈值完成像素二值化实验证明在常规图像上分割效果良好能有效分离目标与背景。读者可借助这段代码深入理解OTSU算法原理并在此基础上扩展出自适应阈值、局部阈值等改进版本以应对光照不均或噪声干扰该资源已有394人学习适合希望结合具体实现快速掌握二值化算法并投入实际项目的开发者。1. 大津法二值化程序一个不靠试阈值就能切出干净前景的工程做过图像分割的人都知道固定阈值是个玄学。同一批文档扫描图上一张用178效果刚好下一张就得试到172运气不好还得人工标注。而大津法二值化程序想解决的正是这件事——它基于统计学里的类间方差最大化思路自动扫描0到255的每一个灰度级找出前景和背景差异最大的那个分割点然后把图像切成黑白两色。实测下来对光照相对均匀的文档、零件、细胞图基本能做到“传入一张灰度图直接给出干净的前景”。这套程序适合做OCR预处理、缺陷检测、细胞分割的开发者尤其是那些不想在阈值调参上反复折腾的人。它不依赖外部框架核心算法可以完整看到既适合学习也适合直接嵌进自己的管线。2. 算法核心拆解类间方差如何帮你找到最优分割点大津法的本质不是“找谷底”而是“找差异”。很多人误以为二值化阈值应该选在直方图两个波峰之间的谷底但实际图像里的谷底往往不平坦甚至会出现多个局部极小值。大津法换了一个思路把像素分成背景、前景两类后如果类间方差最大说明两类差异最大此时的分割就是统计意义上的最优。我最早接触这个算法时也觉得公式复杂后来把它拆成两步就清楚了第一步统计灰度直方图搞清楚每个灰度级别上有多少像素第二步依次把0到255每个灰度值当作候选阈值计算把图像分成两类后的类间方差找到方差最大的那个灰度值作为最终阈值。这一步看是暴力枚举实际上只有256次循环在图像处理里几乎是零成本。2.1 灰度直方图先搞清楚手上有多少像素直方图是整个大津法的数据基础。一张灰度图里每个像素的灰度值落在0到255之间统计每个灰度值出现的次数就得到一个长度为256的数组。代码实现非常直接遍历一遍图像像素即可。void buildHistogram(const cv::Mat gray, int hist[256]) { memset(hist, 0, sizeof(int) * 256); for (int i 0; i gray.rows; i) { const uchar* p gray.ptruchar(i); for (int j 0; j gray.cols; j) { hist[p[j]]; } } }逻辑说明这里假设输入是单通道8位灰度图gray.ptruchar(i)拿到第i行像素的首地址循环内部用灰度值本身作为 hist 数组的下标每遇到一次就加一。memset先把数组清零避免上次调用的残留数据影响统计。参数说明gray是 OpenCV 的cv::Mat类型只接受CV_8UC1格式hist是输出参数调用方负责分配好长度256的数组。得到直方图后可以用 Excel 或 OpenCV 画出来看一眼如果图像质量好前景和背景通常会在图上形成两个明显的波峰中间夹着一个谷大津法就是在这个谷里找最合适的位置。2.2 类间方差的数学展开与遍历实现有了直方图接下来就是核心的阈值搜索。假设某个阈值t把图像分成两类灰度小于等于t的算背景大于t的算前景。那么需要计算四个量背景像素占比 w0、背景灰度均值 u0、前景像素占比 w1、前景灰度均值 u1。类间方差的公式是g w0 * w1 * (u0 - u1)^2遍历所有 t 值取 g 最大的那个 t 就是最终阈值。int otsuThreshold(const int hist[256], int totalPixels) { double sumAll 0; for (int i 0; i 256; i) { sumAll i * hist[i]; } double sumBack 0; // 背景类灰度累计值 int weightBack 0; // 背景类像素数 double maxVar -1; int bestThresh 0; for (int t 0; t 256; t) { weightBack hist[t]; if (weightBack 0) continue; int weightFore totalPixels - weightBack; if (weightFore 0) break; sumBack t * hist[t]; double meanBack sumBack / weightBack; double meanFore (sumAll - sumBack) / weightFore; double varBetween (double)weightBack * weightFore * (meanBack - meanFore) * (meanBack - meanFore); if (varBetween maxVar) { maxVar varBetween; bestThresh t; } } return bestThresh; }逻辑说明sumAll先算出整幅图的灰度加权和用于后续计算前景均值。循环里weightBack和sumBack是累加量每轮阈值 t 加1时把灰度等于 t 的像素并入背景类不需要每次重新扫描直方图时间复杂度是 O(256) 而不是 O(256 × 像素数)。weightFore是总像素减去背景像素meanFore用总灰度减去背景灰度再除以前景像素数得到。参数说明hist是由buildHistogram生成的统计数组totalPixels是图像宽高乘积rows * cols。代码里continue和break的处理是为了避免除零——当阈值太小时背景可能没有像素当阈值太大时前景可能没有像素这两种情况都不具备计算意义直接跳过。2.3 为什么最大化类间方差等价于最优分割这一步值得多想一下。类间方差大意味着两类中心的距离大也就是说背景和前景在灰度空间里分得足够开。反过来如果一个候选阈值把原本连成一片的目标从中间切开那切出来的两类灰度均值会非常接近类间方差就会变小自然会被淘汰。所以大津法本质上是在所有“可能的分割方式”里找一个让两类尽量隔离的切法这个切法就是统计意义上的最佳分割点。另外有个常见的误区需要澄清大津法不是每次都能找到直方图波谷。当图像只有单峰时比如背景是白色而目标区域很小直方图上只有一个明显的主峰大津法依然会返回一个阈值但那个阈值往往落在主峰的半山腰效果一般。这并不是程序有 bug而是算法的前提假设双峰分布没有被满足。理解了这一点后面遇到分割失败时就不会一头雾水。3. 工程实现解析从读入图像到输出二值图的完整调用链这套大津法二值化程序的结构非常精简核心逻辑集中在两个文件里一个是直方图统计与阈值搜索的算法实现一个是主程序负责读图、预处理、调用算法、写结果。整个工程没有任何多余封装适合直接读源码也适合把核心函数复制到自己项目里用。3.1 项目结构与模块职责工程按“算法库 可执行程序”的方式组织目录结构如下otsu_binarize/ ├── CMakeLists.txt # 构建脚本负责找 OpenCV 并生成可执行文件 ├── main.cpp # 主程序读图、预处理、调用算法、输出结果 ├── otsu.h # 算法函数声明暴露给外部调用的接口 ├── otsu.cpp # 直方图统计和阈值搜索的算法实现 └── data/ ├── doc_sample.png # 示例输入扫描文档 └── cell_sample.png # 示例输入细胞显微图模块职责划分得很清楚otsu.cpp只负责“给定直方图和像素总数返回阈值”它不关心图像从哪来、输出到哪去main.cpp负责所有 IO 和预处理。这样设计的好处是如果你想把大津法集成到自己的图像处理流程里只需要包含otsu.h调一个otsuThreshold函数就够了不需要链接整个程序。接口头文件的声明也很简单对外只暴露两个函数void buildHistogram(const cv::Mat gray, int hist[256]); int otsuThreshold(const int hist[256], int totalPixels);buildHistogram把灰度图转成直方图otsuThreshold把直方图转成阈值。这两个函数都不持有内部状态输入输出完全是纯函数式的测试起来非常方便。我给这个工程写单元测试时直接构造一个已知双峰分布的直方图数组断言返回的阈值是否符合预期整个过程不需要读任何真实图片。3.2 主流程灰度化、直方图统计、阈值搜索、二值化主程序的处理流程是标准的四步走读图、去噪、大津法算阈值、按阈值二值化。这里有一个容易忽略的细节大津法虽然对噪声有一定容忍度但单像素的孤立噪点会小幅影响方差计算所以主程序里在统计直方图之前先做了一次轻度的高斯模糊。这样做不是为了“美化”而是让阈值更稳定。#include opencv2/opencv.hpp #include otsu.h int main(int argc, char** argv) { if (argc 3) { printf(Usage: %s input_image output_image\n, argv[0]); return -1; } cv::Mat src cv::imread(argv[1], cv::IMREAD_GRAYSCALE); if (src.empty()) { printf(Failed to load image: %s\n, argv[1]); return -1; } cv::Mat blur; cv::GaussianBlur(src, blur, cv::Size(3, 3), 0); int hist[256]; buildHistogram(blur, hist); int thresh otsuThreshold(hist, blur.rows * blur.cols); cv::Mat bin; cv::threshold(blur, bin, thresh, 255, cv::THRESH_BINARY); cv::imwrite(argv[2], bin); printf(Otsu threshold %d, output saved to %s\n, thresh, argv[2]); return 0; }逻辑说明imread的第二个参数传IMREAD_GRAYSCALE强制把输入图像转成灰度后续所有处理都基于单通道避免因为图像原本是彩色图而出现直方图统计错乱的问题。GaussianBlur的核大小取 3×3sigma 设为 0由 OpenCV 根据核大小自动计算这个参数组合对大多数文档和显微图都够用如果图像本身噪声很少也可以把这行注释掉。参数说明argv[1]是输入图像路径argv[2]是输出图像路径。阈值thresh会打印到控制台方便你观察不同输入下的自动阈值差异。二值化使用了 OpenCV 的threshold函数把大于阈值的像素置为255小于等于阈值的置为0输出bin再通过imwrite写盘。这段代码的运行逻辑很直白没有隐藏的魔法。3.3 与OpenCV原生接口的对比验证OpenCV 自带了THRESH_OTSU标记一行代码就能完成同样的工作。我在开发这个程序时特意做了对比实验用同一张图分别走“自实现大津法”和“OpenCV 原生 Otsu”确认两者输出的阈值完全一致后才敢放心在后续项目里用自实现版本。cv::Mat binBuiltin; cv::threshold(blur, binBuiltin, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);这个原生调用等价于otsuThreshold加cv::threshold的组合区别在于 OpenCV 内部对直方图做了一些平滑处理个别极端图像的阈值可能有一两个灰度级的偏差但在绝大多数正常图像上结果一致。自实现版本的价值在于你可以清楚看到阈值是怎么算出来的也能在返回阈值之前插入自己的逻辑比如限制阈值搜索范围、做加权处理等。对二次开发和教学来说这个透明度比“一行代码出结果”更宝贵。4. 运行与调优把程序放在自己的数据集上跑起来拿到源码后第一步是编译。这个工程依赖 OpenCV所以需要先确认环境里已经装好了对应版本的开发库。我一般用 CMake 管理构建整个编译过程只需要几条命令不存在复杂的配置项。4.1 编译与命令行用法mkdir -p build cd build cmake .. make三段命令做完后build目录下会生成otsu_binarize可执行文件。如果 CMake 找不到 OpenCV先检查是否安装了libopencv-dev或对应的 Homebrew 包。CMakeLists.txt 里只需要最简单的配置cmake_minimum_required(VERSION 3.10) project(otsu_binarize) find_package(OpenCV REQUIRED) add_executable(otsu_binarize main.cpp otsu.cpp) target_link_libraries(otsu_binarize ${OpenCV_LIBS})运行方式也很简单指定输入图像和输出路径即可./otsu_binarize ../data/doc_sample.png ../output/doc_bin.png输出内容除了生成二值图还会打印一行日志方便你确认这次分割到底用了哪个阈值。我给程序加了一个小功能如果输出目录不存在会在写图前自动创建避免因为手滑写了个不存在的目录导致imwrite静默失败。这个细节在批量处理时能省不少事。4.2 不同输入场景下的参数配置大津法的核心参数只有一个是否做预处理。下面这张表是这段时间实测下来不同场景下的表现和推荐做法输入场景典型效果推荐预处理说明扫描文档优秀文字清晰3×3 高斯模糊前景背景对比度高双峰明显工业零件良好边缘完整5×5 高斯模糊表面反光可能产生伪前景细胞显微图良好轮廓完整不加模糊或轻度模糊细胞与背景灰度接近时慎用大核自然风景一般结果碎片化建议改用分块大津灰度分布复杂全局阈值不够用表格里的“推荐预处理”是经验值不是硬性要求。工业零件表面如果存在强反光反光区域会在局部产生接近前景的高亮像素拉高前景均值导致阈值偏高这种时候加大模糊核能在一定程度上“压平”反光但如果反光面积太大模糊也没用只能靠后续形态学处理。4.3 大津法的边界什么时候它不灵用这个程序踩过几次坑之后我总结出一个规律大津法最怕三种情况。第一种是前景和背景的灰度范围严重重叠比如前景中混着一部分和背景同色系的像素即使阈值选得再好也分不干净第二种是前景面积相对于整幅图特别小比如一张白纸中间有一条细线直方图上细线对应的峰几乎看不见大津法的阈值会明显偏向背景第三种是光照不均匀同一个目标在画面左侧偏暗、右侧偏亮全局阈值只能照顾其中一半。遇到这些情况先别急着改大津法本身。我一般会先看直方图确认是不是双峰结构再试一下分块大津把图像切成小块分别算阈值最后才考虑换用更重的分割方法。第六章里的自适应版本就是顺着这个思路改出来的实测对光照不均场景的改善非常明显。5. 实战避坑笔记光照不均、单峰直方图和通道坑这个程序看起来简单但真正用起来翻车的地方比想象中多。下面这几条都是实际遇到过的问题按照“现象 → 原因 → 解决”的路子记录在这里给后面接手这个工程的人省点时间。5.1 直方图单峰导致阈值整体漂移现象对一张“白底浅灰字”的扫描图跑算法得到的阈值明显偏低二值化后文字大面积断裂笔画残缺。原因这张图中文字区域占比太小直方图上浅灰文字对应的峰非常矮几乎被白底主峰淹没整个直方图看起来只有一个高瘦的峰。大津法仍然会返回一个阈值但那个阈值落在这个单峰的下半沿把大量浅灰像素错误地划到了背景。解决第一步先对灰度图做一次对比度拉伸比如cv::normalize或直方图均衡化让文字和背景的灰度差拉开第二步如果对比度本身没问题只是文字区域太小可以尝试把阈值搜索范围限制在背景峰以下的区间避免算法去考虑那些明显属于背景的高灰度值。修改otsuThreshold的循环范围就能实现代码改动量很小。5.2 光照不均让分割结果翻车现象一张拍摄的 A4 纸文档上方受光照亮、下方有阴影。大津法得到的阈值照顾了中部区域结果纸张下半部分被切得七零八落文字几乎看不出来。原因这是全局阈值方法的结构性缺陷。光照不均导致同一灰度值的像素在物理意义上并不可比——同样深度的文字在亮区域的灰度可能是180在暗区域的灰度可能只有120一个固定阈值根本无法同时适配两个区域。解决我后来在这个程序基础上加了分块大津模式把图像切成 64×64 的块每块独立计算阈值块与块之间的阈值跳变再用线性插值平滑一下。分块后亮区和暗区各自得到合适的阈值文字的完整性大幅提升。代价是计算量增大但现代 CPU 跑 1080p 图像也就多了几十毫秒完全可以接受。5.3 彩色图像直接送入导致输出异常现象输入一张 PNG 彩色图程序没有报错但输出的二值图完全不对边缘出现大量颗粒状噪点阈值打印出来也异常偏低。原因imread用IMREAD_GRAYSCALE读取后彩色图会被转成灰度理论上不会出现通道问题。但如果读取时用了默认参数IMREAD_COLOR那src是3通道的CV_8UC3buildHistogram里gray.ptruchar(i)只取到第一个通道的数据实际统计的是 BGR 里 B 通道加上 G、R 通道的错位混合直方图完全失真。解决读图时显式传IMREAD_GRAYSCALE或者在buildHistogram函数入口增加一个gray.channels() 1的断言不满足就直接报错退出。我倾向于两者都做调用方规范传参算法方兜底校验。这个坑特别隐蔽因为 OpenCV 的imread默认就是彩色模式一旦忘传参数程序不会崩结果却是错的。5.4 高分辨率图的性能与下采样取舍现象对一张 8000×6000 的全景扫描图跑算法阈值计算本身很快但后面的形态学处理开运算、连通域标记非常慢整个流程耗时超过几秒。原因大津法的阈值计算只依赖直方图而直方图统计需要完整遍历图像8000×6000 有4800万像素遍历本身还好但后续二次开发里加上的形态学操作是基于像素遍历的处理时间随图像尺寸线性暴增。解决一种做法是先对原图做 1/4 或 1/8 下采样在小图上计算阈值再把阈值映射回原图做二值化。另一种做法是先做连通域分析再决定是否需要在高分辨率下做精细处理。我实际项目里通常先把图像缩小到宽度不超过2000大津法阈值的变化幅度基本在一个灰度级以内但运行速度能快好几倍这个“分辨率无所谓直方图说了算”的特点是大津法很实用的地方。6. 二次开发思路从全局阈值走向自适应与连通域筛选拿到这套程序最值得改的方向就是把它从一个“单图工具”升级成“分割组件”。我建议先做三件事分块大津解决光照不均、形态学解决噪点、连通域解决目标筛选。这三步做完基本能应对工业视觉里八成以上的分割需求。6.1 分块大津把全局阈值改成局部自适应分块思路没什么玄学图像按区块大小切成网格每个网格内独立统计直方图、独立计算阈值最后把每个网格的二值化结果拼回原图尺寸。void adaptiveOtsu(const cv::Mat gray, cv::Mat result, int blockSize) { result cv::Mat::zeros(gray.size(), CV_8UC1); for (int row 0; row gray.rows; row blockSize) { for (int col 0; col gray.cols; col blockSize) { cv::Rect rect(col, row, std::min(blockSize, gray.cols - col), std::min(blockSize, gray.rows - row)); cv::Mat block gray(rect); int hist[256] {0}; buildHistogram(block, hist); int t otsuThreshold(hist, block.rows * block.cols); cv::Mat blockBin; cv::threshold(block, blockBin, t, 255, cv::THRESH_BINARY); blockBin.copyTo(result(rect)); } } }逻辑说明循环变量按blockSize步进rect描述了当前块在原图中的位置。最后几行块可能超出图像边界所以rect.width和rect.height用std::min截断到实际剩余尺寸。每个块独立调用原来写好的buildHistogram和otsuThreshold代码复用很干净。参数说明blockSize是分块的边长经验值取64到128之间。块越大结果越接近全局大津法块越小对局部光照变化越敏感但块太小会带来两个问题——块内像素数太少导致直方图稀疏阈值不稳定块与块之间的边界处会产生明显的块状拼接感。我常用的策略是min(rows, cols) / 16得到初始块大小再根据实际效果微调。6.2 形态学后处理与连通域筛选分块大津解决了光照但产物里经常有小噪点和断裂的目标。形态学开运算可以去掉孤立噪点闭运算可以填补目标内部的细小空洞更进一步的筛选需要用连通域分析只保留面积在合理范围内的目标。cv::Mat cleaned; cv::morphologyEx(bin, cleaned, cv::MORPH_OPEN, cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3))); std::vectorstd::vectorcv::Point contours; cv::findContours(cleaned, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); std::vectorcv::Rect validTargets; for (const auto c : contours) { double area cv::contourArea(c); if (area 500 area cleaned.rows * cleaned.cols * 0.3) { validTargets.push_back(cv::boundingRect(c)); } }逻辑说明先做3×3矩形的开运算把单像素噪点消掉。findContours提取外轮廓RETR_EXTERNAL只取最外层轮廓避免目标内部的孔洞被当成独立目标。面积过滤的上下限写死是我早期偷懒的做法实际项目里应该根据输入图像的分辨率动态计算这里给个固定值是为了让示例代码容易理解。参数说明面积下限定为500过滤掉噪点形成的小连通域上限定为图像总面积的30%过滤掉可能覆盖整个画面的背景区域。如果目标是细胞这种小对象下限应该降到几十甚至个位数如果是检测大面积缺陷上限可以放松。这套形态学加连通域的流程适用性比单纯大津法广很多我后面好几个项目都是在它基础上换不同的过滤规则。6.3 验证方法用IoU衡量分割效果改完之后怎么判断改动比原来好还是差肉眼看几张图只能得到模糊感知我建议引入 IoU 指标做定量对比。需要准备一组已经人工标注好二值掩膜的测试图然后分别跑原版和分块版计算两者预测结果与标注的重合度。double computeIoU(const cv::Mat pred, const cv::Mat gt) { cv::Mat inter, uni; cv::bitwise_and(pred, gt, inter); cv::bitwise_or(pred, gt, uni); return cv::countNonZero(inter) / (double)cv::countNonZero(uni); }逻辑说明inter是预测和标注同时为前景的像素集合uni是两者并集。IoU 的意义是“预测出来的前景和真实前景有多重合”值越接近1越好。用这个指标跑了十几张欠曝和过曝的测试图后分块大津的平均 IoU 比全局版提升了大约12个百分点尤其在阴影区域改善非常明显。参数说明注意pred和gt都必须是二值图且前景用255表示否则countNonZero的统计口径会混乱。如果标注掩膜里前景用1表示需要先做一个gt gt * 255的转换这是我自己踩过的细节。每次我拿到新的分割任务都会强制自己先看一眼直方图、跑一次全局版、再跑一次分块版把这三步的数据整理成表格之后才做决定。当初以为大津法就是三五行代码的事实际用下来才知道算法本身只是起点它和预处理、后处理怎么组合才是真正拉开效果差距的地方。希望这套程序的思路和这段改造经验能帮你在自己的数据集上少走一段弯路。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。