☰
C#实现OCR:图片与扫描PDF文本提取完整指南
2026/10/4 2:12:43 网站建设 项目流程

做了这么多年 C# 开发,最让我觉得“不起眼但极度救场”的功能,就是给图片和扫描PDF提取文本。这种需求几乎每个项目周期都会遇到:客户发来一批扫描合同要检索关键字、上位机要读仪表屏幕截图、或者档案系统要把旧的纸质材料变成可搜的电子文本。用 C# 读取图片和扫描PDF中的文本,本质上就是两个技术点:找一个能用的OCR引擎,再把扫描PDF当成“一张张图片”逐页喂给它。这篇博文就围绕这两个点,把我自己踩过的坑和最终沉淀下来的可复用方案讲清楚。如果你正在做文档归档系统、自动录入工具或者资料检索功能,这篇文章可以直接照着抄作业,也能帮你少走不少弯路。

1. 需求拆解:扫描PDF和图片提文本,难在哪

1.1 扫描PDF和普通PDF的本质区别

先说一个很容易让新手栽跟头的概念:扫描PDF本质上不是“电子文档”,它是“图片的集合”。普通的PDF里面保存的是文字、字体、排版指令,你用PDF阅读器选中文字可以直接复制。但扫描PDF是扫描仪或者手机拍完之后直接合成的一个PDF,每一页其实是一整张像素图。正因为如此,直接从扫描PDF里读取文字是不可能的,必须先把页面渲染成图片,再做OCR识别。

这个区别决定了技术路径:如果项目里既有普通PDF又有扫描PDF,你不能统一走同一条解析通道。先说明这一点,后面处理逻辑就好理解了。在我实际做的文档管理系统里,我会先尝试用文本提取库读取PDF,如果提取出来全是空字符串,基本可以断定它没有文本层,再自动切换到“渲染+OCR”通道。这个判断逻辑非常实用,能防止你把原本就有文本层的PDF也白白消耗OCR时间,毕竟OCR比文本提取慢得多。

1.2 这类需求常见的业务场景

说实话,能用得上“读取图片和扫描PDF文本”的地方太杂了。我从自己接过的项目和周围同行聊到的情况里,挑几个高频场景:

  • 档案数字化:公司把十年来的纸质合同、报销单、人事档案扫描成PDF,需要建立全文检索功能,关键词能定位到具体页面。
  • 发票与证件识别:财务要提取发票号、金额、税率;人事要提取身份证号和姓名,这本质是图片OCR之后再做正则结构化。
  • 上位机与工业视觉:很多设备屏幕没有数据接口,只能通过摄像头抓图或截屏,然后把读到的数字文本回填到MES系统。这就是C#上位机里常说的“读图取数”。
  • 手机拍照资料入库:用户随手拍的照片,有脏背景、歪斜、光照不均,OCR之前还得做图像预处理。

这些场景的共同点在于:文本以“图像”形式存在,必须识别而不是“读取”。所以掌握一套稳定的C# OCR方案,在你做任何数据类项目时都是加分项。而且这类需求一旦上手,很容易形成一套通用的工具类,后面新项目直接复用,性价比非常高。

2. 技术选型:C#生态下OCR和PDF渲染库怎么搭配

2.1 OCR引擎:为什么我选Tesseract

C#生态里能用的OCR引擎其实不多,主要就是Tesseract的封装库、Windows自带的OCR接口、以及一些商用SDK。商用SDK识别率高但收费,接口还得看厂商文档;Windows.Media.Ocr在UWP里能用,但放到普通控制台服务或跨平台部署时会有兼容性麻烦。所以工程上最稳的还是Tesseract,社区活跃、语言包全、离线可用,而且基于LSTM的识别模型在中文场景下表现已经相当够用。

在NuGet里对应的包叫Tesseract,作者的封装做得比较清晰。需要注意版本:老项目里常见的是3.x封装,对应Tesseract 3引擎;新项目尽量用5.x,底层是Tesseract 5,识别准确度和速度都有提升。我项目里用的Tesseract NuGet版本是5.2.0,支持net8.0,完全没有问题。如果只是做一个小工具,也可以省掉封装库,直接调Tesseract原生命令行,但那样在并发、结果解析和异常处理上会麻烦很多,所以我不建议。

