ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度学习图像翻译实战:基于pix2pix的试卷手写擦除方法

深度学习图像翻译实战:基于pix2pix的试卷手写擦除方法 简介面向计算机视觉与深度学习方向的毕业设计这套基于深度学习的试卷手写文字擦除项目提供完整源码、模型与文档适用于教学、竞赛及科研场景。资源共29个文件以Python脚本为核心涵盖数据加载、损失函数、网络结构、训练与测试流程py文件对应训练、预测与评估sh脚本一键执行MD/readme说明算法原理与用法模型参数文件亦随包提供压缩包仅94KB轻量易部署。内容预览显示项目采用生成对抗与分割网络完成手写痕迹去除并集成PSNR等评估指标及ONNX转换工具便于效果对比与跨平台部署模型与文档也已打包可快速复现整套实验流程。已有124人学习下载可据此开展课题原型设计、论文实验或工程实践也能扩展字段、优化结构验证手写擦除算法的实际表现。1. 试卷手写擦除不是抠图题而是深度学习图像翻译题一个常见的认知是试卷手写文字擦除无非是把黑色笔迹过滤掉和印刷体分离后删除。真拿扫描卷跑一遍就会发现黑色中性笔的墨水密度和印刷油墨几乎一样两者灰度直方图完全重叠连人眼都要靠上下文判断哪些笔画是后写上去的。把它定义成“图像翻译”image-to-image translation问题用深度学习模型学习从“含手写卷”到“干净卷”的映射才是这套毕业设计真正的工作量所在。这类能力在教育行业很实用比如错题重做、试卷复用、题目去噪。下面从任务建模开始讲逐步落到数据合成、训练参数和真实场景里的坑。2. 先回答“为什么不能用阈值分割”任务建模与模型选型2.1 传统方法的三个死角恰好是深度学习的切入点我见过不少本科毕设一开始都试图用 OpenCV 的阈值分割、颜色滤波或形态学变换来做擦除理由是“手写和印刷都是黑字阈值选好就能分离”。实际跑一遍就会撞上三个问题。第一灰度重叠印刷体的像素值和黑色中性笔的像素值分布在同一个区间全局阈值怎么调都会同时误伤两边。第二交叉笔画手写答案写在印刷体附近时笔画直接叠在印刷字上二值化后两者连通成一个连通域形态学腐蚀会把印刷体笔画一起掏掉。第三真实试卷上有网格线、水印、彩色校徽这些噪声让颜色空间的干净分割变得不可能。这三个死角的共同点是“只看像素值不够必须看上下文”。比如一横出现在答题填空区域周围字距整齐人能判断它是学生写的而同样的横出现在印刷标题里结构紧凑连贯人又不会误判。深度学习模型能隐式学习这种区域级和字形级语义。这也是为什么标题里的“深度学习”不是炫技而是解决任务必需的手段。2.2 把它建模成 paired 图像翻译首选 pix2pix 而非 CycleGAN任务定义很简单输入含手写的试卷图 X输出对应干净卷 Y学一个生成器 G: X→Y。因为训练数据可以人工构造出精确的配对同一张试卷擦除前后这是一个标准的 paired image-to-image translation 问题pix2pix 是最稳妥的方案。有人会问为什么不用 CycleGAN两个原因。其一CycleGAN 是为无配对数据设计的适合“只有真实脏卷、没有对应干净卷”的场景但它的约束更弱容易为了骗过判别器而把印刷体也擦掉生成结果不可控。其二我们在数据环节可以低成本地制作配对样本既然有监督信号就没必要绕开。实际中真正合理的分工是pix2pix 主攻配对合成数据CycleGAN 只在需要把真实扫描卷接入训练时用来做风格迁移或域适应。模型选型上还有一个折中方案如果毕设周期短、机器一般可以先把生成器单独用 L1 损失训练能稳定收敛再引入判别器。这种做法训练快、不挑卡效果已经能应付大部分“印刷体清晰、手写擦除”的场景GAN 部分更多是让输出看起来更自然。2.3 U-Net 生成器加 PatchGAN 判别器结构与关键参数结构上我一般沿用 pix2pix 的经典组合。生成器是 U-Net编码器下采样到 1/8 分辨率瓶颈层插 9 个残差块解码器再上采样回来靠 skip connection 把低层细节直接拼给高层。这样设计的原因是手写擦除既要“删掉”手写笔画又要“保住”印刷体的细线U-Net 的跳跃连接让解码器能直接访问编码器早期的边缘信息是传统自编码器做不到的。判别器用 70×70 PatchGAN它不判断整张图是否真实而是将输入切成约 70×70 感受野的局部区域逐个判断真实与否。手写擦除关心的是局部纹理是否自然比如擦过的区域还有没有笔画残留、有没有模糊糊块PatchGAN 正好能约束这种局部质量计算量也远小于全图判别器。下面是一张常用的结构参数表方便对照源码逐层检查。模块操作的细节输出尺寸变化EncoderConv4×4, stride2, BN, LeakyReLU(0.2)256→128→64→32→16Bottleneck9×ResNet Block通道51216×16 保持不变DecoderConvTranspose4×4, stride2, BN, ReLU16→32→64→128→256PatchGAN4层Conv最后输出 N×N patch256→16×16 概率图损失函数上我一般用 L1 加 LSGAN 对抗损失的组合权重 λ 取 100。L1 保证结构一致性对抗损失保证视觉自然度。打印刷体是最怕模糊的L1 天生偏好平均值回归如果发现输出偏糊可以再加一个 VGG 感知损失但对毕设来说 L1GAN 通常够用。3. 造数据比调参重要合成手写试卷训练集的完整脚本3.1 为什么毕业设计要从合成数据开始配对数据是这个任务里最稀缺的资源。真实扫描卷只有带手写的版本想拿到干净版本要么找老师要原始空白卷要么人工用修图软件擦掉手写人力成本极高。常见做法是合成先准备干净的试卷底图按规则把手写文字叠加到底图上加完的图就是 dirty原始底图就是 clean配对天然成立要多少有多少。对毕设来说合成数据还有一个好处可以控制难度。先只生成简单的“填空区中性笔手写”训练一批再引入红笔批改、铅笔笔迹、倾斜扫描、光照不均匀等噪声模型的效果和瓶颈都能直观看到。答辩时也更容易讲清楚“数据是怎么构建的、为什么这么做”。3.2 核心合成脚本透明层叠加生成配对样本下面这段代码是整套合成方案的核心它做的事情是在干净试卷图指定位置叠加手写字迹输出 dirty 图同时保留 clean 图配对样本直接生成。# synth_pairs.py —— 在干净试卷上叠加手写笔迹生成 (dirty, clean) 对 import random import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont def render_handwriting(clean_path, font_path, answers, dirty_path): answers: [(x, y, w, h, text), ...]每个框是一处答题区域 font_path: 开源手写字体文件路径.ttf clean cv2.imread(clean_path) # BGR rgb cv2.cvtColor(clean, cv2.COLOR_BGR2RGB) base Image.fromarray(rgb).convert(RGBA) overlay Image.new(RGBA, base.size, (0, 0, 0, 0)) draw ImageDraw.Draw(overlay) for (x, y, w, h, text) in answers: # 字体大小按框高度自适应模拟不同书写大小 font_size int(h * random.uniform(0.55, 0.8)) font ImageFont.truetype(font_path, font_size) # 随机位置抖动模拟书写偏移少量红笔模拟批改痕迹 dx x random.randint(-3, 3) dy y random.randint(-3, 3) if random.random() 0.15: color (255, 0, 0, random.randint(160, 255)) # 红色批改 else: color (20, 20, 20, random.randint(180, 255)) # 黑色中性笔 draw.text((dx, dy), text, fontfont, fillcolor) dirty Image.alpha_composite(base, overlay).convert(RGB) dirty_bgr cv2.cvtColor(np.array(dirty), cv2.COLOR_RGB2BGR) cv2.imwrite(dirty_path, dirty_bgr)代码逻辑不难但有几个参数是影响训练质量的关键。alpha 通道的 180~255 区间控制笔迹深浅值越低字越虚能模拟铅笔或快没水的中性笔字体大小按框高自适应相当于给模型提供了“答题区域尺度”的先验。红笔 15% 的生成比例是我加上去的真实批改场景如果不做模型会对红色通道完全无感遇到真实红笔试卷会直接翻车。3.3 批量生成目录并切分训练验证集合成脚本只负责单张图还需要一个批量脚本管理目录结构。我一般把 clean 和 dirty 分目录存放文件名保持一致训练时按文件名配对读取。# build_dataset.py —— 批量生成并切分 train / val import os import random import glob clean_imgs glob.glob(data_clean/*.png) random.shuffle(clean_imgs) split_idx int(len(clean_imgs) * 0.1) for split, part in [(train, clean_imgs[split_idx:]), (val, clean_imgs[:split_idx])]: for split_dir in (fdata/{split}/clean, fdata/{split}/dirty): os.makedirs(split_dir, exist_okTrue) # 每张 clean 图按同一套答案渲染对应 dirty 图 # 建议把答案区域标注单独存成 JSON方便复查和增量增强实际增强我会再加四组整图轻微透视变换模拟扫描摆放歪斜HSV 里亮度抖动模拟复印深浅对 dirty 加高斯噪声模拟扫描仪的传感器噪声偶尔把手写层做一次模糊模拟纸张渗透到背面的痕迹。增强代码不复杂但要记住一点同一对的 clean 和 dirty 必须做完全相同的几何变换否则配对就废了。这属于容易踩但又必须写的细节。4. 用 PyTorch 跑通训练生成器判别器的配合节奏4.1 输入输出约定为什么我保留 RGB 而不转灰度很多做图像任务的毕设会顺手把图转成灰度但在手写擦除里我建议保留 RGB。真实试卷里圆珠笔是蓝色的批改是红色的这些信息在灰度空间里可能和印刷体混在一起而在 RGB 空间里颜色本身就是一个强特征。模型不仅要擦黑色笔迹还要学会“看到红色就优先擦掉”这种语义在灰度图上是学不到的。输入上训练时把 dirty 和 clean 都缩放到 256×256归一化到 [-1, 1]。如果生成器是标准的 U-Net输入输出通道数都是 3。推理时可以用 512×512训练 256 推理 512 会造成一点感受野变化但实测效果通常没问题显存不够时这是一个常见的折中。4.2 训练主循环对抗损失和 L1 的权重关系下面是一个最小可跑的 PyTorch 训练主循环省略了数据加载和模型定义细节重点看生成器和判别器的更新节奏。# train.py —— 关键训练步骤 import torch import torch.nn.functional as F from models import Generator, Discriminator G Generator().cuda() D Discriminator().cuda() opt_G torch.optim.Adam(G.parameters(), lr2e-4, betas(0.5, 0.999)) opt_D torch.optim.Adam(D.parameters(), lr2e-4, betas(0.5, 0.999)) l1_loss torch.nn.L1Loss() lambda_l1 100.0 # L1 权重压制判别器的破坏倾向 for epoch in range(200): for dirty, clean in train_loader: dirty, clean dirty.cuda(), clean.cuda() # 生成器前向 fake G(dirty) # 更新判别器真图判真假图判假 d_real D(clean, dirty) d_fake D(fake.detach(), dirty) d_loss 0.5 * (F.mse_loss(d_real, torch.ones_like(d_real)) F.mse_loss(d_fake, torch.zeros_like(d_fake))) opt_D.zero_grad() d_loss.backward() opt_D.step() # 更新生成器尽量骗过判别器 输出贴近真实干净图 fake G(dirty) g_adv F.mse_loss(D(fake, dirty), torch.ones_like(d_real)) g_l1 l1_loss(fake, clean) g_loss g_adv lambda_l1 * g_l1 opt_G.zero_grad() g_loss.backward() opt_G.step()这里有三个参数值得解释。第一Adam 的 betas 取 (0.5, 0.999) 是 GAN 训练的常见设置0.5 一阶矩衰减能抑制训练震荡。第二lambda_l1 定为 100是 pix2pix 论文验证过的经验值我一般不改它如果发现印刷体被擦伤再往大调比如 150 甚至 200。第三d_loss 前加了 0.5 系数相当于把判别器学习率减半让生成器先跑起来。4.3 checkpoint 保存策略每个 epoch 都要留后悔药训练 GAN 最怕训了三天发现某个中间 epoch 的效果反而最好后面的过拟合或模式崩溃全白费。所以保存策略我固定是每个 epoch 结束存一个 G.pth 和 D.pth同时在验证集上跑一次把 dirty、clean、fake 三张图横向拼在一起保存成 image_epoch_xxx.jpg人眼扫一眼就知道效果走到哪一步了。显存不大时BatchNorm 的 batch size 建议在能放进显存的前提下尽量大16 左右最好。小于 8 时 BatchNorm 统计不稳定生成结果会出现明显的块状不均匀这是毕设里很常见的翻车点后文避坑部分会展开说。5. 避坑现场手写擦除毕设的 5 个高频翻车点5.1 真实扫描卷上擦不干净合成数据失效现象在合成的验证集上 PSNR 很高一换真实手机拍的试卷手写几乎没擦掉印刷体还变虚了。原因合成数据只用了手写字体文件笔画形状、纸张纹理、拍照光照都和真实扫描卷差距巨大。模型学到了“合成手写的样式”没学到泛化能力。真实手写油墨渗透、纸张泛黄、阴影遮挡在合成集里都没有对应样本。解决我一般分两步。第一步给合成数据加真实的退化和增强包括高斯噪声、运动模糊、亮度不均、纸张纹理贴图。第二步收集 20 到 50 张真实脏卷只作为测试集定期验证用效果反推合成脚本缺什么。如果时间充裕可以用 CycleGAN 把真实脏卷迁移成“合成风格”参与训练这个技巧能明显拉低合成和真实的分布差距。5.2 印刷体笔画被擦断或变瘦现象手写确实擦掉了但印刷体的横线、撇捺出现了断点字迹明显变细。原因L1 损失对边缘结构的惩罚不够判别器又在鼓励生成干净的图两者一叠加生成器发现“把浅色细线也删掉”能同时降低两个损失。这在笔画宽度小的字体上尤其严重。解决先把 lambda_l1 提到 150 以上观察印刷体是否恢复如果还不行给 L1 加上边缘权重用 Canny 检测印刷体边缘边缘处损失权重乘以 5 或 10强迫模型优先保住这些位置。再不行把训练输入缩放到 512给模型更多像素去表达细节。5.3 擦除后出现模糊的“幽灵笔画”现象手写区域变成一块灰蒙蒙的雾隐约还能看到原来字的轮廓没有完全干净。原因这是 L1 回归的典型问题L1 对多模态输出会倾向于输出均值模糊同一位置既有手写又有空白模型输出了一个折中的灰色。判别器不够强、patch 尺寸不够大时也压不住这种模糊。解决优先换更强的判别器。PatchGAN 的 patch 从 70×70 加宽到 140×140感受野变大能更敏锐地发现“这里应该有干净纸张纹理却不能糊成一块”。其次是让判别器每个 step 更新一次的基础上生成器每两个 step 更新一次迫使生成器更认真地应对判别器。5.4 红色圆珠笔、红批改被擦成黑块现象测试集里出现红笔内容时模型把红色区域全涂成了深色块印刷体反而正常。原因训练数据里红笔占比太少或者我前面合成脚本里红笔 alpha 范围过窄模型几乎没有见过红色手写只能把它当成印刷体纹路处理。这不是模型坏了是数据分布问题。解决在合成阶段把红笔生成比例提高到 20%~25%并且让红色在 RGB 空间做随机偏移把从纯红到暗红都覆盖全。我还在一个版本里专门生成纯红色写的整行数字配合少量红色印刷的标题让模型学会区分“红色印刷体”和“红色手写”的上下文差异。5.5 训练震荡不收敛损失曲线像锯齿现象生成器和判别器的损失都在 0.5 附近来回跳验证集输出时好时坏几乎每 5 个 epoch 就变化一次。原因GAN 训练本身是博弈最可能是判别器学得太快直接把生成器样本判得毫无希望梯度也带不回去。另一个常见原因是 lr 太大2e-4 对很多任务都要再降到 1e-4。解决把判别器的更新频率降下来两个 batch 才更新一次 D一个 batch 更新一次 G。再把 D 的 lr 单独降为 G 的一半比如 G 用 2e-4、D 用 1e-4。最后是记住一个原则G 的 L1 损失是托底的对抗损失是润色的只要 L1 还在正常下降训练基本不会真正崩掉。6. 从“能擦”到“擦得干净”分块推理与质量验证模型训练完下一步是把效果稳定复现到真实扫描图上。我最常用也最推荐的一个落地技巧是重叠分块推理。整张扫描卷分辨率常到 3000×4000 以上直接送进模型显存一是放不下二是即便放得下高分辨率下的感受野和训练时不一致输出会变钝。把长边切成 512×512 的块块与块之间留 64 像素重叠推理完成后把重叠区域做线性融合就能避免接缝处出现断层。代码上只需要用一个滑动窗口循环没有任何额外学习成本。验证质量时我一般同时看三个东西PSNR、SSIM、以及人工放大对比。PSNR 和 SSIM 都有对应实现直接算生成图和真实干净卷的差值即可但这两项指标对手写擦除的参考价值有限因为手写区域在整张图中占比不大大面积的空白纸张会把指标拉得很高显得结果不错。人工对比才是关键我固定放大 200%重点看三处印刷体笔画是否连续、手写交叉处是否残留、纸张底色是否均匀。这三处过关模型的工程价值才算真正成立。这套方案做下来最大的体会是手写擦除的难点不在模型结构而在数据和验证。模型结构用 pix2pix 的经典组合就够了难点是合成看不见的、足够覆盖真实分布的配对数据并养成每个 epoch 留 checkpoint、每轮验证都用真实图像对比的习惯。我早期走过不少弯路总在调生成器结构后来发现把合成数据的增强做厚效果提升比换模型明显得多。希望这个落地路径对你做毕业设计或实际落地方向有帮助。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进