C# WinForm 集成 Tesseract-OCR 实现截图文字识别与可复制文本 简介这份资源是面向C#桌面开发者的WinForm集成Tesseract-OCR演示工程适合需要在.NET Framework项目中快速落地图片文字识别功能的初中级开发者也可作为OCR入门学习与二次开发的参考模板。压缩包共21个文件约32.16MB包含6个cs源码文件、5个dll依赖库、2个config配置、2个resx资源、1个traineddata语言训练数据、1个sln解决方案及csproj工程文件等覆盖从项目结构到运行所需的完整组成。测试环境为VS2019搭配.NET Framework 4.7.2并配有博客说明与B站视频演示便于对照理解调用流程。目前已有524人学习下载。通过该示例可掌握Tesseract引擎在WinForm中的引用方式、识别参数配置与结果输出思路快速搭建可运行的OCR小工具并在此基础上扩展截图识别、批量处理等实际场景。1. C# WinForm 接 Tesseract-OCR从截图到可复制文本的最小闭环手上有个 WinForm 上位机客户现场拍回来的设备铭牌、纸质工单、电表读数照片要人工一条条敲进系统。这种活干过一次就不想干第二次。C# WinForm 加 Tesseract-OCR 就是解决这个场景的界面上框选一块区域点一下按钮图片里的文字直接进 TextBox再落库或导出。它适合做内部工具、产线辅助录入、票据批量识别这类对精度要求没那么苛刻、但要求离线可跑、不依赖外部接口的场合。Tesseract 是纯本地引擎装完语言包就能识别中英文配合 WinForm 的 PictureBox 和 Graphics 做框选整个链路在 VS 里就能闭环。这篇按我实际搭过的顺序讲环境怎么配、代码怎么写、参数怎么调、坑在哪最后给一个能直接抄的识别封装。2. 环境准备与 Tesseract 在 WinForm 里的接入方式2.1 两种接入路线进程调用 vs NuGet 封装Tesseract 在 .NET 里落地常见做法就两条路。第一条是直接调tesseract.exe用Process.Start传参识别结果写到一个 txt 文件再读回来。这条路最省事不挑 .NET 版本VS2015 也能跑缺点是每次识别都要起进程慢而且路径里有空格就翻车。第二条是用 NuGet 上的封装库把 native 的tesseract50.dll、leptonica这些通过 P/Invoke 调起来识别在进程内完成速度快能拿到置信度、词框这些结构化数据。我一般选第二条。原因很直接进程调用拿不到每个词的坐标做框选预览、结果高亮的时候没法对齐而且频繁起进程在产线连续识别场景下延迟很明显。封装库的代价是要处理 x86/x64 的 native dll 位数匹配问题这个后面避坑章节细说。选型上还要注意 Tesseract 的版本。4.x 之后引入了 LSTM 引擎中文识别比 3.x 好一大截但训练数据文件.traineddata也大了不少。做中英文混排用chi_simeng组合别只装chi_sim否则数字和英文标点会识别得很差。2.2 装语言包与目录结构约定Tesseract 本体装完后tessdata目录里默认只有eng。中文要单独下chi_sim.traineddata放进去。我习惯在项目输出目录下建一个tessdata文件夹把用到的语言包拷进去程序启动时用相对路径指过去这样打包成安装程序后不会因为客户机器没装 Tesseract 而找不到文件。目录结构大概是这样YourApp/ bin/Debug/ YourApp.exe x64/ tesseract50.dll leptonica-1.80.0.dll x86/ tesseract50.dll leptonica-1.80.0.dll tessdata/ eng.traineddata chi_sim.traineddatax64和x86两个目录是封装库要求的运行时按当前进程位数自动加载。语言包放tessdata路径在代码里拼。2.3 用 NuGet 装包并跑通第一段识别在 VS 里对项目右键「管理 NuGet 程序包」搜 Tesseract装那个下载量最高的封装包。装完引用里会多出对应的程序集。下面是最小可运行代码读一张本地图片输出识别文本using Tesseract; using System; using System.Drawing; namespace OcrDemo { public class OcrRunner { // tessdata 目录用相对路径避免客户机绝对路径不一致 private const string TessDataPath ./tessdata; public string Recognize(string imagePath) { // 用 using 确保引擎释放否则连续识别会内存上涨 using (var engine new TesseractEngine(TessDataPath, chi_simeng, EngineMode.Default)) { // 加载图片LoadFromFile 内部会做灰度化预处理 using (var img Pix.LoadFromFile(imagePath)) { using (var page engine.Process(img)) { // GetText 返回纯文本GetMeanConfidence 返回平均置信度 float confidence page.GetMeanConfidence(); Console.WriteLine($置信度: {confidence:F2}); return page.GetText(); } } } } } }逻辑说明TesseractEngine构造时传语言包目录和语言组合EngineMode.Default让 Tesseract 自己选 LSTM 还是 legacy。Pix.LoadFromFile是 leptonica 的图片加载支持 png、jpg、bmp、tiff。page.GetText()拿纯文本GetMeanConfidence()拿 0 到 1 的平均置信度低于 0.6 基本可以判定这张图质量不行该提示用户重拍。参数说明语言字符串chi_simeng里的加号是「同时加载」识别时按顺序尝试。如果只做数字识别用eng加白名单反而更准白名单后面讲。EngineMode有三个值Default、LstmOnly、LegacyOnly新项目一律Default或LstmOnly。3. WinForm 界面图片加载、框选与识别结果回填3.1 用 PictureBox 加 Graphics 做区域框选整张图直接丢给 Tesseract背景杂物多的时候识别率会掉。实际用的时候都是让用户框一块区域只识别框内内容。WinForm 里用PictureBox显示图片在它上面叠一个透明的Panel或者直接在PictureBox的Paint事件里画选择框。核心是鼠标三个事件MouseDown记起点MouseMove实时画矩形MouseUp定终点。为了不闪烁开双缓冲。下面这段是框选逻辑private Point startPoint; private Rectangle selectionRect; private bool isSelecting false; private void pictureBox1_MouseDown(object sender, MouseEventArgs e) { if (e.Button MouseButtons.Left) { isSelecting true; startPoint e.Location; selectionRect Rectangle.Empty; } } private void pictureBox1_MouseMove(object sender, MouseEventArgs e) { if (!isSelecting) return; // 用当前点和起点构造矩形注意宽高可能为负 int x Math.Min(startPoint.X, e.X); int y Math.Min(startPoint.Y, e.Y); int w Math.Abs(e.X - startPoint.X); int h Math.Abs(e.Y - startPoint.Y); selectionRect new Rectangle(x, y, w, h); pictureBox1.Invalidate(); // 触发重绘 } private void pictureBox1_MouseUp(object sender, MouseEventArgs e) { isSelecting false; // 框太小视为误操作忽略 if (selectionRect.Width 5 || selectionRect.Height 5) selectionRect Rectangle.Empty; } private void pictureBox1_Paint(object sender, PaintEventArgs e) { if (selectionRect ! Rectangle.Empty) { using (var pen new Pen(Color.Red, 2)) { e.Graphics.DrawRectangle(pen, selectionRect); } } }逻辑说明MouseMove里用Math.Min和Math.Abs处理从右下往左上拖的情况否则矩形宽高为负DrawRectangle画不出来。Invalidate触发Paint重绘把旧框擦掉画新框。MouseUp里加一个最小尺寸判断防止单击产生一个 1x1 的框。参数说明Pen的宽度 2 是视觉习惯太细看不清太粗挡字。框选坐标是相对PictureBox的如果图片有缩放SizeMode Zoom要按缩放比例换算回原图坐标这个换算在下一节讲。3.2 坐标换算PictureBox 缩放后的裁剪PictureBox的SizeMode设成Zoom时图片按比例缩放居中显示鼠标坐标和原图坐标不是一回事。裁剪前必须换算否则裁出来的区域和用户框的对不上。换算公式要考虑图片实际显示区域和偏移private Rectangle GetImageRect() { // 计算图片在 Zoom 模式下的实际显示区域 Image img pictureBox1.Image; if (img null) return Rectangle.Empty; float ratio Math.Min( (float)pictureBox1.Width / img.Width, (float)pictureBox1.Height / img.Height); int displayW (int)(img.Width * ratio); int displayH (int)(img.Height * ratio); int offsetX (pictureBox1.Width - displayW) / 2; int offsetY (pictureBox1.Height - displayH) / 2; return new Rectangle(offsetX, offsetY, displayW, displayH); } private Bitmap CropSelection() { var imgRect GetImageRect(); if (imgRect Rectangle.Empty || selectionRect Rectangle.Empty) return null; // 把控件坐标转成原图坐标 float ratio (float)imgRect.Width / pictureBox1.Image.Width; int x (int)((selectionRect.X - imgRect.X) / ratio); int y (int)((selectionRect.Y - imgRect.Y) / ratio); int w (int)(selectionRect.Width / ratio); int h (int)(selectionRect.Height / ratio); // 边界裁剪防止越界抛异常 x Math.Max(0, Math.Min(x, pictureBox1.Image.Width - 1)); y Math.Max(0, Math.Min(y, pictureBox1.Image.Height - 1)); w Math.Min(w, pictureBox1.Image.Width - x); h Math.Min(h, pictureBox1.Image.Height - y); var bmp new Bitmap(w, h); using (var g Graphics.FromImage(bmp)) { g.DrawImage(pictureBox1.Image, new Rectangle(0, 0, w, h), new Rectangle(x, y, w, h), GraphicsUnit.Pixel); } return bmp; }逻辑说明GetImageRect算出图片在控件里的实际显示矩形包含居中偏移。CropSelection把框选坐标减去偏移再除以缩放比得到原图坐标。最后用Graphics.DrawImage把原图对应区域画到新Bitmap上这就是要送给 Tesseract 的图。参数说明ratio是显示尺寸除以原图尺寸Zoom模式下宽高比一致用一个 ratio 就行。边界裁剪那几行是后悔药用户贴着图片边缘框的时候不做裁剪会抛OutOfMemoryException这个异常名字很误导其实是坐标越界。3.3 识别结果回填与状态栏进度识别是耗时操作放 UI 线程会卡界面。用Task.Run丢到后台识别完用Invoke回填。状态栏和进度条同步更新让用户知道程序没死。private async void btnRecognize_Click(object sender, EventArgs e) { var bmp CropSelection(); if (bmp null) { MessageBox.Show(请先在图片上框选识别区域); return; } btnRecognize.Enabled false; toolStripStatusLabel1.Text 识别中...; toolStripProgressBar1.Style ProgressBarStyle.Marquee; try { string result await Task.Run(() { // 把 Bitmap 存成临时文件或转成 Pix这里用临时文件最省事 string tmp System.IO.Path.GetTempFileName() .png; bmp.Save(tmp, System.Drawing.Imaging.ImageFormat.Png); var runner new OcrRunner(); string text runner.Recognize(tmp); System.IO.File.Delete(tmp); return text; }); txtResult.Text result; toolStripStatusLabel1.Text 识别完成; } catch (Exception ex) { toolStripStatusLabel1.Text 识别失败; MessageBox.Show(识别出错: ex.Message); } finally { btnRecognize.Enabled true; toolStripProgressBar1.Style ProgressBarStyle.Blocks; bmp.Dispose(); } }逻辑说明Task.Run里做识别不阻塞 UI。ProgressBarStyle.Marquee是滚动条样式因为 Tesseract 不提供进度回调用跑马灯表示「在忙」。识别完await回到 UI 线程直接赋值给txtResult不需要手动Invokeasync/await已经处理了上下文切换。参数说明临时文件用.png后缀Tesseract 对 png 支持最好。finally里恢复按钮和进度条bmp.Dispose()释放裁剪图不释放的话连续识别几十次内存就上去了。4. 识别参数调优让中文和数字都认得更准4.1 页面分割模式PSM怎么选Tesseract 的PageSegMode决定它怎么理解图片版面。默认是Auto但自动模式在单行文字、单个数字这种场景下经常翻车因为它会去找段落结构。常见取值和对应用法PSM 值含义适用场景Auto自动分析版面整页文档、不确定内容SingleBlock整块文本一段连续文字无分栏SingleLine单行文本一行数字、一行标题SingleWord单个词铭牌上的一个字段SingleChar单个字符电表读数单个数字SparseText稀疏文本界面截图、零散文字代码里这样设using (var engine new TesseractEngine(TessDataPath, chi_simeng, EngineMode.Default)) { // 识别单行数字用 SingleLine比 Auto 准很多 engine.DefaultPageSegMode PageSegMode.SingleLine; using (var img Pix.LoadFromFile(imagePath)) using (var page engine.Process(img)) { return page.GetText(); } }逻辑说明DefaultPageSegMode是引擎级设置设一次对该引擎后续所有识别生效。如果同一程序里既要识别整页又要识别单行就建两个引擎实例或者每次识别前改这个属性。参数说明做设备铭牌识别字段是「型号XXX」这种单行用SingleLine。做整张工单用Auto或SingleBlock。做电表数字用SingleChar配合数字白名单准确率能到 95% 以上。4.2 字符白名单与黑名单Tesseract 支持限定识别字符集。做纯数字识别时把白名单设成0123456789.它就不会把0认成O、1认成l。这是提升数字识别最有效的一招。using (var engine new TesseractEngine(TessDataPath, eng, EngineMode.Default)) { // 只识别数字和小数点排除字母干扰 engine.SetVariable(tessedit_char_whitelist, 0123456789.); // 关掉字典校正避免把数字纠正成单词 engine.SetVariable(load_system_dawg, 0); engine.SetVariable(load_freq_dawg, 0); using (var img Pix.LoadFromFile(imagePath)) using (var page engine.Process(img)) { return page.GetText().Trim(); } }逻辑说明tessedit_char_whitelist限定输出字符集白名单外的字符不会被输出。load_system_dawg和load_freq_dawg是系统词典和频率词典数字识别时关掉否则 Tesseract 会尝试把1234往单词上靠反而出错。参数说明白名单里想加负号就写0123456789.-。中文识别不要设白名单汉字太多设了反而限制。黑名单用tessedit_char_blacklist比如识别中文时把|、~这些噪声字符拉黑。4.3 图像预处理灰度、二值化、放大Tesseract 对图片质量敏感。手机拍的图有阴影、倾斜、分辨率低直接识别效果差。预处理三板斧转灰度、二值化、放大。Tesseract 官方建议字符高度至少 20 像素低于这个数识别率断崖下跌。private Bitmap Preprocess(Bitmap src) { // 放大 2 倍小图识别率提升明显 int newW src.Width * 2; int newH src.Height * 2; var scaled new Bitmap(newW, newH); using (var g Graphics.FromImage(scaled)) { g.InterpolationMode System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; g.DrawImage(src, 0, 0, newW, newH); } // 转灰度并二值化 var gray new Bitmap(newW, newH); for (int y 0; y newH; y) { for (int x 0; x newW; x) { Color c scaled.GetPixel(x, y); // 加权平均算灰度人眼对绿色最敏感 int lum (int)(c.R * 0.299 c.G * 0.587 c.B * 0.114); // 阈值 128大于的变白小于的变黑 int v lum 128 ? 255 : 0; gray.SetPixel(x, y, Color.FromArgb(v, v, v)); } } scaled.Dispose(); return gray; }逻辑说明先放大再二值化顺序不能反先二值化再放大会把锯齿也放大。灰度用加权平均直接(RGB)/3效果差。二值化阈值 128 是经验值光照不均的图用固定阈值不行得上自适应阈值OpenCVSharp 的AdaptiveThreshold但那是另一个话题。参数说明放大倍数 2 到 3 倍比较合适放太大反而慢且没提升。GetPixel/SetPixel逐像素操作慢大图要用LockBits或者 OpenCVSharp这里为了代码好懂用逐像素实际项目建议换。5. 避坑与排查Tesseract 在 WinForm 里最容易翻车的五件事5.1 报「Failed to find library」或「找不到 tesseract50.dll」现象程序在开发机跑得好好的拷到客户机或者换 Release 编译就抛异常提示找不到 native dll。原因封装库按进程位数去x64或x86子目录加载 native dll如果项目平台目标设成Any CPU且没关「首选 32 位」进程位数和 dll 目录对不上。另外发布时x64/x86目录没跟着 exe 一起拷过去也会这样。解决项目属性里把平台目标明确设成x64取消勾选「首选 32 位」。确认输出目录下有x64和x86两个文件夹里面 dll 齐全。打包成安装程序时把这两个目录加进安装清单。5.2 中文识别出来全是乱码或问号现象识别结果里中文变成???或者一堆乱码。原因tessdata目录里没有chi_sim.traineddata或者语言字符串写成了chi_sim但文件实际叫别的名字。还有一种情况是语言包版本和 Tesseract 引擎版本不匹配4.x 引擎配 3.x 的语言包会出问题。解决确认tessdata目录下有chi_sim.traineddata文件大小正常几十 MB。语言字符串用chi_simeng。语言包从对应版本的官方仓库下别混用。5.3 连续识别内存持续上涨最后崩溃现象识别几十张图后程序内存占用越来越高最后OutOfMemoryException。原因TesseractEngine、Pix、Page这些对象没释放。封装库底层是 native 内存GC 管不到必须显式Dispose。另外裁剪出来的Bitmap没释放也会累积。解决所有实现IDisposable的对象都用using包起来。TesseractEngine可以复用不用每次识别都新建建一个静态实例反复用程序退出时释放。Bitmap用完立即Dispose。5.4 识别速度慢一张图要好几秒现象单张图识别耗时超过 3 秒产线连续识别跟不上。原因图片分辨率太高比如 4000x3000 的手机原图Tesseract 处理大图很慢。或者每次识别都新建TesseractEngine引擎初始化本身就要几百毫秒。还有EngineMode用了LegacyOnlylegacy 引擎比 LSTM 慢。解决识别前把图缩到合适尺寸字符高度 30 到 50 像素就够整图宽度控制在 1500 像素以内。TesseractEngine做成单例复用。EngineMode用Default或LstmOnly。多张图批量识别用多线程但注意 Tesseract 引擎不是线程安全的每个线程一个引擎实例。5.5 框选区域和实际裁剪区域对不上现象用户框的是左上角裁出来的是中间一块或者裁出来的图偏移了。原因PictureBox的SizeMode是Zoom或StretchImage时图片显示尺寸和原图尺寸不一致鼠标坐标没做换算。StretchImage还会改变宽高比换算更麻烦。解决用Zoom模式保持宽高比按 3.2 节的换算逻辑处理。如果一定要用StretchImagex 和 y 方向要分别算缩放比。最省事的做法是SizeMode设Normal图片不缩放但大图显示不全用户体验差不推荐。6. 把识别封装成可复用组件与批量处理技巧单张识别跑通后实际项目里往往是批量处理一个文件夹几百张图或者从数据库读一批图片路径循环识别后写回。这时候有几个技巧能让代码干净且快。第一个是引擎复用。TesseractEngine初始化开销大做成静态字段整个应用生命周期只建一次。但要注意它不是线程安全的批量处理如果用多线程每个线程持有自己的引擎实例。我一般用ThreadLocalTesseractEngine或者Parallel.ForEach里每个分区建一个。第二个是识别结果的结构化。page.GetText()只给纯文本如果要做字段提取比如从「型号ABC123」里抠出ABC123用page.GetIterator()拿词级别的结果配合正则匹配。下面这段演示按行遍历并提取using (var iter page.GetIterator()) { iter.Begin(); do { // 拿到当前行的文本 string lineText iter.GetText(PageIteratorLevel.TextLine); if (lineText.Contains(型号)) { // 用正则从行里抠出型号值 var match System.Text.RegularExpressions.Regex.Match(lineText, 型号[:]\s*(\S)); if (match.Success) { string model match.Groups[1].Value; Console.WriteLine(提取到型号: model); } } } while (iter.Next(PageIteratorLevel.TextLine)); }逻辑说明GetIterator返回一个可以按层级遍历的对象PageIteratorLevel有TextLine、Word、Symbol等。按行遍历比按词遍历更适合做字段提取因为字段名和值通常在同一行。正则里的[:]同时匹配中英文冒号现场数据两种都有。参数说明iter.Next(PageIteratorLevel.TextLine)的层级参数要和GetText的一致否则遍历会乱。正则里的\S匹配非空白字符如果型号里有空格改成.?并加结束锚点。第三个技巧是识别结果缓存。同一张图重复识别没意义用图片的 MD5 做 key 缓存结果第二次直接返回。产线场景里同一批工单可能被重复扫描缓存能省不少时间。最后一个习惯每次识别都把原图、裁剪图、识别文本、置信度记一条日志。出问题的时候能回溯是哪张图、哪个参数导致的。我吃过亏客户说「识别错了」但没存原图根本没法复现。后来养成习惯识别失败或置信度低于阈值的图自动存到一个failed目录定期拿出来看慢慢就能总结出哪类图需要特殊预处理。这个方案值不值得做取决于你的场景里图片质量是否可控——可控就值得不可控就先解决拍摄环节。希望帮到你。本文还有配套的精品资源点击获取