2.2 扫描PDF渲染:Pdfium全家桶怎么选

扫描PDF要转成图片,这一步我用的是Pdfium,也就是Google开源的PDF解析引擎的封装。选择理由很简单:开源、解析稳定、渲染质量高、支持跨平台。NuGet上围绕Pdfium的呈现选择有好几个,我整理了一个对比表格:

包名特点适用场景
PdfiumViewer老牌封装,包含WinForms控件,但更新较慢桌面工具、需要界面预览时
PDFtoImage基于Pdfium,呈现代码简洁,跨平台服务端批量渲染,推荐
Docnet.Core偏底层,文档少,定制性强需要深度定制渲染逻辑时

我自己在项目里用PDFtoImage更多,因为它API非常简洁。核心就一行:把PDF路径传进去,它会返回每一页的图片流,然后直接对图片做OCR。如果你做的是WinForms工具,需要界面预览,那PdfiumViewer更合适。这个选择并不冲突,工具型项目和服务型项目各用各的就好。不必纠结哪个更高级,关键是看你的程序在哪跑、界面要不要展示。

2.3 语言包、运行库和部署准备

Tesseract默认只带一套英文语言数据,或者某些环境只装了eng,所以中文识别很容易出现问题。你需要单独下载chi_sim.traineddata,也就是简体中文语言包,放到程序的tessdata目录下。官方仓库分了tessdata_fast和tessdata_best两个版本,区别很明显:

  • tessdata_fast:体积小,识别速度快,适合批量处理、对精度要求不太高的场景。
  • tessdata_best:体积大,识别精度更高,适合证件、票据等需要精确结果的场景。

我建议先下载tessdata_fast跑通流程,如果准确率不够,再替换成tessdata_best重测。另外,如果你处理的材料里中英文混杂,引擎语言参数要写"chi_sim+eng",不能只写中文,否则英文单词会被拆散,断句乱掉。

部署时还有一个坑:服务器上的语言模型目录一定要跟着程序一起走,或者在配置里明确指定绝对路径。我遇到过同事把tessdata忘了拷贝,程序部署到服务器后直接抛异常,报“failed to load language chi_sim”,排查半天才发现是模型文件没带上去。这类问题其实很好定位,但头一回遇到时确实容易慌,以为是代码写错了。

3. 核心实现:从图片和扫描PDF中提取文本的完整流程

3.1 项目搭建与NuGet包安装

直接用.NET 8的控制台项目演示,命令行如下:

dotnet new console -n OcrDemo cd OcrDemo dotnet add package Tesseract --version 5.2.0 dotnet add package PDFtoImage --version 4.0.2 dotnet add package SixLabors.ImageSharp

如果你用的是Visual Studio,直接在NuGet包管理器里搜以上三个包即可。ImageSharp不是必需的,但它用来做图像预处理非常方便,而且跨平台,不会出现System.Drawing在Linux上不能用的问题。如果你只是Windows上做个小工具,用System.Drawing.Common也没问题,但要注意在.NET 6之后它在非Windows平台不受支持,服务端部署就可能是个大坑。

3.2 图片OCR:最基础也最核心的调用

图片读取文本的代码非常简短,但每个细节都有讲究:

using Tesseract; using var engine = new TesseractEngine(@"./tessdata", "chi_sim+eng", EngineMode.LstmOnly); using var pix = Pix.LoadFromFile(@"D:\samples\invoice.png"); using var page = engine.Process(pix); string rawText = page.GetText(); Console.WriteLine(rawText); foreach (var word in page.GetSegmentedRegions(PageIteratorLevel.Word)) { Console.WriteLine($"[{word.X1},{word.Y1},{word.X2},{word.Y2}]"); }

这段代码里几个点要解释一下:

  • EngineMode.LstmOnly表示只用LSTM神经网络模型识别,比默认的Legacy+LSTM混合模式要快一些,实际效果差别不大。
  • Pix.LoadFromFile是Tesseract封装库提供的图片类型,支持常见图片格式。如果你手上的图是Base64字符串,可以用Pix.LoadFromMemory先转一下。
  • GetText拿到的就是一个整页纯文本,带换行符但一般不带坐标。
  • GetSegmentedRegions能返回每个词的边界框,这个能力在做区域定位、表单录入的时候非常有用。

