C# OpenCvSharp去除文字中的线条实现详解
2026/8/31 5:46:41 网站建设 项目流程

简介:本资源是一个基于C#与OpenCvSharp实现文字图像中干扰线条去除的完整工程实践项目,面向图像处理初学者、OCR预处理开发者及文档数字化相关技术人员,解决扫描件或拍照文本中因表格线、划痕、噪点等导致OCR识别率下降的核心问题。项目采用二值化、形态学腐蚀/膨胀、霍夫直线检测、连通域分析等典型OpenCV图像处理流程,显著提升文字区域纯净度与结构完整性。压缩包共94个文件,包含10个核心C#源码(如frmMain.cs)、1个Visual Studio解决方案(.sln)、11个动态链接库(dll)、6个可执行文件(exe)及配套配置、资源与调试文件,整体体积35.46MB,目录结构清晰,便于快速编译运行与模块化学习。目前已有163人下载学习,提供开箱即用的GUI演示程序、完整项目配置与可调试源码,有助于深入理解文字图像去线的技术路径与OpenCvSharp工程化落地细节。

C# OpenCvSharp 去除文字中的线条

做OCR预处理或者票据自动化的朋友,看到这个项目标题估计都会心一笑——辛辛苦苦扫描出来的单据、试卷、合同,文字上面横着一道下划线,或者表格线直接压在数字上,OCR引擎直接识别成乱码。这个项目干的事很简单也很实用:用C#配合OpenCvSharp,把图片里文字区域的横线、竖线、表格线自动找出来并去掉,同时尽量保住文字笔画本身,让后面接OCR或者人工看图都舒服很多。

我在实际项目里做过不少这类图像清理,一开始也试过Photoshop手动抹、用Hough变换检测直线,后来发现最稳的路线还是OpenCV官方文档里那套经典的“提取水平/垂直线”思路——通过形态学操作,用长条形的核把线条结构单独筛出来,生成掩膜之后再用修复算法填掉。这套方法在C#里用OpenCvSharp实现起来非常顺手,也就几百行代码的事。这篇文章我把完整思路、每一步的原理、完整的C#代码以及我在实际调试中踩过的坑全部整理出来,适合正在做票据识别、文档去线、试卷去下划线这类需求的开发者参考。

1. 项目整体思路与方案选型

1.1 需求拆解:去线为什么不是简单“涂白”

先把问题说清楚。所谓“去除文字中的线条”,核心场景通常是这几类:

  • 扫描试卷或单据时,下划线、横线、竖线与文字重叠;
  • 表格线把数字、字母区域切碎,OCR识别困难;
  • 身份证复印件、合同上面有干扰性的装饰线、水印线;
  • 手写笔记中,删除线、划线覆盖在手写文字上。

很多人第一反应是“直接把某一行像素变白不就行了”,但实际行不通。原因是线条颜色和文字颜色往往都是黑色,直接按颜色过滤会连文字一起干掉。就算线条是红色的,简单按颜色抠掉线条,交叉处的文字笔画也会缺一块。

所以正确的思路是:先定位线条所在的区域,生成一个掩膜(mask),再对这个掩膜区域做像素修复(inpaint)。这样线条被去掉,而掩膜边缘之外的文字笔画尽可能保留。如果不做inpaint,单纯把掩膜区域填充成背景色,线条和文字交叉的位置会出现明显的“断笔”,识别效果依然好不到哪去。

1.2 为什么选OpenCvSharp而不是其他方案

这个项目是用C#落地,那图像处理库的选择就很关键。C#生态里常用的有OpenCvSharp、EmguCV,以及一些商业库。

OpenCvSharp是我个人用得最多的一个。相比EmguCV,OpenCvSharp的API设计更贴近OpenCV C++原生接口,几乎没有额外包装,查C++文档就能直接对应着写C#代码,网上OpenCV的资料也基本都能套用。这对“拿现成算法快速落地”来说非常重要——你不需要把Python案例翻译成另一套API风格,而是几乎一行行对照搬过来就行。

