简介手写试卷擦除工具是一套基于Python的深度学习图像修复系统面向计算机、人工智能、电子信息等专业本科生用于自动识别并擦除试卷上的手写笔迹同时保留印刷体内容。项目集成BiSeNetV2、NAFA、SA-GAN等多种轻量级语义分割与图像修复模型覆盖训练、推理、测试与ONNX导出全流程各模块职责明确便于理解原理、调试修改或二次开发如替换主干网络、接入新数据集、优化擦除边缘效果。压缩包共69个文件以45个Python脚本和6个shell脚本为代码主体另含项目说明文档、依赖配置、示例图片等整体仅472KB轻量易部署。目前已有58人学习使用非常适合作毕业设计、课程设计或大作业选题也是深度学习图像修复方向不错的入门实战案例。配套说明文档详细给出环境配置、数据准备、训练命令、推理示例及常见问题解答可帮助读者快速跑通全流程并按自身需求扩展功能。1. 项目概述与整体方案设计1.1 这个工具到底解决了什么问题先说我做这个项目的动机。家里有小孩在读小学每次考试完的试卷、练习册上全是红笔批改和铅笔订正的痕迹。老师要求错题整理家长想把错题重新打印出来给孩子再做一遍但原卷已经被写满了拍照复印出来全是笔迹排版也是乱的。市面上确实有扫描全能王这类App带的试卷擦除功能但要会员、要联网、传上去也担心隐私。我就想自己做一个离线的、免费的、可控的擦除工具用Python加深度学习模型从数据准备到训练再到部署完整走一遍。把需求拆开看这个任务的本质是输入一张包含手写笔迹的试卷图片输出一张干净的空白试卷图片。它属于图像到图像的翻译任务和超分辨率、去噪、去雨属于同一类问题。注意它和OCR光学字符识别完全相反——OCR是想把字认出来我们要的是把字抹掉。所以模型学习的不是手写内容是什么而是手写内容覆盖区域的背景长什么样。1.2 为什么不用传统图像处理方案接到需求时我第一反应是能不能用OpenCV直接做比如通过颜色检测把手写区域扣掉再用周围像素填充inpainting。实测真的不行原因有三个第一手写笔迹的颜色不稳定。铅笔是灰的蓝色水笔、黑色签字笔、红色批改笔混在一起单纯靠颜色阈值分割会漏掉大量浅色笔迹还会误伤印刷体。第二试卷背景不是纯白。印刷试卷有底色、有网格线、有图片直接用cv2.inpaint填充网格线会被抹成糊状印刷字体边缘也会被破坏。第三手写痕迹和印刷体在空间上有大量重叠。孩子写答案就写在印刷体的空格里笔画交叉叠压传统形态学处理根本分不开。深度学习模型则可以通过大规模数据学习笔迹区域的纹理和背景之间的分布关系以像素级重建的方式补全被遮挡的印刷内容。说白了模型学的不是去掉黑色像素而是这个地方没有字的时候应该长什么样。1.3 技术选型与方案对比这个项目我对比过三条技术路线整理成表格供参考方案代表模型优点缺点我的选择传统图像处理OpenCV颜色分割 inpainting无需训练、速度快处理不了重叠和复杂背景擦除后糊斑严重不采用深度学习生成模型条件GANpix2pix、CycleGAN效果上限高边缘纹理自然训练不稳定推理资源占用高调参痛苦可作为进阶升级监督学习图像修复网络U-Net系ResNet backbone训练稳定、效果可控、导出部署方便需要成对训练数据最终采用性价比最高最终选了U-Net结构加ResNet编码器。原因很直接U-Net在医学图像分割里被验证过无数次对小目标和细节纹理的恢复能力强它的跳跃连接能把编码器的高分辨率特征直接传给解码器笔迹擦除这种需要精细恢复印刷线条的任务正好用得上。而且U-Net全卷积结构对输入尺寸宽容度高模型体量小导出ONNX之后在CPU上跑一张图也就一两秒。2. 数据准备最耗时间的环节2.1 成对训练数据从哪来训练这种图像翻译模型最理想的数据是同一张试卷的写满版和空白版成对出现。但现实中不可能找到大量真实的、一一对应的试卷对网上也没有现成的公开数据集。我花了两天时间想到一条可行的路合成数据。合成思路说起来也简单找一批真实的空白试卷PDF转成图片作为底图再用真实的手写字体库把手写内容贴上去。这样我就能得到像素级对齐的成对数据——底图是干净标签贴了文字的图是输入。这里要说明一下用合成数据训练出来的模型能不能泛化到真实场景我的实测结论是能但要满足两个条件。第一合成时的手写字体必须足够多样不能只用一两种字体第二合成时要加充分的随机扰动模拟真实拍照的光线、角度、噪声。我自己在真实试卷上测擦除效果大概能达到合成数据的九成水平后续用少量真实图片做微调还能继续提升。2.2 合成数据的完整实现我用Python写了一个数据合成脚本核心步骤是用PyMuPDFfitz把空白试卷PDF逐页转成PNG图片分辨率统一为150dpi收集一批开源手写字体文件ttf包括楷体手写、行书、圆体、英文手写体至少十种对手写内容进行随机变换随机字体、随机字号、随机颜色蓝色/黑色/铅笔灰、随机旋转、随机间距把文本渲染到图片的随机位置确保和印刷字体重叠关键代码大概长这样import fitz import numpy as np import random from PIL import Image, ImageDraw, ImageFont def render_text_with_augmentation(base_img, text, font_paths): 在手写字体渲染过程中加入随机扰动 img base_img.copy() draw ImageDraw.Draw(img) # 随机选一个手写字体 font_path random.choice(font_paths) # 随机字号 18~32 font_size random.randint(18, 32) font ImageFont.truetype(font_path, font_size) # 随机颜色: 蓝、黑、铅灰 color random.choice([(30, 60, 180), (50, 50, 50), (130, 130, 130)]) # 随机旋转回字容器 x random.randint(20, img.width - 300) y random.randint(20, img.height - 100) draw.text((x, y), text, fontfont, fillcolor) # 随机旋转整个文本块 if random.random() 0.5: angle random.randint(-5, 5) img img.rotate(angle, center(x, y), fillcolor(255, 255, 255)) return np.array(img, dtypenp.float32) / 255.0这一步有两个细节值得单独拿出来说第一个是颜色空间。我最初用纯RGB合成后来发现真实试卷的铅笔痕迹在RGB空间里和白色背景的差异很小模型学起来很吃力。于是我把输入从RGB三通道调整策略改为训练时同时输入RGB图和灰度图让模型自己学权重。实际效果是灰度通道在擦铅笔痕迹时贡献最大彩色通道在擦红色批改时更关键。第二个是版面干扰。真实试卷有密封线、有装订孔、有页码、有二维码我最初合成数据时底图太干净导致模型在真实试卷上出现边界伪影。后来我在合成时专门在底图上加了随机噪声、模拟光照不均匀的渐变以及在边缘加黑色条带模拟扫描阴影模型在真实数据上的表现明显提升。2.3 数据增强与划分策略合成数据生成了大概8000张训练图1000张验证图。但我没有直接用这批数据训练而是叠加了在线数据增强随机亮度/对比度扰动模拟不同打印质量随机加高斯噪声和椒盐噪声模拟扫描件随机旋转不超过3度模拟放试卷时的倾斜随机锐化或模糊处理模拟不同对焦效果数据划分上特别提醒一句一定要按试卷来源划分而不是按图片划分。也就是说同一张试卷派生出来的合成图片不能同时出现在训练集和验证集里否则模型只是记住了这张试卷的版式验证集指标虚高到真实场景直接崩。我第一批数据就是踩了这个坑验证集PSNR高达38dB放到真实试卷上却一塌糊涂。3. 模型构建与训练过程3.1 网络结构设计与损失函数模型我用了标准U-Net架构编码器部分换成ResNet34去掉了全连接层解码器逐级上采样恢复分辨率。整体参数量约1500万单张512x512的图在GTX 1080 Ti上训练时显存占用不到6GB。如果你设备更弱可以把ResNet34换成ResNet18效果差距不大但速度提一倍。损失函数是这项目的核心调优点。我一开始只用L1 Loss像素级绝对误差训练出来的模型有个致命问题擦完的试卷像隔了一层半透明毛玻璃印刷字体的笔画变得模糊。这是因为L1/L2这类逐像素损失天然倾向于平均所有可能的输出导致结果平滑。后来我在L1的基础上加了感知损失Perceptual Loss用VGG16提取高层特征做相似度约束。感知损失的作用是让模型在特征层面欺骗人眼保留印刷字体的锐利边缘。最终损失L λ1 * L1 λ2 * Perceptualλ1取1.0λ2取0.1。训练时用Adam优化器初始学习率0.0002batch size为8训练了40个epoch后损失基本收敛。我在验证集上对比过加了感知损失之后印刷体区域的锐度肉眼可见提升SSIM从0.91提高到0.95。3.2 训练细节与关键参数训练代码本身不复杂核心流程就是标准的前向传播-计算损失-反向传播-更新梯度我直接给出自己反复调试后确定的一套稳定配置import torch import torch.nn as nn from torch.utils.data import DataLoader from models.unet import UNetResNet34 model UNetResNet34(in_channels3, out_channels3) model model.cuda() criterion_l1 nn.L1Loss() criterion_perceptual PerceptualLoss() # VGG16特征匹配 optimizer torch.optim.Adam(model.parameters(), lr2e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max40) for epoch in range(40): for batch in train_loader: x batch[input].cuda() # 带手写的试卷 y batch[label].cuda() # 干净空白试卷 pred model(x) loss_l1 criterion_l1(pred, y) loss_per criterion_perceptual(pred, y) loss loss_l1 0.1 * loss_per optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()几个训练参数的经验值这里特别展开说一下输入分辨率512x512。太小了恢复不了网格线细节太大了显存不够且训练速度骤降归一化方式图像像素直接除以255映射到[0,1]没有用ImageNet的mean/std归一化。因为这个任务输入输出都是自然图像统一到[0,1]区间更直观而且导出ONNX后预处理可以简化学习率策略CosineAnnealing比StepLR效果好。StepLR在epoch 20骤降时损失曲线有明显跳变Cosine则平滑收敛BatchNorm的坑训练时BatchNorm在batch size8时统计量不稳定我加了nn.SyncBatchNorm如果多卡或者干脆换GroupNorm。单卡训练下GroupNorm(32组)比BatchNorm稳定很多。3.3 测试评估与效果验证训练完成后测试不只是跑一遍验证集看数字更要做真实场景的盲测。评估指标我用两类客观指标PSNR峰值信噪比、SSIM结构相似度。这两个指标在验证集上分别是33.5dB和0.95。主观指标把擦除前后图片放大对比关注三点——印刷字体是否变形、网格线/括号是否连贯、笔迹残留是否可见。我让家人都各自拿自己批改过的试卷来测试最终确认这套流程在蓝笔、黑笔、铅笔批注上效果都可用。这里想纠正一个常见误解PSNR高不代表擦除效果好。因为PSNR的计算是以整张图为单位的如果模型只是把整体变暗变模糊PSNR可能反而升高。真正要看的是一块一块对比——笔迹覆盖区域的恢复质量。我后来写了一个工具函数把手写区域标注出来单独计算PSNR这样评估才科学。4. ONNX导出与推理部署4.1 导出ONNX的完整操作训练好的PyTorch模型不能直接交付给用户用户电脑上大概率没有PyTorch环境。我选择导出ONNX格式好处是ONNX Runtime的CPU推理速度比PyTorch快而且不需要安装深度学习框架。导出过程比想象中简单核心代码只有几行import torch import onnxruntime as ort import numpy as np model.eval() dummy_input torch.randn(1, 3, 512, 512) torch.onnx.export( model, dummy_input, eraser.onnx, export_paramsTrue, opset_version11, do_constant_foldingTrue, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}} )导出的注意点在dynamic_axes。如果不需要批量推理建议把batch维固定为1这样ONNX Runtime可以做更多图优化。但如果要支持多张并行推理batch维设成动态即可。H和W维度我直接固定成512因为模型是全卷积的理论上可以支持任意尺寸但Torch的插值操作在动态尺寸下会引入额外计算开销所以固定HW更稳定。4.2 导出的常见坑输入输出不一致我在第一次导出ONNX后发现PyTorch模型和ONNX模型的输出有细微差别测试中最大像素差达到0.02。排查下来有四个原因第一归一化对齐问题。PyTorch推理时如果用了transform.NormalizeONNX推理时也必须做完全相同的预处理差一个mean/std值都不行。我在导出前决定移除所有归一化层让模型直接接受[0,1]输入。第二opset版本问题。我用的F.interpolate在低版本opset下会导出成效率较低的实现甚至行为有轻微差异。建议用opset_version11以上我最终锁定在15实测稳定。第三BatchNorm的坑。模型在eval模式下导出没问题但如果模型里有Dropout或训练模式的BatchNorm导出时需要注意设置model.eval()。这个坑我自己踩过一次导出时没设eval结果ONNX推理结果混乱不堪。第四模型输出值的范围。我的模型输出层没有加Sigmoid输出范围是[0,1]附近的小数推理时直接clip到[0,1]再乘255即可。不加Sigmoid的原因是让网络没有信息瓶颈但这要求推理端做clip否则会出现轻微色偏。4.3 CPU推理的完整实现ONNX Runtime在CPU上跑推理效率比我预期好。512x512的图在普通笔记本CPUi5-1135G7上单张推理约0.8秒加上图像读入和写出的IO开销整个流程控制在1.5秒以内。完整的推理代码封装如下import onnxruntime as ort import numpy as np from PIL import Image class HandwritingEraser: def __init__(self, onnx_patheraser.onnx): self.session ort.InferenceSession(onnx_path, providers[CPUExecutionProvider]) self.input_name self.session.get_inputs()[0].name def erase(self, image: Image.Image) - Image.Image: # 调整尺寸到512x512 w, h image.size img image.resize((512, 512), Image.BILINEAR) # 转numpy并归一化 arr np.array(img, dtypenp.float32) / 255.0 arr arr.transpose(2, 0, 1)[None] # HWC - NCHW # 推理 out self.session.run(None, {self.input_name: arr})[0][0] # 后处理 out out.transpose(1, 2, 0) out np.clip(out * 255.0, 0, 255).astype(np.uint8) result Image.fromarray(out) return result.resize((w, h), Image.BILINEAR)最后一行的resize回原尺寸很关键。如果直接输出512x512再让用户自行放大边缘和文字会变模糊。先输出512x512再插值回原尺寸视觉效果要优于直接把原图缩放输入。5. 常见问题排查与避坑指南5.1 训练阶段问题速查问题现象可能原因解决办法训练损失不下降学习率过大或过小先用0.0002观察前500步损失变化输出全是灰色模糊块损失只用了L2/L1没有感知损失加Perceptual Loss擦不干净铅笔笔迹合成数据中铅笔样本太少调整合成脚本中灰色笔迹的比例到20%以上印刷字体变形底图分辨率太低或压缩过度底图用150dpi以上PDF渲染不要用截图验证集效果好但真实数据差数据划分泄露按试卷来源划分数据确保训练验证不重叠5.2 推理部署阶段问题速查问题现象可能原因解决办法ONNX输出和PyTorch不一致BatchNorm未切eval、归一化不一致设置model.eval()统一预处理流程大图推理内存爆掉直接把原图缩放输入固定512x512输入输出再插值回原尺寸推理速度太慢CPU推理没有做线程优化设置Ort的线程数参数实测4线程最优擦除后出现黑白噪点输出没有clip到[0,1]范围推理后必须加np.clip操作5.3 实操经验与后续扩展最后分享几个我在实际项目中反复验证的小技巧。第一混合真实数据微调。纯合成数据训练的模型在全新的真实试卷上偶尔会出现擦除不彻底的问题。我的做法是把手机拍摄的真实试卷用LabelMe标注出笔迹区域只标注30到50张然后以很小的学习率0.00002微调模型。这招能把真实场景效果再提升一截是性价比极高的优化手段。第二批量处理文件夹。实话说一张一张测试太累了。我写了一个简单的批量处理脚本用os.walk遍历文件夹内所有jpg/png图片挨个擦除后输出到指定目录处理一次月考的全部试卷也就两三分钟。这个工具现在已经成了我家固定使用的错题整理助手。第三后续还可以做的扩展方向。这套流程不止能擦手写试卷——把数据换成带水印的文档模型就能擦水印换成带噪声的旧照片模型就能去噪。整个框架是通用的换数据就行。如果追求更高效果可以把U-Net换成pix2pix架构加判别器但训练难度会大不少不是必需就不用上。这个项目做完之后最大的启发是深度学习项目真正卡脖子的环节往往不是模型代码而是数据质量。合成数据只要足够贴近真实分布模型是愿意给好结果的。我也把完整的训练脚本和推理代码整理成了工程文件夹方便有同样需求的朋友直接按这个流程跑通遇到问题欢迎交流。本文还有配套的精品资源点击获取