不少新手会问:为什么我的图片识别出来全是乱码?大多数时候不是引擎问题,而是图片质量太差。OCR的本质是模式识别,像素模糊、噪点多、对比度低,再强的模型也白搭。所以在真实项目中,我通常会在调用OCR之前先做一次图像预处理。

3.3 扫描PDF逐页处理:渲染+识别组合拳

扫描PDF的处理流程,就是“页面渲染”和“图片OCR”的组合。用PDFtoImage把PDF每一页渲染成高分辨率图片,然后循环调用Tesseract识别。下面是一个能直接运行的完整代码:

using PDFtoImage; using Tesseract; using var engine = new TesseractEngine(@"./tessdata", "chi_sim+eng", EngineMode.LstmOnly); // PDFtoImage 4.x 的调用方式 var pages = Conversion.ToImages(@"D:\samples\scanned.pdf", dpi: 300); int pageIndex = 0; foreach (var pageImage in pages) { using var memoryStream = new MemoryStream(); pageImage.SaveAsPng(memoryStream); memoryStream.Seek(0, SeekOrigin.Begin); using var pix = Pix.LoadFromMemory(memoryStream.ToArray()); using var page = engine.Process(pix); string pageText = page.GetText(); Console.WriteLine($"===== 第 {pageIndex + 1} 页 ====="); Console.WriteLine(pageText); File.WriteAllText($"page_{pageIndex + 1}.txt", pageText); pageIndex++; }

看着简单,但有三个细节必须处理到位。

第一,dpi参数设成300。渲染分辨率过低,OCR识别率直线下降;过高又会导致图片内存暴涨。我做过对比,300 DPI是一个性价比很高的临界点,A4纸页面渲染出来大约是2480x3508像素,既能识别出小号字体,内存占用也还在可控范围。

第二,PDFtoImage返回的页面对象要记得释放。它内部用的是非托管资源,如果不小心处理,批量处理几百页的PDF很容易把内存拖垮。上面代码里我用using和局部变量约束生命周期,是个基本习惯。

第三,不要在一个循环里反复创建TesseractEngine。引擎初始化时要加载语言模型,非常耗时,一次初始化可能需要几百毫秒。正确做法是程序启动时创建一次engine,整个处理过程复用它,最后再释放。这一点在并发和批量场景下尤其重要。

3.4 指定矩形区域识别和文本裁剪

有时候你不需要识别整张图片,而只需要提取图片里某一块区域的内容。比如上位机里的屏幕截图,有用的数字只占屏幕一角;或者扫描表单里,客户只需要填写的备注区。这种情况下,比较聪明的做法是先裁剪区域,再喂给Tesseract,识别速度和准确率都能提升。

用ImageSharp可以做区域裁剪,代码也不复杂:

using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; using var image = Image.Load(@"D:\samples\screen.png"); var cropRect = new Rectangle(100, 200, 400, 120); // X, Y, Width, Height image.Mutate(x => x.Crop(cropRect)); using var ms = new MemoryStream(); image.SaveAsPng(ms); ms.Seek(0, SeekOrigin.Begin); using var pix = Pix.LoadFromMemory(ms.ToArray()); using var page = engine.Process(pix, PageSegMode.SingleLine); Console.WriteLine(page.GetText());

这里有两个好处:一是排除了区域外文字的干扰,二是页面分割模式可以换成SingleLine或者SingleWord,这样识别单行数字、设备编码时,准确率会比整页识别高很多。关于矩形区域的坐标怎么获取,简单粗暴的办法是用画图工具打开图片,鼠标悬停就能看到坐标;工业场景里也可以做成可视化配置界面,让操作员手工框选。

3.5 结果保存和结构化处理

提取出的裸文本通常不是最终产物。真实业务里,你往往还需要把文本落库、转JSON、或者根据规则提取关键字段。我常用的处理思路有三个:

  • 整页文本入库:直接把每页文本写入数据库或Elasticsearch,用来做全文检索。
  • 去掉冗余空白字符:用正则把连续换行、空格压缩掉,避免检索时被空白干扰。
  • 关键字段抽取:针对身份证、发票这类固定版式,可以先用GetSegmentedRegions拿到字段坐标,再结合正则从文本中抽取需要的号码。