另一个好处是.NET生态下的部署体验。OpenCvSharp提供了OpenCvSharp4OpenCvSharp4.runtime.win两个NuGet包,后者直接带Windows运行库,打包时会自动拷贝native的dll到输出目录,不像某些库需要手动配置环境变量。对于桌面工具、上位机这类C#项目的常规开发模式来说,省心不少。

1.3 常见去线方案的对比

在定方案之前,我把网上能看到的几种去线方法都过了一遍,大概有这几类:

方案原理优点缺点
颜色阈值过滤按像素颜色范围直接去掉线条色简单,速度快线条颜色与文字接近时失效,交叉处会伤字
Hough变换检测直线检测图像中的直线段,再把这些线段区域涂掉对直线情有独钟,适合规整表格线检测参数多,断线、弯曲线条效果差,线段合并麻烦,斜线处理难
轮廓长宽比筛选提取连通域,按外接矩形长宽比找线条轮廓能处理不规则线条文字笔画也经常出现长条形状,容易误判,参数不好调
形态学长条核提取用宽/高为1的长条核做开运算,把线条结构单独分离对水平/垂直线效果极好,实现简单,参数直观对斜线无能为力,需要按方向分别处理
深度学习语义分割训练一个分割模型把线像素标出来通用性最强,斜线曲线都能去成本高,需要打标和训练,杀鸡用牛刀

最终我选的是形态学这条路线。原因很直接:绝大多数实际场景里的干扰线都是水平下划线、表格横线竖线这类规整结构,形态学方法对它们的提取效果接近完美,而且不用训练模型,几行代码就能跑通。官方文档里那张经典的“提取水平与垂直线”示例图,用的就是这条路子。

2. 核心算法原理与关键步骤

2.1 形态学开运算为什么能“筛”出线条

说到形态学操作,先说点理论,不然代码写出来也是照着抄,遇到问题不好排查。形态学的开运算(Open)就是先腐蚀再膨胀,作用是“去除小的噪声、断开细的连接”。

关键是选择的核(kernel)形状。如果用矩形核Size(w, h)对二值图做开运算,那么图中凡是“尺寸小于这个核”的结构都会被抹掉,而“尺寸明显大于核”的结构会保留下来。

这里有个很巧妙的用法:如果用一个高度只有1像素、宽度很长的核,比如Size(100, 1),对二值图做开运算,那么图像中宽度不够长的横向结构(比如文字里的竖笔画、短横笔画)会被腐蚀掉,而横向延伸足够长的线条(比如下划线、表格横线)会被保留下来。

也就是说,长条形核天然地“偏好”同方向的长结构。提取横线用宽扁核,提取竖线用窄高核,两个方向各做一次,再合并,就把图中所有横平竖直的线条都捞出来了。

我再用大白话解释一遍:想象你拿一把很宽的梳子横着往纸上刷,短小的笔画被梳子齿带走了,但一条长长的横线能从头到尾撑住梳齿的间隙,所以留了下来。这个“梳子”就是我们的长条核。

2.2 二值化是前提:把图像变成黑白分明

形态学操作作用在灰度图或二值图上效果最直观。实际处理中,我一般先把原图转灰度,再做一次二值化。

二值化的目标是把文字和线条变成白色(255),背景变成黑色(0)。OpenCvSharp里最常用的是Otsu自适应阈值:

Mat gray = new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); Mat binary = new Mat(); Cv2.Threshold(gray, binary, 0, 255, ThresholdTypes.BinaryInv | ThresholdTypes.Otsu);

这里用了BinaryInv,也就是“反二进制阈值”,意思是灰度值大于阈值时置为0,小于阈值时置为255。因为扫描件通常是白底黑字,反向后文字变成白色、背景变成黑色,正好符合多数形态学处理时“前景为白”的习惯。

如果你的图片是深色底浅色字,那就改用ThresholdTypes.Binary,让文字照样变成白色。实在判断不准的时候,可以先输出二值图看一眼,再决定用哪种模式。

2.3 完整算法流程一览

