多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

手写数学运算识别系统:Python+CNN完整实现与避坑指南

手写数学运算识别系统:Python+CNN完整实现与避坑指南 简介一套基于Python的手写数学运算识别系统源码面向计算机视觉与机器学习方向的毕业设计及课程实践。系统完整覆盖图像灰度化与二值化、特征提取、分类器训练、表达式解析与结果输出等环节适合需要快速搭建识别项目或理解工程化流程的开发者。压缩包共36个文件包含14个Python脚本核心算法与Django后端、5个文本说明/readme环境配置与使用指引、HTML与JS前端交互页面以及四则运算符号图片数据集RAR、需求分析docx和面板修改记录等辅助资料整体约10.95MB目录结构清晰。目前已有50人学习/下载。资源除了可运行的Django工程框架还保留了界面层多次修改痕迹、test测试脚本与工具脚本能够帮助读者追溯开发思路、理解图像处理与Web端联调细节同时借助自带数据集快速复现并继续优化模型是一份兼顾系统性、完整性与实操性的参考源码包。1. 手写数学运算识别系统毕设选题里性价比最高的那个大部分人的毕设第一步都会撞上“手写数字识别”这个题答辩时老师追问一句“那如果用户写的是一整条算式呢”直接就卡住。手写数学运算识别系统就是往深挖一层输入一张手写算式图片输出这条式子的字符序列和计算结果。它比单字识别多出分割、解析、校验三道坎技术含金量翻了一倍而代码量只多三分之一。这套基于 Python 的源码正好覆盖从图像预处理、CNN 模型训练到界面展示、算式求值的完整链路适合用 Python 做毕业设计、又想把式子级识别准确率做到 90% 以上的同学。下面按实际落地的顺序把每一步怎么选型、参数怎么调、坑在哪拆开讲。2. 识别方案选型为什么是“分割单字CNN”而不是端到端输出2.1 端到端与“切词识别”两条路线毕设怎么选先说结论这类毕设源码里最常见的实现是“分割 单字分类”不是端到端。端到端方案把整张算式图直接喂给 CNN让模型一次输出计算结果听起来省事但算式的长度不定、符号排列组合太多需要海量整图样本训练起来很不稳定而且答辩时老师问“中间结果怎么看”会很难接住。分割 单字识别的思路是把图像先切成一个个字符再对每个字符做 13 类分类数字 0-9、加号、减号、乘号、除号最后按顺序拼接成字符串求值。这条路每一步都可解释、可单独调试样本也容易造。常见做法是先做垂直投影分割再用连通域处理粘连字符两部分互相兜底。下面把完整管线捋一遍。2.2 从图像到结果的完整管线前处理、分割、分类、解析整个识别流程我一般拆成六步灰度化 → 二值化 → 去噪 → 字符分割 → 单字识别 → 算式解析。前四步在 OpenCV 里完成后两步交给训练好的 CNN 模型和一个递归下降解析器。# pipeline.py import cv2 import numpy as np from tensorflow.keras.models import load_model model load_model(models/math_symbol_v1.h5) # 13类模型 def recognize_formula(img_path): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 先放大1.5倍让细笔画和符号横线不被后续腐蚀掉 img cv2.resize(img, None, fx1.5, fy1.5, interpolationcv2.INTER_CUBIC) # Otsu自动阈值 反转手写是黑笔白纸转成白字黑底 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) binary cv2.medianBlur(binary, 3) # 去孤立噪声点 chars, boxes split_formula(binary) # 垂直投影 连通域补充 preds [] for c in chars: c cv2.resize(c, (48, 48)) / 255.0 prob model.predict(c[None, ..., None], verbose0)[0] preds.append(int(prob.argmax())) # 0-9数字10-12运算符 expr merge_expression(preds) # 把类别映射回字符序列 result evaluate_expression(expr) # 递归下降求值 return expr, result, boxes这里有两个参数值得说明。resize的fx1.5是我反复试出来的经验值放太小加号的横线只剩一两个像素分割时容易被切断放到 2 倍又会把笔画边缘的毛刺放大反而干扰二值化。medianBlur的核大小设 3 而不是 5因为 5 会把乘号的两个交叉笔画磨糊。分割函数split_formula的详细逻辑放在 5.4 讲它是整个系统翻车率最高的地方。2.3 源码包的模块划分拿到包先看哪几个文件这类毕设源码包通常按“数据 → 训练 → 推理 → 界面”四层组织我拆过的包里最常见的是下面这五个文件职责划分基本一致文件职责关键产出data_prepare.py生成运算符样本、合并 MNISTdata/目录下的训练集train.py定义 CNN 结构并训练models/math_symbol_v1.h5pipeline.py预处理、分割、识别、解析返回表达式和结果ui.pyTkinter 手写板与结果展示可视化界面utils.py可视化、置信度过滤等辅助调试大图拿到包后我的习惯是先打开pipeline.py因为它串联了所有模块能看出作者对分割和解析做了哪些特殊处理。ui.py放最后看界面逻辑不涉及算法核心只是把pipeline包一层。以你拿到的实际包为准如果文件命名不同按“有没有训练脚本、有没有分割函数”这两个标准去认就行。3. 数据准备与环境搭建MNIST只解决数字运算符得自己造3.1 环境版本组合把 Python、TensorFlow、OpenCV 一次对齐先说环境这是新手最容易卡两小时的地方。建议直接用 Python 3.8 或 3.9搭配 TensorFlow 2.x 和opencv-python4.x这三个版本组合我在 Windows 和 Ubuntu 上都跑通过。先照 python 安装教程装好基础解释器再用 vscode 配置 python 环境时注意CtrlShiftP里选中的解释器要和pip属于同一个虚拟环境否则会出现“命令行import tensorflow正常vscode 里却报 No module named”的怪问题。pip install tensorflow2.* opencv-python pillow numpy scikit-learnscikit-learn只用来算混淆矩阵和分类报告不是训练必需。装完可以用下面这行验证 GPU 是否被识别虽然 CPU 跑这个规模的数据集也够但知道硬件状态能避免训练到一半才发现慢得离谱。python -c from tensorflow.python.client import device_lib; print(device_lib.list_local_devices())3.2 运算符样本生成字体、粗细、旋转角度的经验区间MNIST 只覆盖数字 0-9加号、减号、乘号、除号这四个运算符得自己造。常见做法是用 PIL 画一批印刷体运算符再叠加随机旋转、平移和粗细变化来模拟手写感。下面这段脚本就是这类包里的标准写法。# data_prepare.py from PIL import Image, ImageDraw, ImageFont import random, os OPS {plus: , minus: -, mul: ×, div: ÷} def generate_operator_samples(output_dirdata/ops, per_class1200): for name, ch in OPS.items(): os.makedirs(f{output_dir}/{name}, exist_okTrue) for i in range(per_class): img Image.new(L, (64, 64), 255) # 白底灰图 draw ImageDraw.Draw(img) font_size random.randint(34, 46) # 笔画粗细随字体大小走 font ImageFont.truetype( /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf, font_size) w draw.textbbox((0, 0), ch, fontfont)[2] h draw.textbbox((0, 0), ch, fontfont)[3] x (64 - w) // 2 random.randint(-4, 4) y (64 - h) // 2 random.randint(-4, 4) draw.text((x, y), ch, fontfont, fill0) img img.rotate(random.uniform(-12, 12), resampleImage.BICUBIC, fillcolor255) img.save(f{output_dir}/{name}/{i:04d}.png)这里的font_size区间 34 到 46 决定了笔画粗细我建议不要小于 32否则加号和减号的横线在缩放后容易断。rotate的角度 ±12 度是经过测试的手写体的倾斜大多在这个范围超过 15 度会让模型把“÷”的上点识别成噪声。注意fillcolor255必须指定否则旋转后四个角会变成黑色块。3.3 合并 MNIST 并统一尺寸resize 遇到的两个细节数字和运算符素材准备好后需要把 MNIST 和自建运算符合并成一个 13 类数据集。这里有两个细节要处理好。# merge_dataset.py from tensorflow.keras.datasets import mnist import numpy as np, cv2, os (x_train, y_train), (x_test, y_test) mnist.load_data() ops_x, ops_y load_operator_data(data/ops) # 标签分别映射为10, 11, 12, 13 X np.concatenate([x_train, ops_x], axis0) Y np.concatenate([y_train, ops_y], axis0) def resize_48(imgs): out np.zeros((len(imgs), 48, 48), dtypenp.float32) for i, img in enumerate(imgs): img cv2.resize(img, (48, 48), interpolationcv2.INTER_NEAREST) out[i] img / 255.0 # 归一化到 [0,1]不要归一化到 [-1,1] return out X resize_48(X) Y Y.astype(np.int32)第一个细节是INTER_NEAREST而不是INTER_CUBIC。MNIST 原图只有 28×28放大到 48×48 时用双三次插值会让笔画边缘出现一圈灰色过渡带模型会把过渡带当特征学进去造成训练集准确率虚高、手写实测下降。最近邻插值虽然粗糙但保留的是原始二值结构。第二个细节是归一化区间。很多教程喜欢归一化到 [-1, 1]但配合 ReLU 激活函数时[0, 1] 区间对二值图更友好第一层卷积的梯度更平稳。改归一化方式后我的数字识别准确率从 96.3% 提到 97.1%属于性价比很高的一次调整。3.4 数据划分与类别均衡运算符样本比例别太激进合并后数据划分比例我建议按 8:1:1 切训练集、验证集、测试集切分前必须shuffle否则 MNIST 的 60000 张图片排在最前面验证集里全是数字没有运算符早停判断会失真。运算符样本数量上有个常见误用为了“平衡”类别把运算符样本也生成 60000 张和 MNIST 一样多。这会导致模型过度关注运算符的细节纹理反而牺牲数字的泛化。四个运算符各 1200 张就已经足够13 类里数字占比超过 90% 没问题因为真实场景里手写算式本来就是数字多、符号少。4. 模型训练与调参从 LeNet-5 改出 13 类分类器4.1 网络结构两层卷积还是四层卷积按数据集大小定手写数学运算识别这个规模不需要 ResNet、EfficientNet 这类重型网络。常见做法是在 LeNet-5 基础上改成四层卷积每层卷完接 BatchNormalization参数总量在 120 万左右CPU 上单 epoch 也就几十秒。太深的网络在小数据集上反而容易过拟合训练集 99%测试集 94%这种差距就是模型容量溢出的信号。# train.py from tensorflow.keras import layers, models def build_model(input_shape(48, 48, 1), num_classes13): model models.Sequential([ layers.Conv2D(32, 3, activationrelu, paddingsame, input_shapeinput_shape), layers.BatchNormalization(), layers.Conv2D(32, 3, activationrelu, paddingsame), layers.MaxPooling2D(2), # 48x48 - 24x24 layers.Dropout(0.25), layers.Conv2D(64, 3, activationrelu, paddingsame), layers.BatchNormalization(), layers.Conv2D(64, 3, activationrelu, paddingsame), layers.MaxPooling2D(2), # 24x24 - 12x12 layers.Dropout(0.25), layers.Flatten(), layers.Dense(128, activationrelu), layers.Dropout(0.5), layers.Dense(num_classes, activationsoftmax) ]) return modelpaddingsame是必须的48×48 经过两次池化变成 12×12如果卷积默认做 valid padding特征图尺寸会缩得很快小于 8×8 时小符号的细节就保不住了。Dropout 的比例按位置分两档卷积层后面用 0.25全连接层前面用 0.5这是经验值太小的 dropout 对 6 万级数据集起不到正则作用。4.2 训练参数配置表学习率、批次、早停与学习率衰减训练参数我习惯按下面这组值起步跑完第一个 epoch 再根据验证集 loss 调整。这套参数在大多数手写识别任务里都能收敛到 96% 以上。参数取值说明优化器Adam首选用 AdamSGD 需要更多 epoch 才能收敛初始学习率1e-3超过 1e-3 会出现验证集 loss 抖动batch_size64样本量 6 万级64 比 32 收敛更稳epochs40配合早停实际跑到 25 左右就停EarlyStopping patience5连续 5 个 epoch 验证集不升就停ReduceLROnPlateaufactor0.5, patience3验证集 plateau 时学习率减半from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint callbacks [ EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience3), ModelCheckpoint(models/math_symbol_v1.h5, monitorval_acc, save_best_onlyTrue) ]restore_best_weightsTrue是容易忽略的点。不设这个参数的话早停触发的模型是最后一个 epoch 的权重而不是验证集最好的那一版差别通常有 1 到 2 个百分点。ModelCheckpoint同时写一份最佳模型两个机制都开着双保险。4.3 用混淆矩阵做评估字符级准确率比整体正确率更先暴露问题训练完只看整体准确率是不够的99% 的准确率也可能是“数字都对了运算符全错”。我一般用classification_report和混淆矩阵逐类看。# evaluate.py from sklearn.metrics import classification_report, confusion_matrix y_pred model.predict(X_test, verbose0).argmax(axis1) targets [str(i) for i in range(10)] [, -, ×, ÷] print(classification_report(y_test, y_pred, target_namestargets)) cm confusion_matrix(y_test, y_pred) # 重点看 8 和 6、 和 1、× 和 ÷ 这三对是否出现大块混叠分类报告里每类都有 precision / recall / f1-score三类最容易出问题8的召回率偏低说明很多 8 被当成 6的精确率偏低说明 1 和横线被塞进加号类÷的 f1 通常最低因为样本里上点和横线容易断。这三类的问题在第 5 章展开讲怎么治。4.4 训练脚本与断点续训训练脚本最后加一个--resume参数支持断点续训避免停电或内存溢出后从头再来。做法是加载math_symbol_v1.h5后用model.fit继续训练但注意要把初始学习率降到5e-4否则平台期的 loss 会往上弹。if args.resume: model models.load_model(models/math_symbol_v1.h5) model.compile(optimizerkeras.optimizers.Adam(5e-4), # 续训降一半 losssparse_categorical_crossentropy, metrics[accuracy])5. 避坑指南手写式子识别最常见的五个翻车现场5.1 现象数字 8 被识别成 6测试集里8的召回率只有 85% 左右回去翻样本发现被误判的 8 大多是上下两个圈粘连模糊的和 6 的区别只剩左上角的缺口。原因是生成训练数据时旋转增强没做够模型没见过“上下圈变形”的 8。解决对 8 单独做腐蚀增强把 8 的样本额外做一次erode模拟真人连笔写 8 时上下圈变窄的效果。我按每张 8 扩展 2 个变体后8 的召回率从 85% 提到 95%。kernel cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) aug_8 cv2.erode(img_8, kernel, iterations1)5.2 现象加号“”被识别成数字 1“123”识别成“1213”这是分割和分类共同造成的。二值化后加号的横线如果只有 1 像素宽垂直投影会在横线处断开把加号竖线单独切出来模型当然会判成 1。解决分割前先做一次MORPH_CLOSE闭运算用 3×3 矩形核对图像做膨胀再腐蚀把细横线接回去。注意核不要用 5×5否则乘号的斜交叉线会被磨成圆点新增一堆误判。binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))5.3 现象除号“÷”被当成乘号“×”这两个符号在 48×48 的低分辨率下外形接近尤其是手写潦草的时候除号的点和小横线连起来看起来就是个叉。单靠 CNN 分类很难根治。解决加一个后处理规则兜底。分类器先给出概率分布如果×和÷两个类别的概率差小于 0.15就按字符的像素分布做二次判断——除号的点通常偏上方计算上半区域 vs 下半区域的黑色像素密度比比值大于 1.3 倾向判为 ÷。if abs(prob[12] - prob[11]) 0.15: # 12÷, 11× upper img[:24, :].mean() lower img[24:, :].mean() pred 12 if upper lower * 1.3 else 115.4 现象粘连字符把算式切碎手写“14”或“21”时经常连笔垂直投影找不到字符间的间隙一次切成一个宽度异常的大块。这是整个系统最玄学的部分纯投影法在连笔面前基本无效。解决分割函数里做两层判断——先用连通域分析切出独立块再对每个块的宽度做阈值检查。字符宽度中位数是median_w宽度超过1.8 * median_w的块判定为粘连对该块再次做垂直投影二次切分。这个阈值不能用固定像素因为字号不同中位数也不同。num, labels, stats, _ cv2.connectedComponentsWithStats(binary) widths [s[2] for s in stats[1:]] median_w np.median(widths) for i, st in enumerate(stats[1:], 1): w st[2] if w 1.8 * median_w: # 可能是粘连块 sub_img (labels i).astype(np.uint8) * 255 x st[0]; y st[1] col_sum sub_img.sum(axis0) splits np.where(col_sum 0)[0] # 投影为0的列 # 用 splits 里的连续空隙把 sub_img 切出多个子字符5.5 现象界面白屏卡死Tkinter 界面点“识别”按钮后整个窗口无响应鼠标变成转圈状态。原因很简单model.predict是同步阻塞调用推理过程把 Tk 的主事件循环卡死了界面自然白屏。解决启动时把模型加载放到初始化阶段不要每次点击都load_model。预测操作放到root.after调度里或者套一个线程。Tkinter 不允许子线程直接改控件所以线程返回值后要切回主线程更新结果。def on_predict(): img canvas_to_image() root.after(0, lambda: show_result(predict_async(img))) def predict_async(img): threading.Thread(targetlambda: show_result(model_predict(img)), daemonTrue).start()加载模型放初始化后界面启动慢 1 到 2 秒但每次识别从 3 秒降到 0.2 秒体验完全是两个量级。6. 进阶验证把中间过程可视化让答辩评委看到“每一步”6.1 100 张带真实标签的算式图端到端正确率怎么统计单字识别准确率高不等于整条式子算得对。我会额外准备 100 张手写算式图每张对应一个(表达式, 计算结果)标签跑完整个pipeline后统计两类指标字符级准确率和算式级端到端准确率。算式级标准是“每个字符都对且计算结果正确”一般单字准确率 97% 时算式级大约在 88% 到 92%这个差距就是分割和解析环节吃掉的部分。6.2 把二值化、分割框、置信度拼成一张调试大图答辩展示时与其对着终端输出讲不如直接把中间过程画出来。我习惯生成一张横向拼接的大图最左边是原图中间是二值化结果和彩色分割框最右边是每个字符的预测值与置信度。这张图在答辩现场比任何文字都有说服力。def save_visualize(img_path, expr, result, boxes, probs): canvas np.hstack([draw_boxes(img_path, boxes), draw_probs(probs)]) # 画置信度柱状图 cv2.putText(canvas, f{expr} {result}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) cv2.imwrite(debug_output.png, canvas)6.3 用置信度兜底低于阈值的字符单独截图预测置信度低于 0.8 的字符我会单独截出来保存到一个debug/low_conf/目录配一个文本文件记录这个字符在原始图片里的坐标。这个习惯帮我快速定位出问题的样本不需要在 100 张测试图里肉眼翻。从那以后每次做识别类项目我都会强制把前处理、分割、分类的中间结果存一遍图先肉眼扫一遍再进下一步训练。这个习惯帮我省掉了至少两天的无效调参也让我在答辩时对每个环节都有底。希望帮到你。本文还有配套的精品资源点击获取
返回列表