简介:基于MATLAB的发票识别项目源码包,内含可交互的GUI界面,面向图像处理课程设计、毕业设计,以及希望掌握OCR落地流程的MATLAB开发者。资源共124个文件,压缩包仅1.77MB:110个bmp测试样本用于验证识别效果,9个m脚本覆盖灰度化、二值化、形态学膨胀与开运算、边缘检测、字符分割和模板匹配等关键步骤,fig文件为界面源文件,另附txt说明辅助上手。项目以发票号码、金额等字段的自动提取为主线,清晰演示如何将预处理、分割与匹配算法组合为可运行系统;运行后可观察发票原图到结构化数据的完整链路,对理解连通组件分析、阈值选择等工程应用很有价值。已有1475人学习浏览,适合需要真实项目参考、深入理解图像算法实现的学习者。
1. 发票识别用 MATLAB 做落地:一个带 GUI 的完整项目长什么样
拿到一张发票照片,手工把发票代码、号码、金额敲进 Excel,一分钟起步,一天几百张就是大半天。这个基于 MATLAB 的发票识别项目,核心不是炫算法,而是把「图像预处理 → 区域定位 → 字符分割 → OCR 识别 → 结构化输出」整条链路封装成一个带 GUI 界面的工具:上传图片、点按钮、结果直接出现在表格里。对做课程设计、毕业设计的同学,以及想快速搭建票据识别原型的从业者,这套资源的价值在于流程完整、每一步都能可视化调参,不是只能跑通 Demo 的黑匣子。下面的拆解按识别流程走,重点说实现思路、参数怎么设、以及那些不跑一遍就发现不了的坑。
2. 识别流程与算法选型:一张发票图上要拆开的六件事
2.1 发票上要识什么:关键字段提取清单
发票印刷版式相对固定,识别前先把目标字段拆清楚。以常见的增值税普通发票为例,要提取的核心字段集中在几个固定区域:
| 字段 | 常见位置 | 字符特点 | 典型识别错误 |
|---|---|---|---|
| 发票代码 | 右上角 | 10~12 位数字 | 0 识别成 O,1 识别成 I |
| 发票号码 | 右上角 "No" 后 | 8 位数字 | 号码与代码粘连 |
| 开票日期 | 右上方或备注区 | 中文年、月、日 | 年与月之间空格被漏掉 |
| 价税合计(小写) | 右下角 | ¥ 开头,两位小数 | 小数点被识别成逗号 |
| 购方名称 | 左上方 | 企业名称,较长 | 多字、漏字 |
| 销方名称 | 左下方 | 企业名称,较长 | 与地址行混淆 |
这里有一个容易被忽略的点:发票网格线非常密集,二值化之后网格和文字混在一起,直接送进 OCR 会得到一长串乱码。所以流程上必须把「找发票区域」和「切出字符行」放在识别前面,而不是指望识别器自己区分文字和表格线。
2.2 完整识别流水线:从图像输入到结构化输出
整个项目的处理管线可以分成六步,每一步的输入输出非常明确:
- 读图:支持 jpg、png、bmp 格式的发票照片或扫描件。
- 预处理:彩色图转灰度、去噪、二值化,目标是让文字和背景分离干净。
- 发票区域定位:从图像里裁出发票主体,排除桌面、手指、阴影等背景干扰。
- 字符分割:把一行文字按投影切成单个字符或字段块。
- OCR 识别:用 MATLAB 自带的
ocr函数,限制字符集之后得到文本。 - 结构化输出:用正则表达式从 OCR 文本里筛出金额、日期、发票代码,填进表格。
把这六步拆开的好处是每步都能单独调试。实际开发中,识别率低往往不是 OCR 不行,而是前面的预处理和分割没做好。常见做法是每步都输出一张中间结果图,肉眼确认字符是否清晰、分割是否切对了位置,再往后走。
2.3 算法选型:为什么是「预处理 + 分割 + OCR」而不是模板匹配
很多初次接触发票识别的开发者第一反应是字符模板匹配:把数字 0~9 的模板做好,逐个像素比对。这个思路在老式印刷体身份证号上勉强能用,但发票上有几个现实问题:字体不是统一印刷体、分辨率差异大(手机拍的和扫描件完全两回事)、金额里有 ¥ 和中文大写数字。模板匹配对字体、清晰度、倾斜角度极其敏感,稍微拍歪一点就大面积翻车。
MATLAB 的ocr函数基于深度学习的检测识别模型,对印刷体中文、数字、英文的鲁棒性远好于模板匹配。配合CharacterSet参数限制只识别「数字 + 小数点 + ¥ + 中文数字」时,准确率还能再上一档。GUI 部分用新版 MATLAB 的 App Designer 来搭,比老牌 GUIDE 更直观,控件回调之间通过 app 对象传递数据,对新手更友好。整套方案的核心逻辑就一句话:用预处理和分割控制输入质量,用 OCR 的能力兜底,用后处理清洗修正识别结果的错误。
3. 图像预处理与发票区域定位:识别率一半靠这里
3.1 灰度化与去噪:中值滤波为什么比高斯滤波更合适
发票照片一般是从手机或扫描仪来的,会带两类噪声:传感器噪点和 JPEG 压缩痕迹。第一步把彩色图转成灰度,然后去噪。这个环节很多人直接上高斯滤波,但我会优先选中值滤波:
img = imread(imgPath); gray = rgb2gray(img); % 彩色转灰度,保留亮度信息 denoised = medfilt2(gray, [3 3]); % 3x3 中值滤波去噪中值滤波的原理是把每个像素替换成邻域像素的中位数,对孤立噪点(椒盐噪声)抑制效果明显,同时不会像高斯滤波那样把文字边缘抹糊。发票文字笔画密集,边缘清晰度直接影响后续二值化和字符分割,所以这里宁慢勿糊。窗口大小默认用[3 3],如果原图分辨率较高、笔画较粗,可以试[5 5];超过 5 会把笔画细节连带磨掉,一般不建议。
3.2 二值化:自动阈值为何比固定阈值稳
去噪之后是二值化,目标是让文字变成白色、背景变成黑色(或反过来)。固定阈值img > 0.5对光线均匀的扫描件没问题,但手机拍摄的发票经常出现半边亮半边暗,固定阈值会把暗部的字直接切没。这里用 Otsu 自动阈值:
thresh = graythresh(denoised); % Otsu 计算全局阈值 bw = imbinarize(denoised, thresh); % 按阈值转二值图 if mean(bw(:)) > 0.5 % 前景比例异常时反转极性 bw = ~bw; endgraythresh根据灰度直方图自动找一个阈值,让前景和背景的类间方差最大,光照不均时比手写固定阈值稳得多。还有个细节:imbinarize默认把灰度值大于阈值的部分置 1,如果发票是深色背景浅色字,结果会黑白颠倒。判断手段是看mean(bw(:)),前景像素超过一半说明反了,取反即可。这一步是玄学重灾区,同样的参数换一张图效果可能天差地别,所以建议手动加一个阈值偏移量接口,方便后面批量调参。
3.3 发票区域定位:从边缘检测到最大矩形提取
二值化之后的图里,除了发票还有桌面纹理、手指阴影、其他纸张的边缘。发票本身是一个矩形,四周有印刷边框线,利用这个特点可以直接把发票主体裁出来:
edges = edge(bw, 'canny'); % Canny 边缘检测 se = strel('rectangle', [30 30]); % 30x30 矩形结构元 closed = imclose(edges, se); % 闭运算连接断线 stats = regionprops(closed, 'BoundingBox', 'Area'); % 提取连通域信息 [~, idx] = max([stats.Area]); % 找最大连通域 bbox = stats(idx).BoundingBox; invoice = imcrop(gray, bbox); % 裁剪出发票区域这段代码的核心逻辑是:发票边框线在边缘图上是断断续续的线段,用imclose做闭运算后,发票外框会连成一个最大的连通域。regionprops统计每个连通域的面积和位置,面积最大的一般就是发票本体。strel的尺寸很关键,[30 30]适合 1000 像素宽以上的图;分辨率低时结构元太大,会把相邻区域也连进来,建议按图片宽度比例设置,比如round(size(gray,2)/40)。裁剪后建议顺手保存中间结果,后面 OCR 出问题能快速定位是定位错了还是识别错了。
4. 字符分割与 OCR 识别:把连续文字切成可识别的单元
4.1 投影法分割:水平投影切行、垂直投影切列
发票区域裁出来后,下一步是把一行行文字切开。投影法是字符分割里最直接的手段:把二值图按行求和,文字行的像素和在阈值以上,空白行接近 0,自然分出文本行;再对每一行按列求和,分出单个字符块。对发票这种印刷体、行列整齐的场景,投影法简单有效:
projH = sum(bw, 2); % 水平投影,每行像素总数 thresholdH = mean(projH) * 0.5; % 动态阈值 rowIdx = find(projH > thresholdH); % 找到所有文字行位置 % 把相邻行合并成区间 rowRanges = []; start = rowIdx(1); for k = 2:length(rowIdx) if rowIdx(k) - rowIdx(k-1) > 3 rowRanges(end+1, :) = [rowIdx(k-1), start]; % 断行超过3像素,结束当前行 start = rowIdx(k); end end rowRanges(end+1, :) = [rowIdx(end), start];投影阈值用mean * 0.5是经验值,因为发票网格线在二值图里也会投影出小峰值,用一个比例阈值可以滤掉那些细线。合并区间时「间距超过 3 像素算换行」这个参数,对 800 像素宽以上的图都适用;分辨率更高时可以放宽到 5 像素。每一行再按同样的思路做垂直投影,就能把一行文字切成单个字符块。注意发票金额区域里的字符间距均匀,但汉字比数字宽,按连通域切会比按投影切更好,实践中通常混合使用:先投影切行,行内用连通域bwlabel切字符。
4.2 MATLAB 自带 ocr 函数:使用与结果解析
字符分割完成后,可以逐块识别,也可以把整行、整块区域直接交给 OCR。MATLAB 的ocr函数在发票这个场景下表现超出预期,关键是设置CharacterSet:
ocrResults = ocr(invoice, 'CharacterSet', '0123456789.¥¥元角分壹贰叁肆伍陆柒捌玖拾佰仟万亿'); text = ocrResults.Text; % 查看字符级的识别结果 for k = 1:length(ocrResults.CharacterBoundingBoxes) oneChar = ocrResults.CharacterBoundingBoxes(k, :); charText = ocrResults.Text(k); fprintf('字符 %s 位于 [%d %d %d %d]\n', charText, oneChar); endCharacterSet参数限定识别器只输出指定字符,对发票这种封闭词表场景提升非常明显:原本「0」可能被识别成「O」、「1」可能被识别成「l」,限定后这些混淆会大幅减少。ocr返回的对象里有Text(整块识别文本)、WordBoundingBoxes(词级位置)、CharacterBoundingBoxes(字符级位置)几个常用字段,后处理时基本都是用Text加正则去筛。
这里有个性能问题:ocr函数处理一张 2000x1500 的发票图可能要 10 秒以上,如果跑整个区域会更慢。常见做法是先通过投影法把「金额」「发票代码」等字段区域单独裁出来,再分别识别,而不是整张发票直接送 OCR。这样做既能提速度,也方便针对每个字段单独调整预处理参数。
4.3 识别结果清洗:用正则表达式把字段从杂文本里筛出来
OCR 输出永远是「大致正确」的文本,里面混着空格、误识别字符和漏字。直接从ocrResults.Text里找字段不现实,必须用正则表达式做结构化提取:
text = ocrResults.Text; % 金额:数字 + 小数点 + 两位小数,允许千分位逗号 amountMatch = regexp(text, '[0-9][0-9,]*\.[0-9]{2}', 'match'); % 日期:XXXX年XX月XX日 dateMatch = regexp(text, '\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日', 'match'); % 发票号码:8位连续数字 invNoMatch = regexp(text, '(?<=No[:: ]?)\d{8}', 'match', 'once');正则表达式是识别结果清洗的最后一层防线。金额允许中间出现逗号(千分位),日期把「年」「月」「日」之间的空格容忍掉,发票号码用「No」作为前向定位,减小误匹配概率。清洗完还不算完,建议加一步逻辑校验:金额必须大于 0、日期月份必须在 1~12 之间、发票号码是 8 位数字。不符合的直接标记为「识别可疑」,留给人眼复核,不要硬填进表格。这一步逻辑校验虽然简单,但能避免下游财务处理时被脏数据坑到。
5. GUI 整合与避坑:回调卡顿、数据错位的三个典型现场
5.1 GUI 布局:图像显示区、按钮组、结果表格的分工
识别算法跑通之后,封装 GUI 是让项目真正「能用」的关键一步。用 App Designer 新建一个工程,布局分成三块:左侧是图像坐标区(UIAxes),用来显示原始发票和识别标注;右侧是一个结果表格,列出每个字段的识别值和置信度;底部放三个按钮——上传图片、开始识别、导出结果。控件之间的数据传递不推荐用guidata那种老方式,直接存成 app 属性:
function startupFcn(app) app.ResultTable.Data = cell(0, 3); % 清空表格 app.ResultTable.ColumnName = {'字段', '识别结果', '置信度'}; app.ResultTable.ColumnWidth = {90, 200, 80}; end表格的Data属性要求是 cell 数组或数值矩阵,字段名是字符串、置信度是数值,混在一起时必须统一转成 cell。这里新手最容易翻车:直接往Data里塞结构体或表类型,界面上一行都显示不出来。建议所有结果在写入表格前统一走一遍cellstr转换。
5.2 回调设计:上传图片、识别、导出三个核心回调
三个按钮对应三个回调函数。上传图片回调负责读文件和即时显示:
function uploadButtonPushed(app, event) [file, path] = uigetfile({'*.jpg;*.png;*.bmp', '发票图片'}); if isequal(file, 0) return; end app.rawImg = imread(fullfile(path, file)); % 存在 app 属性里 imshow(app.rawImg, 'Parent', app.ImageAxes); % 立即显示 end识别回调是核心,逻辑是调runInvoiceRecognition函数、把结果转成 cell、写入表格。注意一个关键点:识别是个耗时操作,直接写在回调里会阻塞 GUI 线程,界面会卡住。图片尺寸较大时先缩放:
function recognizeButtonPushed(app, event) if isempty(app.rawImg) return; end workImg = app.rawImg; maxDim = max(size(workImg, 1), size(workImg, 2)); if maxDim > 1200 scale = 1200 / maxDim; workImg = imresize(workImg, scale); % 限制最长边 end result = runInvoiceRecognition(workImg); % 返回 cell 数组 app.ResultTable.Data = result; end导出回调把表格内容写进 CSV 文件,方便 Excel 打开:
function exportButtonPushed(app, event) [saveFile, savePath] = uiputfile('*.csv', '保存识别结果'); if isequal(saveFile, 0) return; end if ~isempty(app.ResultTable.Data) writecell(app.ResultTable.Data, fullfile(savePath, saveFile)); end endimresize这一步至关重要。发票原图动辄 2000 像素宽,直接送 OCR 会让界面卡死几十秒,先缩到 1200 像素以内,速度能快 3~4 倍,识别率几乎不掉。注意识别结果显示的坐标也是基于缩放后的图,后续叠加矩形框时要保持一致。
5.3 常见问题:识别不准的五个典型现场
现象 1:识别执行时界面白屏无响应,点哪里都没反应。
原因:OCR 在主线程同步执行,图像较大时耗时数十秒,GUI 消息循环被阻塞。
解决:识别前imresize限制最长边,后续再把整张识别改成按字段区域分块识别,单次耗时控制在 2 秒以内。实在要处理大图,用parfeval开后台任务,识别完成后再回填界面。
现象 2:二值化后文字断成一截一截的,OCR 识别率暴跌。
原因:发票纸张褶皱或拍摄阴影导致局部光照不均,全局阈值把暗部文字切碎。
解决:改用局部自适应阈值imbinarize(denoised, 'adaptive'),并配合strel('line')形态学闭运算把断开的笔画重新连上。参数上优先调闭运算结构元尺寸,而不是一味调阈值。
现象 3:金额小数点被识别成逗号或句号。
原因:小数字号字体中,小数点只有 1~2 个像素宽,OCR 容易把它和周围的噪声混淆。
解决:识别前把金额区域单独裁出来做 2 倍放大,再设置CharacterSet明确包含小数点;后处理用正则[0-9][0-9,]*\.[0-9]{2}匹配,小数点前后必须有数字,能过滤大部分误识别。
现象 4:发票定位裁出来的区域带了大片背景。
原因:发票边框与背景中的深色桌面、其他纸张连成一个大连通域,最大连通域取错。
解决:regionprops之后加一个宽高比过滤条件,发票宽高比一般在 1.5~2.5 之间,超出范围的连通域直接排除,再取剩余连通域里面积最大的。
现象 5:导出 CSV 后用 Excel 打开中文乱码。
原因:MATLAB 的writecell默认按 UTF-8 编码写入,老版本 Excel 默认按 GBK 读。
解决:写文件时指定编码fopen+fprintf按 GBK 写,或者改导出为 xlsx 格式用writetable,避免编码问题。
6. 进阶技巧:参数调优模板与批量识别一票到底
项目跑通后真正考验人的是批量识别稳定性。我会建议在工程里加一个批量模式和一个调参模板,这两个东西能大幅提升实际使用体验。
批量识别用dir遍历目录下所有发票图片,结果统一汇总进一张表:
function batchRecognize(folderPath) files = dir(fullfile(folderPath, '*.jpg')); results = cell(length(files), 5); for k = 1:length(files) img = imread(fullfile(folderPath, files(k).name)); field = runInvoiceRecognition(img); % 统一入口函数 results(k, :) = [field, {files(k).name}]; fprintf('已处理 %d/%d:%s\n', k, length(files), files(k).name); end writecell(results, fullfile(folderPath, '批量结果.csv')); end调参这件事,我的习惯是写一个可配置脚本,把二值化阈值偏置、闭运算结构元尺寸、OCR 字符集做成输入参数,跑一遍就打印每个字段的识别结果和置信度。看结果不要只看最终表格,把预处理中间步骤拼到一张图上显示出来,能快速定位是哪一步出了问题。从那以后我每次调识别率,都强制走一遍这个流程:先看中间结果图,再改参数,而不是直接盯着最终识别率瞎试。参数再怎么玄学,中间步骤可视化之后,大部分问题都能在十分钟内定位。希望帮到你。
本文还有配套的精品资源,点击获取