整个去线流程可以拆成下面几步:

  1. 读取图片,转灰度;
  2. 二值化,让文字和线条成为白色前景;
  3. 用宽扁核做开运算,得到横线掩膜;
  4. 用窄高核做开运算,得到竖线掩膜;
  5. 合并两个掩膜,轻微膨胀,保证线条区域被完整覆盖;
  6. 用原图 + 掩膜做inpaint修复,把线条区域填补掉;
  7. 保存结果。

这个流程对应OpenCV官方“Extract horizontal and vertical lines”示例,只不过我把它换成了C#实现,并且针对“去除文字中的线条”场景做了一些调整——比如最后用inpaint而不是直接涂白。

3. C# + OpenCvSharp 完整代码实现

3.1 环境准备与NuGet包

新建一个.NET控制台项目或者WinForms/WPF应用都可以,图像处理部分完全一样。我演示用的环境是.NET 6 + OpenCvSharp4。

用NuGet安装两个包:

dotnet add package OpenCvSharp4 dotnet add package OpenCvSharp4.runtime.win

第一行是核心库,第二行是Windows运行库。记得把项目配置成x64,因为OpenCvSharp自带的native库是64位的,默认AnyCPU在运行时偶尔会有加载问题。

3.2 第一步:读取图片并生成二值图

下面是完整的核心处理类,我加上了注释。

