基于深度学习的试卷手写笔迹擦除:从数据合成到ONNX部署全流程解析

📅 发布时间:2026/9/1 5:04:38
基于深度学习的试卷手写笔迹擦除:从数据合成到ONNX部署全流程解析
简介一套面向手写试卷智能擦除的Python深度学习工具集成BiSeNetV2、NAFA、SA-GAN等轻量级语义分割与图像修复模型可自动识别并擦除手写笔迹同时保留印刷体内容。资源以zip包形式发布共69个文件以45个py脚本为主另有shell脚本、readme、md说明文档和jpg示例图片压缩包仅472KB体量紧凑。py脚本完整覆盖训练、预测、评估、数据加载、损失函数及ONNX导出等工作流整体设计清晰开箱即用。随包提供详细项目说明文档涵盖环境配置、数据准备、训练命令、推理示例和常见问题解答各模块均已本地验证可运行。代码结构清晰、模块职责明确适合计算机、人工智能、电子信息等专业本科生毕设、课程设计或大作业也是深度学习图像修复方向的入门实战参考已有58人学习便于理解原理、调试修改或二次开发。 你有没有遇到过这种情况手头有一堆已经写满答案的旧试卷想还原成空白卷给孩子再练一遍或者给下一届学生用手动用PS一张张擦少说半小时起步擦快了还会把印刷体一并抹掉。我花了两周时间折腾了一个“手写试卷擦除工具”用Python训练了一个深度学习模型专门干这事并且把完整的训练、测试、ONNX转换部署全流程都跑通了。这篇文章就顺着这条链路把数据合成、模型选型、训练细节、效果评估、ONNX导出和跨语言部署一次讲清楚适合正在做图像翻译、图像修复类项目或者对模型落地部署感兴趣的读者参考。1. 为什么“擦掉手写”看着简单做起来却要动深度学习很多人的第一反应是不就是“去噪”吗用OpenCV二值化、找连通域、把深色笔迹去掉不就行了。真上手试过就知道这条路只在理想情况下成立。1.1 传统图像处理方案的边界在哪里我先用大津阈值做前景分割想把“深色笔迹”和“浅色纸张”分开。纯黑色签字笔在A4纸上效果还算可以但换成蓝色圆珠笔、红色批改笔、铅笔灰度直方图直接叠在一起阈值怎么调都会误伤。再用连通域分析把“手写块”单独挑出来结果印刷体文字本身也是一堆连通域尤其宋体、黑体的笔画结构跟手写汉字在外形上很接近规则根本没法写。形态学腐蚀膨胀也试过能磨掉浅色笔迹但代价是印刷体的细笔画也跟着变淡尤其小字号文字腐蚀一次就缺胳膊少腿。到这里我就确认了这不是一个纯图像处理问题而是需要对内容有“语义理解”的任务。模型必须知道哪些笔画是后加的手写体哪些是原始印刷结构这种判断靠手工特征做不到。1.2 目标检测修复路线为什么也不够干脆我又想过另一条路先用目标检测模型把“手写区域”框出来再对框内做图像修复。听起来挺顺但在试卷场景里有个致命问题手写答案经常和印刷体挨在一起甚至直接压在印刷体上。检测框一画框里既有手写又有印刷修复算法分不清谁该保留谁该擦除结果就是印刷体被“脑补”出一堆不存在的笔画文字结构被破坏。而且检测框的边缘还会留下明显的处理痕迹。说到底试卷擦除需要做到“像素级”的操作而不是“区域级”。检测只能告诉我们“大概在哪”但真正决定成败的是“哪些像素属于手写哪些属于印刷”。1.3 最终选型图像到图像的端到端翻译最后我选择的是图像到图像翻译方案等价于训练一个条件生成模型输入带手写的试卷图像直接输出干净的试卷图像。这种做法的好处是训练时不需要人为标注手写区域的像素级mask只要拿到成对的“脏图”和“干净图”模型自己会去学“去掉干扰项、保留结构项”的特征分离。这个过程可以类比成给照片加滤镜的反向操作。滤镜学会的是“把普通照片变成特定风格”我们的模型学的是“把混入笔迹的试卷还原成原始版面”任务本质上都是在像素空间里学习一个映射函数。2. 训练数据从哪来合成数据管线的设计深度学习模型训练最头疼的不是模型结构而是数据。试卷擦除这种任务没有现成的公开数据集网上也找不到大规模的“同一张卷子写前写后”的对比图。所以我把思路转向了合成数据自己造出大量成对的训练样本。2.1 成对数据的“免费标注”思路合成的核心思路很简单印刷体试卷底图本身就可以当作“干净图”也就是标注我在底图上随机叠加手写笔迹得到“脏图”。这样每一对样本的标签都是自动生成的完全不需要人工标注。具体来说印刷体底图我用了两个来源一是公开的扫描版试卷PDF渲染成图片二是用Word或LaTeX排版后转成图片。注意字体覆盖要广宋体、黑体、楷体都得有字号也要覆盖正文、标题、小字注释否则模型会对没见过的印刷字体非常敏感。手写笔迹我用了公开手写数据集比如CASIA-HWDB、IAM也自己手写了一批常用答题语句。笔迹图像要预先抠成透明背景的PNG合成时在底图的随机位置、随机角度、随机缩放进行叠加。叠加区域要重点覆盖试卷的答题空位比如横线填空区域、大题空白区域这样才符合真实场景。2.2 模拟真实拍摄与扫描噪声如果只用干净的电子版图像训练模型在真实场景里会直接崩掉。真实试卷来源不外乎两种扫描仪扫描和手机拍摄两者都带噪声。我加入了这几类增强亮度与对比度抖动模拟不同纸张材质和光线环境高斯模糊与运动模糊模拟拍摄时手抖或扫描对焦不准高斯噪声与椒盐噪声模拟传感器噪点和纸张颗粒感透视变换模拟手机从侧面拍试卷产生的畸变合成时我用PIL和numpy就能完成核心逻辑大概是import numpy as np from PIL import Image def synthesize(clean_img, handwriting_list): dirty clean_img.copy() for hw_img in handwriting_list: hw hw_img.rotate(np.random.uniform(-10, 10), expandTrue) hw hw.resize(scale_hw(hw, np.random.uniform(0.8, 1.2))) pos random_answer_position(clean_img) # 优先落在答题区 dirty.paste(hw, pos, maskhw.split()[-1]) return dirty2.3 数据合成中必须控制的几个比例这里有个非常容易踩的坑如果所有手写笔迹都老老实实写在空白位置模型学出来的效果就是“只擦空白处的手写”一旦碰到手写和印刷体重叠的情况就彻底失效。所以我在合成时专门提高过手写叠加在印刷体文字上面的概率大概有30%到40%的样本会让笔迹与印刷体产生重叠。另外一个比例是笔迹颜色分布。真实试卷里有黑色签字笔、蓝色圆珠笔、红色批改笔、铅笔如果训练数据里黑色占95%模型就会把蓝色笔迹当背景保留。我在合成时把颜色抖动范围拉大让笔迹颜色覆盖常见书写工具的输出范围尤其保证红色和蓝色占一定比例。测试时这个改动救了大命。3. 模型选型与训练细节U-Net变体、损失函数与调参经验数据管线搭好之后我开始试模型结构。坦白说一开始我用了pix2pix那种带GAN的完整框架后来发现训练不稳定收敛也慢最后换成了“U-Net为主、损失组合里加适量约束”的方案效果反而更稳。3.1 网络结构的选择深浅之间的取舍我最终采用的是带注意力机制的U-Net变体在编码器的中间层加入CBAM注意力模块。CBAM会同时关注通道维度和空间维度的重要特征对“手写区域”这种局部干扰的定位很有帮助。整个模型参数量不大512x512输入批量大小为8时在一张12GB显存的卡上能跑动。为什么不直接上更复杂的生成模型因为试卷擦除任务对细节保留的要求很高模型太深反而容易丢失高频信息印刷体的小字号文字笔画会被“光滑”掉。U-Net天然的跳连接结构保证了低层细节能够直接传递到输出层这是它在这个任务上表现稳定的核心原因。3.2 损失函数组合为什么只用L1会发虚只使用L1损失训练出来的模型输出图像会偏模糊因为L1在像素平均意义下是最优的但它不关心“笔画是否锐利”。我测试过几组损失组合的差异整理成一张表方便对比损失组合训练速度主观效果典型问题仅L1快发虚、边缘糊印刷体细节丢失L1 SSIM中结构保持好一些仍偏软L1 SSIM VGG感知损失中感知真实感大幅提升训练时间长一点上述组合 GAN损失慢最锐利但最不稳定模式崩溃风险高我最终用的是L1 0.1倍SSIM损失 0.05倍VGG感知损失没有加GAN。感知损失用VGG16的relu1_2、relu2_2、relu3_3三层特征做对比这样模型会在“人眼看得舒服”的语义层面去约束输出而不是单纯逐像素对比。3.3 训练配置与显存、速度的平衡训练参数我直接给出可复现的一套输入图像裁剪为512x512Adam优化器初始学习率2e-4batch size为8训练100个epoch。每个epoch随机抽取训练子集在验证集上监控SSIM和L1验证loss连续8个epoch不下降就早停保存最佳模型。训练时间在单张RTX 3090上大约是9到10个小时。如果你的显存小可以把输入裁剪降到384x384batch size降到4但我不建议低于256x256因为手写笔画的细节在低分辨率下会彻底丢失模型学出来会有种“雾里看花”的模糊感。推理阶段大尺寸试卷图不能直接整张喂进模型。我的做法是分块推理把扫描件切成512x512的tile边缘重叠16像素预测完再拼回去重叠区域取平均这样可以避免拼接缝。实测一张4096x3072的试卷图用CPU跑大概4秒GPU不到1秒。4. 测试评估比PSNR更重要的“清除率”与“保真度”模型训练完很多人习惯只看PSNR和SSIM我一开始也是这样。后来发现这两个指标跟实际体验存在明显偏差比如一张图PSNR很高但手写笔迹还残留清清楚楚的一小片算出来的数值根本不会反映这个问题。4.1 客观指标之外的人工抽检维度我后来搭建了一套更偏“实用主义”的测试流程对每张测试图从三个维度打分手写清除率、印刷体保真度、背景干净度。清除率看的是手写笔迹有没有留痕保真度看的是印刷体笔画有没有变淡、变粗、断线背景干净度看的是纸张纹理有没有被磨平或者出现伪影。人工抽检的比例不需要100%但场景要覆盖全黑色笔迹卷、蓝色笔迹卷、红色批改卷、铅笔卷、手写压印刷体重叠卷、手机拍摄畸变卷。每个场景抽20张三个维度分别评分综合下来比单一PSNR靠谱得多。4.2 最容易翻车的三个场景与针对性解决第一个坑是手写与印刷体重叠合成数据里重叠比例低于20%时模型在重叠区域会产生“抠图残影”印刷体部分笔画被误擦。把重叠样本比例提到30%到40%之后这个现象明显改善。第二个坑是铅笔笔迹。铅笔灰度值跟纸张背景很接近模型训练初期经常把铅笔字整体忽略。我的解决办法是在合成数据里加入“低对比度笔迹”增强把笔迹的alpha和颜色都调淡一档强迫模型学会识别这种微弱信号。第三个坑是红色批改笔和蓝色圆珠笔混合出现在同一张试卷上。模型对单个颜色学习得不错但混合在一起时偶尔会把红色笔迹保留、蓝色擦掉。排查后发现是合成数据里混合颜色的样本太少比例调到15%左右就稳定了。4.3 一个漏网现场的完整排查记录有一个案例我印象特别深测试图是一张手机拍摄的语文卷模型输出后其他区域都正常唯独作文格区域的印刷横线被擦出了明显的“断口”。我当时以为是模型问题后来排查发现是拍摄角度导致的透视畸变让横线区域在分块边缘产生了错位。模型本身没有错是预处理环节缺了透视校正。这让我意识到部署阶段的前处理跟模型训练一样重要。我后来在推理管线里加了方向校正和透视校正试卷图先检测页面边界做对齐再送入模型断口问题就不再出现了。5. 从PyTorch到ONNX导出、量化与跨语言部署模型训练完成只是第一步真正要落地给老师、家长用不能要求每个人都装PyTorch和显卡。我选择把模型导出为ONNX用ONNX Runtime做推理这样Python、C、甚至Web端都能调用还能做int8量化压缩。5.1 导出时的算子与动态轴陷阱PyTorch模型转ONNX首先把模型切到eval模式关掉梯度。我用的是opset_version13这个版本对U-Net里常用的卷积、上采样、注意力算子支持都比较成熟。opset版本不是越高越好太新的算子在某些旧版ONNX Runtime里反而跑不起来。动态轴这块要特别留意。试卷图片尺寸不固定我设置了batch、height、width三个维度为动态导出命令长这样torch.onnx.export( model, dummy_input, eraser.onnx, opset_version13, input_names[input], output_names[output], dynamic_axes{ input: {0: batch, 2: height, 3: width}, output: {0: batch, 2: height, 3: width} } )导出后用onnx.checker.check_model验证一遍再用ONNX Runtime跑一次对比PyTorch输出与ONNX输出的最大绝对误差误差在1e-5级别就算正常。如果误差过大优先检查模型里有没有用GridSample这类算子它经常是数值不一致的元凶。5.2 静态量化与校准数据量化是用力最猛的一步。我试过动态量化但U-Net里大量卷积操作用动态量化收益极小速度基本没变化。真正有效的是静态量化需要准备一份校准数据集在导出后重新跑一遍前向统计激活值范围。我用的是ONNX Runtime的量化工具from onnxruntime.quantization import quantize_static, QuantType, CalibrationDataReader quantize_static( eraser.onnx, eraser_int8.onnx, calibration_data_readercalibration_reader, weight_typeQuantType.QInt8 )int8量化后模型体积从120MB降到30MB左右CPU推理速度提升大约2到3倍。但要注意量化对细线条笔画的精度有损伤印刷体的细笔画偶尔会被“磨平”建议量化后用同一套测试集重新评估一遍清除率和保真度如果损失不可接受可以退回FP16精度或者用per-channel量化。5.3 Python与C侧调用ONNX Runtime部署侧我做了两个版本。Python版本直接调用onnxruntime库核心代码很短import onnxruntime as ort sess ort.InferenceSession(eraser_int8.onnx, providers[CPUExecutionProvider]) input_data preprocess(image) # 归一化、转CHW、加batch维 output sess.run([output], {input: input_data})[0]C版本是给一个桌面小工具用的核心逻辑也差不多Ort::Session session(env, Leraser_int8.onnx, session_options); std::vectorint64_t input_shape {1, 3, height, width}; Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); auto output_tensor session.Run(Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), 1);这里最容易出错的是归一化参数。训练时如果用的是ImageNet的mean和std推理时也必须原样使用。我见过很多次把0到1归一化和0到255归一化混用的情况模型输出直接变成全黑或全灰其实不是模型问题是预处理没对齐。实测下来桌面端CPU单线程跑一张4096x3072的试卷图分块推理大约需要4秒。换成int8量化模型后能压到3秒以内如果部署环境有支持VNNI的现代CPU还能更快。GPU环境下用CUDAExecutionProvider一张图稳定在0.5秒量级。最后再分享一个小经验不管是用PyTorch训练还是ONNX部署都不要把试卷图像压缩到256x256以下再送进模型那会让手写笔画的细枝末节全部消失模型再强也学不出“擦除手写但保留印刷体”这种精细操作。我自己刚开始图省事统一resize到224x224结果测试集指标一直上不去后来改成训练时随机裁剪512x512、推理时分块处理效果立刻上了一个台阶。工具做完后我把整个流程沉淀成了一个命令行工具加一个简单的GUI封装老师拿到手只需要选文件夹、点一下“批量处理”就能把整本旧试卷恢复成干净版本。真正让我觉得这个项目值得做的时刻是看到同一位老师把同一张卷子翻来覆去用了五遍。本文还有配套的精品资源点击获取