
先回答那个最初的问题有没有现成的软件能自动识别 JPG 图片上某个区域的文字直接用识别结果给文件改名我翻了很久结论是有类似工具但要么只能整图识别要么区域框选体验极其糟糕要么改名规则死板真正契合批量、固定区域、自动命名这个场景的几乎没有。所以最后的选择是花一个周末用 WPF 写了一个小工具调用腾讯云的 OCR API把框选区域 - 批量识别 - 自动改名这条流水线串了起来。这篇文章就把整个方案的选型逻辑、代码实现、以及实测踩坑过程完整记录下来。1. 需求拆解这个工具本质是一条区域OCR 文件重命名流水线1.1 先搞清楚业务场景到底长什么样这类需求最常见于两类场景。第一类是单据/凭证归档。比如扫描了一百份发票每张发票的右上角都有发票号码现在要求所有文件名改成发票号_日期_金额这种格式或者产品质检单、合同扫描件、快递面单统一位置都有编号或条码。手动操作是打开图片、放大、找到号码、复制、关掉、改名一张至少20秒一百张就是一个多小时而且眼睛盯久了必定出错。第二类是素材库整理。比如设计师从客户那里收到几百张产品截图图上有产品编码或吊牌信息自媒体运营拿到一批带标题的封面图或者有人拿相机翻拍了大量书页、卡片、标签需要按上面的关键词归档。这类文件往往叫IMG_20240901_143725.jpg这种无意义的名字找起来全靠缘分。总结下来需求可以精确定义为三个条件区域固定同一批图片里要识别的文字在每张图上的位置基本一致。内容可OCR文字清晰不是手写狂草字体相对规范。命名规则简单识别出来的文字略微清洗后就可以直接作为文件名。如果这三个条件成立那人工重复劳动就是纯浪费。1.2 把需求翻译成功能清单明确了场景以后功能需求自然而然就出来了支持加载一批 JPG 图片。支持在图片预览上拖拽框选一个矩形识别区并保存坐标。按保存的坐标对每张图的固定区域做 OCR。识别结果自动清洗为合法的 Windows 文件名自动处理非法字符。批量改名之前可以先预览原名 - 新名的对照列表确认后再执行。遇到识别失败或重名时不能中断整个批次要有兜底策略。这个清单看起来简单但实现过程中每一步都有坑。我后面会逐一展开。2. 技术选型本地 OCR 不行吗为什么是 WPF 腾讯云2.1 本地 OCR 的现状能用但体验不一定省心动手之前我其实先试了本地 OCR 方案主要对比了两个引擎Tesseract 和 PaddleOCR。Tesseract 的问题在于对场景图片的识别率不够稳定。如果你处理的图是纯白底黑字的印刷体Tesseract 调好参数后还能看但单据、标签、实物照片往往背景复杂、光照不均、字体不标准Tesseract 默认参数下经常识别出一堆乱码。它需要针对特定字体做训练这对普通人来说成本太高。PaddleOCR 本地识别率确实好很多尤其对中文场景。但引出一个部署问题PaddleOCR 的 Python 环境依赖一堆动态库打包成 exe 后体积轻松突破 200MB还要考虑 GPU/CPU 兼容性给同事部署的时候很容易炸环境。如果我自己开发用的是 C#还得通过进程间调用去拉起 Python 服务复杂度上来了。本地 OCR 的第二个痛点是无差别的识别结果往往不在固定区域。虽然也可以用 OpenCV 找轮廓或者在裁剪后的区域上做识别但这类图像预处理对小白不友好。所以我的结论是本地 OCR 适合离线、隐私敏感、图片量大到云 API 成本无法承受的场景但如果你想快速做出来、准确率又高云 OCR 是性价比最高的选择。2.2 云 OCR 为什么选腾讯云 OCR 主流有腾讯、百度、阿里三家。我选择腾讯的核心理由有三个。第一免费额度够用。腾讯云通用印刷体识别每月有免费调用额度个人整理几百张图片完全够用。就算超过免费额度单次调用成本也是按万次计算的个人项目可以忽略不计。第二SDK 和文档对 C# 友好。腾讯云官方提供了 .NET SDKNuGet 搜索TencentCloudSDK就能直接装调用接口非常顺手。不需要自己用 HttpClient 拼签名省掉了一大堆麻烦。第三通用印刷体识别的准确率在线。我用了几十张真实环境测试对常见的黑体、宋体、微软雅黑印刷文字识别准确率很高尤其是识别区域已经被用户裁剪过的前提下准确率还能进一步提升。顺便说一句如果对数据隐私要求极高那就不要用云 API选 PaddleOCR 本地部署但如果不是涉密数据云 OCR 是效率与准确率的最优解。2.3 WPF 到底赢在哪选 WPF 是我一开始就想好的没怎么犹豫。原因也很直白框选交互做起来舒服。WPF 对 Canvas、鼠标事件、矩形绘制的支持非常成熟做一个类似截图工具的拖拽框选效果只需要几十行代码。C# 是腾讯云 SDK 的一等公民。调用 OCR API 的代码和界面代码是同一个语言生态不需要跨语言桥接。部署简单。.NET 的 self-contained 发布可以打成一个文件夹拷给谁都能跑装个.NET 运行时也不是什么难事比 Python 那套打包环境省太多心。数据绑定写改动列表很方便。批量改名前的预览列表用 WPF 的 DataGrid/ListView 绑定一个集合天然就支持排序、筛选、标记错误简直不要太顺手。所以整套技术栈就定为C# / .NET WPF 腾讯云 OCR SDK。3. 程序架构设计把框选一次批量执行落地的关键3.1 三层模块划分这个工具虽小但功能边界一定要清晰否则写着写着就变成一坨。我在项目里分了三个层UI 层WPF加载图片预览、鼠标框选区域、展示改名预览列表、执行改名。OcrService 层负责调用腾讯云 OCR API把给一张图片输出文本这个动作封装好UI 层完全不感知 API 细节。FileService 层负责文件名清洗、冲突处理、实际重命名操作。这样以后想从腾讯云换到百度云或者想增加本地 OCR 引擎只需要替换 OcrServiceUI 层完全不用动。3.2 区域坐标的保存与复用这是整个工具最核心的设计也是它区别于一次性脚本的地方——坐标必须可复用。同一批图片通常来自同一个扫描仪或同一个截图流版式一致所以只要在一张图上框选一次后面的图全都用同一组坐标去裁剪。坐标我存成一个简单的 JSON 文件放在程序目录下的region.json{ X: 120, Y: 80, Width: 640, Height: 96, Remark: 发票号区域 }下次启动程序时自动加载显示在预视图上可以随时重新框选覆盖保存。这个功能看似简单实际使用率极高。因为我整理素材时往往是今天处理一批 A 发票明天处理一批 B 快递单坐标需要按批次切换。3.3 批量任务的流水线设计批量处理不是简单的for循环调 OCR我把它设计成一条流水线加载图片列表 - 逐张裁剪固定区域 - 裁剪后区域转Base64 - 调用腾讯云OCR - 拿到文本并清洗 - 生成目标文件名 - 检查重名/非法字符 - 返回预览结果每一步都可能失败。比如图片本身损坏、区域坐标超出图片边界、OCR 返回空文本、识别出的文字全部是非法字符……任何一个环节出错都不应该让整个批次崩溃。所以我在代码里用了一个RecordResult结构每张图返回一个结果对象要么成功要么携带失败原因最后统一展示在预览列表里。public class RenamePreviewItem { public string OriginalPath { get; set; } public string RecognizedText { get; set; } public string TargetName { get; set; } public bool IsValid { get; set; } public string ErrorMessage { get; set; } }UI 层只需要绑定这个集合前端就能非常直观地展示哪些图片会改成什么名、哪些图片识别失败了。3.4 预览-确认机制防止批量误改的重要保险丝批量改名最危险的事情是识别错了但程序照样改最后把一堆文件弄成了错误的名字。我在这里加了一道保险默认预览手动确认。所有识别完成之后先把原文件名 - 新文件名的对照表展示出来用户勾选哪些要执行改名确认后程序才真正调用File.Move。这样即使 OCR 出了错你也能在预览阶段发现提前修正。4. 核心代码实现WPF 框选、裁剪、调 OCR、安全改名4.1 WPF 中实现区域框选难点不在画矩形在坐标换算WPF 里实现框选我用的方案是用一个Canvas覆盖在显示图片的Image控件上方然后在Canvas里监听鼠标事件动态绘制矩形。XAML 部分大致是这样Grid Image x:NamePreviewImage StretchUniform StretchDirectionDownOnly/ Canvas x:NameOverlayCanvas BackgroundTransparent MouseLeftButtonDownOverlayCanvas_MouseLeftButtonDown MouseMoveOverlayCanvas_MouseMove MouseLeftButtonUpOverlayCanvas_MouseLeftButtonUp/ Rectangle x:NameSelectionRect StrokeRed StrokeThickness2 Fill#33FF0000 VisibilityCollapsed/ /Grid鼠标按下时记录起点移动时更新矩形的位置和尺寸松开时确定区域private Point _startPoint; private void OverlayCanvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { _startPoint e.GetPosition(OverlayCanvas); SelectionRect.Visibility Visibility.Visible; Canvas.SetLeft(SelectionRect, _startPoint.X); Canvas.SetTop(SelectionRect, _startPoint.Y); SelectionRect.Width 0; SelectionRect.Height 0; } private void OverlayCanvas_MouseMove(object sender, MouseEventArgs e) { if (e.LeftButton ! MouseButtonState.Pressed) return; var pos e.GetPosition(OverlayCanvas); var x Math.Min(pos.X, _startPoint.X); var y Math.Min(pos.Y, _startPoint.Y); var w Math.Abs(pos.X - _startPoint.X); var h Math.Abs(pos.Y - _startPoint.Y); Canvas.SetLeft(SelectionRect, x); Canvas.SetTop(SelectionRect, y); SelectionRect.Width w; SelectionRect.Height h; } private void OverlayCanvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { var pos e.GetPosition(OverlayCanvas); var x Math.Min(pos.X, _startPoint.X); var y Math.Min(pos.Y, _startPoint.Y); var w Math.Abs(pos.X - _startPoint.X); var h Math.Abs(pos.Y - _startPoint.Y); // 保存时换算为图片真实像素坐标 SaveRegionToImageCoordinates(x, y, w, h); }这里有一个几乎所有第一次写的人都必踩的坑屏幕显示坐标 ≠ 图片真实像素坐标。Image控件设置了StretchUniform后显示区域和图片原始尺寸往往是不一致的。如果直接把 Canvas 上的坐标当成图片坐标去裁剪框选的区域会偏。必须做一个比例换算private void SaveRegionToImageCoordinates(double x, double y, double w, double h) { var imgWidth PreviewImage.Source.Width; var imgHeight PreviewImage.Source.Height; var viewWidth PreviewImage.ActualWidth; var viewHeight PreviewImage.ActualHeight; double scaleX imgWidth / viewWidth; double scaleY imgHeight / viewHeight; int realX (int)(x * scaleX); int realY (int)(y * scaleY); int realW (int)(w * scaleX); int realH (int)(h * scaleY); Region new CropRegion(realX, realY, realW, realH); }这么做以后不管图片怎么缩放框出来的区域在真实图片上都是准确的。4.2 裁剪区域图片用 System.Drawing 就够了拿到真实坐标后裁剪图片用System.Drawing很简单public byte[] CropRegionToBytes(string imagePath, CropRegion region) { using (var bmp new Bitmap(imagePath)) { if (region.X 0) region.X 0; if (region.Y 0) region.Y 0; if (region.Width 0 || region.Height 0) return null; // 超出图片边界时收缩 int w Math.Min(region.Width, bmp.Width - region.X); int h Math.Min(region.Height, bmp.Height - region.Y); if (w 0 || h 0) return null; using (var crop bmp.Clone(new Rectangle(region.X, region.Y, w, h), bmp.PixelFormat)) using (var ms new MemoryStream()) { crop.Save(ms, ImageFormat.Jpeg); return ms.ToArray(); } } }值得注意的一点是裁剪区域通常都比较小直接传给 OCR API 有时会因为分辨率不足识别不准。我的经验是可以先放大 1.5~2 倍再转 Base64腾讯云的识别率会明显提升。放大用 Graphics 的InterpolationMode.HighQualityBicubic就行public static Bitmap ScaleImage(Image image, double scale) { int newW (int)(image.Width * scale); int newH (int)(image.Height * scale); var bmp new Bitmap(newW, newH); using (var g Graphics.FromImage(bmp)) { g.InterpolationMode System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; g.DrawImage(image, 0, 0, newW, newH); } return bmp; }4.3 调用腾讯云 OCR官方 SDK 比手动拼 HTTP 省心太多腾讯云的官方 .NET SDK在 NuGet 上搜TencentCloudSDK就能安装。调用通用印刷体识别基础版非常直接using TencentCloud.Common; using TencentCloud.Ocr.V20181119; using TencentCloud.Ocr.V20181119.Models; public class TencentOcrService : IOcrService { private readonly OcrClient _client; public TencentOcrService(string secretId, string secretKey) { var credential new Credential { SecretId secretId, SecretKey secretKey }; // 地域根据自己的资源位置选一般 ap-guangzhou 或 ap-beijing _client new OcrClient(credential, ap-guangzhou); } public async Taskstring RecognizeTextAsync(byte[] imageBytes) { var base64 Convert.ToBase64String(imageBytes); var request new RecognizeGeneralBasicOCRRequest { ImageBase64 base64 }; var response await _client.RecognizeGeneralBasicOCRSync(request); var sb new StringBuilder(); foreach (var item in response.TextDetections) { sb.AppendLine(item.DetectedText); } return sb.ToString().Trim(); } }SDK 里面有一点容易踩坑如果你用的 SDK 版本比较新同步调用方法名会带Sync后缀如果版本旧一点可能直接叫RecognizeGeneralBasicOCR。编译的时候留意一下方法签名就行。我上面用的是新版 SDK 的写法。腾讯云 OCR 还提供了一个更准的接口叫RecognizeGeneralAccurateOCR单价贵一点但准确率更高。我平时先用基础版一旦发现识别率不够再切换到准确版。接口参数完全一致只换方法名和返回类型这就是封装IOcrService层带来的好处。4.4 文本清洗OCR 结果不能直接当文件名OCR 识别出来的文本五花八门直接用作文件名会出各种问题。我在FileService里做了几层清洗public static string CleanAsFileName(string text) { if (string.IsNullOrWhiteSpace(text)) return string.Empty; // 1. 去掉首尾空白和控制字符 var cleaned text.Trim(); cleaned new string(cleaned.Where(c !char.IsControl(c)).ToArray()); // 2. 替换 Windows 文件名的非法字符 foreach (char c in Path.GetInvalidFileNameChars()) { cleaned cleaned.Replace(c, _); } // 3. 去掉多余空白避免文件名中出现连续空格 cleaned Regex.Replace(cleaned, \s, ); // 4. 限制长度防止超长文件名 if (cleaned.Length 100) { cleaned cleaned.Substring(0, 100).Trim(); } return cleaned; }这套清洗逻辑看起来简单但实际踩坑很多。比如 OCR 结果里经常混入|、?、*这类字符虽然函数里一次替换了但我最初漏掉了控制字符导致某些图片改名前要把控制字符一个个挑出来。另外如果识别结果里包含换行合并成单行时也要额外处理。4.5 批量改名主流程代码最后把整个流程串起来。核心逻辑在BatchRenameService里public async TaskListRenamePreviewItem PreviewAsync( Liststring imagePaths, CropRegion region, string outputDirectory) { var results new ListRenamePreviewItem(); // 控制并发防止把免费额度打爆也防止内存炸掉 using var semaphore new SemaphoreSlim(4); var tasks imagePaths.Select(async path { await semaphore.WaitAsync(); try { var cropBytes _imageService.CropRegionToBytes(path, region); if (cropBytes null || cropBytes.Length 0) { return new RenamePreviewItem { OriginalPath path, IsValid false, ErrorMessage 裁剪区域为空或超出图片范围 }; } var ocrText await _ocrService.RecognizeTextAsync(cropBytes); var cleaned _fileService.CleanAsFileName(ocrText); if (string.IsNullOrEmpty(cleaned)) { return new RenamePreviewItem { OriginalPath path, IsValid false, ErrorMessage 识别结果为空 }; } var targetPath _fileService.GenerateUniqueTargetPath(outputDirectory, cleaned, Path.GetExtension(path)); return new RenamePreviewItem { OriginalPath path, RecognizedText ocrText, TargetName Path.GetFileName(targetPath), IsValid true }; } catch (Exception ex) { return new RenamePreviewItem { OriginalPath path, IsValid false, ErrorMessage ex.Message }; } finally { semaphore.Release(); } }); results.AddRange(await Task.WhenAll(tasks)); return results; }GenerateUniqueTargetPath负责处理重名问题。比如两张图都识别出ABC123那第二张就应该自动命名成ABC123_1.jpgpublic string GenerateUniqueTargetPath(string directory, string baseName, string extension) { var targetPath Path.Combine(directory, baseName extension); int index 1; while (File.Exists(targetPath)) { targetPath Path.Combine(directory, ${baseName}_{index}{extension}); index; } return targetPath; }执行改名的时候用File.Move就完成了。如果目标目录和源目录不同File.Move会自动搬运文件正好适合从临时目录整理到归档目录的场景。5. 实测与踩坑为什么我的坐标会偏、QPS 会超、识别总出错5.1 坐标偏了的根本原因DIP 和缩放第一版做完我用一批扫描件实测结果发现框选区域识别出来全是错的行。排查到最后问题就出在StretchUniform上。WPF 的ActualWidth和图片本身的PixelWidth是两套单位中间隔着 DPI 缩放和布局缩放。如果电脑设置了 125% 或 150% 的显示缩放ActualWidth已经是 DIP 单位和图片像素之间的换算关系更复杂。我的最终解决办法很简单不在 Image 控件上用 DIP 单位搞换算而是统一在Image的Loaded事件里拿到Source.Width和Source.Height作为真实的像素基准再结合ActualWidth算出一个scaleX/scaleY。这一招对绝大多数场景足够了。如果以后要处理超高 DPI 屏幕可以再引入VisualTreeHelper.GetDpi(Visual)做补偿但一般个人工具没必要那么极致。5.2 OCR 的 QPS 限制批量处理不能无脑并发第一次批量跑我天真地写了个Parallel.ForEach全量并发调 OCR结果跑到一半出现大量请求失败。查了下原因腾讯云通用印刷体基础版的 QPS 限制大约只有 1~2 次每秒免费额度的并发能力更弱。解决办法是用SemaphoreSlim(1)或者SemaphoreSlim(2)把并发压下来。实测下来每秒 1 次每张图耗时约 0.5~1 秒100 张图跑完也就两三分钟完全可以接受。与其追求并发导致频繁失败、写一堆重试逻辑不如老老实实排队。另外必要的重试机制还是要有的。我在OcrService里包了一个简单的重试请求失败后等待 500ms最多重试 3 次。不需要引入什么高级熔断框架一个for循环就够了。5.3 识别结果为空 / 全是乱码时怎么办批量过程中最常遇到两类失败识别结果为空区域定错了或者这张图的对应位置确实没有文字。这种情况我会在预览列表里标红手动处理。识别结果有换行/多余符号比如识别出发票号码AB123456而不是AB123456。这时我增加了两个可选后处理规则在界面上给用户勾选去掉所有非字母数字字符适合编码类文字以冒号/空格为分隔符取最后一段适合标签内容格式。设计这两个规则是因为OCR 结果经常带前缀或后缀直接当文件名会很长很难看。我实测下来加了清洗规则选择之后成功率提升非常明显。5.4 无效文件名和隐藏文件的问题还有一个容易忽略的地方如果识别出的文字全是.或者空格Windows 会认为文件名非法或指向隐藏文件。清洗时一定要把Path.GetInvalidFileNameChars()替换掉后再判断最终字符串是否为空、是否以点结尾。我在清洗函数里补了一句cleaned cleaned.Trim().TrimEnd(.); if (cleaned . || cleaned ..) return string.Empty;这个小细节救了我一次否则一批文件就可能被File.Move直接抛出异常。5.5 从能用到好用几个值得跟进的功能工具跑到能用之后我陆续加了几个让体验提升一个小档次的功能记忆多组区域模板不同批次用不同区域可以在界面上保存多套模板下拉切换。支持 PNG/BMP 输入有些截图是 PNG 格式统一处理时不能只认 JPG。其实代码里用Bitmap读取根本不挑格式只是在文件过滤器里加上就行。识别结果写入日志每次批量处理生成一个result_时间戳.csv记录原路径、识别文本、新文件名、是否成功方便事后复查。这招在整理大量文件时极其好用出了问题能追溯。缩略图预览在预览列表里加一列缩略图识别结果对不对扫一眼图片就知道。WPF 绑定byte[]到Image.Source需要转BitmapImage代码量不大但非常提升体验。写在最后的个人体会这个工具我从需求确认到第一版跑通大约花了一个周末。代码量不算大核心逻辑加界面大概六七百行但把区域框选OCR 调用批量改名三个环节串起来后确实解决了一个非常实在的痛点。我后来拿它整理了手头 400 多张供应商报价单全程只手动处理了十来张识别失败或重名的剩下全是自动完成。如果你也经常被一批乱七八糟的文件名折磨我建议不要去买那些定价还行的商业软件自己动手做一个。技术上没有任何一个环节是难点WPF 框选是入门级交互腾讯云 OCR 的 SDK 文档齐全文件改名更是File.Move一行的事。真正花时间的反而是把各种异常情况想清楚、把清洗规则调好这些才是让工具从能跑变成好用的关键。