举个例子:识别一张发票,原始文本里有“发票号码:12345678”这样的串。你不能直接整段入库就算完,最好再用Regex.Match(text, @"发票号码[::]\s*(\d+)")把号码抽出来,单独存成一个字段。这样业务方查询和统计都能直接用,不用每次翻全文。结构化处理虽然不是OCR的核心,但往往决定了这套工具最终能不能真正落地到业务流程里。

4. 实战中的坑:中文识别、DPI设置、内存和并发问题

4.1 中文识别结果全变成乱码或者方块

这个问题出现频率最高。如果Tesseract报错“Failed loading language chi_sim”,那基本是语言包没找到,检查tessdata目录是否在程序运行目录下,或者是否设置了TESSDATA_PREFIX环境变量。如果没报错但输出乱码,则要看输入图片本身。拍照件里的倾斜、透视、阴影都会让识别率明显下降,建议先做旋转矫正、裁边、提亮预处理。

还有一个很隐蔽的坑:页面分割模式会影响中文识别。Tesseract里tessedit_pageseg_mode默认是PAGING_MODE_AUTO,适合普通文档;但如果识别的是单行数字、表格单元格或者固定版式的卡片,可能需要手动指定模式。比如识别仪表数字可以用PageSegMode.SingleLine,识别身份证这种分区明确的可以用SingleBlock。

using var page = engine.Process(pix, PageSegMode.SingleBlock);

建议你在做一个新类型图片时,花个15分钟把几种常用模式都跑一遍,记录下哪种模式准确率最高。这个经验积累下来,后面每个新需求都能少折腾。我把常用场景和推荐模式整理成了下表:

场景推荐PageSegMode
整页文档Auto
单行数字/条码SingleLine
分段明显的表单SingleBlock
只识别一个词SingleWord
带表格的发票Auto

4.2 扫描件清晰度差:300 DPI也没救怎么办

低清扫描件是很多传统行业的老大难。扫描仪质量一般、纸张泛黄、字迹浅,这种图直接喂给OCR效果肯定差。我处理这类问题的三板斧是:灰度化、二值化、缩放。

灰度化就是把彩色图片转成黑白灰,避免颜色干扰;二值化是把像素点分成黑和白两种,有利于OCR模型定位笔画边缘;缩放则是把过小的文字放大。用ImageSharp可以非常方便地做这些操作:

using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; using var image = Image.Load(@"D:\samples\dark_scan.png"); image.Mutate(x => x.Grayscale().BinaryThreshold(0.55f).Resize(1.6f)); image.SaveAsPng(@"D:\samples\preprocessed.png");

这里BinaryThreshold(0.55f)就是把亮度阈值设为55%,高于阈值的像素转白、低于的转黑。阈值怎么定需要看实际材料,我的经验是先按0.5-0.6试,扫描件泛黄严重的可以调到0.45左右。颜色模型转换、缩放比例这些参数,最好做成配置文件,让现场人员微调,别写死在代码里。

4.3 PDF渲染出的图片是空白或者全黑

用PDFtoImage渲染某些扫描PDF时,偶尔会碰到渲染出来的图片一片黑或一片白。我遇到过两种典型情况。

一种是PDF页面的旋转标记没被正确处理。扫描仪生成的PDF可能带旋转元数据,渲染库执行时如果没应用旋转,图片内容就偏了,看起来像异常。排查方法是把渲染出的图片用看图工具打开,如果能看到内容但方向不对,考虑在OCR之前调用图片旋转。

另一种是颜色空间问题。个别扫描仪的PDF用的是特殊色彩配置,渲染库默认输出RGB时内容丢失。这种情况下可以试试调整渲染参数,或者在PDFtoImage的Issue区找一下对应版本的兼容性讨论。如果只是极个别文件异常,稳妥办法是换一个渲染库做交叉验证,比如Docnet.Core再跑一遍,看是否同样白屏。白屏问题多数不是OCR的问题,而是PDF解析层的问题,记住这一点能少绕很多弯。

4.4 批量处理时内存飙升线程卡死

真实项目里很少只处理单个文件,往往是拖入一个文件夹几百个PDF。如果不做并发控制,直接把所有PDF页面加载到内存,程序很快就OOM。我踩过一次很深的坑:一个同事写的批量工具,同时并行处理20个PDF,每个PDF约100页,跑了不到两分钟,整个服务内存涨到3GB以上,最后直接被杀掉。

