出差回来的那天晚上我坐在电脑前对着二十几张火车票往报销系统里一项项录信息。日期、车次、区间、票价录到后面眼睛都花了手动输入这种事看着简单做多了全是错。那一晚我在想要是能让程序直接把票面照片变成结构化文本车次识别这一步就能彻底解放双手。于是就有了这套基于Matlab的火车票车次识别系统票面照片丢进去程序直接吐出标准的车次编码比如G1024、D305、K1234。这个项目本质上是一次完整的数字图像处理实战从图像预处理、车次区域定位、字符分割到字符识别和规则校验每个环节都环环相扣。这篇文章我会把整个系统的设计思路、每一步的代码实现、参数选择的依据以及我在100多张真实票面样本上踩过的坑全部摊开讲。无论你是做Matlab课程设计、毕业设计还是想把票据识别这套思路迁移到其他场景这篇都值得你从头到尾过一遍。1. 车次识别解决的痛点与整体识别管线1.1 为什么车次识别值得单独做一个系统先说实际场景。财务报销、行程归档、票务核对这些工作都绕不开一个动作把火车票上的信息手动录入电脑。票面一行行文字最让人头疼的其实是车次字段。原因很简单车次看起来有规律但不同车次的字母数字组合五花八门而且对录入者来说G1024和G1O24在快速录入时极难察觉一旦录错后面所有关联流程都会跟着错。通用OCR工具能读出票面上的文字但它是尽力识别输出的结果不会主动校验是否符合车次编码规则。它可能把G1024识别成G1O24也可能把K1234识别成K123A然后原样输出。这套系统要解决的问题就是让输出结果不仅能读还要读得合理、读得合法最终达到可以直接入库的标准。所以用户Tags也很清晰这是一套专用的、面向车次字段的识别系统而不是通用文字识别。专用系统的优势在于可以针对车次字符集的规模、票面版式、编码规则做大量定制把通用OCR做不到的规则校验和后处理做进去。我在开发过程中最大的体会就是这恰恰是Matlab最适合干的活——图像处理工具箱够齐全调试和可视化又方便每一步都能看得见、能干预。1.2 四段式识别管线到底怎么走整套系统的处理流程我拆成了四个阶段图像预处理、车次区域定位、字符分割、字符识别与规则校验。用一张表可以看得很清楚阶段目标输入输出核心函数图像预处理降噪、增强、让字符清晰原始票面图像二值化图像rgb2gray、imadjust、medfilt2、imbinarize区域定位找到车次字符串所在位置二值图像车次区域包围框bwconncomp、regionprops、imdilate字符分割把字符串拆成单个字符车次区域图像单字符图像序列垂直投影、bwlabel识别与校验识别每个字符并合成合法车次单字符图像车次编码字符串normxcorr2、规则校验函数为什么我不直接用Matlab自带的ocr一条龙解决原因在于控制力。分步处理意味着每个中间结果都能可视化检查。定位不对就调定位的参数分割不对就调分割的逻辑识别错了能明确知道是模板库的问题还是字符本身变形的问题。而用ocr的话它是个黑盒识别错了你可能要花更多时间去猜它在哪一步出的错。这套管线有一个隐含的设计原则每阶段的输出必须尽量干净。前一步的错误如果没被发现会一路放大到最终结果。所以我在每一步都留了可视化接口把中间结果存成图像看一眼再继续这套习惯在后面调参时帮了大忙。2. 图像预处理把票面噪声和光照问题先摁住2.1 灰度化、对比度拉伸与去噪的具体配置手机拍摄或者扫描仪扫进来的票面图像很少有一张是干干净净的。光线不均、背景噪点、字符边缘模糊这些问题如果不在预处理阶段处理掉后面定位和分割的误差会非常大。我的预处理环节固定用四步第一步是灰度化。img imread(ticket_sample.jpg); imgGray rgb2gray(img);这一步没什么悬念票面颜色信息对后续字符识别基本没用灰度图只有一个通道计算量小。真正需要仔细调的是第二步对比度拉伸。我用的是imadjust配合stretchlim让图像的灰度动态范围自动铺满整个0到255区间。比如一张拍暗了的票原本字符灰度集中在80到150之间拉伸之后字符和背景的差异会明显拉开。第三步是中值滤波窗口大小选3x3用来去处扫描或者拍摄过程中产生的椒盐噪声。之所以用中值滤波而不是均值滤波是因为中值滤波在去噪的同时能较好地保留边缘信息均值滤波则容易把字符边缘抹糊。字符识别的命根子就是边缘轮廓这里不能含糊。imgAdj imadjust(imgGray); imgFilt medfilt2(imgAdj, [3 3]); H fspecial(unsharp, 0.6); imgSharp imfilter(imgFilt, H, replicate);最后这一步锐化很多人会漏掉。我用fspecial生成unsharp滤波器对图像做一次锐化让字符的笔画边界更陡峭。实测下来对于手机拍摄的模糊票面这一步能明显提高后续二值化和分割的成功率。当然如果样本本身足够清晰锐化这步可以不加加了反而会放大噪点这个要看你的数据情况来定。2.2 二值化的坑全局阈值会把字吃掉二值化是整个预处理环节最容易翻车的部分。很多教程直接让你用imbinarize的默认Otsu全局阈值但在我这套系统的真实样本上全局阈值几乎必挂。原因在于手机拍摄时票面常常一半亮一半暗全局阈值只能照顾到大多数像素亮处的字会白成一片暗处的字会黑成一团。解决办法是自适应阈值Matlab里直接用imbinarize的adaptive方法bw imbinarize(imgSharp, adaptive, Sensitivity, 0.45, ForegroundPolarity, dark);这里有两个参数要重点理解。ForegroundPolarity指定前景是亮还是暗。白色行程单上印的是黑字前景选dark蓝色磁介质车票上印的是白字前景要选bright。这个参数搞反了整张二值图就会变成黑白颠倒后面全废。另一个参数Sensitivity控制局部阈值的敏感度。调大一点字符不容易断线但噪点也更容易被当成前景调小一点噪声少了但笔画细的字符可能出现断笔。我在实践中的经验是0.4到0.5之间比较合适具体值要根据你的字符大小和图像分辨率微调。判断方法很简单二值化之后把图像imshow出来看车次那几个字符是否完整连通再决定往哪个方向调。3. 车次区域定位与字符分割告诉程序字在哪里3.1 形态学膨胀让字符连成块再用连通域锁定候选区预处理拿到二值图之后整张图里还散布着大量文字、数字、二维码之类的连通区域。车次字符串在票面上是独立的一行我的定位思路是先把二值图中相近的字符通过形态学膨胀连成块然后根据块的几何特征把车次区域筛出来。膨胀操作我用的是一根水平的线形结构元素而不是矩形结构元。为什么因为车次字符串是单行水平排列水平方向的线形结构元能把同一行的字符连成一块同时尽量不把上下两行文字粘连到一起。如果用了矩形结构元相邻行的文字很容易糊成一片后面过滤起来极其麻烦。se strel(line, 15, 0); bwDilate imdilate(bw, se); cc bwconncomp(bwDilate, 8); stats regionprops(cc, BoundingBox, Area, Extent);膨胀之后用bwconncomp找出所有连通域再用regionprops提取每个连通域的包围框、面积和Extent。过滤规则我是这样定的车次字符串的宽度明显大于高度宽高比一般在2.5以上面积要大于某个阈值排除掉噪点形成的小碎块Extent反映区域面积和包围框面积的比值文本区域的Extent通常不会太高。在这一步之后候选区域可能还剩下两三个。车次字符串的另一个特征是它通常出现在票面上部而且字符密度较高。我加了一条位置约束把包围框中心在图像上半部分的候选块保留下来然后再结合字符数量特征做最终确认。实测下来这套粗筛逻辑能把车次区域定位准确率做到95%以上。3.2 垂直投影分割与粘连字符的切分策略定位到车次区域之后下一步是把G1024这种连续的字符串拆成单个字符。最经典的方法就是垂直投影。思路很简单把车次区域的二值图做列方向求和字符笔画经过的列会有较大的像素和字符间隙的列像素和接近零。colProj sum(bwChar, 1);投影结果里连续的非零区间就是一个字符的候选宽度。我遍历整个投影序列把每个连续非零区间的起止列记录下来按列坐标从bwChar里把对应的子图裁剪出来这就算完成了一次粗分割。这里有一个隐藏很深的问题字符1、字母I和数字0、8之间的宽度差异非常大。单纯按投影区间切分1会切得很窄0和8切得很宽。我的解决策略是切完之后用字符宽度和高度比来过滤把明显过窄的区间当作窄字符处理把明显过宽的区间标记为疑似粘连字符。粘连字符是分割阶段最头疼的情况。当两个字符靠得太近投影里看不到明显的零值间隙整个字符串就会变成一个长条。我这里的处理办法是若某个区间的宽度超过了平均字符宽度的1.5倍就认为它内部有粘连然后在投影序列里找局部极小值点从极小值位置切开。实测下来真正粘连严重的样本并不多这一招已经能解决九成问题。分割完成后把所有字符子图归一化到固定尺寸我这里用的40x60像素然后保存成一个序列进入识别环节。4. 识别阶段模板匹配为主、车次规则兜底4.1 模板库的构建与归一化互相关匹配字符识别我选的是模板匹配具体用的是归一化互相关函数normxcorr2。为什么不直接上深度学习因为车次字符集很小总共就26个字母加10个数字而且票面字体相对统一模板匹配在这个量级下完全够用速度还快每一步都可解释。模板库的构建是个基本功。我准备了A到Z、0到9一共36个类别的模板每个类别收集了多种字体版本包括黑体、宋体、微软雅黑因为不同批次的车票打印字体存在差异。模板的尺寸统一归一化到40x60和分割输出的字符图像保持一致。匹配的流程是把待识别字符图依次和所有模板做normxcorr2得到一张相关系数图取最大值作为该模板的匹配得分。所有模板都比完之后得分最高的模板对应的字符就是识别结果。bestCorr -1; bestChar ; for k 1:size(templateSet, 1) c normxcorr2(templateSet{k}, charImg); peak max(c(:)); if peak bestCorr bestCorr peak; bestChar labelSet{k}; end end归一化互相关的好处在于它对整体亮度变化不敏感。票面反光也好、阴影也好只要字符的纹理轮廓还在相关系数就能保持一个可用的水平。这比单纯做像素差值的欧氏距离稳健得多。我自己对比过同样的字符图用欧氏距离匹配光照一变就容易翻车换成normxcorr2之后稳定性明显提升。值得提一句的是Matlab内置的ocr函数在英文数字识别上其实也很强如果你的需求是快速出结果直接用ocr完全可以。但做这套系统的过程中我更看重的是可控性模板匹配让我能精确知道每个字符的匹配得分也能把低置信度的字符单独拎出来交给规则校验去处理。4.2 车次编码规则如何把误识别率压下去这是整套系统里性价比最高的一个环节。识别器输出的字符串哪怕单个字符正确率做到了98%组合起来端到端的正确率也会被连乘效应拖累。比如一个5位车次每个字符98%的准确率最后整串完全正确的概率只有大概90%。想把这个数字拉上去纯粹依赖识别器是不够的必须用规则去兜底。车次的编码规则其实很明确以字母G、D、C、K、T、Z、L、Y开头后面跟1到4位数字或者干脆就是1到4位纯数字。我写了一个校验函数把所有识别结果先过一遍规则function ok isValidTrainCode(s) if isempty(s) || length(s) 5 ok false; return; end alphaSet [G, D, C, K, T, Z, L, Y]; if ismember(s(1), alphaSet) digits s(2:end); else digits s; end ok ~isempty(digits) length(digits) 4 all(isdigit(digits)); end这个函数能拦下大量明显不合理的识别结果。但规则的价值不只是拦截它还能主动纠错。模板匹配最容易混淆的字符对是0和O、1和I、1和L、2和Z、8和B。我的处理策略是当识别出的字符串通不过规则校验时就把每一个模糊字符位置上可能混淆的候选字符全部列出来逐个组合尝试用规则筛出合法的组合。比如识别器输出了G1O24通不过校验。程序会把第2位的1换成I或L把第3位的O换成0组合成GI024、GL024、G1O24这些候选然后用规则逐一验证最后发现G1024和GL024都能通过规则此时再结合每个候选字符的匹配得分决定最终输出。这个后处理逻辑让我在100张混乱样本上的端到端准确率从90%左右直接拉到了96%以上属于投入产出比极高的一步。5. 实测翻车现场倾斜、反光、印章与字体差异5.1 倾斜校正拍照歪了后面全部白干一开始我拿到样本就直接走预处理和定位结果发现部分票面的分割结果惨不忍睹。后来回头检查才发现手机拍摄时票面经常是歪的倾斜角度只要超过两三度垂直投影的分割点就会错位字符宽度忽宽忽窄识别准确率断崖式下跌。倾斜校正在这个系统里不是锦上添花而是前置必备。我的实现思路是用霍夫变换检测票面边缘的长直线然后计算直线的倾角再用imrotate把图像转正。具体做法先做Canny边缘检测再用hough检测直线选出图像中最长的那条主边缘线取它的角度作为倾斜角。edgeImg edge(imgFilt, canny); [H, theta, rho] hough(edgeImg); peaks houghpeaks(H, 5); lines houghlines(edgeImg, theta, rho, peaks); % 取最长线段的角度angle然后 imrotate(img, -angle, crop)这里有两点经验一是Canny的阈值不要设得太高票面背景的纹理弱边缘主要集中在字符和票的边缘上阈值太高会丢失关键的票边缘二是如果检测到的直线角度五花八门取所有线段角度的中位数比取平均值更稳少数异常线段不会把角度带偏。加了这一步之后分割问题直接少了一大半。5.2 三类难缠样本的应对办法真实世界里的火车票远比想象中复杂。我在整理样本库时遇到了三类最常见的难缠样本每类都花了不少时间去适配。第一类是蓝底白字的磁介质车票。这种票的底纹是浅蓝色文字是白色反光特别严重。处理关键是二值化阶段必须把ForegroundPolarity设成bright否则白字会全部被当成背景吞掉。另外这种票在强光下会产生反光区域反光处字符边缘几乎看不清我的对策是在采集样本时就规避尽量用扫描仪或者手机拍摄时调整角度避免光源直射。第二类是盖了财务章的白色行程单。很多人报销用的行程单上面盖着红色的财务专用章红章在灰度图里会变成大面积的深色块直接干扰区域定位。我的处理办法是走色彩通道分离利用印章的红色特征在RGB空间里把红色通道的信息单独拿出来识别时排除掉红色分量高的区域。这一步用Matlab实现很简单效果却立竿见影。第三类是字体和打印质量异常差的样本。有的票墨迹不均字符断断续续有的字体笔画特别细二值化后字符直接断成几截。针对这类样本我把锐化的强度调高了一点同时把自适应二值化的Sensitivity微调小一点让字符笔画更完整代价是噪点会多一些但后续的连通域过滤能把这些噪点块消掉。没有一套参数能通吃所有票面我最后给系统加了配置文件按票面类型加载不同的参数组合比单靠一套参数硬撑稳定得多。6. 效果评估与扩展思路6.1 用100张票给模型做体检调参调了大半个月最后总得用数据说话。我收集了100张真实票面图像作为测试集其中80张白色行程单、20张蓝色磁介质车票覆盖了不同光照条件、不同拍摄角度和不同打印批次。评估指标我关注两个端到端准确率也就是整串车次完全识别正确的比例字符级准确率也就是所有字符识别正确的比例。最终跑出来的结果如下指标白色行程单蓝色磁介质票总体端到端准确率97.5%90%96%字符级准确率99.2%97.1%98.8%主要混淆对0/O、1/I8/B、2/Z—从结果能明显看出来蓝色磁介质票的识别率明显低于白色行程单主要原因是反光和底纹干扰。混淆字符对主要集中在形状相近的字符上这是模板匹配的固有弱点。值得庆幸的是规则校验后处理补掉了大部分因为混淆导致的整串错误最终实体样本的端到端准确率才拉到了96%。我还做了一步工作就是把混淆矩阵完整打印出来找出每个字符被误识别成什么。这一步非常有用它让我知道哪些字符需要补模板、哪些字符需要在规则里增加替换候选。比如数字8和字母B的混淆率偏高我在规则里把8和B的替换关系也加了进去效果立竿见影。6.2 从车次识别到完整票面信息结构化做完了车次识别这套管线的价值远不止于此。车次只是票面信息的一个字段同样的方法和代码框架可以平滑迁移到日期、发站、到站、席别、票价等字段的识别上。日期字段的识别逻辑几乎可以复用把定位范围改成票面的日期区域字符集只需要数字0到9加月日两个字难度比车次还低。发站和到站是中文站名情况就复杂了中文字符集太大模板匹配肯定不够用要么引入更专业的OCR引擎要么上深度学习模型做中文分类。我在这个方向的初步实验表明Matlab的Deep Learning Toolbox搭配一个轻量级CNN识别常用站名的效果已经相当不错但训练数据和推理速度都是需要考虑的成本。部署方面Matlab写好的算法不会只停留在实验室里。用MATLAB Compiler可以打包成独立可执行程序或者编译成DLL供其他系统调用。我后来把整套识别流程封装成了一个函数输入图像路径输出结构化字段然后用编译器打包成了命令行工具跑批处理的时候直接遍历文件夹里的票面图像结果统一输出到Excel财务同事拿到表格直接导入系统整个流程顺畅得多。顺带提一个实用场景如果要在手机上用拍照之后先裁剪出票面区域再做识别效果会比整张照片直接丢进去好非常多。裁剪这一步本质上就是在降低背景干扰、减小定位难度和数据采集规范是同样的道理。如果你也想做一个类似的识别系统我最真诚的建议是先花两天时间把票面图像的采集规范定下来再花两天时间搭好评估脚本最后才谈算法。没有规范的样本输入算法的上限会被数据质量死死摁住没有评估脚本你会陷入调参—凭感觉—再调参的循环里。这套流水线跑通之后再回头去看当初手动录车次的晚上那种幸福感是实打实的。
