2026/9/10 11:17:50

深度学习实战:基于U-Net与GAN的试卷手写文字擦除系统解析

深度学习实战:基于U-Net与GAN的试卷手写文字擦除系统解析 简介基于深度学习的试卷手写文字擦除系统是一套个人优秀毕业设计评审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_lines5, max_chars15): 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, krandom.randint(5, max_chars) )) # 手写墨迹不是纯黑给一个灰度区间 color (30 random.randint(0, 40),) * 3 (random.randint(120, 180),) draw.text((x, y), text, fontfont, fillcolor) mask_draw.text((x, y), text, fontfont, fill255) 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 PatchGAN3.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_channels3, out_channels3): super().__init__() # 下采样四层每层是 Conv BN LeakyReLU self.e1 self._block(in_channels, 64, normFalse) 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, normTrue): 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], dim1)) d self.d2(torch.cat([d, e2], dim1)) d self.d3(torch.cat([d, e1], dim1)) 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 size4单卡 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_channels3, out_channels3).to(device) state torch.load(ckpt_path, map_locationdevice) if model_state in state: G.load_state_dict(state[model_state]) else: G.load_state_dict(state) G.eval() return Gmap_locationdevice是跨设备加载的关键参数。调用方传入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, maskNone, dilation5): 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), sigmaX3) alpha (mask / 255.0)[..., None] result pred * alpha orig * (1 - alpha) else: result pred return result.astype(np.uint8)预处理里的permute(2, 0, 1)把 HWC 变成 CHWunsqueeze(0)给网络加 batch 维推理完再逆回来。后处理里dilation参数控制 mask 向外膨胀的像素数手写墨迹有渗透mask 给出的区域往往比实际笔迹略小膨胀 5 个像素可避免边缘残留。sigmaX3的高斯模糊让替换区域边缘羽化过渡更自然。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, maskNone) _, encoded cv2.imencode(.jpg, out) return Response(base64.b64encode(encoded.tobytes()), media_typeimage/jpeg) app.run(host0.0.0.0, port5000)注意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, dtypenp.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, alpha1.05, beta5)把对比度拉回来一点。手写残留问题如果调损失还解决不了优先回看数据合成的字体库是否过窄多加入两个手写风格字体往往比调参更有效。5.3 训练时监控合成测试集而不是只看训练 loss训练中途判断模型是否可用要划出一批合成测试集里面包含训练时没见过的字体和书写角度每 5 个 epoch 在这批数据上算一次平均 L1 和平均 SSIM。训练 loss 下降很快但测试集指标不变说明模型在背训练样本立即减小学习率或增强数据多样性。答辩时最有说服力的展示是打印四张图放在一页 PPT 上原图、mask 可视化、擦除结果、原图与结果的叠加差异图。差异图能直观显示系统只动了手写区域、没动印刷体这比罗列指标更直观。验证到这里这套从数据生成、模型训练到系统交付的路径就完整闭环了。本文还有配套的精品资源点击获取