首页 / 资讯中心 / 文章详情

gocv图像投影实战:水平投影与垂直投影实现字符分割

gocv图像投影实战:水平投影与垂直投影实现字符分割 ★ FEATURED ARTICLE
做机器视觉这些年我越来越发现一个反直觉的事实最土的办法往往最能解决问题。水平投影和垂直投影就是典型代表——一个按行统计像素一个按列统计像素听起来像本科作业可字符分割、表格线定位、文档区域提取里全都绕不开它。让我重新入坑 gocv 的是一个 Go 服务里的文档自动归档需求不想引Python 那套重型运行环境还得快速定位扫描件里的文字位置。折腾下来我用 gocv 图像水平投影和垂直投影搭了一条预处理流水线今天就把原理、完整实现和踩过的坑一次讲清楚。1. 先把投影这件事想明白1.1 水平投影和垂直投影到底是什么水平投影翻译成人话就是把图片按行切开统计每一行里属于前景的像素有多少个。垂直投影同理按列切开统计每一列里前景像素有多少个。这里的“前景”通常来自二值化之后的白色像素也就是我们真正感兴趣的内容。我简单画一个例子。假设下面是 5 行 7 列的二值图1 代表前景0000000 0110110 0110110 0110110 0000000水平投影就是每一行的 1 的个数结果是[0, 4, 4, 4, 0]。垂直投影就是每一列的 1 的个数结果是[0, 3, 3, 0, 3, 3, 0]。看到这个结果你大概就懂了投影本质上做的是一种“密度压缩”。它把二维图像压成一维数组却保留了内容在某个方向上的聚集位置。如果你发现水平投影中间几行突然有值就说明这些行上有横向内容堆积如果垂直投影中间几列连续有值就说明这些列上有竖向内容堆积。正是这种特性让它成为分割和定位的利器。1.2 为什么这类需求在 gocv 里特别值得做我之前做图像处理更多用 Python毕竟 OpenCV 的 Python 绑定点子多、生态全。但在后端服务里Python 的部署有时挺烦的解释器、依赖库、虚拟环境全都要伺候。Go 就不一样编译出来一个可执行文件扔到服务器上就能跑。gocv 是 OpenCV 的 Go 绑定API 风格也贴近原生 OpenCV处理图像水平投影、垂直投影这类操作完全够用。有人觉得 Go 不适合做视觉因为图像处理需要大量矩阵运算。但投影这个操作不是矩阵论的大工程它本质上是求和、计数、阈值比较。Go 写循环做这些事性能并不差。实测一张 2000 像素宽的灰度图纯 Go 逐像素遍历做垂直投影单次也就几毫秒到十几毫秒。放到 HTTP 服务里这个开销完全可以接受。1.3 投影能解决的三个典型场景投影适合的场景其实很有边界感不是万金油但一旦适合效果非常直接。第一个是 OCR 前的文本行切分。文档扫描件里文字行天然形成水平方向的密集区域水平投影能快速把行与行之间的空白带找出来每一段连续非零区域就是一行文字。第二个是字符切分。拿到一行文字后再做垂直投影笔画的左右空档会让列方向出现明显的低谷按这个低谷切开就能得到单个字符的边界框。第三个是表格线检测。表格的横线在水平投影里表现为一条连续的高值横带竖线在垂直投影里表现为连续的高值竖带。配合形态学操作可以把整张表格的网格结构粗定位出来。这三个场景足够覆盖我遇到的大部分文档处理需求。下面从环境开始讲起。2. 准备阶段gocv 环境与 Mat 的基础操作2.1 安装 gocv 的前置条件gocv 不是纯 Go 库它底层调用 OpenCV所以安装前需要先把 OpenCV 装好。Linux 系统一般用系统包管理器装开发包装好之后执行go get -u gocv.io/x/gocvmacOS 可以通过 Homebrew 装 OpenCVWindows 则需要用官方预编译包或本机构建。Windows 上大家报错最多的就是找不到opencv_world动态库本质上是环境变量没指对建议把 OpenCV 的 bin 目录加进系统 PATH。版本上我建议选和 OpenCV 4.x 配套的 gocv 版本。不同版本的 API 略有差异尤其新增函数和常量名文章里的代码以较新的版本为例。2.2 Mat 的像素读取方法gocv 里所有图像数据都装在一个叫Mat的结构体里。你要读单个像素可以直接用GetUcharAt(row, col)。注意顺序第一个参数是行号 y第二个参数是列号 x别写反了。GetUcharAt只适合单通道 8 位图也就是灰度图或二值化之后的图。如果你把一张三通道彩图直接拿去GetUcharAt拿到的只会是第一个通道的数值而且容易产生语义混乱。所以我在做投影前通常会先把图像转成灰度再二值化确保Mat类型是CV_8U。另一个必须养成的好习惯是Mat.Close()。gocv 是 C/C 库的绑定很多Mat在堆上分配内存不 Close 就会泄漏。尤其是循环处理视频帧或批量图片时如果不及时释放内存占用会肉眼可见地往上涨问题还特别难排查。2.3 二值化为什么是投影的前提投影统计的是“前景像素”如果图像是灰度图一个像素值 100 算前景还是背景很难说。所以投影之前一定要做二值化把每个像素分成两类要么是 255要么是 0。gocv 里最常用的二值化接口是Threshold。比如把灰度图按 127 分界binary : gocv.NewMat() defer binary.Close() gocv.Threshold(gray, binary, 127, 255, gocv.ThresholdBinaryInv)这里有个特别容易踩的坑你要搞清楚前景到底是黑色还是白色。黑字白底的扫描件文字是黑的前景但二值化后如果想把文字变成白色像素就要用ThresholdBinaryInv。白字黑底的图反过来用ThresholdBinary。投影函数统计的是值大于 0 的像素所以选错模式结果就跟预期完全相反。3. 核心代码水平投影和垂直投影的完整实现3.1 读图和二值化先写一个能跑起来的骨架。用 gocv 直接把图片按灰度读入package main import ( fmt log gocv.io/x/gocv ) func main() { src : gocv.IMRead(scan.png, gocv.IMReadGrayScale) if src.Empty() { log.Fatal(无法读取图像) } defer src.Close() fmt.Printf(图像尺寸: %d x %d, 通道数: %d\n, src.Cols(), src.Rows(), src.Channels()) binary : gocv.NewMat() defer binary.Close() // 黑字白底用 BinaryInv白字黑底用 Binary gocv.Threshold(src, binary, 127, 255, gocv.ThresholdBinaryInv) h : horizontalProjection(binary) v : verticalProjection(binary) _ h _ v }这里直接读成灰度图后面少一次CvtColor。如果你读的是彩色图记得先gocv.CvtColor转成灰度。3.2 水平投影的循环实现水平投影的逻辑非常直白遍历每一行统计该行前景像素个数。核心函数如下func horizontalProjection(bin gocv.Mat) []int { rows, cols : bin.Rows(), bin.Cols() proj : make([]int, rows) for r : 0; r rows; r { count : 0 for c : 0; c cols; c { if bin.GetUcharAt(r, c) 0 { count } } proj[r] count } return proj }这段代码返回一个长度为rows的整数切片。切片的下标就是图像的行号值就是该行前景像素的个数。拿到这个切片之后你很容易看出哪些行有内容、哪些行是空白。例如proj[20] 300表示第 20 行有 300 个前景像素大概率处于某个文字行的中间位置。3.3 垂直投影的循环实现垂直投影的逻辑就是把行和列遍历顺序反过来外层循环按列内层循环按行func verticalProjection(bin gocv.Mat) []int { rows, cols : bin.Rows(), bin.Cols() proj : make([]int, cols) for c : 0; c cols; c { count : 0 for r : 0; r rows; r { if bin.GetUcharAt(r, c) 0 { count } } proj[c] count } return proj }这两个函数是整篇文章的核心。它们的复杂度是 O(rows × cols)对一张百万像素级别的图也就是百万量级的像素读取Go 跑起来并不慢。如果你觉得遍历太慢可以进一步做降采样但实际工程里投影之前往往先裁剪 ROI计算量往往没想象中那么大。3.4 把投影结果可视化出来调试的时候只看一个整数数组很难直观判断结果。我习惯把投影值打印成 ASCII 条形图或者直接画到一张和原图等大的图上。终端打印更简单核心思路是归一化func printProjection(proj []int, maxWidth int) { maxVal : 0 for _, v : range proj { if v maxVal { maxVal v } } if maxVal 0 { return } for _, v : range proj { bar : v * maxWidth / maxVal for i : 0; i bar; i { fmt.Print(#) } fmt.Println() } }这种方式能非常快速地带你看清投影的形态有没有明显的峰、峰与峰之间有没有干净的间隔、有没有不该出现的毛刺。我第一次调阈值时就是靠这个 ASCII 图发现噪声导致的行间隙被填满的。4. 实战案例用投影把一行字拆成单个字符4.1 第一步水平投影找文本行现在来做一个完整任务把图片里的一行行文字切出来再进一步切出单个字符。拿到水平投影数组之后不能简单地把所有非零行都算作文本因为噪声也可能造成零星的非零值。我通常会用一个findSegments函数把连续满足条件的位置合并成一个区间type Segment struct { Start int End int } func findSegments(proj []int, minValue, minWidth int) []Segment { segs : make([]Segment, 0) inSeg : false start : 0 for i, v : range proj { if v minValue { if !inSeg { start i inSeg true } } else if inSeg { if i-start minWidth { segs append(segs, Segment{Start: start, End: i - 1}) } inSeg false } } if inSeg len(proj)-start minWidth { segs append(segs, Segment{Start: start, End: len(proj) - 1}) } return segs }对水平投影调用findSegments(proj, 5, 5)就能得到若干文本行区间。这里的minValue是每行最少要有多少个前景像素防止单点噪声minWidth是最小行跨度防止把单个噪点当成一整行。4.2 第二步在每一行内做垂直投影拆字拿到文本行的起止行号后下一步就是在那个行区域内做垂直投影。做法很简单先从原二值图里把这一行的 ROI 裁剪出来然后对这个 ROI 执行之前写的verticalProjection。gocv 里裁 ROI 用Rect对应的接口裁完之后接着走同样的统计逻辑。垂直投影的数组同样可以用findSegments处理。此时返回的每一段连续区间基本上就是一个字符的左右边界。再配合之前水平投影得到的行的上下边界每个字符的矩形框就完整出来了。这个流程对打印体、等宽字体、英文数字效果特别好。中文字因为笔画多、结构复杂投影会出现同一汉字内部左右断开的情况比如“川”这类左右部件分离的字会被切成两半。这个后面我会专门讲怎么处理。4.3 分割后还要做什么投影给出的是一组粗边界框距离可直接使用的字符框还有一段路。工程上我会加三个过滤规则第一是宽高比过滤。数字和英文字母的宽高比通常在一个范围内太扁或太宽的候选框多半是噪声或粘连字符。第二是面积过滤。投影段虽然连续但可能来自扫描污点计算候选框内前景像素总数低于阈值就丢掉。第三是字符间距合并。如果两个字挨得太近垂直投影可能分不开反而变成一个宽框需要根据平均字符宽度判断是否需要重新切分。投影分割最大的价值是快而且不依赖模型。它能帮你把无关区域去掉、把字符候选框定位到局部后续再交给分类器或深度学习模型准确率会高很多。4.4 表格线检测的另一种玩法同样的思路换到表格线上就是另一个效果。表格里的水平线在二值图中是一整行连续的前景像素所以水平投影在这个行位置会出现一个很高的尖峰。垂直线的特征同理在对应列产生高尖峰。实际操作时我常用的做法是先对二值图做一次横向形态学膨胀把横向线段连接得更完整再做水平投影找到横线位置然后做纵向形态学膨胀用垂直投影找竖线位置。两者求交集就能恢复出表格的网格骨架。5. 参数调优、常见问题和经验5.1 阈值全局阈值 vs 自适应阈值前面代码里固定用 127 当阈值这在干净、光照均匀的文档上没问题。但我在一批偏色扫描件上翻过车背景局部偏灰文字局部偏浅用 127 一刀切导致笔画断裂投影出现大量假峰。这时应该考虑 Otsu 或自适应阈值。Otsu 会根据图像灰度分布自动找一个类间方差最大的阈值gocv 的Threshold接口也能用阈值参数传 0 再叠加 Otsu 枚举值即可。如果噪声比较重先做一次高斯模糊或中值滤波再二值化效果会稳定很多。我的经验是投影算法本身很脆弱它完全依赖二值化的质量。如果二值化错了后面所有投影结果都是错的。所以遇到投影结果怪异先别怀疑投影函数回去检查二值图和预处理。5.2 遇到粘连和断裂怎么办投影最大的两个敌人一个是笔画断裂一个是字符粘连。断裂会让投影值在某个字符内部掉到 0导致一个字符被切成两半。常见来源是光线不均、阈值过高、笔画太细。处理手段分几步先降低阈值或者用自适应阈值再做闭运算膨胀再腐蚀把同一字符内部的间隙填上。闭运算对修复断裂非常有效但内核太大又会把相邻字符连起来所以内核尺寸需要根据字符笔画宽度调。粘连会让两个字符在垂直投影里变成一个峰分不开。常见来源是阈值过低、字符间距本身小、图像分辨率低。处理时先用开运算去掉多余噪点再看投影段宽度是否远超正常值。如果超了就用连通域分析或按平均字符宽度在峰谷处二次切分。这些调参没有一个万能值每条都要结合具体图片试。我的习惯是做一个参数面板把阈值、内核大小、最小面积都做成可调项跑一批图看统计效果再定默认值。5.3 性能、内存和 Mat 生命周期有些读者会担心 Go 逐像素遍历太慢。真实情况要看图片大小和循环优化。一张 1000×1000 的图双重循环一百万次每次只是读一个uint8耗时通常可以接受。真正慢的是在循环里反复调用绑定函数比如在循环里对单个像素做 OpenCV 操作那会有大量 CGO 开销。我建议在投影前先缩放到合适尺寸。比如 300dpi 的 A4 扫描件可能有 2500×3500 像素直接做投影也不算灾难但如果只是找文本行位置完全可以先缩到一半速度能涨三到四倍边界定位精度足够用。内存管理上要特别留意每个Mat用完都要Close()。在批量处理图片时千万别一股脑defer Close()因为函数不退出就不会释放。正确做法是在每张图片处理完的路径上显式调用Close()或者用局部函数封装让defer在局部作用域结束时就触发。5.4 常见问题速查表我把实际调试中遇到的典型问题整理成一张表方便你快速对照现象可能原因处理方式投影全是 0二值化模式选反前景变成了背景确认黑字白底用ThresholdBinaryInv投影全是 255 或接近满值阈值太低背景没被滤掉提高阈值或改用 Otsu一个字符被切成多段笔画断裂降低阈值、闭运算、先高斯模糊多个字符连成一段字符粘连或阈值太低提高阈值、开运算、按字距二次分割投影有明显毛刺椒盐噪声中值滤波或形态学开运算整幅图投影正常但 ROI 错位坐标没有加上 ROI 起点偏移分割列区间时加上裁剪区域的左边界和上边界这里要特别强调最后一行的坐标偏移问题。我早期犯过这个错对整幅图做垂直投影得到列区间后直接拿这个区间坐标去原图画矩形结果画偏了。原因就是我先裁剪了 ROI但列区间是在 ROI 内部计算的没有把 ROI 的起始横坐标加回去。这个小问题最容易在调试中被忽略。6. 思路的延伸和我的实践心得6.1 投影和连通域分析怎么配合投影定位快但不擅长处理结构复杂的对象。比如中文合体字、叠字、旋转文本框投影结果往往不干净。这时候我会把投影和连通域分析结合先用投影缩小搜索范围再在候选区域内跑连通域分析得到更精确的字符或组件块。还有一点投影对图像倾斜非常敏感。如果文档扫描件旋转了哪怕两三度水平投影的峰和峰之间就会被斜行的过渡区域污染出现“行间不归零、行内不高不低”的尴尬状态。所以在投影之前做一次倾斜校正是非常值得的投资。校正不一定用复杂算法检测边缘直线、求主流角度然后旋转回去很多场景就够了。6.2 我对 gocv 的实际体会有人在博客评论区问过我为什么不用 Python我的回答是看场景。做实验、写比赛代码、快速验证算法Python 确实舒服。但如果要把它放在常驻服务里用 Go 封装一层图像模块部署和运维会轻松很多。gocv 的文档确实不算丰富遇到陌生 API我最常做的事是去查 OpenCV 原版文档再把 C 或 Python 示例对应到 Go 调用上这个思路一直很管用。这套水平投影和垂直投影的代码我后来又复用到表格结构识别、票据切分、甚至雷达云图区域定位上。原理不变变的只是二值化的预处理和后续的判定规则。如果你正在做类似文档处理的模块建议先把这两个投影函数沉淀成自己的基础工具后续很多预处理都能直接调用。
阅读完成 · 觉得有帮助?
咨询建站