using OpenCvSharp; public class LineRemover { public static Mat RemoveLines(string inputPath) { // 1. 以彩色方式读图,inpaint时用彩色图效果更好 Mat src = Cv2.ImRead(inputPath, ImreadModes.Color); if (src.Empty()) throw new Exception("图片加载失败,检查路径"); // 2. 转灰度 Mat gray = new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); // 3. 二值化:白底黑字图翻转,让文字和线条变成白色 Mat binary = new Mat(); Cv2.Threshold(gray, binary, 0, 255, ThresholdTypes.BinaryInv | ThresholdTypes.Otsu); // 4. 提取水平线 Mat horizontal = ExtractHorizontal(binary); // 5. 提取垂直线 Mat vertical = ExtractVertical(binary); // 6. 合并两个方向的线条掩膜 Mat lineMask = new Mat(); Cv2.BitwiseOr(horizontal, vertical, lineMask); // 7. 轻微膨胀,保证掩膜完全包裹线条边缘 Mat dilateKernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3)); Cv2.Dilate(lineMask, lineMask, dilateKernel); // 8. 用inpaint修复线条区域 Mat result = new Mat(); Cv2.Inpaint(src, lineMask, result, 3, InpaintMethod.Telea); return result; } private static Mat ExtractHorizontal(Mat binary) { int cols = binary.Cols; // 核的长度取图像宽度的1/20,至少为1 int kernelLength = Math.Max(cols / 20, 1); Mat kernel = Cv2.GetStructuringElement( MorphShapes.Rect, new Size(kernelLength, 1)); Mat horizontal = new Mat(); Cv2.MorphologyEx(binary, horizontal, MorphTypes.Open, kernel); return horizontal; } private static Mat ExtractVertical(Mat binary) { int rows = binary.Rows; // 核的长度取图像高度的1/20,至少为1 int kernelLength = Math.Max(rows / 20, 1); Mat kernel = Cv2.GetStructuringElement( MorphShapes.Rect, new Size(1, kernelLength)); Mat vertical = new Mat(); Cv2.MorphologyEx(binary, vertical, MorphTypes.Open, kernel); return vertical; } }

调用方式很简单:

Mat result = LineRemover.RemoveLines("input.png"); Cv2.ImWrite("output.png", result);

3.3 对“交叉处伤字”的处理细节

代码里最关键的一步是第8行的Cv2.Inpaint。这里展开讲讲。

Inpaint(修复)算法的原理是:根据掩膜周围的有效像素,用插值方式猜测掩膜区域原本的样子。OpenCvSharp支持两种算法:

  • InpaintMethod.Telea:基于快速行进方法,从掩膜边缘逐层向内部推进,速度较快;
  • InpaintMethod.NS:基于偏微分方程,修复结果更平滑,但速度稍慢。

实际处理文字线条场景,我一般用Telea。半径参数设为3比较合适——半径太大,交叉处的文字笔画会被糊成一片,半径太小,线条边缘又盖不干净。这个值建议根据你图片的分辨率适当调整:300dpi扫描件用3~5没问题,手机拍的图可以试到7~10。

有朋友会问:既然直接inpaint就可能伤字,能不能避免交叉部分?答案是很难完全避免,因为线条本身就是盖在笔画上的。我们能做的是尽量让掩膜只覆盖线条区域而不是整个字,所以前面用形态学单独提取线条、而不是直接对整行像素涂白,这个区别很重要。

3.4 如果想同时处理反色图片

二值化那里,不同来源的图片颜色习惯不一样。有些票据是红头文件,文字是红色;有些截图是深色背景浅色文字。

最稳妥的办法是写一个自适应判断:计算二值化后白色像素占比,如果白色像素过多,说明当前是“白字黑底”的反转状态,再做一次取反。

// 统计白色像素比例 int whiteCount = Cv2.CountNonZero(binary); double whiteRatio = (double)whiteCount / (binary.Rows * binary.Cols); // 如果白色比例过高,说明图片可能是深底浅字,取反 if (whiteRatio > 0.7) { Cv2.BitwiseNot(binary, binary); }

这样处理票据、合同、扫描件时通用性会强很多。当然,这个阈值不是一个严格标准,建议不同场景自己跑一遍看看效果。

4. 参数调优与效果提升

4.1 核长度怎么选

形态学提取线条的原理决定了对核长度比较敏感。核太短,可能把一些文字笔画也误判成线条;核太长,较短的线段就提取不全。

我的经验是:

  • 提取横线时,核长度取图像宽度的1/15 ~ 1/30。图像越宽,线条对应的物理长度越可能被拉长,所以按比例来比固定数值更科学;
  • 核高度固定为1,不要随意加大。一旦核高度变成2甚至3,就会有更多横向笔画被误收进来;
  • 提取竖线时同理,核长度取图像高度的1/15 ~ 1/30,宽度固定为1。

另外一个技巧是,如果发现线条被提取得断断续续,可以先用一个更大的核做一次“膨胀”或者“闭运算”,把断裂的线段连起来,再开运算提取。但闭运算也会带来新的问题——线条上的文字笔画会被一起连进来,所以做之前最好先测试一下。

4.2 关于膨胀次数的取舍

代码里我用了一个3x3的膨胀核对合并后的掩膜做了一次膨胀。这个操作的目的很简单:线条边缘通常不是纯黑,而是有灰色过渡的,如果不膨胀,inpaint只会修复线条内部,边缘的灰色残留还会留在图里,看着不干净。

但是膨胀次数不能多。膨胀本质是扩大掩膜范围,掩膜范围越大,inpaint对周围文字的侵蚀就越严重。我的建议是最多膨胀1~2次,且核不要超过3x3。如果你发现线条边缘已经盖得很干净,那么膨胀这步可以直接去掉。

4.3 更极端的场景:只去横线

有些场景下只需要去掉下划线,不需要处理竖线。比如作文纸扫描件,可能只需要清掉横线而保留格子竖线,或者反过来。

这时候只需要在RemoveLines方法里把竖线提取那段注释掉,以及最终合并时只使用horizontal掩膜。不要两个都跑,再在BitwiseOr里掩盖掉,那纯粹是浪费算力。

// 只保留横线时,合并那行换成 Mat lineMask = horizontal.Clone();

4.4 提高OCR识别率的额外招数

去完线之后,如果你要接OCR,我建议再做两件小事:

  1. 对结果再做一次自适应阈值或者大津阈值,把灰度图重新二值化。因为inpaint生成的像素往往是灰蒙蒙的中间色调,OCR引擎对这类区域的特征提取比较吃力;
  2. 如果图片有倾斜,可以先做一次校正再去除线条。倾斜角度超过2度时,形态学长条核提取横线就不再准确,线的方向不完全是水平的了。
// 二值化增强 Mat binaryResult = new Mat(); Cv2.Threshold(result, binaryResult, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu);

5. 常见问题与排查技巧实录

5.1 表格线断断续续,提取不完整

表现:最终输出的图里,一些横线中间缺了一段,像虚线一样。

原因:核长度设得太短,导致长横线的某些区域没有被完整识别出来;或者二值化后线条内部有大量噪点,形态学操作把这些噪点当成了“非线条”结构。

解决

  • 把核长度调大,从图像宽度的1/30逐步增加到1/15;
  • 如果线条本身是虚线,那开运算提取后自然就是断的。这种情况先做一个闭运算,把虚线连接成实线再提;
  • 检查二值化参数,确保线条在二值图上是一条连续的白色带。

5.2 文字笔画被误删

表现:去线之后字变“瘦”了,尤其是带长横笔画的汉字,比如“一”“二”“三”这类字,笔画被当成线条抹掉了。

原因:长条核在提取线条时,也会提取到文字中方向一致、长度足够的笔画。

解决

  • 把核长度调大,越长的核只会保留真正长的线条,短笔画自然的“长度抗性”就更强;
  • 核宽度保持为1,不要加大;
  • 如果文字笔画确实很长(比如书法字体中的长横),可以考虑把掩膜线条限定在“超过某个最小长度”的连通域内。简单做法是用Cv2.FindContours找出每个线条轮廓,过滤掉面积太小的,再画回掩膜。
// 过滤小面积连通域 Mat filteredMask = Mat.Zeros(lineMask.Size(), MatType.CV_8UC1); Cv2.FindContours(lineMask, out Point[][] contours, out HierarchyIndex[] hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); foreach (var contour in contours) { double area = Cv2.ContourArea(contour); if (area > 500) // 阈值按图片尺寸调整 { Cv2.DrawContours(filteredMask, new[] { contour }, -1, new Scalar(255), -1); } }

注意,找轮廓处理的是掩膜图,也就是步骤4之后合并出来的黑白图,别把原图传进去了。

5.3 inpaint后文字变得模糊、有重影

表现:线条确实没了,但交叉处的笔画糊成一团,或者出现发白的雾状痕迹。

原因:inpaint实际上是“猜测”被掩膜盖住的像素,周围信息不足时猜出来的结果自然不锐利。这在细笔画与粗线条交叉时非常明显。

解决

  • 把inpaint半径从3调小到1或2,优先保证笔画形状;
  • 改用NS算法试试,有些场景下NS的结果更平滑;
  • 对自己做锐化,比如Cv2.Laplacian边缘增强或者unsharp mask,把模糊感压下去;
  • 如果线条是纯色且背景是纯色,可以不用inpaint,直接把掩膜区域填充为背景色,效果反而更干净。前提是线条和文字交叉处不多。

5.4 内存泄漏与运行变慢

C#写OpenCvSharp最常见的坑就是Mat对象把内存吃满。OpenCvSharp虽然封装了OpenCV,但底层native内存不会自动被C#垃圾回收器很好管理。

我在代码里写了很多中间Mat变量,正式项目里记得用using包裹或者手动Dispose

using (Mat binary = new Mat()) using (Mat horizontal = new Mat()) using (Mat vertical = new Mat()) { // 处理逻辑 }

如果在一个循环里批量处理几百张图片,不释放中间变量,内存占用会直线飙升,很容易把桌面程序搞崩。这个是真实项目里最容易翻车的点,比算法本身还致命。

最后的经验之谈

我在实际做这个需求的时候,最开始用的方案是先Hough变换检测直线,再把这些线段的邻域涂成白色。效果勉强能用,但一旦线条有轻微弯曲、或者和文字笔画交织得比较深,结果就稀碎。后来换成形态学提取+inpaint修复,几乎是一把过,大部分常见扫描件都能处理得比较干净。

如果你是在WinForm或者WPF里做工具类应用,建议把处理逻辑放到后台线程去跑,不然图片稍微大一点界面就卡死。另外,处理前把图片缩放到合理尺寸也很重要,3000x4000的大图直接用大核做形态学操作,耗时能到一两秒,但缩放到1500以内后再处理,速度能快三五倍,效果几乎没有差别。

希望这篇帖子能帮你搞定类似的去线需求。整个方案说实话不复杂,核心就是形态学核的形状、二值化方向、inpaint半径这三个参数,只要理解了它们之间的关系,遇到变形的场景也能很快自己调出来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询