批量处理几百张jpg图片,还要按图片里的文字改成对应文件名——没做过这件事的人不知道,纯手工操作真的能把人逼疯。我自己早年就被一批扫描件折磨过:打开图片、看内容、敲键盘改名、回车,循环几百次。后来我发现,这类需求完全可以交给程序来做——用WPF写一个桌面工具,把图片指定区域交给腾讯云OCR识别,再把识别出的文字直接作为文件名。基于这个思路,我搭了一个批量图片区域识别改名的小工具,今天把完整方案拆开讲给你。
这个方案的核心思路特别简单:选区域、调识别、自动改名三步走。适合产品图片归类、单据扫描归档、截图批量规范命名这类场景,只要你手里的图片内容里有一块稳定的文字区域,就能用这套办法把工作量打下来。文章后面会把WPF界面、区域选择、调用腾讯云API、重命名逻辑逐段拆解,代码也给了可以直接参考的版本。
1. 需求分析与整体方案设计
1.1 这个工具到底要解决什么问题
很多人第一次听到“批量图片区域识别改名”,第一反应是“这不就是OCR嘛”。但真要落地,远比“整张图识别文字”复杂。比如单据扫描的时候,同一批图片里,需要的往往只是右上角的单据编号;产品图需要的可能是左下角的产品型号。如果直接识别整张图,OCR结果会被无关文字干扰,生成的文件名可能前面带着一堆乱码。所以核心问题不是“识别文字”,而是“如何稳定地把制定区域的文字提取出来,变成可用的文件名”。
在这个基础上,还有一连串现实问题要考虑:不同批次的图片分辨率是否一致?识别区域能不能保存复用?如果识别出来的是空字符串怎么办?不同图片识别出相同文字导致重名怎么办?文件名里出现\/:*?"<>|这些非法字符又怎么办?这些问题不做完整,工具就只能停留在实验室阶段,不能真正拿来处理几百张真实图片。
1.2 方案选型:为什么是WPF + 腾讯云OCR
先回答标题里那个问题:有没有现成软件?有,但大多限制了区域选择的灵活性,或者对批量规模收费。自己做的好处是完全可控——区域框选规则可以自定义,命名规则可以自定义,甚至以后想接别的OCR引擎都能直接换。
为什么UI框架选WPF?因为Windows平台做桌面工具,WPF是我用过最顺手的一套:界面布局灵活,支持XAML快速搭建,数据绑定处理列表和进度更新也比WinForm舒服。而且C#调用各类API和SDK都很自然,适合这个工具“桌面UI + 云端OCR”的组合。
为什么识别引擎选腾讯云OCR?可以做一张表横向对比:
| 方案 | 识别率 | 中文支持 | 接入成本 | 费用 | 离线可用 |
|---|---|---|---|---|---|
| 腾讯云OCR | 高 | 好 | 低 | 有免费额度 | 否 |
| Tesseract本地OCR | 中 | 一般 | 高 | 免费 | 是 |
| 私有化OCR服务 | 高 | 好 | 很高 | 高 | 是 |
从个人开发或小团队工具的角度,腾讯云OCR的免费额度已经够测试和中小规模使用了。接入SDK也省心,把区域图片base64发过去,返回JSON结果,整个流程在十几行代码里完成。Tesseract本地识别我后来也试过,但在中英文混排、背景复杂的JPG上,识别率明显不如云端API,还要花时间调预处理参数,不如先把产品做出来。
1.3 整体处理流程
工具的整体流程并不复杂,但每一步都有细节。设计如下:
- 用户选择一个存放jpg的文件夹,界面列出所有图片文件。
- 用户点击预览区的图片,用鼠标拖拽框选需要识别文字的区域;可以添加多个区域。
- 用户先点“试识别”,对当前图片执行整条流程,确认文字识别正确。
- 确认后点“开始批量”,程序依次对每张图片:读取原图,按区域坐标裁剪,把裁剪内容转成base64,调用腾讯云OCR,从返回结果中提取文字,清洗成合法文件名,最后重命名原文件。
- 处理过程中,界面实时显示当前处理进度、成功/失败状态。
这里有一个设计选择:区域坐标用“像素坐标”还是“相对比例坐标”。像素坐标简单直接,适合一批图片分辨率固定不变的情况;相对比例坐标适合图片分辨率有变化的情况。我的做法是两种都支持,默认用像素坐标,开发者可以在配置文件里切换。对于同批次图片,其实用像素坐标就够了,因为拍摄或扫描条件通常固定。
这个流程设计好了,后面就是逐个环节的落地。
2. 开发环境准备与项目骨架搭建
2.1 工具链与依赖包
开发之前先列一下我用的工具链:
- Visual Studio 2022,工作负载选“.NET 桌面开发”。
- .NET 6 或更高版本,WPF项目模板。
- NuGet包:
TencentCloudSDK(或者更精确的TencentCloudSDK.Ocr,根据SDK版本选择),用于调用腾讯云OCR。 - 如果不想用官方SDK,也可以直接用
HttpClient调HTTP API,但强烈建议用SDK,省去签名计算这些麻烦事。
需要先说的一个坑是:TencentCloud SDK在不同版本之间API命名会有差异,我在2.0.x和3.0.x版本都踩过不一样的地方。建议直接查NuGet上最新稳定版的文档。我这篇文章里的代码基于我实际使用过的版本,如果API发生变化,对照官方示例改一下调用处就可以。
2.2 创建项目与界面布局
新建一个WPF项目,项目名随意,比如ImageRegionRenamer。主窗口的布局我分成三个大区:
- 顶部工具栏:选择文件夹、框选区域、试识别、开始批量、设置API密钥。
- 中间预览区:左侧放文件列表,右侧放图片预览,图片上叠加一个透明的Canvas用于绘制区域矩形。
- 底部状态栏:进度条和当前状态文字。
XAML骨架如下(省略了部分样式和命名空间):
<Window x:Class="ImageRegionRenamer.MainWindow" Title="批量图片区域识别改名工具" Height="720" Width="1080"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <!-- 顶部工具栏 --> <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8"> <Button x:Name="BtnSelectFolder" Content="选择文件夹" Click="BtnSelectFolder_Click" Width="100" Margin="0,0,8,0"/> <Button x:Name="BtnAddRegion" Content="框选区域" Click="BtnAddRegion_Click" Width="100" Margin="0,0,8,0"/> <Button x:Name="BtnClearRegions" Content="清空区域" Click="BtnClearRegions_Click" Width="100" Margin="0,0,8,0"/> <Button x:Name="BtnTestOcr" Content="试识别" Click="BtnTestOcr_Click" Width="100" Margin="0,0,8,0"/> <Button x:Name="BtnStartBatch" Content="开始批量" Click="BtnStartBatch_Click" Width="100" Margin="0,0,8,0"/> <Button x:Name="BtnSettings" Content="API设置" Click="BtnSettings_Click" Width="100"/> </StackPanel> <!-- 主区域 --> <Grid Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="260"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <!-- 文件列表 --> <ListBox x:Name="FileList" Grid.Column="0" SelectionChanged="FileList_SelectionChanged"/> <!-- 图片预览与绘制层 --> <Grid Grid.Column="1"> <Image x:Name="PreviewImage" Stretch="Uniform"/> <Canvas x:Name="OverlayCanvas" Background="Transparent"/> </Grid> </Grid> <!-- 底部状态栏 --> <StatusBar Grid.Row="2"> <ProgressBar x:Name="ProcessProgress" Width="300" Height="18" Margin="8,0"/> <TextBlock x:Name="StatusText" Margin="12,0"/> </StatusBar> </Grid> </Window>这里有个很关键的细节:Image和Canvas放在同一个Grid单元格里,Canvas要放在Image之后,才能覆盖在图片上方。OverlayCanvas的Background要设为Transparent,否则会挡住鼠标事件或者收不到鼠标事件,这个问题我当时调试了好一会儿。
2.3 腾讯云OCR接入准备
在写代码前,先到腾讯云控制台把账号和权限配好:
- 登录腾讯云控制台,搜索“文字识别 OCR”,开通对应服务。
- 在“访问管理”里新建API密钥,拿到
SecretId和SecretKey。 - 在代码里把它们配置到工具中。我推荐把密钥放到本地配置文件中,第一次启动时弹窗让用户填写,并保存到
appsettings.json或config.json,而不是硬编码在代码里。
这里也要提醒一句:密钥等同于账号凭证,别直接提交到公开仓库。我的做法是让工具启动时读取环境变量或用户目录下的配置文件,代码里不保留真实密钥。
关于调用哪个OCR接口的问题:如果识别的是印刷体、分辨率还不错的文字,使用GeneralAccurateOCR(通用印刷体识别-高精度版)是最合适的;如果主要目标是表格里的单元格文字,那可以换成TableOCR;如果区域图片比较模糊,可考虑先用普通GeneralBasicOCR。实测下来,清晰印刷体场景下GeneralAccurateOCR的准确率最高。
3. 核心功能实现与关键代码
3.1 区域选择:鼠标拖拽与坐标换算
区域选择的交互逻辑是:用户先点“框选区域”,然后在预览图片上按住鼠标左键拖拽,松开后记录一个矩形区域。多个区域用不同颜色的矩形显示在OverlayCanvas上。代码分三步:按下记录起点,移动更新矩形,抬起记录终点。鼠标事件的核心就是这三个动作,在MouseDown里用e.GetPosition(OverlayCanvas)拿到Canvas坐标,减去图片显示区域的偏移,再按比例映射到像素坐标;MouseMove里根据当前点和起点计算矩形,更新对应Rectangle的Canvas.SetLeft、Canvas.SetTop、Width、Height;MouseUp时把矩形坐标和尺寸保存到区域列表。
这里最容易被忽略的是坐标换算。WPF里Image控件用了Stretch="Uniform",图片显示在屏幕上的尺寸和实际像素尺寸不等,如果直接把鼠标屏幕坐标当成图片像素坐标去裁剪,区域就错位了。正确做法是先算出图片在控件中的显示区域,再做对应变换。
private Rect GetImageDisplayRect(Image image) { if (image.Source == null) return Rect.Empty; var img = (BitmapSource)image.Source; double imgWidth = img.PixelWidth; double imgHeight = img.PixelHeight; double controlWidth = image.ActualWidth; double controlHeight = image.ActualHeight; double scale = Math.Min(controlWidth / imgWidth, controlHeight / imgHeight); double displayWidth = imgWidth * scale; double displayHeight = imgHeight * scale; double offsetX = (controlWidth - displayWidth) / 2; double offsetY = (controlHeight - displayHeight) / 2; return new Rect(offsetX, offsetY, displayWidth, displayHeight); }得到显示区域后,鼠标坐标减去偏移量,再按显示宽高与像素宽高的比例换算,就是图片的真实像素坐标。拖拽时实时画矩形,这里用Rectangle控件加到Canvas上,注意把坐标换算成相对于Canvas的坐标。
如果图片体积很大,比如单张5000万像素的图片,在Image控件里加载原图会很占内存。我的建议是在列表展示时使用缩略图,识别裁剪时再按路径重新加载原图,这样界面操作不卡,资源占用也可控。缩略图加载用BitmapImage的DecodePixelWidth属性,只解码指定宽度,内存占用明显降下来。
3.2 调腾讯云OCR:图片裁剪、编码与请求
有了区域坐标之后,从原图中裁剪出待识别区域。我倾向于在WPF里直接使用CroppedBitmap,这是WPF自带的能力,不依赖System.Drawing:
private string CropToBase64(string imagePath, Int32Rect region) { BitmapImage bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad; bitmap.UriSource = new Uri(imagePath); bitmap.EndInit(); CroppedBitmap cropped = new CroppedBitmap(bitmap, region); PngBitmapEncoder encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(cropped)); using (MemoryStream ms = new MemoryStream()) { encoder.Save(ms); byte[] bytes = ms.ToArray(); return Convert.ToBase64String(bytes); } }有些接口对base64体积有上限(比如官网写的是图片base64编码后大小不超过7MB,具体以当前官网为准),如果识别区域很大,裁剪出的图片编码后会很大。可以在编码前把裁剪区域等比缩小,或者改用JpegBitmapEncoder配合QualityLevel压缩。实际项目里,区域裁剪出来通常只有几十到几百KB,base64之后也不会超过限制,所以我没有做过多的压缩处理。但如果你处理的图片区域特别大,建议加上一个缩放逻辑。
接下来是调用腾讯云OCR。用官方SDK的写法大致如下:
using TencentCloud.Common; using TencentCloud.Common.Profile; using TencentCloud.Ocr.V20181119; using TencentCloud.Ocr.V20181119.Models; public class OcrService { private readonly string _secretId; private readonly string _secretKey; public OcrService(string secretId, string secretKey) { _secretId = secretId; _secretKey = secretKey; } public async Task<string> Recognize(string imageBase64) { Credential cred = new Credential { SecretId = _secretId, SecretKey = _secretKey }; HttpProfile httpProfile = new HttpProfile(); httpProfile.Endpoint = "ocr.tencentcloudapi.com"; ClientProfile clientProfile = new ClientProfile(); clientProfile.HttpProfile = httpProfile; OcrClient client = new OcrClient(cred, "ap-guangzhou", clientProfile); GeneralAccurateOCRRequest req = new GeneralAccurateOCRRequest(); req.ImageBase64 = imageBase64; GeneralAccurateOCRResponse resp = await client.GeneralAccurateOCR(req); if (resp.TextDetections != null && resp.TextDetections.Length > 0) { // 把所有识别文本拼接起来,剔除空行 List<string> lines = new List<string>(); foreach (var item in resp.TextDetections) { string text = item.DetectedText?.Trim(); if (!string.IsNullOrWhiteSpace(text)) { lines.Add(text); } } return string.Join(" ", lines); } return string.Empty; } }注意代码里的if (resp.TextDetections != null && resp.TextDetections.Length > 0)是必须的,因为接口即使识别不出文字,也可能返回正常响应,只是数组为空。
还有一个容易踩的坑:SDK不同版本的命名空间和ClientProfile配置写法不完全一样。比如有的版本用Region作为构造参数,有的版本直接放ClientProfile里。遇到编译不过就先去NuGet看当前版本的示例,不用纠结我这里的代码和最新SDK完全一致。
3.3 提取文字后的文件名处理
OCR识别出来的文本,不能直接拿来当文件名。第一个问题是非法字符:Windows文件名不能包含\ / : * ? " < > |,而OCR识别出的文字里可能有/、:、?等。第二个问题是首尾空格和换行。第三个问题是文件名长度可能超过255字节。所以必须做一次清洗:
private string BuildFileName(string recognizedText, string extension) { if (string.IsNullOrWhiteSpace(recognizedText)) { recognizedText = "未识别"; } char[] invalidChars = Path.GetInvalidFileNameChars(); foreach (char c in invalidChars) { recognizedText = recognizedText.Replace(c, '_'); } // 替换空白字符 recognizedText = recognizedText.Replace("\r", "").Replace("\n", "_"); recognizedText = recognizedText.Trim(); // 限制长度,避免太长的文件名 int maxLength = 80; if (recognizedText.Length > maxLength) { recognizedText = recognizedText.Substring(0, maxLength); } return recognizedText + extension; }这里的maxLength我取80,对于大多数场景够用了。扩展名直接保留原文件的扩展名。如果识别结果为空,用“未识别”占位,后面重名时再加序号。
接下来是重命名逻辑。直接调用File.Move,但要注意如果目标文件已存在会抛异常。所以要先检查目标文件是否存在,存在就在末尾追加(1)、(2)这样的编号:
private string GetUniqueFilePath(string directory, string fileName) { string candidate = Path.Combine(directory, fileName); string nameWithoutExt = Path.GetFileNameWithoutExtension(fileName); string ext = Path.GetExtension(fileName); int index = 1; while (File.Exists(candidate)) { candidate = Path.Combine(directory, $"{nameWithoutExt}({index}){ext}"); index++; } return candidate; }必须注意一个问题:当批量处理时,A图片可能识别出“abc”,B图片也可能识别出“abc”。由于处理是并行的,检测目标文件是否存在不能保证原子性,两个线程可能同时判定文件不存在,或者同时计算出了同样的新文件名。如果并发量不高,靠File.Move的异常捕获也能兜底,但最稳妥的还是串行处理,或者用一个锁来保护重命名部分。我在实际实现中用了SemaphoreSlim限制并发为3,并且在重命名前先串行更新一个“已用文件名”集合,尽量降低冲突概率。
3.4 批量处理与界面交互
批量处理最怕的就是界面卡死。WPF的UI线程一旦被耗时操作占据,整个窗口就会变白无响应。我的做法是:把所有耗时步骤放进async/await,每处理完一个文件就更新UI。整体代码结构类似下面:
private async void BtnStartBatch_Click(object sender, RoutedEventArgs e) { var files = GetAllImageFiles(); int total = files.Count; ProcessProgress.Maximum = total; ProcessProgress.Value = 0; using (SemaphoreSlim semaphore = new SemaphoreSlim(3)) { List<Task> tasks = new List<Task>(); foreach (var file in files) { await semaphore.WaitAsync(); tasks.Add(Task.Run(async () => { try { var result = await ProcessSingleFile(file); await Dispatcher.InvokeAsync(() => { // 更新列表状态 UpdateStatus(file, result); ProcessProgress.Value++; }); } finally { semaphore.Release(); } })); } await Task.WhenAll(tasks); } StatusText.Text = "批量处理完成"; }这个写法的关键点是信号量控制并发、Task.WhenAll等待所有任务结束、Dispatcher.InvokeAsync更新UI。但要注意SemaphoreSlim在Task.Run内部使用时有释放顺序问题,上面的示例其实并不完全严谨——更稳妥的做法是使用Parallel.ForEachAsync,.NET 6之后可以直接用:
ParallelOptions options = new ParallelOptions(); options.MaxDegreeOfParallelism = 3; await Parallel.ForEachAsync(files, options, async (file, ct) => { var result = await ProcessSingleFile(file); ProcessProgress.Dispatcher.Invoke(() => { ProcessProgress.Value++; AddLog(file, result); }); });Parallel.ForEachAsync简洁很多,省去手写信号量的麻烦,我这篇文章推荐用这个方案。它接受一个异步委托,控制并发也很方便。
关于“试识别”按钮:严格意义上是批量流程的单张执行版。用当前选择的图片,走一遍裁剪、识别、命名预览(但先不实际改名),把识别结果显示在界面上,让用户确认区域和命名规则是否OK。这个步骤非常重要,能避免批量跑完发现全部改错名。我实际用的时候,几乎每次换一批图都会先试识别一张,确认后才会批量执行。
试识别的流程代码和批量处理单张是同一个方法,只是多了一个previewOnly参数,预览时不执行File.Move。
4. 常见问题与排查技巧实录
4.1 识别结果为空或者缺字段
最常遇到的情况是区域裁剪对了,但识别结果为空。我排查这类问题一般按步骤来:
- 先在界面里把裁剪出来的区域图片单独存到本地,用看图软件打开,看看是否清楚。如果肉眼都看不清,OCR识别不出来很正常。
- 检查图片本身是什么类型。如果区域里是手写文字,通用印刷体识别接口基本无能为力,要换成手写体识别接口。如果区域里有表格线,普通接口会把表格线也当成内容干扰。
- 检查区域坐标是否正确。特别是用了缩略图预览又切到原图裁剪时,坐标没有做换算,容易截到完全不同的区域。
另一个容易被忽视的问题是:区域坐标正确,但裁剪时图片方向不对。比如手机拍摄的jpg带有EXIF旋转信息,BitmapImage和System.Drawing对旋转的处理方式不同,可能造成区域错位。我的处理方式是先把原图按EXIF方向修正,再做区域裁剪,否则不同手机的图片结果会不一样。
4.2 文件名非法字符与重名问题
文件名非法字符已经在BuildFileName里处理了,这里补充一个实际遇到的例子:产品名称里带/,比如“ABC/D-01”,如果不替换,重命名直接抛ArgumentException。替换成_之后变成“ABC_D-01”,可读性还行。
重名问题也不要只靠GetUniqueFilePath兜底,因为并发处理时检查-移动不是原子操作。我在实际项目中遇到过两个线程同时识别出“订单号123”,都判定目标文件不存在,最后后移动的那个抛了异常。最终我用了一个全局的HashSet记录已经占用的名字,并在重命名前用锁包住“检查-加锁-移动-释放”这段逻辑,才彻底解决。如果你的批次量不大,直接用串行加File.Move捕获异常也能对付,但上锁更稳妥。
4.3 界面卡死与API限流
界面卡死的根因基本都是UI线程被阻塞。排查方式很简单:看是否所有操作都走了async/await,看有没有在UI线程上做大量循环或IO。区域裁剪如果图片很大,在UI线程上执行也会卡顿,应该放到后台线程。
API限流的表现是批量跑到中途,连续很多张返回“请求过多”或LimitExceeded错误。腾讯云OCR免费额度有一定并发限制,官方文档会写明当前版本的QPS限制。解决办法有两个:一是降低MaxDegreeOfParallelism,把并发数调低到1或2;二是在每张请求之间加一个短暂延时,比如await Task.Delay(100)。我实测下来,并发数控制在2到3,单张间隔100毫秒左右,绝大多数情况都不会触发限流。如果仍然触发,再根据错误信息逐步调低。
另外,如果整天批处理几千张图片,要注意免费额度可能用完,超出后开始计费。批量处理之前最好看一眼账户的用量提醒,避免跑完收到账单。
4.4 不同图片来源的识别差异
这个方法最适合“同批次来源稳定”的图片,比如同一台扫描仪、同一台相机、同一套截图工具。原因很简单:同批次图片的分辨率、色彩、方向基本一致,区域坐标一次定义后可以直接复用。如果混入不同来源的图片,区域坐标就可能错位,识别率也会参差不齐。
我遇到过的情况是:一批图纸扫描件,分辨率都是200dpi,区域框好之后批量处理很顺利;后来混入了一个同事用手机拍的照片,区域坐标完全对不上,识别结果乱七八糟。后来我加了一个“按相对比例坐标识别”的选项,把区域坐标按图片宽高比例换算,手机照片和扫描件虽然横竖比例不同,但至少不会跑得离谱。如果你的图片来源五花八门,建议先分拣成几个批次,每批使用固定区域。
这个工具从我最早手写一个简陋的批处理脚本,到现在整理成WPF桌面工具,中间踩过的坑基本都是坐标换算、并发控制和文件名清洗这些看起来不起眼、实际却绕不开的细节。我个人的体会是:这类“小工具”的难点从来不在OCR本身,而在于把用户的真实使用场景完整跑通——毕竟几百张图一旦改错名,恢复起来比手动改名还痛苦。
最后分享一个实用的小技巧:批量执行前,先把目标文件夹做一次完整备份,或者至少在配置里加一个“改名之前自动备份原始文件列表”的选项。这样即使识别结果有问题,也能根据备份的映射关系一键还原。这个习惯帮我省过好几次大麻烦,建议你实际使用的时候也保留这一步。