做图像处理做得久了,难免有个体会:不少看着高级的活儿,根基反而是特别朴素的小操作。比如把一张合同扫描件里的文字块自动拆成一行一行、再拆成一个一个字符,很多人上来就考虑上模型,可实际上真正第一步往往是先把图做成二值图,再沿着横向、纵向各数一遍白色像素——这就是我要聊的水平投影和垂直投影。
原理一句话就能讲清楚:水平投影是统计图像每一行里白色像素的个数,垂直投影是统计每一列里白色像素的个数。二维图像经过这一步直接被压成两条一维数组,文本行切分、字符分割、车牌定位、表格线检测,这些都从这两条数组上找到了突破口。我在几个实际项目里都是用这个方案先做预处理,既稳又快。
我目前主要用Go写机器视觉程序,用的库是gocv,也就是OpenCV在Go生态里最成熟的绑定。这篇文章从投影原理出发,用gocv把水平投影和垂直投影从代码到调试完整过一遍。想快速在OCR、文档分析项目里落地行切分的人,或者刚碰gocv想搞明白图像基础操作怎么写的人,都可以拿这篇当作一个能直接抄作业的参考。
整体流程大概是这样:读图 -> 灰度化 -> 二值化 -> 水平投影 -> 垂直投影 -> 区间切分 -> 可视化验证。下面一步一步来。
1. 为什么投影能解决切分问题
1.1 用行和列的“密度”理解投影
先举一个生活化的例子。想象一下雪地上走过一排人,站在二楼往下看,每个人在雪地上踩出的脚印位置是不同的。如果沿着水平方向把雪地划成很多条细长的“横条”,数一数每条横条里有多少脚印,得到的就是水平投影;如果沿着垂直方向划“竖条”,数每条竖条里有多少脚印,得到的就是垂直投影。
换成图像来说,一张二值图里白色像素就是“脚印”。水平投影相当于把图像按行切开,每一行数一下白色像素的数量,最后得到一个长度为图像行数的一维数组;垂直投影则是按列统计,得到一个长度为图像列数的一维数组。这一步的本质是降维:把一张几千乘几千的二维矩阵,压缩成两条一维曲线,后续的分析和切分直接在这两条曲线上做,计算量小很多。
这也是为什么投影算法在机器视觉预处理里地位很高——它不依赖训练数据,不需要GPU,纯粹靠像素分布规律就能解决一大批版面分析问题。很多看似玄乎的OCR前置工作,其实都是从这一对简单的统计操作开始的。
1.2 文本行切分是怎么用投影的
投影最经典的用法就是文本行切分。假设现在有一张扫描的文字段落,做二值化之后字符是白色,背景是黑色。先对整张图做水平投影:文字所在的行,白色像素数量很大;行与行之间的空白区域,白色像素数量接近0。于是水平投影曲线上会出现一个个“凸起”和“凹陷”,凹陷就是行间距,凸起就是文字行。
拿到这些凸起对应的行号区间之后,再把每一行单独抠出来,对该行做垂直投影,一样的方式:字符所在的列白色像素多,字符间隙的列白色像素少,垂直投影的谷底就是字符切分点。整段文字就这样被一层一层拆开,先拆行,再拆字,整个过程不需要人工参与。
有个点需要提前说一下:这套方法对规整的印刷体非常有效,对倾斜或者有严重透视变形的图片会打折扣。原因也很直观,文字一旦倾斜,水平投影里行与行之间原本干净的空白会被倾斜的笔画“穿破”,谷底抬起来,切分就不干净了。后面我会讲一些应对倾斜和噪声的思路。
1.3 除了OCR,投影还能用在哪些场景
投影可干的事不止是文字切分。我这些年遇到过的场景大概有这几类:
- 车牌字符分割:车牌区域定位成功之后,水平投影先确定字符占用的上下边界,垂直投影再确定每一个字符的左右边界,这套流程比用连通域分析更稳定。
- 表格线检测:网格线的特征是横平竖直,对表格图做水平投影和垂直投影,会得到非常尖锐的峰值,根据峰值位置可以还原出表格横线与竖线的坐标,后续做单元格拆分就很方便。
- 文档版面分析:一页报纸或者论文,标题、正文、图片区块的投影特征差异很大,用水平投影可以先粗分出段落块,再用垂直投影判断这个块是单栏还是双栏。
- 简单目标定位:如果目标在图像里是一个明显的高密度区域,比如一行LED灯珠、一排试剂孔,投影找峰值的思路完全可以用。
可以说,投影是机器视觉里一个万金油级的前置算子。很多更复杂的算法,比如基于连通域的分割、基于形态学的分块,本质上也在间接利用这些分布特征。
2. gocv环境与二值化前置处理
2.1 把gocv跑起来
gocv是OpenCV C++库的Go绑定,安装本身不难,但是因为要编译C/C++代码,第一次装的时候需要一点耐心。用Go Modules的方式,在项目里执行:
go get -u gocv.io/x/gocv执行完还不算完,gocv依赖系统里的OpenCV库,Linux环境下一般要先装好基础依赖包。我记得Ubuntu系可以这样装:
sudo apt install libopencv-dev如果是Windows或者macOS,gocv官方文档里也提供了对应的安装脚本。这里不多展开,因为不同系统的差异比较大,装好之后先跑一个最小的demo验证环境,能输出OpenCV版本号就说明OK了:
package main import ( "fmt" "gocv.io/x/gocv" ) func main() { ver := gocv.OpenCVVersion() fmt.Println("OpenCV Version:", ver) }我第一次装的时候卡在链接阶段,后来发现是系统里同时存在多个OpenCV版本,把多余的版本清理掉就好了。遇到编译报错先别慌,绝大多数问题都出在依赖库版本不匹配上。
2.2 读图与灰度化
投影统计的是像素分布,不需要颜色信息。所以第一步就是把图像转成灰度图。用gocv读图时可以顺便指定灰度模式:
img := gocv.IMRead("text.png", gocv.IMReadGrayScale) if img.Empty() { log.Fatal("read image failed") } defer img.Close()如果你的图片已经用彩色模式读进来了,也可以手动转灰度:
gray := gocv.NewMat() defer gray.Close() gocv.CvtColor(img, &gray, gocv.ColorBGRToGray)这两种写法效果一样,看实际场景选择。需要注意的一点是,IMRead的第二个参数决定了读入通道数,如果后面还要显示原图或者做颜色相关的处理,就别在读取时降成灰度;如果只是做投影这种纯几何统计,直接灰度读入能省一次转换和一部分内存。
2.3 二值化:让投影变得干净
投影之前必须做二值化。你可能会想,灰度图不是也能统计吗?理论上是可以,但灰度图里有渐变、噪声、阴影,投影曲线上会有很多不规则的毛刺,切分阈值很难定。二值化之后,像素只有0和255两种,统计结果干净利落,前后景分明。
gocv里最省事的做法是用Otsu自动阈值,它根据灰度直方图的分布自动计算分割阈值,不用你手动去试:
bin := gocv.NewMat() defer bin.Close() gocv.Threshold(gray, &bin, 0, 255, gocv.ThresholdBinary+gocv.ThresholdOtsu)第三个参数0在Otsu模式下会被忽略,因为阈值由Otsu算法自己算出来。这里我强烈建议,凡是做投影的场景,能上Otsu就上Otsu,除非你测试过固定阈值的效果更好。手动试出来的阈值换一张图可能就不灵了,Otsu至少能保持一定程度的自适应性。
二值图里有一个特别容易踩的坑:前景和背景的颜色方向。我们通常约定投影统计的是白色像素,也就是255那一类。如果你的图片是白底黑字,用ThresholdBinary得到的二值图背景是白色、字符是黑色,投影统计的“白色像素”实际是背景,表现就是整张图的投影几乎都非零,行与行之间根本没有谷底。这种情况通常有两个解决办法:
// 方案一:用反向二值化,让字符变白、背景变黑 gocv.Threshold(gray, &bin, 0, 255, gocv.ThresholdBinaryInv+gocv.ThresholdOtsu) // 方案二:先正常二值化,再做一次位取反 gocv.BitwiseNot(bin, &bin)我只用方案一的情形比较多,一步到位,代码也干净。至于哪种方向正确,判断标准很朴素:字符区域应该是白色。拿到二值图之后看两眼,白字黑底就是对的,黑字白底就要反转。
3. 核心实现:水平投影和垂直投影
3.1 第一版:用GetUCharAt逐像素统计
最直观的写法是遍历每一个像素,逐个判断是不是白色。gocv的Mat提供了GetUCharAt方法,可以读取指定位置的像素值:
func HorizontalProjection(src gocv.Mat) []int { proj := make([]int, src.Rows()) for y := 0; y < src.Rows(); y++ { count := 0 for x := 0; x < src.Cols(); x++ { if src.GetUCharAt(y, x) > 0 { count++ } } proj[y] = count } return proj }对应的垂直投影:
func VerticalProjection(src gocv.Mat) []int { proj := make([]int, src.Cols()) for x := 0; x < src.Cols(); x++ { for y := 0; y < src.Rows(); y++ { if src.GetUCharAt(y, x) > 0 { proj[x]++ } } } return proj }这个版本的优点是非常容易读懂,适合第一次接触投影算法的人做验证。缺点也很明显:GetUCharAt内部要做类型检查和越界断言,在大图上循环调用,开销不小。我拿一张大约1200×800的二值图做过对比,这个版本跑一次水平投影大概要十几毫秒,垂直投影因为内存访问顺序不连续,还要更慢一些。
如果你只是做一次离线预处理,这个速度倒也不是不能用。但如果要在高分辨率视频帧或者批量处理大量图片时用,就得考虑更高效的办法了。
3.2 用RowPtr直接访问行内存
gocv的Mat在内存布局上,每一行是连续存储的。RowPtr方法可以直接拿到某一行数据的字节切片,省掉GetUCharAt的函数调用和类型断言,性能提升非常明显:
func HorizontalProjectionFast(src gocv.Mat) []int { proj := make([]int, src.Rows()) for y := 0; y < src.Rows(); y++ { row := src.RowPtr(y) count := 0 for x := 0; x < src.Cols(); x++ { if row[x] > 0 { count++ } } proj[y] = count } return proj }注意RowPtr返回的切片长度是行数 x 通道数,对于我们处理单通道二值图来说,通道数就是1,所以下标x对应第x列像素值。如果传入的是三通道彩色图,内存布局完全不同,直接按字节统计就会得到奇怪的结果。所以用RowPtr之前,务必确认Mat的类型确实是CV_8UC1。
垂直投影也可以用RowPtr优化,只是列的访问天然跨行,没法做到连续访问,但至少省掉了GetUCharAt的开销:
func VerticalProjectionFast(src gocv.Mat) []int { proj := make([]int, src.Cols()) for y := 0; y < src.Rows(); y++ { row := src.RowPtr(y) for x := 0; x < src.Cols(); x++ { if row[x] > 0 { proj[x]++ } } } return proj }我在自己的机器上测试,RowPtr版本大约比GetUCharAt版本快3到5倍。不同机器、不同图像尺寸会有差异,但量级感受就是这样。对于生产环境里的批量图像处理,这个小优化往往就能省下不少时间。
3.3 投影结果可视化
投影算出来之后是一堆int数组,光看数字不够直观,尤其是调试过程中,很难一眼看出曲线哪里是峰、哪里是谷。我习惯把投影画成一张柱状图,直接在屏幕上或者保存成文件看。
用gocv画图很简单,思路是创建一张白色的单通道图,然后在每个坐标位置画一条竖直的黑线,线的长度按投影值归一化到图片高度:
func DrawProjection(proj []int, height int) gocv.Mat { img := gocv.NewMatWithSize(height, len(proj), gocv.MatTypeCV8U) img.SetTo(gocv.NewScalar(255, 255, 255, 255)) maxVal := 0 for _, v := range proj { if v > maxVal { maxVal = v } } if maxVal == 0 { maxVal = 1 } for i, v := range proj { barHeight := int(float64(v) / float64(maxVal) * float64(height-2)) pt1 := image.Pt(i, height-1) pt2 := image.Pt(i, height-1-barHeight) gocv.Line(&img, pt1, pt2, gocv.NewScalar(0, 0, 0, 0), 1) } return img }保存或者显示:
projImg := DrawProjection(hProj, 200) gocv.IMWrite("h_proj.png", projImg) projImg.Close()有了这张图,二值化方向对不对、噪声重不重、行间谷底是否干净,一眼就能判断。我后面排查问题几乎全靠它。
4. 实战:文本行切分与字符切分
4.1 从投影数组找连续区间
有了投影数组,下一步就是从里面找出哪些连续区间属于文字区域。核心逻辑是:遍历数组,当值大于阈值时认为进入了前景区域,当值小于等于阈值时认为离开了前景区域:
type Span struct { Start int End int } func FindSpans(proj []int, threshold int) []Span { var spans []Span inSpan := false start := 0 for i, v := range proj { if v > threshold { if !inSpan { inSpan = true start = i } } else { if inSpan { inSpan = false spans = append(spans, Span{Start: start, End: i}) } } } if inSpan { spans = append(spans, Span{Start: start, End: len(proj)}) } return spans }threshold在多数干净图像上可以直接取0,也就是只有完全没有白色像素的行才算空白行。但总有些图带着噪声或者笔画延伸到了行间空白地带,这时候阈值可以调成2或者3,把像素数极少、不算有效前景的杂质行过滤掉。
这里有个经验:阈值太小过滤不了噪声,阈值太大又会吃掉笔画稀疏的字符,导致一些左右结构的字被切裂。所以一般先画投影图观察一下谷底的高度,再决定阈值。我通常从0开始,碰到毛刺再往上加到2、3,极少超过5。
4.2 完整流程:按行再按列切开
下面把这几个函数串起来,实现一个完整的文本行和字符切分流程。
package main import ( "fmt" "image" "log" "gocv.io/x/gocv" ) func main() { // 读图并二值化,假设是白底黑字,所以用 BinaryInv 让字符变白 img := gocv.IMRead("text.png", gocv.IMReadGrayScale) if img.Empty() { log.Fatal("read image failed") } defer img.Close() bin := gocv.NewMat() defer bin.Close() gocv.Threshold(img, &bin, 0, 255, gocv.ThresholdBinaryInv+gocv.ThresholdOtsu) // 第一步:水平投影,找出每一行的上下边界 hProj := HorizontalProjectionFast(bin) lines := FindSpans(hProj, 0) fmt.Printf("共检测到 %d 行文字\n", len(lines)) // 第二步:对每一行做垂直投影,切出字符 for i, line := range lines { region := bin.Region(image.Rect(0, line.Start, bin.Cols(), line.End)) vProj := VerticalProjectionFast(region) chars := FindSpans(vProj, 0) fmt.Printf("第 %d 行:字符数量 %d\n", i, len(chars)) // 这里可以基于 chars 的左右坐标在原图或者原图上框出每个字符 for _, ch := range chars { // ch.Start, ch.End 就是当前字符的左右列范围 _ = ch } region.Close() } }Region返回的是原二值图的一个“视图”,本质上是共享内存区域,用完一定要Close,避免引用计数一直不减,导致内存释放不掉。这个我在早期版本里经常漏掉,后来已经变成肌肉记忆了。
实际跑一遍你会发现,只要输入图像质量还可以,这个流程对不同字号、不同行距的文字块都适应得不错,不需要针对单张图单独调参数。这就是投影方法的性价比所在。
4.3 边界情况和参数细节
这套流程看着简单,碰到现实里的图片还是有几个需要处理的地方。
一是行距非常小的情况。有些扫描件行距压得很低,水平投影的谷底虽然存在,但可能只有一行像素是0,甚至连续几行都是几十个像素。这时候可以先用形态学方法拉大行距特征,比如对二值图做一个水平方向的开运算,或者干脆用一个小尺寸的矩形核对二值图做膨胀,让字符的上下笔画产生更多重叠,投影的峰就会更“胖”,谷底也就更干净。
二是字符间距非常密的情况。印刷体的字距通常都不会太小,但有些艺术字体笔画会延伸到相邻字符之间,垂直投影的谷底会抬起来。处理办法是用一个小的矩形核对二值图做腐蚀,先把细的粘连点断掉,再做垂直投影。注意腐蚀会同时把笔画变细,可能导致字符投影高度下降,但只要不影响切分就行。
三是文字倾斜。如果图片里的文字不是水平的,水平投影的曲线干净程度会直线下降。常见解决办法是先做倾斜校正:用最小外接矩形找到文字整体倾斜角度,然后用仿射变换把图片转正,再来做投影。gocv里旋转变换的API可以直接实现:
rotated := gocv.NewMat() defer rotated.Close() rotated = gocv.WarpAffine(src, gocv.NewMat(), ...)具体参数这里不展开了,核心思路是先算角度后校正,校正干净了投影才能发挥威力。
5. 踩坑记录与调试技巧
5.1 常见问题速查表
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 投影全是0或者近乎0 | 二值化方向反了,白色像素变成背景 | 改用ThresholdBinaryInv,或对二值图做BitwiseNot |
| 投影曲线毛刺特别多 | 图像噪声重、二值化后有很多散点 | 投影前先做一次开运算或者高斯模糊再二值化 |
| 一行文字被切成多段 | 行间有笔画穿越、谷底没有归零 | 调大FindSpans的threshold;对二值图做适度的膨胀 |
| 相邻字符粘在一起分不开 | 字距太小或字体笔画粘连 | 对二值图做轻微腐蚀;先缩小图像再做垂直投影 |
| 截出来的字符区域参差不齐 | 文字本身有修约、笔画长短不一 | 属于正常现象,可以按最大宽度统一裁剪,或者用连通域再做一次校正 |
| 运行时报Mat类型断言错误 | 传入的不是CV_8UC1单通道图 | 确认先用IMReadGrayScale读图或者用CvtColor转灰度 |
5.2 让代码自动判断前背景方向
之前提到的反转问题,手动看虽然不算麻烦,但如果在批处理流程里,几百张图不可能每张都人工确认。我自己写过一个小技巧:统计二值图中白色像素的占比,如果超过一半,说明背景是白色,需要反转,否则保持不动。
func ensureForegroundWhite(bin gocv.Mat) { total := bin.Rows() * bin.Cols() whiteCount := 0 for y := 0; y < bin.Rows(); y++ { row := bin.RowPtr(y) for x := 0; x < bin.Cols(); x++ { if row[x] > 0 { whiteCount++ } } } if whiteCount > total/2 { gocv.BitwiseNot(bin, &bin) } }这个规则适合大多数常规扫描件和文档截图,前提是文字区域面积不能大于背景面积。如果整张图都是黑底白字的代码截图,或者反色艺术海报,这个比例判断就可能失效,但那种情况通常看一眼投影图就能发现,问题不大。
5.3 大图性能优化思路
投影本身复杂度是O(width x height),对大图来说还是有一定开销。如果图片是几千万像素级别的工程图,有几个思路可以组合使用:
- 缩小图像后再做投影。投影关注的是分布特征,对分辨率不敏感。把长边缩到2000像素以内,速度和内存占用都会大幅下降。
- 提前用Region限定ROI。如果已经知道文字区域的大致范围,比如车牌在图片中间区域,就没必要全图投影,先Region切出目标区域再统计。
- 做投影前先做一次粗略的下采样。和缩图思路类似,但只影响投影统计,不影响后续基于切分坐标在原图上做精细处理。
我之前处理一批工程扫描件,原图都是4000×3000以上,直接投影虽然也能跑,但批处理总时间比较感人。后来把长边统一缩到2000像素,处理速度提升了近3倍,切分精度没受多大影响。
再分享一个我自己的排查习惯:写投影代码的时候,先别急着做后续切分,拿到投影数组之后一定先把投影图画出来看一眼。曲线长什么样、谷底干不干净、前景方向对不对,一眼就能判断。这个习惯帮我省下了大量排查时间,比对着数字猜快太多了。
投影这个方法虽然“老”,但在很多版面分析和字符分割场景里,依然是最划算的起点。很多时候,你不需要复杂模型,把基础算子用到极致,效果就已经足够稳。等你把投影调稳了,后面的切分基本就是水到渠成的事。