正确的做法是限制并发数。用SemaphoreSlim控制最多同时处理2到4个文档,并且每个文档内部逐页处理、逐页释放。还可以给每页识别加一个超时机制,因为OCR耗时长的多半是异常文件,超时后记录下来继续处理,而不是让整批任务卡死。

using var semaphore = new SemaphoreSlim(2); async Task ProcessDocumentAsync(string pdfPath) { await semaphore.WaitAsync(); try { // 渲染+OCR处理 } finally { semaphore.Release(); } }

这段代码看起来简单,但它能保证任何时刻最多只有2个文档在跑OCR,内存使用率稳定,任务也不会因为一个坏文件拖垮整体。

5. 生产环境里的优化:预处理、后处理与批量任务兜底

5.1 图像预处理的优先级和顺序

很多教程把预处理写得很玄乎,我个人用下来最重要的三件事就是:尺寸、对比度、边框干扰。尺寸决定文字是否足够大;对比度决定笔画是否清晰;边框干扰则是扫描件中常见的页边缘黑边、装订孔、污渍。

处理顺序我习惯是:先缩放到合适尺寸,再灰度化去色,再做二值化或对比度增强,最后用矩形裁剪去掉边缘黑边。顺序不能乱,比如先二值化再做尺寸缩放,会把噪声连带放大,效果反而差。

当然,OCR识别这种环节,预处理不是越多越好。图像滤镜加得太多,有时候会把真实笔画误伤。我的判断标准是:处理完的图片,用肉眼看是否比原图更“干净清晰”。如果你自己都觉得处理完更糊,那这一步就是在帮倒忙。

5.2 文本后处理和版面重排

OCR识别出来的文本是“流式”的,它并不理解原稿的排版结构。表格行、两栏排版、首行缩进都会被打乱。如果你只是做全文检索,问题不大;但如果要还原版面,就得花更多功夫。

我常用的一个简单方案是:按“行”维度识别,而不是一次性给整页文本。用GetSegmentedRegions拿到每个词或每一行的坐标,然后根据Y坐标排序、X坐标对齐,重新拼接成表格结构。这个方法在识别表单和发票时很管用,但代码量会多一些。如果没有版面还原需求,建议直接拿整页文本,不做过度处理,效率和效果都更可控。

后处理还有一个关键点是字符纠错。OCR输出的中文经常会出现“0/O、1/l/I、8/B”混淆,尤其是在数字、字母混合的场景。你可以针对业务字段做白名单校验。比如识别身份证号,先校验18位且最后一位符合校验规则,不满足就标记为“需人工复核”,不要让脏数据直接进库。

5.3 批量任务的异常兜底和重试机制

批量处理文件不能太天真地认为每个文件都能成功。我见过太多次因为1个损坏PDF导致整批任务中断的情况。所以我的批量工具里一定会有三个机制:失败重试、失败记录、跳过继续。

失败重试一般是识别超时或渲染异常才触发,重试1到2次即可,太多会拖慢整体进度。失败记录是必须的,把出问题的文件路径、异常类型、当时处理到哪一页,统一写到日志和失败清单里。跳过继续保证主流程不受单个文件影响。

try { await ProcessDocumentAsync(pdfPath); } catch (Exception ex) { File.AppendAllText("failed.log", $"{pdfPath}: {ex.Message}{Environment.NewLine}"); continue; }

这套兜底逻辑看起来简单,但在实际交付中非常加分。因为它意味着程序可以无人值守地跑一整夜,第二天早上只需要看失败清单,而不是去查一个已经崩掉的服务。

最后分享一点我在实际项目里的体会:图片和扫描PDF的文本识别,工具链本身并不复杂,真正拉开差距的,是对图片质量的判断、对字段结构化处理的深度,以及对异常文件的兜底能力。我一开始做OCR工具时总觉得效果不好是库不够强,后来发现是把过多精力放在调参上,反而忽略了预处理和后续业务逻辑的重要性。如果你也是刚入门C# OCR这条路,建议你先拿10张真实业务图片跑通整个流程,再把识别准确率、失败率记录下来,一步步优化。工具能自动做的程度永远有限,但你把它放在正确的流程里,它就能稳定帮你省下大量人工录入时间。

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

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

立即咨询