尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C# WPF调用腾讯云OCR实现图片指定区域文字识别与批量文件重命名

C# WPF调用腾讯云OCR实现图片指定区域文字识别与批量文件重命名 不开玩笑这个需求我太熟了。前段时间帮朋友整理一批扫描图纸上千张JPG文件名全是“IMG_20210324_001”这种实际内容编号全藏在图片右下角一小块区域里。人工一张张打开看图、记编号、手动改名别说眼睛受不了光重复劳动就够让人崩溃的。市面上的批量改名软件我基本都试过要么只能按时间、尺寸、序号这些元数据改要么OCR识别一整张图结果把一堆没用的水印、拍摄信息全识别出来文件名乱成一锅粥。这个项目标题问的就是一件事能不能自动识别jpg图片上“指定区域”的文字然后用这段文字直接给文件改名答案是能而且用WPF搭界面、接腾讯云OCR的API完全可以自己做一个顺手的工具。整个过程不复杂核心就三个环节在界面上框选要识别的图片区域、把裁出来的小图丢给OCR接口、拿返回的文字拼好新文件名执行重命名。这篇文章我把完整思路、关键代码和踩过的坑都写出来有类似需求的可以直接照着搭一个。1. 需求拆解为什么现成软件解决不了这个事1.1 真实场景里“区域识别”到底有多普遍你可能觉得这个需求小众其实做文件管理、档案数字化的人每天都在碰。最常见的几类发票、收据、合同扫描件编号、金额、公司名固定出现在某个位置财务归档时恨不得按发票号命名。图纸、工程文件图号、版本号在标题栏右上角几百张图纸想按图号归档。产品照片、检测报告批次号、条码编号在固定位置质检留档要按批次索引。历史照片、老档案标注文字在底片边缘或背面说明区域。这类场景的共同点是一张图上有很多文字但你真正想拿来当文件名的只有那一小块区域里的内容。这就是为什么“全图OCR再改名”的方案不好用它做不到精准提取。1.2 市面方案的三个明显痛点先说说我试过的路线你就能明白为什么最后决定自己写工具。第一类Windows自带的批量重命名或者第三方改名工具。它们只能基于文件属性修改日期、大小、序号生成新名字完全不知道图片内容是什么。不能满足需求。第二类在线OCR网站配合手工改名。你得一张张上传图片再把识别出的文字复制出来手动应用成文件名。几百张图下来操作量巨大而且涉及敏感资料时你根本不敢把文件传到别人服务器上。第三类通用OCR桌面软件。像一些PDF识别工具能识别整页文字但导出结果时给你的是一个单独的文本文件你还是要自己去建立“图片文件 - 识别文字”的对应关系没有真正把改名流程串起来。1.3 把需求翻译成技术方案分析到这里对这个工具的功能定义就很清楚了能预览本地jpg图片支持在图上画出识别区域而且这个区域可以跨图片复用同一批图片的识别位置基本固定。把裁剪出的区域图片调用OCR识别提取文字。根据识别结果批量生成新文件名支持加前缀、后缀、序号等规则。先预览所有识别结果确认无误后统一执行重命名。技术选型上界面我用WPF。原因很直接C#桌面生态成熟WPF的Canvas叠加布局做“图片框选矩形”这种交互最顺手数据绑定写批量文件列表和重命名规则也很方便。OCR这一环对比过几种方案后我选了腾讯云OCR的通用印刷体识别接口。这个选择我下面细说。2. 关键决策OCR方案怎么选为什么用腾讯API2.1 本地OCR和云OCR的取舍做OCR摆在面前两条路本地开源方案或者云端API。本地方案我第一个试的是Tesseract OCR。这玩意部署确实简单NuGet装个包就能用Windows下还有中文识别数据包。但实际测试中文印刷体识别准确率只能说勉强及格遇到扫描件带一点点倾斜、光照不均识别出来就是乱码。你说用在发票、印章这类有背景干扰的图上那更没法看。另一个本地热门是PaddleOCR识别效果好很多但环境配置重依赖一堆打包发布给普通用户用光是运行库就够折腾。云端OCR这边其实各家都有类似服务。我最后定腾讯云OCR主要是三个考虑一是通用印刷体识别对中文的支持比Tesseract好一个量级实际测试扫描版合同、图片上的打印体文字基本能一次识别对二是接口调用简单官方SDK封装好了签名和请求逻辑不要求你会写HTTP签名三是新用户有免费额度个人工具测试阶段几乎不花钱。2.2 腾讯云OCR的接入思路腾讯云的OCR接口走的是标准HTTP POST传一张Base64编码的图片过去返回JSON里面有识别到的每个文字块内容和它在图中的坐标。核心接口是通用印刷体识别GeneralBasicOCR一张图里所有文字都会返回每条包含DetectedText识别出的文本内容。Confidence置信度0到100的数值。Polygon文字块在图片中的坐标点。对于我们的需求其实只关心返回结果里的DetectedText。因为传给它的图片已经是我们精确裁剪过的区域返回的通常就是那一两块文字不需要再按坐标筛选。提示不要贪方便把原图全图传给OCR再靠坐标过滤文字。这个做法有两个问题一是全图文字多接口按调用次数计费传给它的图片越大、识别内容越多成本更高二是全图识别会把区域外的干扰文字也纳入结果你从JSON里再筛坐标逻辑更复杂还容易漏。先裁剪再识别这个方向一定不要搞反。2.3 为什么区域裁剪能大幅提高识别率有道是“输入的质量决定输出的质量”。OCR对手写体、变形字体、低分辨率图片的识别效果本来就有限但如果你把一个干净的小区域裁剪出来送给它图片内容纯粹没有背景干扰文本区域占比大识别准确率会显著提升。这也是我后来测试中体会最深的一点同一个扫描件全图识别时右下角的小字经常识别错单独裁剪放大后再识别基本一字不差。3. WPF界面交互区域框选工具的核心实现3.1 界面布局思路工具界面不需要花哨三块功能区就够左侧主区域图片预览 框选层。右侧上方当前图片的信息、识别区域坐标、识别结果预览。右侧下方命名规则配置和批量重命名操作按钮。WPF做这种布局非常顺手用Grid划两列就行。关键是图片预览这块需要用Grid叠两层底层放Image控件显示图片上层放Canvas用来画框选矩形。Grid Grid.ColumnDefinitions ColumnDefinition Width2*/ ColumnDefinition Width1*/ /Grid.ColumnDefinitions !-- 左侧图片预览与区域框选 -- Border Grid.Column0 BorderBrush#DDD BorderThickness1 Background#2D2D30 Grid x:NameImageContainer ClipToBoundsTrue Image x:NameDisplayImage StretchUniform RenderOptions.BitmapScalingModeHighQuality/ Canvas x:NameOverlayCanvas/ /Grid /Border !-- 右侧参数设置与操作面板 -- StackPanel Grid.Column1 Margin12 !-- 图片列表、命名规则、操作按钮都放这里 -- /StackPanel /Grid3.2 鼠标拖拽框选与坐标换算框选交互的逻辑是这样的鼠标在图片上按下时记录起点拖动时实时画一个半透明矩形松开时记录终点计算出一个矩形区域。关键坑在坐标换算Canvas接收到的鼠标坐标是相对于显示窗口的而实际要裁剪的是原始图片的像素区域两者因为图片缩放比例不同需要换算。图片用StretchUniform显示时图片等比缩放居中显示所以缩放比例是统一的private double GetScaleX(BitmapSource source) { return DisplayImage.ActualWidth / source.PixelWidth; } private double GetScaleY(BitmapSource source) { return DisplayImage.ActualHeight / source.PixelHeight; }这里要注意一个WPF血泪教训不要在Image控件上直接监听MouseDown/MouseUp去取坐标尤其是图片没有完全填满控件区域时坐标会包含多余留白。更稳的做法是在外层Grid也就是ImageContainer上监听然后还要考虑图片居中后左上角的偏移量。我的实现方式是private Point startPoint; private Rectangle? selectionRect; private Int32Rect? selectedRegion; private void ImageContainer_MouseDown(object sender, MouseButtonEventArgs e) { if (DisplayImage.Source is not BitmapSource source) return; startPoint e.GetPosition(ImageContainer); selectionRect new Rectangle { Stroke Brushes.LimeGreen, StrokeThickness 2, StrokeDashArray new DoubleCollection { 4, 2 }, Fill new SolidColorBrush(Color.FromArgb(40, 0, 255, 0)) }; OverlayCanvas.Children.Add(selectionRect); } private void ImageContainer_MouseMove(object sender, MouseEventArgs e) { if (selectionRect is null) return; var current e.GetPosition(ImageContainer); var rect new Rect(startPoint, current); Canvas.SetLeft(selectionRect, rect.X); Canvas.SetTop(selectionRect, rect.Y); selectionRect.Width rect.Width; selectionRect.Height rect.Height; } private void ImageContainer_MouseUp(object sender, MouseButtonEventArgs e) { if (selectionRect is null || DisplayImage.Source is not BitmapSource source) return; var endPoint e.GetPosition(ImageContainer); var rect new Rect(startPoint, endPoint); // 把窗口内坐标换算成原图坐标 double scaleX source.PixelWidth / DisplayImage.ActualWidth; double scaleY source.PixelHeight / DisplayImage.ActualHeight; int x (int)(rect.X * scaleX); int y (int)(rect.Y * scaleY); int width (int)(rect.Width * scaleX); int height (int)(rect.Height * scaleY); selectedRegion new Int32Rect(x, y, width, height); OverlayCanvas.Children.Remove(selectionRect); selectionRect null; }注意上面用的是简化版本假设图片完全填满显示区域。如果图片周围有留白需要额外计算图片显示区域的偏移量不然框选位置会偏。实际做的时候我建议在ImageContainer的SizeChanged事件里计算图片显示区域用如下公式private Rect GetImageDisplayRect(BitmapSource source) { double containerWidth ImageContainer.ActualWidth; double containerHeight ImageContainer.ActualHeight; double imageWidth source.PixelWidth; double imageHeight source.PixelHeight; double scale Math.Min(containerWidth / imageWidth, containerHeight / imageHeight); double displayWidth imageWidth * scale; double displayHeight imageHeight * scale; double offsetX (containerWidth - displayWidth) / 2; double offsetY (containerHeight - displayHeight) / 2; return new Rect(offsetX, offsetY, displayWidth, displayHeight); }框选时把鼠标坐标减去offsetX和offsetY再除以scale才是真正的图片像素坐标。这个细节不做框选区域和实际裁剪位置老是错位。3.3 区域模板的保存与复用同一批图片的识别位置是固定的所以识别区域必须支持保存和加载。我在程序里维护一个“区域模板”列表每个模板包含模板名称、区域坐标相对原图的比例值而不是绝对像素值。为什么存比例值而非像素值因为同批图片分辨率不一定完全一致有的是相机拍的4000x3000有的是扫描仪扫的2480x3508。如果存绝对像素值换一张不同分辨率的图就错位了。存比例值的话加载图片时按当前图片尺寸换算成实际区域适用范围广很多。保存模板的JSON结构大概是{ TemplateName: 发票右下角编号, XRatio: 0.72, YRatio: 0.85, WidthRatio: 0.22, HeightRatio: 0.10 }加载图片时把这些比例值乘上当前图片的像素宽度和高度动态算出区域。3.4 批量图片列表与预览左侧主区域负责单张图片的框选但批量处理还需要一个文件列表来统一管理。我用一个ListBox绑定所有待处理图片支持多选点击某一项就在预览区显示对应图片并自动套用当前选择的区域模板。这里有个性能细节加载几百张高清jpg时如果直接每张都解码成完整BitmapImage内存分分钟爆炸。正确做法是用缩略图在构造BitmapImage时设置DecodePixelWidth让WPF只解码需要的尺寸var bitmap new BitmapImage(); bitmap.BeginInit(); bitmap.UriSource new Uri(filePath); bitmap.DecodePixelWidth 160; // 缩略图宽度 bitmap.EndInit();预览大图时再单独用完整分辨率的BitmapImage这样列表滚动流畅内存占用也稳得住。4. 腾讯OCR接入从图片裁剪到文字返回4.1 准备腾讯云账号与密钥要去腾讯云控制台开通OCR服务然后创建一个API密钥拿到SecretId和SecretKey。这是调用所有腾讯云API的身份凭证。注意SecretKey只在创建时显示一次务必自己保存好。然后NuGet安装官方SDK包我用的包名是TencentCloudSDK它包含OCR的客户端Install-Package TencentCloudSDK用SDK的好处是签名、请求、重试都封装好了不需要自己去算TC3-HMAC-SHA256签名。虽然手写签名也不复杂但没必要重复造轮子。4.2 区域裁剪与Base64编码调用OCR前先把选中的原图区域裁剪出来。WPF里用CroppedBitmap很方便public static byte[] CropImage(string filePath, Int32Rect region) { using var fileStream File.OpenRead(filePath); var decoder BitmapDecoder.Create(fileStream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad); var frame decoder.Frames[0]; var cropped new CroppedBitmap(frame, region); var encoder new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(cropped)); using var outStream new MemoryStream(); encoder.Save(outStream); return outStream.ToArray(); }然后转Base64字符串。这里有个坑Base64编码后体积比原文件大三分之一左右。腾讯云OCR接口对图片大小有限制Base64后不能超过7MB。实际裁剪出来的区域图一般就几十KB完全没问题。但如果是全图识别大尺寸扫描件很容易超限这也是推荐裁剪识别的另一个理由。为了进一步保证识别速度和成功率可以对裁剪图做预处理识别前先转成灰度图、适当放大一到两倍。扫描件常见的问题是文字偏小OCR对太小字的识别率会下降放大之后效果改善明显。灰度化减少颜色干扰尤其对红色印章、彩色背景干扰有效。4.3 调用通用印刷体识别接口SDK调用的核心代码using TencentCloud.Common; using TencentCloud.Ocr.V20181119; using TencentCloud.Ocr.V20181119.Models; public static string RecognizeText(byte[] imageBytes, string secretId, string secretKey) { var credential new Credential { SecretId secretId, SecretKey secretKey }; var client new OcrClient(credential, ap-guangzhou); var request new GeneralBasicOCRRequest { ImageBase64 Convert.ToBase64String(imageBytes) }; var response client.GeneralBasicOCRSync(request); if (response.TextDetections ! null response.TextDetections.Length 0) { // 区域裁剪后通常只有1~2条文字直接拼接 return string.Join( , response.TextDetections.Select(t t.DetectedText)); } return string.Empty; }region参数“ap-guangzhou”是地域节点OCR接口在好几个地域都有选一个离你近的即可实际影响不大。4.4 识别结果的清洗与拼接策略调用成功后返回的TextDetections是一个数组每个元素是一块独立文本。对于裁剪出来的区域图通常识别到的是“编号ABC-123”这种带前缀的内容也可能被拆成两块文本“编号”和“ABC-123”。这里需要做文本拼接和后处理我常用的规则是把所有文本按从上到下、从左到右的顺序排序后再拼接。拼接符用空字符串或者空格具体要看识别内容。如果两块文本之间本来就该连在一起加空格反而多余。把全角冒号、空格、换行符都去掉文件名里不能有这些乱七八糟的字符。如果识别出“编号”这种前缀词根据情况决定保留还是去除。如果是给文件命名通常保留前缀会更清晰。4.5 批量识别与并发控制批量处理时最简单的实现是逐个调用。腾讯云OCR默认有QPS每秒请求数限制个人账号一般默认每秒几次具体以控制台配额为准。如果循环里不控制频率连续快速请求很容易触发限流返回RequestLimitExceeded错误。我的做法是在循环里加一个简单延时或者写一个轻量的令牌桶foreach (var item in pendingFiles) { var result RecognizeRegion(item.FilePath, item.Region, _secretId, _secretKey); if (!string.IsNullOrEmpty(result)) { item.RecognizedText result; } Thread.Sleep(150); // 每张间隔150ms基本不会触发限流 }当然如果图片数量很大几千张串行识别速度会偏慢可以改用Task并行但并发度要控制在3以内并要做好失败重试。我个人倾向于稳妥优先先串行跑反正做文件归档不是实时业务慢点没关系稳定不出错才最重要。5. 批量改名命名规则、冲突处理和执行安全5.1 命名规则的灵活配置识别出文字只是第一步最终要把文字变成合法的文件名。命名规则我用一个模板字符串来实现支持几种占位符占位符含义示例{text}OCR识别出的文字ABC-123{index}序号自动递增001, 002{date}当天日期20250321{original}原文件名IMG_20210324_001比如命名模板设置为“{date}{text}{index}”最终效果就是“20250321_ABC-123_001.jpg”。规则面板里让用户自己填模板比写死逻辑灵活得多。解析模板时用正则匹配花括号里的占位符就行。5.2 Windows文件名的非法字符处理OCR识别出的文字五花八门可能包含\ / : * ? |这些Windows文件名非法字符。识别结果里最常见的是冒号比如识别出“编号ABC”还有斜杠日期“2024/03/21”。这些字符直接放进文件名会报错。统一做一次过滤private static readonly char[] InvalidChars Path.GetInvalidFileNameChars(); public static string SanitizeFileName(string name) { foreach (var c in InvalidChars) { name name.Replace(c, _); } return name.Trim(); }另外一个细节Windows文件名结尾不能是空格或点号也要Trim掉。5.3 重名冲突预处理批量改名最怕改到一半发现目标文件名已存在然后抛异常中断。所以在正式执行前先对所有计划改名的文件做一次冲突预检。我的实现是先建立一个“原路径 - 新文件名”的映射列表然后用一个HashSet记录已经占用的新文件名遇到冲突就自动在文件名后面追加序号var usedNames new HashSetstring(StringComparer.OrdinalIgnoreCase); foreach (var plan in renamePlan) { var newName plan.NewName; while (!usedNames.Add(newName)) { var nameWithoutExt Path.GetFileNameWithoutExtension(newName); var ext Path.GetExtension(newName); newName ${nameWithoutExt}_{usedNames.Count}{ext}; } plan.FinalName newName; }注意HashSet用StringComparer.OrdinalIgnoreCase因为Windows文件系统不区分大小写“ABC.jpg”和“abc.jpg”算同一个名字。5.4 重命名执行的安全策略重命名操作不可逆所以我强烈建议做两件事预览确认和日志记录。界面上的流程是先点击“开始识别”程序批量识别并生成“识别结果表”原文件名、识别文字、新文件名用户可以滚动检查可以手动修改某个新文件名确认无误后再点“执行重命名”。这个环节很多人嫌麻烦直接跳过但出了错后悔都来不及建议保留。执行重命名时逐条操作并记录日志foreach (var plan in renamePlan) { try { var destPath Path.Combine(Path.GetDirectoryName(plan.SourcePath), plan.FinalName); File.Move(plan.SourcePath, destPath); log.AppendLine($OK: {plan.SourcePath} - {plan.FinalName}); } catch (Exception ex) { log.AppendLine($FAIL: {plan.SourcePath} - {plan.FinalName}, 原因: {ex.Message}); } }还有一个小技巧如果担心改名过程中出现意外先复制一份原文件清单或者把整个改名操作预案导出成一个CSV文件保存。这样万一改坏了可以根据CSV把名字改回去。注意如果目标文件名已存在File.Move默认会抛IOException。我在预检阶段已经处理了目标重名问题但执行时仍可能遇到极端情况比如另一个程序创建了同名文件所以异常捕获不能少。6. 踩坑记录与效果实测6.1 常见问题速查表现象可能原因解决办法识别结果为空裁剪区域坐标换算错误裁到了空白区用GetImageDisplayRect校正坐标并在界面上可视化显示裁剪区域识别结果乱码图片分辨率太低文字太小裁剪图放大2倍再识别或转灰度图识别结果多出不相关文字裁剪区域过大包含了周边内容缩小框选区域尽量贴近文字边缘调用API报AuthFailureSecretId或SecretKey填错检查密钥注意SecretKey是否复制完整调用API报RequestLimitExceeded请求频率超出QPS限制循环里加Thread.Sleep或降低并发数重命名时提示文件已存在目标文件名冲突预检阶段用HashSet处理自动加序号内存占用过高大图预览没有释放缩略图用DecodePixelWidth切换大图时强制GC回收6.2 一个容易忽略的问题图片方向手机拍的照片或扫描仪扫出来的图片经常带EXIF方向信息。WPF加载BitmapImage默认会应用EXIF方向所以你看到的图是正的但用CroppedBitmap裁剪时的像素坐标是基于原始像素矩阵的。如果图片本身旋转过裁剪区域就会有偏差。解决方法是裁剪时也考虑EXIF方向或者更简单在加载图片时设置Rotation让原始像素和显示方向一致。实操中我建议统一在读取图片时就调用JpegBitmapDecoder读取EXIF的方向值并手动旋转到正位再做后续所有操作。这个问题说大不大但遇到一次就够你排查半天。6.3 实测效果准确率与速度用我最终做出来的工具实测了一批扫描合同总共200张识别区域是封面右下角的合同编号。裁剪区域大概占原图宽度的25%、高度的8%。识别准确率达到96%以上剩下几个识别错的基本都是原始图片本身扫描质量太差字迹模糊到人眼都看不清。对识别置信度低于80%的结果程序会特别标注出来方便人工复查。速度方面每张图片从裁剪到OCR返回大概耗时0.8到1.5秒。200张图串行识别加上人工检查时间总共十分钟左右搞定。比起人工一张张看图改名效率提升不是一点半点。6.4 后续可以扩展的地方这个工具做出来后我给它加了不少后续功能支持识别多个区域并组合命名比如“客户编号_发票号”、支持把识别结果导出Excel、支持拖拽图片文件夹到窗口自动加载。如果你也有类似需求可以往这几个方向扩展。另外如果不想依赖云端API可以预留一个OCR提供者的接口抽象后面想替换成PaddleOCR本地推理只需要实现同一个接口就行。不过对我个人而言腾讯云的免费额度和识别准确率已经足够覆盖日常场景暂时没必要折腾本地模型部署。实际做这个工具的过程中我最大的体会是很多看似“该有现成软件”的小需求市场上真的没有完全对口的方案自己动手做一个反而比想象中简单。核心代码加起来没多少行WPF负责交互OCR API负责识别关键思路通了剩下的就是拼积木。希望这篇文章能帮你少走点弯路尤其是坐标换算和批量重名处理那两个坑提前做好设计能省不少事。
返回列表