简介:基于深度学习的试卷手写文字擦除系统是一套个人优秀毕业设计,评审98.5分,覆盖模型定义、训练、预测与评估等完整流程,面向计算机相关专业毕业生、需要项目实战的初学者,也可用于课程设计或期末大作业。压缩包共62个文件,总大小约190KB,以44个Python脚本为主力,涵盖dataloader、loss、网络模型、训练/测试脚本等关键模块,另有6个Shell脚本便于一键执行,以及readme、md、txt等说明文档,结构清晰。资源内置模型和运行说明,经本地成功运行验证,可直接上手实践或在此基础上进行二次开发,尤其适合作为毕设项目展示与深入学习深度学习的参考。目前已有166人学习下载,对于希望掌握文字擦除、图像处理相关技术的读者具有较高的借鉴价值。
1. 试卷手写擦除不是“识别”,而是图像翻译
一张扫描试卷里通常混着两种墨迹:印刷体是题目,手写体是学生作答。试卷手写文字擦除系统要做的,是把输入图像里的手写内容消除,同时让印刷体题目、表格线、纸张底色保持原样。它跟 OCR 正相反——OCR 想把字“认出来”,擦除想把字“变没”,输出是一张干净、自然、看不出涂改痕迹的试卷底图。
这类任务在深度学习里属于图像翻译范畴。常见做法是在 pix2pix 的框架下,用一个 U-Net 生成器加一个 PatchGAN 判别器,训练后用训练好的模型做推理,再配合后处理把生成结果和原始图像融合。整套系统可以拆成四条线:训练数据怎么造、生成器和判别器怎么搭、训练参数怎么设、推理和系统交互怎么组织,最后还要解决“如何证明擦除效果”这个答辩论题。
下面按一个可交付的毕设系统的结构来复盘这条完整路径,新手可以直接照着搭,有经验的选手可以重点看数据生成和后处理这两处决定结果上限的部分。
2. 手写擦除的任务建模与训练数据准备
2.1 把“擦除”定义为有监督的图像到图像翻译
输入是一张脏卷 x,我们希望网络输出一张干净卷 y,训练时给网络看成对的 (x, y) 样本,让网络学会 x 到 y 的映射。那就是标准的监督式图像翻译问题。
很多人在选题时第一个问题是:能不能用 CycleGAN,省去配对数据的收集成本?我一般不建议。CycleGAN 的循环一致性损失适合整体风格迁移,比如照片转油画,但手写擦除是局部区域的高频纹理消除,生成器容易偷懒走捷径:要么把整张图变模糊来骗过判别器,要么学了风格迁移之后把印刷体也涂掉。想在一个“局部生成”的任务上稳定收敛,配对数据几乎绕不开。
另一个常见偏差是把这个任务做成“分割 + 修复”。先分割出手写区域,再用 opencv 的 inpaint 补洞。这条路在小规模测试里跑得通,但真实扫描件的笔迹有浓淡深浅,分割出的 mask 稍有误差就会留下白斑或残迹。用深度学习直接做图像翻译,网络会把“消除手写”作为一个整体目标学习,出来的效果更自然。当然,这不意味着丢掉 mask——mask 在后处理和效果评估里仍然是关键角色。
2.2 配对数据从哪来:合成手写是毕设最快闭环的路径
真实场景里要拿到同一张卷子的“有手写版”和“无手写版”几乎不可能,因为试卷已经脏了。可行路径有两条:让学生在同一张卷子上用两种笔迹写两遍再扫描,或者用合成手段把数字孪生式的手写笔迹叠加到干净卷上。前者工作量可控但风格单一,后者是更常见的做法。
合成管线的核心思路是:准备一批干净的试卷扫描图作为背景,用字体渲染的方式在背景上叠加模拟手写文字,同时生成对应的擦除区域 mask。合成代码不复杂,核心逻辑如下:
import cv2 import numpy as np import random import string from PIL import Image, ImageDraw, ImageFont def make_paired_sample(clean_img, font_paths, num_lines=5, max_chars=15): h, w = clean_img.shape[:2] overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) mask = Image.new("L", (w, h), 0) mask_draw = ImageDraw.Draw(mask) for _ in range(num_lines): font_size = random.randint(18, 32) font = ImageFont.truetype(random.choice(font_paths), font_size) # 随机画一点倾斜旋转,模拟书写角度 x, y = random.randint(10, w - 200), random.randint(10, h - 40) text = "".join(random.choices( string.ascii_lowercase + "0123456789", k=random.randint(5, max_chars) )) # 手写墨迹不是纯黑,给一个灰度区间 color = (30 + random.randint(0, 40),) * 3 + (random.randint(120, 180),) draw.text((x, y), text, font=font, fill=color) mask_draw.text((x, y), text, font=font, fill=255) overlay_rgb = overlay.convert("RGB") alpha = random.uniform(0.7, 1.0) dirty = cv2.addWeighted(clean_img, 1.0, np.array(overlay_rgb), alpha, 0) return dirty, np.array(mask)代码里几个参数要特别说清楚。alpha控制墨迹深浅,设为 0.7 到 1.0 才能模拟中性笔和铅笔的浓淡差异;color里的灰度值控制在 30 到 70 之间,避免合成笔迹比真实手写更黑;font_size的 18 到 32 对应答题时不同人字号的差异。字体文件是关键——建议至少混入三种中文字体、一种手写风格字体,字体种类直接决定擦除模型的泛化能力。
mask 有一个隐藏作用:它不仅是训练时的监督信号,也是推理后处理阶段决定“替换图像哪些区域”的依据。训练集里合成手写时随手生成 mask,推理时就能用它约束替换范围,防止网络把印刷体也改掉。这个设计在第四章会展开。
2.3 数据生成的三个质量要点:遮挡率、内容多样性与噪声
第一个要点是遮挡率。合成时不能让手写满版都是,现实中手写作答通常集中在题目空白处,覆盖率超过 30% 的合成样本会让模型学到“整图变干净”的错误映射。建议每张样本的手写行数控制在 3 到 7 行,并且部分样本留空不叠加任何手写。
第二个要点是背景噪声。真实试卷扫描件有纸质纹理、黑点、边缘阴影、印章痕迹,如果只用干净的白底图像训练,推理时遇到真实扫描件会出现色偏和伪影。最简单有效的做法是给合成背景叠加高斯噪声、随机亮度和轻微透视畸变,代价很小但效果提升很明显。
第三个要点是数据量级。我见过不少人纠结“到底要多少张”。如果从零训练,单卡能跑的情况下建议 5000 到 10000 对样本,每张里手写行数随机,不然过拟合很严重。如果是从 ImageNet 预训练的 U-Net 出发,1500 对以上就能看到可用结果。主要训练支出不在数据量,而在字体多样性和背景多样性。
提示:合成数据决定了这个系统的上限。真实考试手写可能有涂改痕迹、连笔、行间穿插,合成覆盖不到时,模型会表现为“擦不干净”而不是“擦错”。
3. 模型结构与训练:用 PyTorch 搭 U-Net + PatchGAN
3.1 生成器选 U-Net 而不是普通编码器解码器
普通 Encoder-Decoder 的问题是下采样过程中丢失了空间细节。擦除任务既要“去手写”又要“保印刷体”,印刷体的笔画细节恰恰是高频信息,经过四次下采样再上采样基本糊掉了。U-Net 的 skip connection 把编码器每一层的特征直接拼接给对应层级的解码器,印刷体结构可以从浅层特征直接流向输出,因此生成结果能保留题目排版原样。
生成器输入输出尺寸一致,设计上可以直接照搬 pix2pix 的 256 输入版本。为了平衡显存和效果,我一般把下采样层控制在四层,每层通道数按 64、128、256、512 递增。输出层用 Tanh 激活函数,把像素值映射到 -1 到 1 之间。代码量不大, PyTorch 里几十行就能写完,关键结构如下:
class UNetGenerator(nn.Module): def __init__(self, in_channels=3, out_channels=3): super().__init__() # 下采样四层,每层是 Conv + BN + LeakyReLU self.e1 = self._block(in_channels, 64, norm=False) self.e2 = self._block(64, 128) self.e3 = self._block(128, 256) self.e4 = self._block(256, 512) # 上采样四层,每层先反卷积再拼 skip 特征 self.d1 = self._deblock(512 + 256, 256) self.d2 = self._deblock(256 + 128, 128) self.d3 = self._deblock(128 + 64, 64) self.d4 = nn.Sequential( nn.ConvTranspose2d(64, 32, 4, 2, 1), nn.ReLU(True), nn.Conv2d(32, out_channels, 3, 1, 1), nn.Tanh() ) def _block(self, in_c, out_c, norm=True): layers = [nn.Conv2d(in_c, out_c, 4, 2, 1), nn.LeakyReLU(0.2, True)] if norm: layers.append(nn.BatchNorm2d(out_c)) return nn.Sequential(*layers) def _deblock(self, in_c, out_c): return nn.Sequential( nn.ConvTranspose2d(in_c, out_c, 4, 2, 1), nn.BatchNorm2d(out_c), nn.ReLU(True) ) def forward(self, x): e1, e2, e3, e4 = self.e1(x), self.e2(x), self.e3(x), self.e4(x) d = self.d1(torch.cat([e4, e3], dim=1)) d = self.d2(torch.cat([d, e2], dim=1)) d = self.d3(torch.cat([d, e1], dim=1)) return self.d4(d)注意torch.cat拼接时维度顺序:来自更深层的特征在前,skip 特征在后,维度匹配由_deblock的输入通道数保证。如果训练时出现size mismatch,先检查这一步。
3.2 PatchGAN 判别器为什么适合“局部擦除”这种任务
整图判别器输出一个全局真假概率,但它无法约束局部区域的细节,生成器会在手写区域偷懒,只把大结构抹掉,留下模糊残迹。PatchGAN 把图像切分成 70×70 的感受野小块,对每个 patch 独立判断真假,相当于强制网络在每个局部区域都做真实纹理生成。手写擦除关注的恰恰是局部:某个区域的笔迹是否被干净移除,背景是否连续。
PatchGAN 的实现核心是全卷积结构,最后一层输出的是 N×N 的特征图而不是单个值,损失函数在特征图的每个像素上分别算 BCE。PyTorch 里用nn.Conv2d叠四层卷积即可,输出大小与输入尺寸有关,但训练时不用关心具体值,只需要对特征图逐点做二分类损失。
3.3 训练配置与关键超参数
训练这套模型建议直接沿用 pix2pix 的经验值,下面这张表是一个经过多轮验证的稳定配置。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 256×256 | 低于 192 会丢失笔画细节,高于 384 显存压力大 |
| batch size | 4 | 单卡 8GB 显存可以承受 |
| 优化器 | Adam(betas=(0.5, 0.999)) | 0.9 会导致训练不稳 |
| 学习率 | 2e-4 | 前 50 个 epoch 固定 |
| 学习率衰减 | 后 50 个 epoch 线性降到 0 | 帮助收敛更平稳 |
| L1 损失权重 | 100 | 太大易模糊,太小纹理不稳定 |
| epoch 总数 | 100 左右 | 显存小可适当减少 |
L1 损失权重是最值得调的参数。它约束生成图像在像素级上靠近干净图,权重大了结果稳定但整体偏糊,权重小了笔迹区域会出现伪纹理。手写笔迹的墨迹边缘属于高频信息,想让边缘锐利,L1 权重通常往 50 方向调,同时把 GAN 损失的贡献放大。判别器更新频率与生成器保持一致,不需要额外调。
3.4 训练循环里的三处细节
核心训练循环用一张伪代码图就能说清,生成器和判别器交替更新。
for epoch in range(epochs): for dirty, clean in train_loader: dirty, clean = dirty.to(device), clean.to(device) fake = G(dirty) # 判别器:真实对为 (dirty, clean),假对为 (dirty, fake.detach()) d_real = D(dirty, clean) d_fake = D(dirty, fake.detach()) d_loss = (BCE(d_real, real_label) + BCE(d_fake, fake_label)) / 2 D.zero_grad() d_loss.backward() opt_D.step() # 生成器:目标是让假对骗过判别器,同时靠近干净图 d_fake = D(dirty, fake) g_adv = BCE(d_fake, real_label) g_l1 = L1Loss(fake, clean) * L1_LAMBDA g_loss = g_adv + g_l1 G.zero_grad() g_loss.backward() opt_G.step()第一处细节在fake.detach(),判别器更新时生成器的梯度被截断,这是 GAN 训练的标准动作。第二处是检查点保存,每 5 个 epoch 保存一次{"model_state": G.state_dict(), "opt_state": opt_G.state_dict()},方便中断后恢复,也方便筛选中间结果。第三处是推理时建议使用 EMA(指数移动平均)权重,在训练过程中维护一份权重影子,推理时用影子权重,画面稳定性会明显好于直接用最后一轮权重。
提示:如果训练 loss 一直不下降,先看数据加载和归一化是否一致,输入是 -1 到 1,输出是 -1 到 1,不要混用 0-255 的像素范围。
4. 从模型到运行:推理擦除与系统落地
4.1 加载本地模型与图像预处理
模型训练完后是一个.pt或.pth文件,这就是交付物里的“模型”部分。加载时最常踩的坑是训练时在 GPU 上保存的权重,推理机器只有 CPU 时会报设备不匹配。可靠写法如下:
def load_model(ckpt_path, device): G = UNetGenerator(in_channels=3, out_channels=3).to(device) state = torch.load(ckpt_path, map_location=device) if "model_state" in state: G.load_state_dict(state["model_state"]) else: G.load_state_dict(state) G.eval() return Gmap_location=device是跨设备加载的关键参数。调用方传入torch.device("cpu")时自动映射到 CPU,传入"cuda:0"时优先使用 GPU。另外一定要记得G.eval(),否则 BatchNorm 和 Dropout 在推理时仍处于训练模式,输出结果不稳定。如果出现“尺寸不匹配”报错,先确认训练时的归一化方式和输入尺寸是不是和推理一致。
预处理流程与训练保持一致:opencv读取为 BGR,转成 RGB,缩放到 256×256,像素值转为 -1 到 1。推理代码如下:
import torch import cv2 import numpy as np def erase_image(model, img, device, mask=None, dilation=5): h, w = img.shape[:2] orig = img.copy() img_resized = cv2.resize(img, (256, 256)) x = img_resized.astype(np.float32) / 127.5 - 1.0 x = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0).to(device) with torch.no_grad(): pred = model(x)[0].detach().cpu() pred = pred.permute(1, 2, 0).numpy() pred = (pred + 1) * 127.5 pred = cv2.resize(pred, (w, h)) if mask is not None: mask = cv2.dilate(mask, np.ones((dilation, dilation), np.uint8)) mask = cv2.GaussianBlur(mask, (0, 0), sigmaX=3) alpha = (mask / 255.0)[..., None] result = pred * alpha + orig * (1 - alpha) else: result = pred return result.astype(np.uint8)预处理里的permute(2, 0, 1)把 HWC 变成 CHW,unsqueeze(0)给网络加 batch 维,推理完再逆回来。后处理里dilation参数控制 mask 向外膨胀的像素数,手写墨迹有渗透,mask 给出的区域往往比实际笔迹略小,膨胀 5 个像素可避免边缘残留。sigmaX=3的高斯模糊让替换区域边缘羽化,过渡更自然。
4.2 后处理不是可选项:mask 约束与边缘羽化
很多毕设只做“输入一张图,输出一张图”,模型直接吐全图,结果往往是印刷体也被改写。正确做法是只在 mask 覆盖的范围内替换原图。这张 mask 在训练数据生成时已经顺手得到,推理时如果是真实扫描件,可以用手写检测模型或简单的像素差分生成,也可以人工框选作答区。
公式上就是result = pred * alpha + orig * (1 - alpha)。alpha在 mask 区域为 1,原图区域为 0,边缘经高斯模糊后是渐变的中间值。这样网络输出的干净底图只贡献在需要擦除的地方,印刷体像素始终来自原图,不会被网络“脑补”改变。这个设计降低了生成器对印刷体的破坏风险,也让最终图像在视觉上保真度更高。
4.3 系统形态:命令行批量处理 + Flask 接口
运行说明是这类交付物的门面。最简单可靠的方式是做一个命令行入口,支持单张图片和整个目录的批量处理:
python erase.py --ckpt checkpoints/latest.pth --input ./test_imgs --output ./resulterase.py内部按“加载模型 → 遍历目录 → 逐张擦除 → 保存结果”来组织,模型文件路径、输入输出目录都从命令行参数读,不要把路径写死在代码里,否则别人拿到代码跑不起来。运行说明要写清四件事:Python 版本(建议 3.8 以上)、依赖安装命令、模型文件放哪、输入输出目录长什么样。
除了命令行,很多毕设会要求一个可视化系统。Flask 写一个轻量接口是最快的方案:
from flask import Flask, request, Response import base64, cv2, numpy as np app = Flask(__name__) @app.post("/api/erase") def erase(): buf = request.files["image"].read() img = cv2.imdecode(np.frombuffer(buf, np.uint8), cv2.IMREAD_COLOR) out = erase_image(model, img, device, mask=None) _, encoded = cv2.imencode(".jpg", out) return Response(base64.b64encode(encoded.tobytes()), media_type="image/jpeg") app.run(host="0.0.0.0", port=5000)注意cv2.imdecode不能直接读 bytes,必须先包一层np.frombuffer。返回图片时用 base64 编码放在 JSON 里或直接作为二进制响应都行,前端fetch拿到响应后URL.createObjectURL即可显示。启动命令python app.py,浏览器访问http://localhost:5000。
4.4 环境配置排查清单
“运行说明”写不好,答辩演示就会卡在环境上。下面几个问题是我见过最多的:
PyTorch 版本与 CUDA 不匹配。建议在项目里写清楚 PyTorch 版本约束,CPU 机器也能跑但速度慢很多。torch.load在无 GPU 机器上报错,基本是没写map_location。解决方法是规定推理端统一用 CPU 加载。
OpenCV 在 Windows 下读中文路径会失败,因为底层用的是 C 函数不支持 Unicode。常见解法:
img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR)写入同理,cv2.imencode后写 bytes 再tofile。
虚拟环境与全局环境混淆。建议运行说明里只写“创建 conda 环境”,不推荐直接在全局环境装依赖,因为 PyTorch 和 OpenCV 的版本冲突一旦发生,排错成本远超重装一个环境。依赖文件用requirements.txt,明确写好torch、torchvision、opencv-python、numpy、flask五个核心包即可。
5. 效果验证与进阶技巧:用检测器给擦除效果打分
5.1 没有真值也能验证:手写检测置信度下降法
擦除效果不像分类任务有准确率可算,测试集没有“干净真值”时可操作性最强的方法是利用现成的手写检测或 OCR 模型做代理指标。原理是:如果手写被真正擦除,检测器在原图上检出的手写区域在擦除图上应大幅减少,置信度应显著下降。
def evaluate_erase(orig_img, erased_img, detector): boxes_before = detector(orig_img) boxes_after = detector(erased_img) # 计算区域内检测置信度均值 score_before = np.mean([b.conf for b in boxes_before]) score_after = np.mean([b.conf for b in boxes_after]) print(f"擦除前置信度: {score_before:.3f}, 擦除后: {score_after:.3f}") return score_after / score_before这个下降比率可以直接写进毕业论文实验章节。PaddleOCR、EasyOCR、百度文字识别接口都能当检测器用,选一个跑测试集即可。对自己的测试集统计一个“平均置信度下降率”,比贴三张对比图更有说服力,答辩时评委问到“怎么证明系统有效”也能直接答上。
5.2 三个最容易翻车的问题与对策
| 现象 | 原因 | 对策 |
|---|---|---|
| 印刷体也被擦掉 | 生成器对全图做变换,mask 没有参与推理 | 后处理强制用 mask 和原图融合 |
| 手写区域擦不干净 | L1 权重过大,生成结果偏向模糊 | 把 L1_LAMBDA 从 100 降到 50,适当增大 GAN 损失 |
| 图像整体发灰、对比度下降 | 归一化处理不一致或生成器输出范围偏差 | 检查预处理是否统一用 -1 到 1,后处理时做一次直方图匹配 |
发灰问题在真实扫描件上经常出现,一个简单的补救是在保存前对输出图像做cv2.convertScaleAbs(result, alpha=1.05, beta=5),把对比度拉回来一点。手写残留问题如果调损失还解决不了,优先回看数据合成的字体库是否过窄,多加入两个手写风格字体往往比调参更有效。
5.3 训练时监控合成测试集,而不是只看训练 loss
训练中途判断模型是否可用,要划出一批合成测试集,里面包含训练时没见过的字体和书写角度,每 5 个 epoch 在这批数据上算一次平均 L1 和平均 SSIM。训练 loss 下降很快但测试集指标不变,说明模型在背训练样本,立即减小学习率或增强数据多样性。
答辩时最有说服力的展示是打印四张图放在一页 PPT 上:原图、mask 可视化、擦除结果、原图与结果的叠加差异图。差异图能直观显示系统只动了手写区域、没动印刷体,这比罗列指标更直观。验证到这里,这套从数据生成、模型训练到系统交付的路径就完整闭环了。
本文还有配套的精品资源,点击获取