尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

手写数学运算识别系统:Python源码实现与工程落地方案

手写数学运算识别系统:Python源码实现与工程落地方案 简介这是一份基于Python的手写数学运算识别系统源码包面向毕业设计及计算机视觉、机器学习初学者。项目完整覆盖图像灰度化、二值化、去噪等预处理流程并结合HOG特征提取、SVM/随机森林/CNN分类器训练以及数学表达式解析与结果输出可辅助教育科研领域实现手写公式自动录入。压缩包共36个文件大小约10.95MB其中14个py脚本承担核心算法与Django逻辑5个txt为配置说明3个html对应前端界面另有readme文档、测试脚本、js交互文件、需求分析Word及数据集压缩包目录中保留基础面板至第三次修改等迭代版本便于理解开发过程。已有50人学习下载。通过源码包可获取完整的项目结构、模型训练与部署思路、界面交互设计以及四则运算图片数据集尤其适合作为课程设计或毕业设计的参考基座在此基础上可快速完成功能验证与性能优化。1. 手写数学运算识别系统一套 Python 源码解决的是「把算式变成可计算文本」上课拍老师板书、考试答题卡自动录题、学生把演算纸拍照存档这些场景里最麻烦的都不是「识别一个数字」而是把一整行手写数学运算式子读出来并且算出一个可验证的结果。手写数学运算识别系统就是干这件事的它先检测纸面上的手写字符再识别数字和运算符号最后拼成类似123×(5-1)的字符串并完成求值。多数以源码.zip形式分发的项目本质是把这条链路里的训练数据、预处理、模型和组装逻辑打包给你让你在本地跑通并改成自己的工具。这套东西很适合三类人正在做课程设计或期末项目、需要一个人工智能方向作品的学生想把手写识别技术接进作业批改或录题系统的一线工程师以及想用 Python 练手、但不想从零搭数据管线的入门者。本文我会顺着「数据怎么准备 → 模型怎么选 → 式子怎么组装 → 会踩哪些坑 → 怎么落地成命令」的顺序把一份典型源码包背后真正值钱的部分讲清楚。2. 数据和预处理手写识别先过数据关再谈模型2.1 数据集怎么选MNIST 只是热身不是方案绝大多数这类源码包训练集都脱胎于 MNIST 或其变体。MNIST 有 6 万张 28×28 的手写数字质量高、量够大但它只有0-9十类没有 - × ÷ ( ) 也没有小数点。你的识别系统如果要做「数学运算」字符集至少要扩到 14 类以上0-9十个数字加号、减号、乘号、除号、左括号、右括号、等号、小数点。有的简化版本只做两位数的四则运算符号类就少很多但项目一旦要多位数、带括号字符集天然要膨胀。选择数据时我一般建议分三个层次直接用 MNIST 做数字底子用开源的扩展手写符号数据补运算符再用自己的笔迹拍几十张补真实分布。公开的扩展数据集能补足 - × ÷这类符号但乘号和x、除号和的混淆问题仍然要靠你自己的样本去解决。如果你的场景不是白纸黑字而是手机拍的作业本、带格子的演算纸那光靠 MNIST 训练出来的模型基本必翻车这一点后面会展开。2.2 把一张纸变成模型能吃的张量预处理 Python 实现手写数学式子识别里预处理承担三件事把彩色纸面变成干净的二值图、把单个字符从纸面抠出来、统一尺寸后喂给神经网络。下面这段是我常用的单字符预处理函数它假设你已经用连通域或投影法把字符块切出来了。import cv2 import numpy as np def load_char_image(path, target_size64): img cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError(f读不到图片: {path}) # 白纸黑字 - 黑底白字让输入分布贴近 MNIST 风格 img 255 - img # 自适应二值化blockSize 必须为奇数C 控制过滤强度 binary cv2.adaptiveThreshold( img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10 ) # 连通域去噪面积小于 50 的连通块直接丢弃 n, labels, stats, _ cv2.connectedComponentsWithStats(binary, 8) clean np.zeros_like(binary) for i in range(1, n): if stats[i, cv2.CC_STAT_AREA] 50: clean[labels i] 255 # 抠出字符外接框长边缩放到 56短边等比缩放后居中 ys, xs np.where(clean 255) if len(ys) 0: return np.zeros((target_size, target_size), dtypenp.float32) x0, x1, y0, y1 xs.min(), xs.max(), ys.min(), ys.max() ch clean[y0:y1 1, x0:x1 1] h, w ch.shape scale 56.0 / max(h, w) ch cv2.resize(ch, (max(1, int(w * scale)), max(1, int(h * scale))), interpolationcv2.INTER_AREA) canvas np.zeros((target_size, target_size), dtypenp.uint8) y_off (target_size - ch.shape[0]) // 2 x_off (target_size - ch.shape[1]) // 2 canvas[y_off:y_off ch.shape[0], x_off:x_off ch.shape[1]] ch return canvas.astype(np.float32) / 255.0这段代码里有几个参数值得说明。adaptiveThreshold里的31是 blockSize表示按 31×31 的局部区域计算阈值适合纸面光照不均的情况C10是给阈值减去的常量C 越大越不容易把浅色笔迹滤掉但噪点也更容易留下。connectedComponentsWithStats按连通域过滤是比单纯做形态学开运算更稳的做法因为 50 这个面积阈值可以按你的实际图像分辨率去调。最后resize时长边缩放到 56、再贴到 64×64 画布上是为了保持字符长宽比直接把一个又长又扁的除号拉伸成正方形模型很容易认错。2.3 数据增强手写体的问题是「变形」不是「平移」手写字符的差异主要来自笔迹形变同一个数字 7有人写的时候横斜 10 度有人竖笔带钩这些差异靠平移没法模拟。训练时做增强我一般限制在随机旋转 ±10 度、缩放 0.91.1、平移 ±4 像素偶尔加入极轻的弹性变形。import random from scipy.ndimage import rotate, shift def augment_char(img): # 旋转超过 ±15 度会把 7 和 1、6 和 0 推到彼此的地盘 angle random.uniform(-10, 10) img rotate(img, angle, reshapeFalse, modeconstant) # 平移模拟书写位置不居中 dx, dy random.randint(-4, 4), random.randint(-4, 4) img shift(img, shift(dx, dy), modeconstant) return img很多人做手写增强时习惯把旋转角度推到 ±30 度这在 MNIST 上可能还能看但放到真实手写数学式子里7 和 1、6 和 0 的边界会直接被打破。经验值是旋转不要超过 ±12 度缩放不要低于 0.85因为真实手写不会出现那么夸张的形变。增强的正确姿势是「小幅高频」角度小但每张图都做比偶尔来一张大角度旋转效果好得多。3. 模型选型的岔路口单字分类和序列识别怎么选3.1 两条技术路线的真实边界目前这类源码包基本分成两派。第一派是「先切分再单字分类」把一行式子从物理上切成一串独立字符然后用 CNN 之类的分类器逐个识别最后按坐标拼回字符串。第二派是「整行识别」把一行公式直接做图像到文本的映射主流结构是 CNN 提特征 RNN 建模序列 CTC 对齐也就是常说的 CRNN 或 OCR 路线。选哪条取决于你的式子有多「规整」。如果只是1234、45÷5这种固定结构的两位四则运算切分加分类完全够用模型小、训练快、可解释性强。一旦出现括号嵌套、分数、根号、多位数字连笔字符间的依赖关系就变强了这时候硬切分会在两个地方崩溃一是连笔字符被从中间切断二是(和)这种成对符号没有上下文约束。我在实际项目里的判断标准很简单字符数固定且少于 8 个用单字分类式子结构多变或字符可能粘连直接上序列识别。维度单字分类 切分CNN LSTM CTC 整行识别训练数据需要每个字符的独立标签只需要整行公式的文本标签模型复杂度小CPU 也能训练大建议 GPU对连笔的容忍度低切分错一切错高CTC 能自动对齐可调试性高能看到每个字符的置信度低输出是一整串适合场景固定格式四则运算、课程设计自由手写公式、复杂版面3.2 单字分类路线的可复现代码PyTorch 实现如果你拿到的源码包走单字分类路线模型大概率长这样几层卷积提取特征接全连接分类。下面这个网络是 64×64 输入下的一个稳妥结构字符集 14 类时精度足够。import torch import torch.nn as nn class HandCharNet(nn.Module): def __init__(self, num_classes14): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 64 - 32 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 32 - 16 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2) # 16 - 8 ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128 * 8 * 8, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)训练参数也有讲究。优化器我习惯用 Adam初始学习率1e-3配合weight_decay1e-4做轻量正则。学习率调度用 StepLR每 10 个 epoch 衰减一半训练 30 个 epoch 左右。BatchNorm2d对手写黑白字符特别有用因为它把每层输出拉回标准分布避免了手写字符灰度差异大导致的内部协变量偏移。Dropout 两处共 0.5 和 0.3是防止训练集只有一两万张时直接过拟合的关键。训练时最容易忽略的是类别不均衡特别是运算符样本。一个算式里数字出现的频率远高于除号如果你在数据准备阶段不控制比例最后的模型会对数字过拟合除号被识别成加号是常见结果。我一般把运算符样本比例压到数字的 1/3 左右靠重采样控制。3.3 整行识别路线什么时候必须换这条路一旦式子变成12×(34)÷5或者手写的人习惯把多个字符连着写切分这条路就走不稳了。这时候常见做法是上 CRNNCNN 提取图像特征把特征图的高度压成 1宽度当作时间步喂给双向 LSTM最后接 CTC 损失。import torch.nn as nn class CrnnNet(nn.Module): def __init__(self, cnn, num_chars, hidden128): super().__init__() self.cnn cnn # 复用上面的特征提取部分去掉全连接 self.lstm nn.LSTM(512, hidden, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(hidden * 2, num_chars 1) # 1 是 CTC 的 blank def forward(self, x): # x: (B, 1, H, W)H 是整行公式的高度 x self.cnn(x) # (B, C, H, W) b, c, h, w x.shape x x.view(b, c * h, w) # 压高度宽度当时间步 x x.permute(0, 2, 1) # (B, T, C) x, _ self.lstm(x) return self.fc(x) # (B, T, num_chars 1)CTC 是这个方案的灵魂。它不要求训练时每一个字符框精确对齐只要求「整行图」和「整行文本」配对模型自己学习字符出现在哪些时间步。推理时用贪心解码取每个时间步概率最大的字符、去掉重复和 blank或者用 beam search 拿到整串概率更高的结果。这条路要付出的代价是训练数据变成了「整行图片 整行字符串」不再需要逐字符标注但你需要一个能生成整行公式图的工具来造训练样本。如果你拿到的源码包宣称能识别任意手写数学公式里面大概率是这条路线。4. 组装算式从字符坐标到安全求值的 Python 实现4.1 把识别出的零散字符按坐标拼回字符串单字分类路线的输出是一堆(字符, 坐标框, 置信度)要把它们变成123×5这样的可计算字符串前提是排序正确。这里有个细节直接按 x 坐标排序会把同一行里上下结构的字符排错位置比如除线的分子分母先按 y 聚类成行、再在行内按 x 排序才是稳妥顺序。def boxes_to_text(boxes, id2char, line_height40): # boxes: list of (x_center, y_center, width, height, class_id, prob) # 第一步按 y 中心聚类成行 boxes.sort(keylambda b: (b[1], b[0])) lines [] cur_line [] last_y None for box in boxes: if last_y is None or abs(box[1] - last_y) line_height: cur_line.append(box) else: lines.append(cur_line) cur_line [box] last_y box[1] if cur_line: lines.append(cur_line) texts [] for line in lines: line.sort(keylambda b: b[0]) # 行内按 x 排序 texts.append(.join(id2char[b[4]] for b in line)) return \n.join(texts)line_height是聚类阈值取值取决于你预处理时字符缩放的比例。如果字符长边统一缩放到 56行高差 40 像素以内都算同一行阈值设大了会把上下两行式子并成一行设小了会把一行式子拆成两段。实际调试时我会把这个参数暴露成命令行选项因为不同拍摄角度下同一行式子 y 坐标的抖动范围完全不同。4.2 运算符消歧为什么 x 总被识别成乘号1 总被识别成 /手写数学式子里最容易认错的三对是乘号和字母x、减号和长横线、数字 1 和斜杠/。单纯靠图像模型很难彻底解决更可靠的做法是在模型输出后加一层上下文规则。下面这段是我常用的消歧后处理。import re def disambiguate(s: str) - str: # 统一运算符写法 s s.replace(×, *).replace(X, *).replace(x, *) s s.replace(÷, /).replace(, -) # x 在左右都是数字或括号时几乎不可能是字母保留乘号 s re.sub(r(\d|\*|\*)\s*\*\s*(\d|\(), r\1*\2, s) # 孤立的 / 在数字之间大概率是除号在开头或结尾可能是小数点和分数斜杠 s re.sub(r(\d)/(\d), r\1/\2, s) # 把连续减号压缩成一个避免手写长横线被拆成多条 s re.sub(r-{2,}, -, s) return s这里最关键的认知是不要指望模型输出一步到位。手写字符天然模糊模型输出的是一个概率分布而概率最高的那个字符在上下文里可能根本说不通。后处理规则的本质是牺牲局部灵活性、换取整体可读性。如果你拿到的源码包里没有这么一层规则识别精度会明显偏低这也是判断一个源码包是否成熟的最快方式。4.3 识别完还要求值为什么不能直接 eval式子识别出来不等于系统完成你的目标是算出结果。很多初学者会把识别出的字符串直接丢给eval(123*5)这在本地可以但只要这个系统会被别人调用eval就是一个灾难任何可执行 Python 代码都可能被打进字符串里。安全且实用的做法是用ast模块把表达式解析成语法树只放行你认可的节点类型。import ast import operator _ALLOWED_OPERATORS { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.UAdd: operator.pos, ast.USub: operator.neg, } def safe_eval(expr: str): tree ast.parse(expr, modeeval) for node in ast.walk(tree): if isinstance(node, ast.Expression): continue if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): continue if type(node) in _ALLOWED_OPERATORS: continue raise ValueError(f表达式包含未允许的语法: {type(node).__name__}) code compile(tree, expr, eval) return eval(code, {__builtins__: {}}, {})safe_eval里只允许数字常量、四则运算符、正负号括号由语法树自然支持。eval时的全局命名空间被清成空字典本地命名空间也是空的业务代码里的函数和变量都不会暴露给表达式。这样处理后即使识别结果里混入了奇怪的字符最多报错不会造成安全问题。手写识别系统面向的是普通用户输入不可控这个防护不是锦上添花而是底线。5. 避坑手写数学运算识别最常见的翻车现场与排查5.1 训练准确率很高一到真实纸面就崩数据域断层现象模型在训练集上准确率 98%但拿着手机对着作业本拍一张识别结果惨不忍睹。原因这是典型的「数据域断层」。MNIST 字符是规整的、居中的、28×28 的而真实手写有纸纹、有笔迹深浅、有倾斜、有与格子线的重叠。模型学到的是「标准手写」的特征不是「你的用户的手写」的特征。很多源码包标注的精度都是在实验室数据上测的换个数据域立刻现出原形。解决把真实拍摄的图像按 7:3 的比例切出一部分加入训练集另一部分只做测试。如果项目刚起步没有真实数据先把预处理加大强度光照不均用自适应阈值、纸纹用形态学开运算滤掉。经验上加入 100 张真实手机拍摄样本并做增强就足以让模型迁移到一个可用状态。5.2 解压依赖报了 ModuleNotFoundError环境版本玄学现象按照源码包里的说明跑python train.py报ModuleNotFoundError: No module named torch或者装上 torch 之后又报No module named cv2。原因源码包通常以zip形式分发里面可能有requirements.txt但不会替你准备 Python 环境。常见坑是当前环境里 Python 3.11/3.12 与旧版 torch 不兼容或者cv2的 pip 包名其实是opencv-python。解决不要直接往系统 Python 里装东西先建虚拟环境。python -m venv .venv激活后pip install -r requirements.txt。如果 requirements 里没锁版本就手动装当前 Python 版本对应的torch--cpu和opencv-python再跑。我的血泪经验是拿到任何源码压缩包第一件事不是解压后双击运行而是先看运行入口文件里 import 了哪些库缺哪个装哪个。5.3 除号认成加号、等号认成减号运算符样本太少现象对数字的识别很准但÷经常成经常成-。原因训练集里运算符和数字的样本比例严重失衡。一个算式里数字出现几十次除号可能只出现两次网络为了压低整体 loss会把所有模棱两可的样本都猜成高频类别。另一个隐蔽原因是字符集类别的 id 映射表写错了比如训练时÷的标签是 11推理脚本里11却对应着。解决先检查id2char和char2id是否一一对应且顺序一致这一步 5 分钟就能排除。排除后如果还是混淆就把运算符样本做重采样让每个运算符的训练样本量达到数字样本量的三分之一以上。同时观察取样的错例如果÷总被认成说明训练集里除号的写法太单一需要补充真实手写的斜线式除号样本。5.4 切分把连笔字符从中间切断单字分类路线的天花板现象手写的12两个数字连笔切分算法把整个连通域当成了一个字符或者从中间切开导致1和2都缺一半下游分类完全错乱。原因基于连通域或投影的切分算法前提是字符之间有物理间隙。连笔书写把字符变成一个连通域投影在 x 轴上没有低谷切分就失效了。这是单字分类路线结构性的缺陷不是调参能解决的。解决两招。第一招是做「过度切分」对大连通域按列投影的局部极小值点做候选切分把候选片段送进分类器再用分类器输出的综合概率挑选最合理的切分路径。第二招是务实一点直接对这类数据切换整行识别路线让 CTC 自己学字符边界。我一般在项目需求里看到「手写随意、可能连笔」的字眼就会放弃单字分类方案。6. 让源码包真正能演示命令行封装与模型落地的验证技巧拿到一份源码包把它跑通最好的方式不是马上打开 Jupyter而是封装成一个命令行工具。这样你随时可以用一张新照片验证效果也可以把它接进未来的服务里。import argparse import torch from preprocess import load_char_image from model import HandCharNet def main(): parser argparse.ArgumentParser(description手写数学式子识别) parser.add_argument(--image, requiredTrue, help输入图片路径) parser.add_argument(--checkpoint, defaultbest.pth, help模型权重路径) parser.add_argument(--threshold, typefloat, default0.7, help置信度阈值) args parser.parse_args() model HandCharNet(num_classes14) model.load_state_dict(torch.load(args.checkpoint, map_locationcpu)) model.eval() img load_char_image(args.image) # 64x64 归一化张量 with torch.no_grad(): logits model(torch.tensor(img).unsqueeze(0).unsqueeze(0)) prob torch.softmax(logits, dim1) conf, cls_id torch.max(prob, dim1) if conf.item() args.threshold: print(confidence below threshold, request manual review) else: print(frecognized: {id2char[cls_id.item()]}, confidence: {conf.item():.3f}) if __name__ __main__: main()这个命令行工具的价值在于把「模型调参」和「系统集成」分开。threshold参数是我建议你保留的实际使用中宁可识别失败也不要强算一句「无法识别请人工确认」比算出一个错误结果更体面。更进一步的落地技巧是把模型导出成 ONNX脱离 PyTorch 环境部署torch.onnx.export(model, dummy_input, hand_net.onnx, opset_version11)之后在纯 Python 或者别的语言环境里用 ONNX Runtime 加载推理速度比原版快不少。验证时不要只看单字准确率。我自己的习惯是拿十张真实纸面照片做端到端测试图片进去式子出来人工核对字符串和计算结果。单字准确率 99% 听上去很好但如果一张式子里有 10 个字符单字 99% 意味着整式正确率只有 90% 左右。所以要盯的是「整式正确率」和「求值正确率」两个指标。我现在拿到任何手写识别源码包第一件事就是翻它的字符集定义和评测口径这两样东西决定了它到底能跑多远。希望帮到你。本文还有配套的精品资源点击获取
返回列表