ARTICLE DETAIL

资讯详情

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

OpenCV投影技术实战:文本行切分、表格识别与颜色定位

OpenCV投影技术实战:文本行切分、表格识别与颜色定位 搞 OpenCV 图像处理久了你会发现很多看着挺唬人的需求最后都归到几个简单操作上。“投影技术”就是其中之一。它不去找边缘、不去算轮廓而是把整张图沿着某个方向“压扁”用一条一维曲线来描述目标在图像里的分布。我在做表格识别、OCR 文本行切分、智能车巡线的时候都用这招解决过实际问题。它到底适合谁如果你要处理扫描件里的文本/表格、要从复杂背景里定位一个颜色目标或者想在嵌入式平台上做低开销的图像特征提取投影都是一个值得优先考虑的方案。先把范围框清楚。OpenCV 里跟“投影”直接相关的功能有两类一类是把二维图像沿 X/Y 方向累加成一条曲线也就是灰度投影/二值投影最常用另一类是直方图反向投影cv2.calcBackProject把一个颜色区域的直方图模型投回原图用来定位目标。至于相机标定里的透视投影、单应变换那是另一套数学体系不在本文展开。下面我从原理、代码、踩坑三个角度把投影技术讲透。1. 核心思路为什么“压扁”图像反而好使1.1 一维曲线里藏着的分割信息先说水平投影和垂直投影。假设我们有一张二值图binary黑色为背景 0白色为目标 1。水平投影就是把每一行所有像素值求和row_sum[r] binary[r, 0] binary[r, 1] ... binary[r, w-1]垂直投影就是把每一列所有像素值求和col_sum[c] binary[0, c] binary[1, c] ... binary[h-1, c]结果就是两根一维数组。文本行多的地方row_sum会出现明显的高峰文本行之间的空白行row_sum几乎为 0。根据这些峰和谷就能准确切出每一行、每一列甚至每个字符。我给新手举一个更容易懂的类比一排装了灯的房间从侧面看每个房间窗口的亮度就是这一排房间的“亮度投影”。文本行相当于亮灯的区域行间距相当于关灯的区域。投影不会管房间里面摆了几张桌子、有几面墙只看“这一排有没有灯”。在图像分割里很多场景根本不需要知道目标的连通关系只需要知道“哪一段有东西、哪一段是空白”投影就是为此设计的。1.2 投影与轮廓检测怎么选很多人拿到分割需求第一反应是找轮廓cv2.findContours加上外接矩形确实很直观。但字符密集、表格线复杂的时候轮廓会粘在一起还得处理嵌套轮廓、合并轮廓代码越写越绕。投影的好处是把问题从二维降到一维计算量小逻辑简单调试也方便。方法擅长场景容易翻车的场景水平/垂直投影文本行切分、表格行列切分、条码定位字符粘连严重、图像倾斜、目标重叠轮廓分析物体边界、连通域、形状筛选字符密集、表格线连通复杂、噪声多直方图反向投影颜色目标定位、肤色检测、小球跟踪背景颜色和目标颜色接近我在做扫描件表格识别时第一版用轮廓外接矩形结果一个表格里几十个单元格线框稍微粗一点字符和线框就连成一个整体轮廓框全乱。换成水平投影切行、垂直投影切列之后代码量少了一半效果反而更稳定。2. 灰度投影水平投影和垂直投影的 OpenCV 实现2.1 二值化是投影的地基投影本身很简单难在投影之前怎么把图变成干净的二值图。灰度图像直接求和没有意义因为背景灰度不均匀时投影曲线会带着一大片起伏。通常做法是先用阈值把目标变成 0/255 二值图再统计。以白底黑字的扫描件为例目标文字是黑背景是白。cv2.threshold的THRESH_BINARY会把大于阈值的设为 255小于阈值的设为 0但文字是黑所以想要文字变白需要取反用THRESH_BINARY_INV。import cv2 import numpy as np image cv2.imread(scan.png) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # Otsu 自动找阈值目标变白 _, thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 转成 0/1方便后续求和统计像素个数 binary (thresh 0).astype(np.uint8)如果图像光照不均匀Otsu 全局阈值容易把阴影部分误判成文字可以换自适应阈值thresh cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10 ) binary (thresh 0).astype(np.uint8)提示投影前一定要确认“目标是不是白色”。如果二值图目标为 0、背景为 255binary.sum(axis1)统计出来的就是背景分布不是目标分布结果会完全反过来。2.2 用 NumPy 一行算出水平和垂直投影二值图准备好之后投影就非常简单了# 形状: binary.shape (height, width) height, width binary.shape # 水平投影每一行目标像素个数 row_sum binary.sum(axis1) # 垂直投影每一列目标像素个数 col_sum binary.sum(axis0)有了row_sum和col_sum接下来就是从曲线里找分割段。我的做法是遍历曲线记录从“非零”到“零”切换的位置得到每一段的起止下标def find_segments(proj, min_len3): 从投影曲线中找到连续非零段。 segments [] start None for i, v in enumerate(proj): if v 0 and start is None: start i elif v 0 and start is not None: if i - start min_len: segments.append((start, i - 1)) start None if start is not None: segments.append((start, len(proj) - 1)) return segments row_segments find_segments(row_sum, min_len3)min_len的作用是过滤掉那些只有一两个像素高的噪点段。比如某个噪点让某一行的投影变成 1但总高度只有 2 像素那它大概率不是真正的文本行直接丢掉。2.3 曲线毛刺太多怎么办投影曲线出现毛刺很正常尤其是二值化不够干净的时候。常规做法是对投影曲线做平滑例如用卷积做移动平均kernel np.ones(3, dtypenp.float64) / 3 row_sum_smooth np.convolve(row_sum, kernel, modesame)但我的建议是先别急着平滑先回头看二值化。投影曲线只是二值化质量的显示器二值化阶段混进大量噪点平滑会把真的窄行和噪点一起抹掉。只有在确认二值化没问题、曲线仍因为文字笔画粗细有锯齿时才适合加一层轻平滑。3. 实战用投影切分表格和文本行3.1 先除掉表格线别让横线污染投影处理表格时有个大坑如果先二值化再投影表格自身的横线会每个像素都贡献非零值整张高亮表格的横向投影会被水平表格线撑满文本行区域反而淹没在线条里。所以切文本/单元格之前通常要把表格线先去掉。OpenCV 里检测水平线最常用的手段是形态学开运算用一个宽而扁的核去保留水平线再在原始二值图上把这些像素抠掉。_, thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 提取水平线 horiz_kernel cv2.getStructuringElement(cv2.MORPH_RECT, (60, 1)) horiz_lines cv2.morphologyEx(thresh, cv2.MORPH_OPEN, horiz_kernel) # 把水平线从二值图里去掉 clean_thresh cv2.bitwise_and(thresh, cv2.bitwise_not(horiz_lines)) binary (clean_thresh 0).astype(np.uint8)核宽度要根据图片大小调。扫描件里一条横线可能占到几十像素宽60不够就试80、100太小会误伤文字笔画太大又可能把残缺的横线漏掉。3.2 水平投影切行、垂直投影切列去掉干扰线后先用水平投影切出每一行row_sum binary.sum(axis1) rows find_segments(row_sum, min_len3) for top, bottom in rows: line_binary binary[top:bottom 1, :] # 对每一行的二值图做垂直投影切出列或字符 col_sum line_binary.sum(axis0) cols find_segments(col_sum, min_len2) for left, right in cols: if right - left 5: continue # 太窄的小段多半是噪点 cell image[top:bottom 1, left:right 1] # 这里 cell 就是从原图裁出来的单元格/字符这里有几个细节值得注意。第一垂直投影时用的是line_binary也就是先切行再在行内部做列投影。顺序不能反。如果直接在整个页面做垂直投影文字行之间的空白会让列统计断裂同一条竖线上的文字会被误判成多个目标。第二right - left 5这种过滤条件要放在 ation 里。表格中的标点符号、逗号往往只有两三个像素宽但它们是有效内容。过滤条件设太大会把标点丢掉设太小又会把噪点当成字符。我一般先用二值化后噪点的大小做一个估计再决定这个阈值。第三裁剪最终结果时用原图image不要用二值图。因为后面如果要做 OCR 或者保存颜色信息原图才保留原始特征。二值图只用于定位分割坐标。3.3 想要更稳可以做一次开运算如果投影切出来的单元格边缘有锯齿或者字符带很多孤立小点可以在投影前对二值图做一次轻度的形态学开运算# 注意这里是 0/255 的 thresh不是 0/1 的 binary denoised cv2.morphologyEx(thresh, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8)) binary (denoised 0).astype(np.uint8)开运算是“先腐蚀再膨胀”能去掉单独的小噪点但会让笔画变细一点。对 300 DPI 扫描件来说2x2 的核影响不大如果原本字符笔画就很细就别用。4. 反向投影让颜色直方图“投影”回图像4.1 反向投影到底在干什么和前面说的水平/垂直投影不同直方图反向投影不是把二维压成一维而是把颜色模型投到每一个像素上。假设你要在画面里找一块橙色的球先在球上框出一块 ROI统计这块 ROI 的 HSV 直方图再用cv2.calcBackProject计算全图中每个像素和这个直方图的匹配程度。匹配度越高的像素越亮得到一张概率图。类比一下你手里有一盒拼图的碎片颜色卡回到一整张彩色大图里逐个像素问“你是不是属于这种颜色”最后把所有匹配的像素点亮。这就是反向投影。为什么要用 HSV 而不是 BGR因为颜色直方图本质上是颜色统计BGR 三个通道对光照敏感同一个橙子在亮处和暗处的 BGR 数值差很多但在 HSV 里 H 通道色相相对稳定。OpenCV 的 HSV 中 H 范围是 0 到 180不是常见的 0 到 360这是很多人容易写错的点。4.2cv2.calcBackProject完整示例import cv2 import numpy as np img cv2.imread(scene.png) roi img[100:200, 200:300] # 手动框选的目标区域 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) roi_hsv cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) # 统计 ROI 的二维色相-饱和度直方图 roi_hist cv2.calcHist([roi_hsv], [0, 1], None, [32, 32], [0, 180, 0, 256]) cv2.normalize(roi_hist, roi_hist, 0, 255, cv2.NORM_MINMAX) # 反投影 prob cv2.calcBackProject([hsv], [0, 1], roi_hist, [0, 180, 0, 256], 1) # 阈值化得到目标候选区域 _, mask cv2.threshold(prob, 50, 255, cv2.THRESH_BINARY)roi_hist的 bin 数量会影响稳定性。[32, 32]表示 H 和 S 各分 32 个 binbin 太少会丢失色彩区分度bin 太多对噪声太敏感。做简单物体定位时[30, 32]或[50, 60]都是常见选择。提示反向投影的结果只是一张概率图不代表目标一定在那里。如果背景里存在和目标颜色相近的区域概率图上会出现一堆假阳性必须继续用形态学、轮廓面积、长宽比等条件筛选。4.3 反向投影之后再用投影定位反向投影图也可以继续用水平/垂直投影来找目标中心。对上面的mask目标区域已经变成白色此时再做行列累加就能得到目标在画面中的包围盒mask_bin (mask 0).astype(np.uint8) mask_row mask_bin.sum(axis1) mask_col mask_bin.sum(axis0) row_seg find_segments(mask_row, min_len5) col_seg find_segments(mask_col, min_len5) if row_seg and col_seg: top, bottom row_seg[0][0], row_seg[-1][1] left, right col_seg[0][0], col_seg[-1][1] cv2.rectangle(img, (left, top), (right, bottom), (0, 255, 0), 2)这种“反投影 投影定位”的组合在 OpenCV 识别物体、目标跟踪、智能车识别色块时非常常见。先用颜色锁定候选区再在候选区里用投影做空间定位比直接全图找轮廓快很多。5. 典型应用场景与工程落地5.1 OCR 前处理文本行切分OCR 的流程通常是定位文本行再识别字符。Tesseract 这类引擎自带版面分析但自己训练识别模型或者做垂直领域识别时投影切分仍然是最可控的第一步。先水平投影切行再垂直投影切字比直接用连通域稳定因为中文汉字的偏旁部首常常互相分离一个汉字可能拆成好几个连通域但投影只看宽度范围不会把一个字劈成两半。当然投影切分要求文字方向基本水平。扫描件如果放歪了投影曲线会变宽变平行与行之间的谷被填平此时要先做倾斜校正。我常用的办法是用cv2.minAreaRect拟合文字区域的最小外接矩形拿到旋转角度再用cv2.warpAffine转正。5.2 智能车巡线列投影找线心智能车视觉里经常要在画面中找一条黑线。如果对整幅图像做逐行扫描找边缘代码费时不说遇到反光、断线还容易丢目标。更快的做法是取一行感兴趣区域在 ROI 内做垂直投影。黑线在二值图上变成一个高峰峰顶就是线的中心位置roi binary[200:280, :] # 取画面下方一条区域 col_proj roi.sum(axis0) center int(np.argmax(col_proj))如果赛道背景复杂白色噪点多直接用argmax会偏移。可以先对col_proj做平滑或者把超过阈值的列选出来取均值效果更稳。这种方案计算量极小完全可以在单片机上跑。5.3 从 Matlab 到 FPGA同一套逻辑的不同实现很多学校课程用 Matlab 做图像处理大作业sum(bw, 2)和sum(bw, 1)几乎和 OpenCV 里的binary.sum(axis1)一一对应。算法原型用 OpenCV 写确实比 Matlab 更适合工程化因为 OpenCV 是 C 和 Python 双接口后面接部署、接服务都很方便。如果要把投影落到 FPGA 上思路需要换一下。水平投影很简单图像按行输入时每一行结束时把该行的累加结果存到一行寄存器里即可。垂直投影稍微麻烦因为像素逐行进入需要一组列累加器每来一列像素就往对应累加器里加最后输出所有列的累加值。这其实就是 OpenCV 里axis0的硬件化。理解了投影的本质再去读 FPGA 图像处理的 RTL 代码就不会觉得那些累加器莫名其妙。工业视觉领域还会有人拿 Halcon 和 OpenCV 对比。Halcon 的算子库和调试界面更商业化很多算法封装得很深但投影这种基础操作无论在哪套工具里原理都一样。OpenCV 免费、生态大、资料多做算法原型是很好的选择。6. 常见问题与排查排错6.1 安装依赖和import cv2的问题很多新手会写一个“安装成功却 import 不到 cv2”的问题。最常见原因是 Python 环境不一致你用终端里的 python 装包但在 IDE 里用的是另一个解释器或者 conda 的 base 环境和项目环境混了。先跑一遍python -c import cv2; print(cv2.__version__)如果终端能过而 IDE 不能去 IDE 里检查sys.executable和安装包时用的是不是同一个 Python。直接用pip install opencv-python安装的是预编译包正常不需要自己编译省掉大量编译报错。如果看到一长串类似cv2.error: OpenCV(4.4.0) C:\Users\...\pip-req-build-...的编译错误多半是你在尝试从源码构建 OpenCV或者装的包和你当前 Python 版本不兼容。优先换官方 wheel放弃源码编译能省半天人生。6.2 投影全部为 0先查二值化而不是查算法投影曲线全 0最常见的原因不是算法错了而是二值化后目标根本不在白色区域。比如黑底白字你用了THRESH_BINARY目标白字变白背景黑字变 0看起来没问题但如果你要识别的是黑字就必须用THRESH_BINARY_INV。还有可能是图片路径写错cv2.imread返回了None后面灰度图全是空。任何 OpenCV 代码都要先检查img cv2.imread(path) if img is None: raise ValueError(图片读取失败检查路径)6.3 曲线毛刺造成分割过多或过少分割过多一般是二值化把噪点当成目标了先做形态学开运算再设置min_len把过短段过滤掉。分割过少常见是相邻内容之间有重叠或者行间距太小投影的谷没有落到 0。此时可以先把图像放大几倍再做投影让间隔更容易被检测出来也可以降低阈值允许空隙处作为弱投影区域。我自己的经验是投影分割不要追求一版参数通吃所有图。文本行和表格的结构差异很大宁可写一个参数配置文件也不要把min_len、阈值这些写死。项目里不同来源的扫描件清晰度、灰度、版式不同参数微调是常态。6.4 表格或文字倾斜导致投影“压不准”倾斜是投影技术最大的敌人。文字稍微歪一点行投影的峰和谷就会互相粘连。处理顺序应该是先倾斜校正再投影分割而不是在投影结果上做补偿。校正角度可以用cv2.minAreaRect获取文字或表格外接矩形角度也可用 Radon 变换在不同角度下做投影找投影曲线峰谷最陡峭的角度这个角度就是文本方向。后者原理和“投影”本身绑定很深适合做学术验证工程上先用minAreaRect更务实。6.5 调试工具把投影曲线画出来投影算法的最大优势是可调试。不要只打印row_sum的数值直接把曲线画出来import matplotlib.pyplot as plt plt.figure(figsize(12, 4)) plt.plot(row_sum, labelrow projection) plt.plot(col_sum, labelcol projection) plt.legend() plt.show()曲线一旦可视化很多问题一眼就能看出来哪里的谷不够深、哪里的峰断开、哪里多出一堆毛刺都比看数字直观得多。我在实际项目里会习惯写一个debug_proj(image, binary)的函数把所有中间结果输出到同一张图省去反复试错的成本。最后说点个人体会。投影这招看起来简单真正拉开效果差距的地方全在预处理和参数管理。二值化干净投影就稳二值化脏再复杂的滤波都是在给脏数据化妆。技术选型上如果目标在二维空间里有明显的“列状”或“行状”分布优先考虑投影如果目标是散落的小块物体投影会丢失太多结构信息还是老老实实做连通域分析。多做几个场景你就能在遇到新需求时本能地判断这一刀应该往哪个方向压下去。
返回列表