简介:这套手写试卷擦除工具是一个基于Python与深度学习的开源项目,集成BiSeNetV2、NAFA、SA-GAN等轻量级语义分割和图像修复模型,可自动识别并擦除试卷上的手写笔迹,同时保留印刷体内容,所有模块均已本地验证可运行,支持自定义试卷图像输入。项目涵盖模型训练、推理预测、评估测试、数据加载、损失函数、工具函数以及ONNX导出等完整流程,代码结构清晰,模块职责明确,便于理解原理与二次开发。压缩包共69个文件,包含45个Python脚本、6个Shell脚本、多个说明与项目文档,大小约472KB,其中Python脚本覆盖训练、推理、测试与ONNX转换,Shell脚本提供一键运行入口,说明文档详解环境配置、数据准备、训练命令、推理示例及常见问题解答。已有58人学习下载,适合计算机、人工智能、电子信息等专业本科生毕业设计、课程设计或大作业,也适合深度学习图像修复方向的入门实战,可在此基础上替换主干网络、接入新数据集或优化擦除边缘效果。
1. 项目背景与核心思路拆解
1.1 为什么要做"手写试卷擦除"这个工具
先说说这个项目到底解决什么问题。教学场景里有个特别常见的需求:老师手里有一套好题,但已经被学生写过了,想再打印出来给下一届用,或者想把空白的卷子电子化归档。手动PS擦除太慢,而且容易把印刷体也擦花。更麻烦的是,试卷扫描件里手写墨迹的颜色深浅、笔迹粗细、覆盖角度各不相同,用传统的图像处理算法——比如阈值分割加inpainting——做出来效果普遍不理想,手写痕迹一重就残留。
这个项目的核心思路,是把"擦除手写"当成一个图像翻译任务来解:输入一张含手写的试卷图,输出一张干净的空白卷。本质上和超分辨率、去雨、去雾是同一类问题,属于像素到像素的深度学习任务。用深度学习模型来做,优势在于它不是靠某个固定阈值去判断"哪里是手写",而是学出了"空卷长什么样"的分布,哪怕手写压住了印刷体的一部分,也能把底下的印刷结构合理重建出来。
我选用PyTorch作为主力框架,配合ONNX做跨平台部署。选择PyTorch的原因很直接:生态成熟,GitHub上现成的预训练模型多,调试也直观;ONNX则是因为最终要在Windows、Linux甚至嵌入式设备上跑,总不能让每台机器都装一套PyTorch环境。
1.2 整体技术方案选型
整个项目分为四个环节:数据准备、模型训练、测试评估、ONNX转换部署。技术栈和版本我列一下,方便你在自己机器上复现时不踩版本坑:
- Python 3.9+(3.8也行,但3.9的typing支持更好)
- PyTorch 2.1.x + CUDA 11.8(如果你显卡驱动不支持CUDA 11.8,降到1.13也能跑,只是训练速度慢一些)
- OpenCV 4.8+(图像读写和预处理)
- Albumentations(数据增强,比torchvision的transforms更顺手)
- ONNX Runtime 1.16+(导出与推理)
模型选型上,我用的是U-Net作为backbone,损失函数走的是L1 + Perceptual Loss的组合。为什么不直接上GAN?因为GAN训练不稳定,对新手不友好,而且试卷擦除任务不追求"生成多奇幻的风格",它追求的是"干净、忠实原卷结构",L1损失天然合适。后面我还试过加一个判别器做PatchGAN,效果提升有限,但训练时间涨了一半,性价比不高,最终版本舍弃了。
注意:这个项目的核心难点并不在模型结构多花哨,而在数据构造和损失函数设计。如果你直接拿公开的街景去模糊数据集来训练,模型学到的东西跟"试卷擦除"完全两回事。
2. 数据准备与标注策略
2.1 合成数据的构造方法
深度学习项目里,数据决定了效果上限。手写试卷擦除这个任务,现实中很难拿到"同一张卷子,既有写过的又有空白的"成对数据。所以我的做法是——合成。
合成思路不复杂:拿一批空白试卷扫描图作为背景,找一批真实手写笔迹图像作为前景,用随机仿射变换、随机颜色扰动、随机透明度混合,把笔迹"贴"到空白卷上,生成带手写的样本。这样天然就有一一对应的标签对。
具体参数如下:
- 背景图:至少准备50张以上不同排版、不同扫描亮度的空白卷,分辨率统一缩放到640×896,保持横纵比在2:3左右,太扁或太方的卷子比例会影响模型泛化。
- 手写笔迹:来源是公开的手写数据集(比如IAM手写数据库的子集),以及网上搜集的试卷扫描件。注意笔迹要多样化——签字笔、圆珠笔、铅笔的效果差异很大,如果模型只在签字笔上训练,遇到铅笔写的卷子基本报废。
- 叠加方式:笔迹区域先做高斯模糊(核大小随机3~5),再做透视变换(旋转范围±15度,缩放0.9~1.1),最后用透明度alpha=0.7~0.95叠加到背景上。为了让模型更鲁棒,我还会在背景上加高斯噪声和亮度扰动,模拟不同扫描仪的底噪。
这一步是最耗时间的,但数据质量直接决定模型上限。我前前后后折腾了两周,反复调整合成参数,才让模型在真实扫描件上有可用表现。
2.2 数据增强与数据集划分
合成数据虽然量大(我生成了约8000对),但也有个问题:太"干净"。为了抹平合成数据与真实扫描件的分布差异,必须做增强。我用的增强组合:
- RandomBrightnessContrast(亮度对比度,概率0.5,幅度0.2)
- GaussNoise(噪声,概率0.3)
- ShiftScaleRotate(平移缩放旋转,概率0.4)
- RandomResizedCrop(随机裁剪缩放,概率0.5,尺度范围0.7~1.0)
训练集、验证集、测试集的划分比例是:训练6000对、验证1000对、测试1000对。验证集和测试集要确保包含一部分真实手写扫描件,不能全用合成数据,否则模型在你自己的测试集上表演完美,一上真实战场就翻车。
这里有个很关键的心得:训练集可以全部合成,但验证集和测试集必须掺真数据。哪怕是拿手机拍的带手写的卷子,模糊一点没关系,能反映真实分布就行。我最初测试集也全是合成数据,模型在val loss上表现很好,结果拿真实扫描件一测,效果惨不忍睹,后来换了测试集策略才校正过来。
3. 模型训练全流程
3.1 网络结构设计与实现
模型主体用的是U-Net结构,编码器部分我做了轻量化调整,没有直接用ResNet34这种大网络,而是用了一个6层的卷积编码器,每层通道数是[64, 128, 256, 512, 512, 512],解码器对称。为什么不用残差网络?因为试卷擦除任务不需要特别大的感受野,手写笔画本身的局部性很强——你只需看周围几十个像素就能推断出下面的印刷体长什么样。大网络反而容易在小数据集上过拟合。
核心代码结构如下:
import torch import torch.nn as nn class UNet(nn.Module): def __init__(self, in_channels=3, out_channels=3): super().__init__() # encoder self.enc1 = self._block(in_channels, 64) self.enc2 = self._block(64, 128) self.enc3 = self._block(128, 256) self.enc4 = self._block(256, 512) self.pool = nn.MaxPool2d(2) # bottleneck self.bottleneck = self._block(512, 512) # decoder self.up4 = nn.ConvTranspose2d(512, 512, 2, stride=2) self.dec4 = self._block(1024, 512) self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec3 = self._block(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = self._block(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = self._block(128, 64) self.out = nn.Conv2d(64, out_channels, 1) def _block(self, in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) b = self.bottleneck(self.pool(e4)) d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return torch.sigmoid(self.out(d1))小细节:最后一层用了sigmoid而不是纯线性输出,因为输入图像做了归一化到[0,1],输出限定在[0,1]区间可以让训练更稳定,也能防止输出越界导致图像出现奇怪的色偏。
3.2 损失函数与训练参数解析
损失函数这块,我踩过不少坑,这里展开讲讲。
早期版本只用了L1 Loss,训练出来边缘是"糊"的。原因是L1 Loss对每个像素一视同仁,模型把"模糊化"当作一个低成本策略——反正模糊了L1也不会高太多。后来我加上了Perceptual Loss(感知损失),做法是先把生成图像和真实干净图像同时送入一个预训练的VGG16,取浅层特征做L1距离。这样模型就不敢随便模糊了,因为模糊图像在VGG特征空间的表示会偏离真实图像很远。
最终的损失公式:
Loss = 0.6 * L1_loss(output, target) + 0.4 * perceptual_loss(output, target)实验下来,L1权重0.6、感知权重0.4时,在验证集上的PSNR(峰值信噪比)和SSIM(结构相似性)综合最优。只加GAN的时候,收敛时间多了一倍,但PSNR反而降了0.3dB,果断放弃。
优化器用的是Adam,学习率初始1e-4,batch size 8(显存8GB以下的机器建议降到4),训练总轮数120轮。学习率在第60轮和第90轮各衰减一次,衰减因子0.1。这样设计是因为前期让模型快速收敛,后期用小学习率精细打磨细节。
3.3 训练过程监控与调参经验
训练日志我每5轮打印一次,记录train loss、val loss、PSNR三个指标。PSNR计算方式很简单:
import numpy as np def calculate_psnr(img1, img2, max_val=1.0): mse = np.mean((img1 - img2) ** 2) if mse == 0: return float('inf') return 20 * np.log10(max_val / np.sqrt(mse))实践经验:val loss从第40轮开始下降明显变慢,第80轮之后基本持平,PSNR在第80轮左右达到峰值,之后训练损失还在降但val指标开始波动,这就是过拟合信号,建议早停。
此外,训练过程中记得开启梯度裁剪(gradient clipping),max_norm设为1.0。这个任务输入输出的数值范围比较窄([0,1]区间),但其实梯度爆炸的概率并不低,尤其是batch size小、使用了BatchNorm的情况下。我中途遇到过loss突然变成NaN的情况,排查了半天才发现是某几张图像里有纯黑区域,配合高学习率导致梯度爆炸,加上梯度裁剪之后训练就稳定了。
4. 测试环节与效果评估
4.1 测试评估指标与可视化验证
测试阶段不能只盯着PSNR和SSIM,这两个指标有个通病:分数高不代表人眼看着舒服。PSNR对全局像素误差敏感,但它会把"输出整体变灰"误判为低误差。所以我额外制作了一个残差热力图,把输出图像和干净标签做逐像素差,差值映射到color map上可视化。这样一眼就能看出模型是不是把印刷体也擦掉了一块——有这种问题的话,热力图在印刷体边缘区域会特别亮。
测试时数据加载要注意:图像不能直接resize就喂给模型,不然长宽比变了,印刷体文字会被拉伸。我在测试管线里加了等比例resize + 边缘padding的逻辑,把图像统一处理成640×896,保持内容不变形。这个细节直接影响最终效果,尤其是卷头标题区域的文字结构。
测试集上我统计了几个关键数字:
| 指标 | 合成测试集 | 真实扫描测试集 |
|---|---|---|
| PSNR | 34.2 dB | 31.8 dB |
| SSIM | 0.967 | 0.941 |
| 平均推理耗时(单张) | 45ms(GPU) | 45ms(GPU) |
真实测试集比合成测试集低2.4dB,这个差距在预期内,但肉眼观感仍然干净利落。如果你的项目要求更严格,建议扩充真实数据到整个训练集的20%以上,再配合弱监督策略来做域适应。
4.2 失败案例分析
测试过程中,我专门挑了一些"刁钻"样本出来看:
- 铅笔写痕:铅笔灰度低、与印刷体灰度接近,模型有时候会误判,把印刷体的一部分也擦掉。优化方案:在合成阶段把一部分前景笔画调低透明度,模拟铅笔效果。
- 手写压线(下划线、表格线):当手写和印刷线条重合时,模型倾向于把整条线擦掉。这种问题靠数据增强无法完全解决,只能接受一定程度的线条断裂,或者在后处理阶段用形态学闭运算把断裂的线条修复回来。
- 红色笔迹批改:红色在RGB空间和黑色墨迹差异很大,如果训练数据里没有或很少红色笔迹,模型看到红色基本不处理。后来我在合成数据里专门加了一批红色笔迹图像,算是解决了。
这些失败案例给我的启示是:深度学习模型不会像人一样"智能地"绕过印刷体,它只是学到了统计规律。要提升特定场景的效果,唯一的办法是让那个场景出现在训练数据里。
5. ONNX转换全流程与部署实践
5.1 PyTorch模型导出ONNX的正确姿势
模型训练完成并验证合格后,就到了部署环节。ONNX转换这一步看似简单,但实际操作中有不少细节。
我先说最基础的导出代码:
import torch import onnx import onnxruntime as ort # 加载训练好的权重 model = UNet(in_channels=3, out_channels=3) model.load_state_dict(torch.load("best_model.pth")) model.eval() # 构造输入张量 dummy_input = torch.randn(1, 3, 640, 896) # 导出ONNX torch.onnx.export( model, dummy_input, "eraser.onnx", export_params=True, opset_version=12, do_constant_folding=True, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size"}, "output": {0: "batch_size"} } )几个值得注意的点:
opset_version我用的是12,兼容性最好。版本太老不支持某些算子,太新对部署环境要求高。dynamic_axes把batch维度设成动态,这样导出后的模型既能一次处理1张,也能批量处理多张。如果不设,onnx模型会绑定死batch=1。- 导出前务必调用
model.eval(),否则Dropout和BatchNorm的推理行为不对,导出的模型推理结果会和PyTorch不一致。
5.2 验证ONNX模型输出一致性
导出ONNX之后,最重要的一步是验证:PyTorch模型的输出和ONNX Runtime的输出要基本一致。这一步我能理解很多人的心情——觉得自己模型都训好了,导出还能出什么问题?但实际情况是,常常会有1e-4量级的微小差异,严重时甚至会有几个像素的明显偏差。
验证代码:
import numpy as np import onnxruntime as ort import torch # 用同一张测试图 test_img = torch.randn(1, 3, 640, 896) # PyTorch推理 with torch.no_grad(): torch_output = model(test_img) # ONNX Runtime推理 ort_session = ort.InferenceSession("eraser.onnx") ort_inputs = {ort_session.get_inputs()[0].name: test_img.numpy()} ort_output = ort_session.run(None, ort_inputs)[0] # 计算最大绝对误差 max_diff = np.abs(torch_output.numpy() - ort_output).max() print(f"Max diff: {max_diff:.6f}") # 如果diff非常小(通常<1e-5),说明转换成功 assert max_diff < 1e-4, "ONNX output mismatch!"我实际操作中遇到过一次最大差异达到0.02的情况,排查后发现是模型里一个nn.Upsample的align_corners参数问题——PyTorch默认align_corners=False,但ONNX导出时这个参数如果没被正确记录,推理结果就会不同。解决办法是把Upsample层显式写成nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False),并且在导出前用torch.onnx.export的operator_export_type参数固定算子行为。
5.3 ONNX Runtime推理代码模板
转换验证通过后,部署推理就简单了。我这里给出一份完整的ONNX Runtime推理模板,支持批量处理目录下所有图片:
import onnxruntime as ort import cv2 import numpy as np from pathlib import Path class EraserEngine: def __init__(self, onnx_path, input_size=(640, 896)): self.session = ort.InferenceSession( onnx_path, providers=['CUDAExecutionProvider', 'CPUExecutionProvider'] ) self.input_name = self.session.get_inputs()[0].name self.input_size = input_size def preprocess(self, img_bgr): """等比例缩放+padding到目标尺寸,保持内容不变形""" h, w = img_bgr.shape[:2] th, tw = self.input_size scale = min(th / h, tw / w) nh, nw = int(h * scale), int(w * scale) img = cv2.resize(img_bgr, (nw, nh)) # padding到目标尺寸 pad_top = (th - nh) // 2 pad_bottom = th - nh - pad_top pad_left = (tw - nw) // 2 pad_right = tw - nw - pad_left img = cv2.copyMakeBorder( img, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_CONSTANT, value=(255, 255, 255) ) img = img.astype(np.float32) / 255.0 img = img.transpose(2, 0, 1) return img[np.newaxis, ...], (scale, pad_left, pad_top) def postprocess(self, output, meta): """还原到原始尺寸""" scale, pad_left, pad_top = meta out = output[0].transpose(1, 2, 0) out = (out * 255).clip(0, 255).astype(np.uint8) if pad_left > 0 or pad_top > 0: h, w = out.shape[:2] crop = out[pad_top:h - (pad_top + (out.shape[0] - int(self.input_size[0]))), pad_left:w - (pad_left + (out.shape[1] - int(self.input_size[1])))] # 上面这行太绕,用下面这种更清晰的方式 real_h = int(self.input_size[0] / scale) real_w = int(self.input_size[1] / scale) crop = out[pad_top:pad_top + real_h, pad_left:pad_left + real_w] return crop def predict(self, img_bgr): meta = None input_tensor, meta = self.preprocess(img_bgr) output = self.session.run(None, {self.input_name: input_tensor})[0] return self.postprocess(output, meta) # 使用示例 engine = EraserEngine("eraser.onnx") img = cv2.imread("test_written.jpg") clean = engine.predict(img) cv2.imwrite("test_clean.jpg", clean)这段代码里我故意保留了注释掉的老写法,说明一下:如果你直接用self.input_size[0] / scale来做后处理裁切,更简单也更稳;用out.shape反推容易因为padding边界问题多裁或少裁一两个像素。
5.4 踩过的部署坑:CUDAs加速与动态尺寸
ONNX Runtime在GPU上跑,大多数时候确实比PyTorch更快,因为图优化做得好。但如果你部署的目标机器只有CPU,推理一张640×896的图大约需要300~500ms,勉强可用但不够流畅。
如果想在CPU上提速,有几个方向:第一,转成INT8量化,模型体积缩小4倍,速度提升2~3倍,精度损失大概在1~2dB PSNR左右,对于试卷擦除这个任务完全够用。第二,换成MobileNet作为编码器骨干,模型参数量从30M降到5M以下,精度损失约0.5dB,但CPU推理时间能压到100ms以内。
另外,ONNX Runtime的providers参数要按顺序写:优先CUDA,其次CPU。如果不写全,遇到没有GPU的环境时整个程序会直接报错,而不是自动fallback到CPU。这算是部署新手最常见的坑之一。
6. 优化方向与扩展思路
整个项目做到这个程度,已经可以在本地顺畅跑通"输入含手写的试卷图 -> 输出干净空白卷"的完整流程了。如果要进一步优化,我会建议按以下优先级推进:
第一,升级为边缘引导的生成网络。在U-Net输入侧并联一个边缘检测分支(用Sobel或Canny提取印刷体边缘),让模型显式地看到"哪些结构必须保留"。
第二,引入对比学习做域自适应。合成数据和真实扫描件的风格差异是模型泛化能力的天花板。用一个预训练的特征提取器,把真实无标注数据的特征拉近合成数据的特征空间,这一招能再提2~3dB PSNR。
第三,做成Web端轻量应用。ONNX模型可以直接用ONNX Runtime Web在浏览器里跑,把整个项目打包成一个HTML + JS的页面,上传试卷图就出结果,不需要任何后端服务。我用WebAssembly版ONNX Runtime试过,推理速度在普通笔记本上约200ms,体验很好。
我在实际测试中还发现一个有趣的现象:模型对"印刷体+手写"的混合输入效果很好,但如果直接输入纯手写笔记(比如一张白纸上的手写内容),模型会把所有内容都擦掉,输出一张纯白图。这其实暴露了模型的本质——它学到的是一种"背景重建"能力,而不是"识别手写并分离"的能力。理解这个边界很重要,能帮你合理设定工具的使用范围,不过度期待。
复盘整个项目,最有价值的经验谈不上模型多精妙,反而是"数据构造"和"损失函数微调"这两个环节投入产出比最高。训练网络本身反而不怎么费心,PyTorch生态把这些都简化了。如果你要复现这个项目,建议把时间重点放在数据多样性上——手写风格、扫描清晰度、笔迹颜色这三样做得越丰富,模型的鲁棒性就越强,其他都是锦上添花。
本文还有配套的精品资源,点击获取