资讯动态

手写数学公式识别系统实战:从OCR误区到端到端深度学习

发布时间:2026/8/27 5:03:45 来源:尧图企业网站定制
简介OCR技术常被理解为对印刷文本的逐字识别但面对手写数学公式时其局限性便暴露无遗公式中的上标、下标、分数线和积分号等关键信息依赖的是二维空间结构关系而非字符序列本身。本文将手写公式识别从概念到工程落地完整拆解解析为何传统OCR路线无法处理结构歧义并引入基于CNN编码器与Attention机制的解码器架构实现从图像到LaTeX序列的端到端映射。文章深入探讨了数据增强策略、Beam Search解码、后处理规则以及训练与推理管线分离等关键技术细节并结合真实场景下的图像预处理与部署优化经验帮助开发者规避上手阶段的常见误区。无论是毕业设计还是产品集成这套手写数学公式识别方案都提供了可直接借鉴的工程实践路径。 先聊一个很多人都会踩进去的误区你以为手写数学公式识别只是“普通OCR加几个数学符号”而已拍一张照片输出1/2或者sqrt(x)就完事了。等你真正拿到一批手写作业、试卷、课堂笔记去试跑才发现系统把∫认成S、把分数线的位置理解错、把x^2拆成x2这时候才会意识到公式识别真正难的不是“认字符”而是理解二维空间里的结构关系。我在做这套基于Python的手写数学公式识别系统时第一阶段也天真地以为调一个现成OCR引擎就行后来被真实数据教育了一轮才老老实实从系统设计、模型选型、数据增强一路重做。这篇文章把我从需求拆解到模型实现、再到工程落地的完整思路写下来包括每个环节里我踩过的坑和取舍逻辑。不管你是准备做毕业设计还是想在项目里集成公式识别能力这套设计思路都能直接拿去用。1. 手写公式识别为什么不能用普通OCR的思路很多人的第一反应是公式也是由字符组成的那我把 Tesseract、PaddleOCR 拉起来识别完再拼一下不就行了表面上看没问题但公式识别和普通文本识别在问题定义上就是两码事。1.1 二维空间结构才是真正的难点普通文字识别是“一维序列任务”处理器一句话从左往右认字符就行。公式本质上是一棵二维结构树分数有分子分母、根号有被开方数和开方次数、求和号有上下限、积分号有上下界。同样的字符序列1 2放在一行里是“12”中间画一条横线就变成了\frac{1}{2}横线上下位置换一下又可能是\frac{1}{2}或\frac{2}{1}。举一个最典型的例子x^2中的2是上标位置靠上、字号稍小如果手写时2写得和x平齐再标准的OCR引擎也只能识别成x2。手写的∫_0^1中0和1的位置相对积分号来说有明显偏移结构解析器必须判断它们属于上下限而不是普通乘法项。分数线—和负号-在图像上几乎一样只能靠周围上下文关系区分上面有内容、下面也有内容才是分数线。个人手写的字符样式差异又比印刷体大得多同样一个x有人写成圆角的有人写成带尾巴的连笔还可能导致一个字符被拆成两段、两个字符黏成一体。所以公式识别系统不能只解决“字符分类”问题还得同时解决“字符定位”“结构解析”“歧义消解”三个问题。这三个问题在普通OCR里基本不用考虑但在公式识别里属于核心矛盾。1.2 两条技术路线切分-识别-解析与端到端传统做法是“三步走”第一步做字符切分把公式图像切成一堆孤立字符小块第二步用CNN对每个小块做字符识别第三步用结构解析算法通常基于规则或语法把这些字符按空间坐标还原成 MathML 或 LaTeX。这套路线的优点是每一步都能单独调优缺点是模型只在一开始“看见”了完整图像后面每一步都用的是上一步的结果切分一旦出错结构解析再强也救不回来。手写连笔特别多的时候字符切分的错误会指数级放大。近几年主流方案已经转向端到端输入整张公式图像输出一个 LaTeX 序列。模型自己学习从图像到序列的映射把“字符切分”和“结构解析”隐式地放在网络内部解决。CROHME 竞赛里主流模型在这条路线上已经实现了比较高的公式级准确率而且工程实现更简单——至少你不需要维护一套复杂得让人头秃的结构解析规则。我做系统时直接选了端到端路线原因很实际字符切分这一步在手写场景下太脆弱了与其花大量精力写规则去修补切分错误不如让模型在“完整图像 完整标注”上学到结构规律。端到端也有代价它对数据量和训练技巧要求更高这在后面第3章会展开讲。2. 系统架构设计一个能落到实处的公式识别系统不只包括一个深度学习模型。它至少包含图像预处理、公式区域定位、模型推理、输出后处理这几个模块。我把它们串成一个完整链路来讲你照着搭就能跑通。2.1 从纸面到 LaTeX 的完整数据流整个系统从用户上传一张照片开始到输出一段可复制的 LaTeX 代码结束中间经历了这么几步图像读入与灰度化把手机拍摄的彩色照片转成灰度图。这一步可以用 OpenCV 直接完成核心是减少后续处理的通道数降低干扰。倾斜纠正与透视校正手拍照片很少有完全正对纸张的需要检测纸张边缘做透视变换。这一步做不好后面的识别效果会明显下降。图像去噪与增强常见做法是高斯模糊后做自适应二值化把笔迹和背景分离开。还可以用形态学操作去掉孤立噪点。公式区域检测如果图片里只有单个公式第3步之后就可以直接裁剪归一化如果一个页面里有很多公式需要先用连通域分析或者目标检测模型把每个公式区域找出来再逐个送入识别模型。送入推理服务得到 LaTeX 序列模型吃进去一张归一化后的公式图像输出如\frac{ab}{c}这样的序列。后处理与合法性校验括号补齐、LaTeX 语法检查、局部修正。这步放在第4章详细说。下面是预处理阶段我用到的关键代码骨架完整复现时直接改输入输出路径就能用。注意预处理的目标不是“把图像变漂亮”而是让后续模型看到尽量干净的输入分布。import cv2 import numpy as np def preprocess_formula_image(img_bytes, padding_ratio0.1): # 字节流解码 img cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR) # 灰度化 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯去噪 blur cv2.GaussianBlur(gray, (3, 3), 0) # 自适应二值化适合光照不均的拍照图 binary cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) # 找连通域并裁剪公式主体区域简单版 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: x, y, w, h cv2.boundingRect(np.vstack(contours)) # 四周留边避免裁掉上标下标 px, py int(w * padding_ratio), int(h * padding_ratio) x0 max(0, x - px) y0 max(0, y - py) x1 min(img.shape[1], x w px) y1 min(img.shape[0], y h py) binary binary[y0:y1, x0:x1] # 保持宽高比的归一化长边缩放到224 h, w binary.shape scale 224.0 / max(h, w) binary cv2.resize(binary, (int(w * scale), int(h * scale)), interpolationcv2.INTER_NEAREST) return binary这套预处理看着简单但有一个原则很重要保留尽可能多的真实结构信息。举个例子很多现成代码喜欢把图像压成正方形结果一个横向很长的公式被压扁上标下标的相对空间关系变形模型识别率掉得很厉害。正确做法是保持宽高比再把图像放到固定尺寸的“画布”上剩余部分补零或者补白。2.2 训练管线和推理管线为什么必须分开设计刚开始做的时候我犯过一个很典型的错误训练代码和数据预处理写在一起导出模型后直接拿去在线推理结果训练时还正常的模型到了线上就崩。后来才发现问题出在管线上。训练阶段的数据预处理是“离线”的可以从样本集里随机旋转、随机缩放、随机加噪声把样本空间撑大帮助模型泛化。推理阶段则要尽可能“保守”输入图片是什么样就是什么样最多做亮度归一化不做随机扰动也不做那种依赖随机种子的增强。两者必须完全隔离否则你在训练时看到的高精度在线部署时根本无法复现。我的做法是整理成两份配置环节训练管线推理管线输入尺寸随机缩放范围0.8~1.2固定长边224保持宽高比增强策略旋转±5°、弹性形变、笔画腐蚀膨胀不做随机增强归一化每张图独立计算均值方差使用训练集统计的固定均值和方差输出解码teacher forcing用真实标签beam search自回归解码这样设计之后训练指标和线上指标才有可比性。如果你把它们混在一起哪怕模型结构一模一样线上效果也会和验证集差一大截排查起来非常痛苦。2.3 模型服务接口怎么定义在实际项目中模型通常不是直接给用户用的而是作为一个独立服务提供给上层应用。我把服务接口定义成下面这样它足够简单前端调用方便也方便后面做并发和监控。请求{ image_base64: /9j/4AAQSkZJRgABAQEASABIAAD/..., max_length: 128, beam_size: 5 }响应{ latex: \\frac{ab}{c}, score: -1.2356, elapsed_ms: 85.3, status: ok }接口里顺手返回了score和耗时这在调试阶段非常有用score 可以用于判断模型对当前输入的自信心如果分数很低前端可以提示用户“请在光线充足的环境下重拍”或者自动走增强流程耗时则用于监控服务健康状况方便定位性能瓶颈。3. 核心模型的实现讲完外围系统接下来是我觉得最值得细说的部分——模型本身怎么选、数据怎么喂、训练有哪些坑。3.1 模型选型为什么我落到了 Encoder-Decoder Attention 这个组合公式识别模型的选型我对比过三条技术路线第一是纯CNN做序列输出。把图像拉成特征图后直接用CTC解码成字符序列。优点是速度快、结构简单但公式识别的输出是结构化的 LaTeX 序列CTC 假设输入输出近似单调对齐这对普通文本行适用对公式这种强二维结构来说效果一般。第二是 Transformer 图像 Patch 嵌入。视觉 Transformer 的变体最近在公式识别上也表现得不错尤其在数据量充足时性能比较强。缺点是训练收敛慢对数据规模的依赖更强在小规模手写数据集上容易欠拟合部署时显存占用也相对高。第三是 CNN 编码器 RNN/GRU 解码器 Attention 机制。这个组合是目前工程落地最稳的选择CNN比如 ResNet18 或 ResNet34负责从图像中提取空间特征GRU 解码器负责生成 LaTeX 序列Attention 让解码器在生成每一步时“回看”图像的相关区域。它既比纯CNN多了结构建模能力又比纯Transformer更容易在中小规模数据上收敛。我最终选了第三条路线工程理由占了大头。它训练稳定显存友好推理时能控制在 CPU 可接受的延迟范围内后续如果要换更大规模的视觉骨干改动也相对小。这里引用一张我当时画的简单流程示意手写公式图像 ↓ CNN编码器ResNet18→ 空间特征图 ↓ Attention 模块动态查看图像相关区域 ↓ GRU解码器 → 逐 token 生成 LaTeX 序列 ↓ 后处理 → 输出 LaTeX模型定义的核心部分大概是这样的import torch import torch.nn as nn class Encoder(nn.Module): def __init__(self, d_model256): super().__init__() self.cnn nn.Sequential( nn.Conv2d(1, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(), ) self.proj nn.Conv2d(256, d_model, 1) def forward(self, x): # x: (batch, 1, H, W) feat self.cnn(x) # (batch, d_model, H/4, W/4) return self.proj(feat) class Decoder(nn.Module): def __init__(self, vocab_size, d_model256, n_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.gru nn.GRU(d_model, d_model, n_layers, batch_firstTrue) self.out nn.Linear(d_model, vocab_size) def forward(self, tokens, encoder_features): # tokens: (batch, seq_len) embed self.embedding(tokens) # 简化版实际需要配合attention context out, _ self.gru(embed) return self.out(out)实际工程里Attention 模块会根据解码器当前隐状态计算编码器特征图上各个位置的权重实现“看到哪生成到哪”的效果。这一步对公式识别尤其重要——生成\frac之后Attention 要能准确落到分子上生成完分子再跳到分母上相当于隐式地完成结构遍历。3.2 数据集的构建与增强公式识别模型的训练最影响效果的往往不是网络结构而是数据和标注。公开数据集我用到的主要是这几个CROHME 系列手写数学公式识别竞赛的数据集包含训练集和测试集是社区最常用的评测基准。它提供在线笔迹数据和离线渲染图像非常适合做端到端模型。MathBrush包含大量手写数学表达式的数据集来源多样。自建数据为了覆盖目标用户的手写风格建议收集一定量真实手写样本。哪怕只有几百张对模型泛化能力提升也很明显。公开数据集的问题在于它毕竟是“竞赛手写体”和真实用户的手写风格有差距。真实场景里有人用圆珠笔在网格纸上写有人用马克笔在白板上写笔迹粗细、纸张背景都不一样。所以数据增强必须做得足够“狠”。我常用的增强手段包括随机旋转 ±5°模拟拍照视角倾斜。随机缩放和拉伸模拟不同拍摄距离。笔画膨胀/腐蚀模拟圆珠笔和马克笔的粗细差异。随机增加高斯噪声、椒盐噪声模拟低光照拍照的噪点。局部遮挡和裁剪模拟公式被手挡住一部分的极端情况。弹性形变模拟纸张不平整造成的扭曲。数据增强有一个坑容易忽略公式结构本身对形变敏感。旋转角度太大会被旋成∥上标和主体的层级关系可能丢失笔画膨胀太狠会变成一个实心团。增强参数不是越大越好需要通过小规模实验标定。我实践下来旋转 ±5°、弹性形变强度在 20 个像素以内是比较安全的区间。3.3 训练的工程细节模型结构选好后训练策略就成了主要矛盾。下面几个细节是我反复调参后沉淀下来的经验。字符表构建先把所有 LaTeX 标注映射成 token。公式中常见 token 包括数字、字母、运算符、希腊字母、结构命令如\frac、\sqrt、\sum以及特殊边界符号。字符表需要覆盖训练集和验证集的所有 token并预留unk、pad、sos、eos。我实际构建的字符表大概在 200 个 token 左右足以覆盖大部分中小学和大学数学公式。输入尺寸模型输入固定为单通道灰度图宽度和高度按比例调整后放入统一画布。我实测下来的推荐尺寸是 160×448即高度160、宽度448的长方形画布既保留公式横向展开的特点又不会让运算量失控。优化器与学习率优先选择 AdamW初始学习率 3e-4配合 warmup cosine 衰减策略。训练初期前 10% 的步数用于预热让梯度方向稳定下来之后按余弦曲线缓慢降低学习率。这个组合在注意力模型中效果很稳几乎没有因为学习率设置不当而训练崩溃的情况。损失函数与标签平滑解码器每一步输出对词汇表的概率分布用交叉熵损失。我习惯加上 0.1 的标签平滑它会把目标概率分布的“尖峰”稍微摊平抑制模型过度自信对最终公式准确率有稳定的正向收益。训练时的 teacher forcing公式序列是自回归生成的训练时如果用模型自己的预测结果作为下一步输入误差会逐步累积导致训练不稳定全部用真实标签又会让模型在推理时无法适应自己的错误。所以我采用 Scheduled Sampling 策略训练初期 100% 使用真实标签训练后期以一定概率混入模型自己的预测让模型逐步学会纠错。4. 解码、后处理与输出可靠性模型训练完之后还有一个经常被低估的环节解码和后处理。说实话我在这个环节吃了不少亏。模型概率输出最高的 LaTeX 序列未必是语法上合法的 LaTeX如果直接把模型预测交给用户用户渲染时很容易报错。4.1 Beam Search 的选择解码时可以直接贪心每一步选概率最大的 token但手写公式场景里贪心很容易走到岔路上。比如生成\frac之后模型需要紧跟一个分子表达式如果贪心在第 3 步选错了 token后面再怎么生成都是错的回不了头。Beam Search 的思路是维护多个候选序列每步扩展 top-k 个分支让“潜伏”的更优路径有机会后来居上。我实际使用的是 beam size 5在这个配置下识别准确率比贪心解码高不少速度损失又控制在可接受范围内。beam size 再往上加收益会迅速衰减反而会增加内存和解码耗时。解码时还要设置最大长度防止模型陷入“无限生成”。我用 max_length128正常情况下一个复杂积分公式 30~60 个 token 就能生成完。如果触底了优先返回当前分数最高的完整序列同时打一个警告标记提醒上层应用该结果可能不完整。4.2 后处理把模型输出变成合法 LaTeX模型输出后处理是我认为整个系统里最容易被忽视、实际又最能救命的部分。神经网络输出的 LaTeX 序列并不是天然合法的。举例来说模型可能生成\frac{ab缺了右花括号或者\sqrt{x也一样缺括号。如果不做修正用户复制到任何编辑器都会编译失败。我实现了一套轻量级的后处理规则按优先级处理花括号配对统计{和}数量缺失时在序列末尾按需补齐。命令参数补全\frac后面必须跟两个分组\sqrt后面必须跟一个分组。如果缺失用空分组{}占位避免 LaTeX 编译直接报错。括号粗匹配检查 LaTeX 中(、)、[、]的数量配对关系缺失时在末尾补齐。相邻命令合法性检查比如禁止两个\frac之间没有任何间隔导致解析歧义必要时插入{}分隔。下面是后处理逻辑的简化示意import re def fix_latex(seq): # 处理 frac 后缺花括号的情况 while seq.count(r\frac{) seq.count(r}): seq } # 处理 sqrt 后缺花括号的情况 while seq.count(r\sqrt{) seq.count(r}): seq } # 简单括号配对检查 open_cnt seq.count({) - seq.count(r\{) close_cnt seq.count(}) - seq.count(r\}) if open_cnt close_cnt: seq } * (open_cnt - close_cnt) return seq这一层后处理不需要做得很复杂它的目标是“让输出可编译”而不是“让输出语义完全正确”。语义正确性主要由模型和解码器负责后处理只兜底。5. 评估指标与典型错误分析模型做出来之后怎么科学地评估它很多人只看一眼“识别准不准”但公式识别领域本身有更细的评估维度。不清楚这些你很难判断一个模型到底是真的变强了还是只是换了个花样过拟合。5.1 用什么样的指标评估我维护了三套指标分别对应不同层面的目标公式级准确率ExpRate预测的 LaTeX 序列和真实标注完全一致的占比。这是最直观的硬指标但也很残酷——一个公式只要错了一个字符就算失败所以这个分数往往比你想象的低。编辑距离Edit Distance把预测序列转成真实序列需要的最少增删改次数除以真实序列长度。它能感知“部分正确”的程度适合训练过程中的早停判断。结构准确率更适合线下分析把 LaTeX 转成结构树比较分子的子树是否匹配、分母的子树是否匹配。这个指标过于繁琐我一般只在你需要定位错误来自字符识别还是结构解析时才用。我建议任何做这个项目的人至少同时报告 ExpRate 和字符级编辑距离。只看 ExpRate 会有种“模型挺差”的错觉只看编辑距离又容易掩盖系统性结构错误。5.2 我实测中遇到的失败模式在真实手写样本上测试时我总结了几类高频失败模式下面这张表可以直接拿来当项目验收清单失败模式典型场景主要原因改善方向上下标丢失手写上标和主体平齐上标与主体相对位置不明显增加上标/下标增强样本提高图像分辨率分数与除号混淆手写分数线的横线过短结构与上下文信息不足提高模型对分数结构的先验表达能力连笔字符粘连写ln时 n 和 l 相连笔画切分不充分强化数据增强中的弹性形变与笔画腐蚀公式区域裁剪过紧求和符号的上下限被裁掉预处理阶段裁剪边界失误扩大 padding留出足够边距少见希腊字母误认θ被识别成O或0数据分布中少见类别占比低在训练集中对少见字符做类别重采样我最开始遇到最多的是“上标丢失”和“分数与除号混淆”它们给我最大的教训是手写公式识别不仅要看字符本身的长相还要看字符与周围元素的空间关系。这也从侧面印证了为什么端到端模型比“切分 识别 规则解析”的路线更稳——它天然把空间上下文纳入建模过程。6. 工程落地时避开的几个大坑如果你只是想在实验室里跑通模型前面的内容已经够用。但项目一旦要上线还会有几个比较“隐性”的问题冒出来我专门整理出来这部分是项目从“能跑”到“能用”的关键。6.1 图像场景差异竞赛数据集里的图像大多是干净的扫描图背景单一没有透视变形。真实用户拿手机拍课堂笔记的时候页面往往有阴影、有网格线、有无关文字甚至还有手指遮挡。如果直接把这些图送进模型效果会明显下降。我的建议是在预处理层引入一个“公式区域定位 透视纠正”步骤。如果只是单公式截图用 OpenCV 的连通域分析就够了如果是一个页面里多道题建议用目标检测模型先做公式区域检测再把检测到的每个区域裁剪出来分别做识别。这一步相当于在全局页面和公式识别模型之间加了一个“视野聚焦”模块非常有效。另外网格纸几乎无处不在。网格线如果没有在二值化中去除会和公式中的加号、等号混在一起对公式的识别形成干扰。需要在形态学处理阶段过滤掉细线型的网格和空白行结构。6.2 部署选型与性能模型训练在 GPU 上不代表推理也必须在 GPU 上。公式识别任务对延迟的敏感度不算特别高如果是教学工具类场景一张图 300ms 以内的延迟是可接受的CPU 也能做。我建议优先导出 ONNX再用 ONNX Runtime 做推理。这样部署时可以不依赖 PyTorch 环境服务体积更小、启动更快在 CPU 上通常也比原生态 PyTorch 快一些。如果需要进一步提速比如上 GPU可以再考虑用 TensorRT 或指定 CUDA 执行提供程序。推理时还有一个便宜又好用的优化把图像一次性批量推理。如果用户上传的是整页公式一个页面里可能有 10 道题把 10 个公式区域拼成一个 batch 一次性推理比逐张推理节省好几次前向传播时间吞吐量提升非常明显。6.3 混合公式场景的处理很多实际需求不只是“识别一个独立公式”而是“识别页面里夹杂着文字、表格和公式的混合内容”。这类情况下公式识别不能孤立工作要把它嵌入到一个更大的文档解析流水线中。一种更简单的思路是先通过文档版面分析区分文本和公式区域文本区域交给普通OCR公式区域交给公式识别模型。如果整个项目完全用深度学习方法做可以考虑用一个通用的版面分析模型做公式检测再接入本系统的识别模块。如果你只想快速验证一个有公式识别能力的原型也可以先用现成工具兜底先跑通用OCR拿到整页文本再用正则或启发式识别出疑似数学表达式的片段最后用核心模型对片段重新识别。虽然不够优雅但能在有限资源下快速跑通链路验证业务可行性。收尾前再分享两句做完这套系统我最大的体会是公式识别项目里模型结构往往不是天花板数据和后处理才是决定上限的隐形因素。如果你拿着同一个 ResNetAttention 结构用不同的数据增强和后处理策略线上效果可以拉开很大差距这个差距往往比你换一个更大更花哨的网络更明显。实际动手时还有一个小技巧把失败样本周期性地拿出来看。不要只盯着平均指标每训练完一版模型挑 50 张错误的图一张一张看是错在哪里。有的是字符认错有的是结构解析错有的是上标漏了。这个习惯能帮你快速锁定数据增强的方向比盲目调模型结构高效得多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价