简介:本资源为基于深度学习的试卷手写文字擦除毕业设计完整项目包,面向计算机视觉方向的高校学生与深度学习入门开发者,可用于毕业设计参考、图像修复课题研究及教育文档数字化场景。包内共29个文件,以22个Python源码文件为核心,涵盖模型定义、损失函数、数据加载与训练测试脚本,另含3个Shell脚本用于训练与转换流程,以及README与Markdown文档说明,压缩包约94KB,结构紧凑便于快速上手。项目围绕手写文字擦除任务,涉及U-Net类分割网络、生成对抗网络、注意力机制与ONNX模型转换等模块,并配有训练权重与测试入口,可帮助读者理解从数据预处理到模型部署的完整链路。目前已有124人学习下载,适合需要完整毕设方案、算法实现细节与排错思路的读者参考借鉴。
1. 试卷手写擦除到底在做什么:从一张答题卡到干净卷面的技术链路
带过毕业设计的人大概都有体会:题目里只要出现「基于深度学习」六个字,真正难的不是模型结构,而是把一份能跑通的源码、一个训好的模型和一份说得清的文档说明凑齐。试卷手写文字擦除这个方向,恰好卡在一个很实用的场景上——把学生写在印刷体试卷上的手写笔迹抹掉,只留下原本的题目印刷内容,还原出一张干净的空白卷。它和照片修复模型、人声抑制这类任务在思路上是相通的:都是把图像里某一类「叠加层」分离出去,保留底层的结构信息。
这个方向适合两类人:一类是正在找毕业设计选题、需要一份完整源码加模型加文档说明的同学,另一类是已经入门深度学习、想找一个图像到图像转换任务练手的工程师。它不需要你从零标注数据,公开的手写数据集和合成方案足够撑起一个能答辩、能复现的项目。下面按「这是什么 → 怎么做 → 坑在哪」的顺序,把整条链路拆开讲清楚。
2. 试卷手写擦除的技术选型:为什么是图像到图像转换而不是 OCR 后重排
2.1 擦除任务的本质是像素级回归,不是文字识别
很多人第一反应是:先 OCR 识别手写内容,定位坐标,再把那块区域涂白。这条路在真实试卷上几乎必翻车。原因在于手写笔迹和印刷体经常粘连、重叠,OCR 的检测框会把手写和印刷体一起框进去,涂白的时候连题目文字一起抹掉。而且试卷版式多样,填空、解答、批注混在一起,检测框的粒度根本对不上。
擦除任务的正确建模方式是图像到图像转换:输入一张带手写的试卷图,输出一张只有印刷体的干净图。模型学的是「哪些像素属于手写层」这个映射,而不是「手写写了什么字」。这也是它和照片修复模型、去噪任务共享同一套技术底座的原因——都是编码器提取特征、解码器重建目标图,中间靠跳跃连接保留结构细节。
从算法族看,主流有三条路线。第一条是纯 CNN 的编码解码结构,比如基于 U-Net 的变体,实现简单、显存占用低,适合毕业设计的算力条件。第二条是生成对抗网络,用判别器逼着生成结果更接近真实干净卷面,效果更锐利但训练不稳定。第三条是近几年热起来的 Transformer 类结构,全局建模能力强,但对数据量和显存要求高,低显存运行模型时容易 OOM。对毕业设计来说,我一般建议从 U-Net 起步,跑通之后再考虑加判别器或换注意力模块。
2.2 数据从哪来:合成方案比真实采集更可控
真实的手写试卷涉及隐私,公开数据集很少直接可用。常见做法是合成:拿干净的印刷体试卷图作为背景,把手写笔迹数据集里的字随机贴上去,同时生成对应的掩码图。这样一对训练样本就是「带手写图 + 干净图」,掩码图还能作为辅助监督信号。
手写笔迹来源可以用公开的手写数字、手写汉字数据集,也可以自己写一批字扫描进去。关键是贴的时候要做随机旋转、缩放、颜色扰动和透明度混合,让合成分布尽量接近真实拍摄的试卷。下面是一段合成脚本的核心逻辑,用 Python 和 OpenCV 实现。
import cv2 import numpy as np import random def synthesize_sample(clean_img, handwriting_imgs, num_strokes=8): """ clean_img: 干净印刷体试卷图 (H, W, 3) handwriting_imgs: 手写笔迹图列表,每张是单通道或三通道 返回: 带手写图, 干净图, 掩码图 """ h, w = clean_img.shape[:2] noisy = clean_img.copy() mask = np.zeros((h, w), dtype=np.uint8) for _ in range(num_strokes): hw = random.choice(handwriting_imgs) # 随机缩放,模拟不同字号 scale = random.uniform(0.5, 1.5) nh, nw = int(hw.shape[0] * scale), int(hw.shape[1] * scale) hw_resized = cv2.resize(hw, (nw, nh)) # 随机位置,保证不越界 if nh >= h or nw >= w: continue y = random.randint(0, h - nh) x = random.randint(0, w - nw) # 随机旋转 angle = random.uniform(-15, 15) M = cv2.getRotationMatrix2D((nw / 2, nh / 2), angle, 1.0) hw_rot = cv2.warpAffine(hw_resized, M, (nw, nh), borderValue=(255, 255, 255)) # 提取笔迹区域(假设背景为白,笔迹为深色) gray = cv2.cvtColor(hw_rot, cv2.COLOR_BGR2GRAY) _, stroke_mask = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV) # 随机颜色扰动,模拟不同笔的颜色 color = np.array([random.randint(0, 80) for _ in range(3)]) roi = noisy[y:y+nh, x:x+nw] roi[stroke_mask > 0] = color mask[y:y+nh, x:x+nw][stroke_mask > 0] = 255 return noisy, clean_img, mask这段代码的逻辑是:遍历每一张手写笔迹图,随机缩放、旋转、定位,然后通过阈值提取笔迹的掩码区域,把该区域像素替换成随机深色,同时更新全局掩码图。参数num_strokes控制每张图叠加的手写数量,一般设 5 到 15 之间,太少模型学不到密集笔迹的擦除,太多会让印刷体被大面积遮挡,训练信号变差。scale范围对应真实试卷上不同字号的手写,angle模拟书写倾斜。掩码图在训练时可以作为额外输出分支的监督,让模型更明确地学到「哪里是手写」。
注意:合成时手写颜色不要只用纯黑,真实笔迹有蓝、黑、红多种,颜色扰动能让模型对笔色更鲁棒。
2.3 模型结构怎么搭:U-Net 的编码器、解码器与跳跃连接
选 U-Net 作为基线,是因为它在小数据集上收敛快、结构清晰、方便在文档说明里画图讲原理。编码器用几层卷积加下采样,逐步提取语义特征;解码器用上采样加卷积,逐步恢复空间分辨率;跳跃连接把编码器同层的特征拼到解码器,保留边缘和纹理细节。对擦除任务来说,跳跃连接尤其重要,因为印刷体的笔画边缘很细,没有它解码器很难重建出锐利的文字。
下面是一个精简版 U-Net 的实现,输入输出都是三通道图像,中间加一个掩码预测分支。
import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = 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): return self.block(x) class UNetErase(nn.Module): def __init__(self, base=32): super().__init__() # 编码器 self.enc1 = ConvBlock(3, base) self.enc2 = ConvBlock(base, base * 2) self.enc3 = ConvBlock(base * 2, base * 4) self.pool = nn.MaxPool2d(2) # 瓶颈 self.bottleneck = ConvBlock(base * 4, base * 8) # 解码器 self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2) self.dec3 = ConvBlock(base * 8, base * 4) self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2) self.dec2 = ConvBlock(base * 4, base * 2) self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2) self.dec1 = ConvBlock(base * 2, base) # 输出头:干净图 + 掩码 self.out_img = nn.Conv2d(base, 3, 1) self.out_mask = nn.Conv2d(base, 1, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) b = self.bottleneck(self.pool(e3)) d3 = self.dec3(torch.cat([self.up3(b), 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_img(d1)), torch.sigmoid(self.out_mask(d1))base控制基础通道数,毕业设计场景下 32 或 64 都够用,显存紧张就降到 16。编码器每下采样一次通道翻倍,解码器每上采样一次通道减半,跳跃连接用torch.cat在通道维拼接。输出两个头:out_img是重建的干净图,out_mask是手写区域掩码。训练时干净图用 L1 或 L2 损失,掩码用二值交叉熵,两者加权求和。掩码分支的作用是给模型一个显式的「手写在哪」的监督,实践中能明显加快收敛。
2.4 训练配置:损失函数、学习率与显存权衡
损失函数我一般用 L1 加掩码 BCE 的组合,权重比 1:0.5 左右。L1 比 L2 在图像重建任务上更容易产生锐利边缘,不会像 L2 那样倾向于输出模糊的平均值。学习率用 1e-4 配 Adam,训练中期如果 loss 平台期就降到 1e-5。batch size 在 8GB 显存下用 4 到 8,输入尺寸裁到 256×256 或 512×512,太大显存扛不住。
训练轮数看数据量,合成样本一万对左右,跑 50 到 100 个 epoch 基本收敛。每个 epoch 后在验证集上看重建图和掩码的指标,重建图用 PSNR 和 SSIM,掩码用 IoU。如果 PSNR 上不去但掩码 IoU 很高,说明模型定位到了手写但重建质量差,这时候要检查解码器的跳跃连接有没有正常拼接,或者损失权重是不是掩码占太大。
3. 从源码到可运行:环境搭建、数据管线与推理脚本
3.1 环境依赖与目录结构
拿到一份「源码+模型+文档说明」的压缩包,第一件事不是急着跑,而是先看目录结构和依赖清单。典型的项目结构长这样:data/放合成脚本和样本,models/放网络定义,train.py和infer.py是入口,weights/存训好的模型,docs/放文档说明。依赖一般是 PyTorch、OpenCV、NumPy、Pillow,版本不要追新,PyTorch 1.13 到 2.x 都能跑,CUDA 版本和显卡驱动对上就行。
# 建议用 conda 建独立环境,避免和系统 Python 冲突 conda create -n paper_erase python=3.9 -y conda activate paper_erase # 安装 PyTorch,按自己的 CUDA 版本选对应命令 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python numpy pillow tqdm tensorboard装完之后先跑一个最小验证:python -c "import torch; print(torch.cuda.is_available())",输出 True 说明 GPU 可用。如果 False,检查 CUDA 版本和 PyTorch 是否匹配,这是新手最容易卡住的地方。
3.2 数据加载与增强的工程细节
数据管线用 PyTorch 的Dataset和DataLoader封装。合成样本可以离线生成存成图片对,也可以在线生成。离线生成的好处是训练时 IO 快,坏处是占磁盘;在线生成省空间但每个 epoch 的样本都不同,相当于做了数据增强。毕业设计建议离线生成一批固定样本,保证实验可复现。
from torch.utils.data import Dataset, DataLoader import cv2 import numpy as np import os class EraseDataset(Dataset): def __init__(self, root, img_size=256): self.root = root self.img_size = img_size self.names = [f for f in os.listdir(os.path.join(root, 'noisy')) if f.endswith('.png')] def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] noisy = cv2.imread(os.path.join(self.root, 'noisy', name)) clean = cv2.imread(os.path.join(self.root, 'clean', name)) mask = cv2.imread(os.path.join(self.root, 'mask', name), 0) # 统一尺寸 noisy = cv2.resize(noisy, (self.img_size, self.img_size)) clean = cv2.resize(clean, (self.img_size, self.img_size)) mask = cv2.resize(mask, (self.img_size, self.img_size)) # 归一化到 [0,1],转 CHW noisy = torch.from_numpy(noisy[:, :, ::-1].copy()).permute(2, 0, 1).float() / 255.0 clean = torch.from_numpy(clean[:, :, ::-1].copy()).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(mask).unsqueeze(0).float() / 255.0 return noisy, clean, mask loader = DataLoader(EraseDataset('data/train'), batch_size=8, shuffle=True, num_workers=4, pin_memory=True)num_workers设成 CPU 核数的一半左右,太多反而拖慢。pin_memory=True在 GPU 训练时能加速数据传输。注意 OpenCV 读进来是 BGR,转 RGB 用[:, :, ::-1],这个细节漏了会导致颜色整体偏,模型学出来的干净图颜色不对。
3.3 推理脚本与批量处理
训练完之后,推理脚本要能处理单张图和整个文件夹。单张图直接前向,文件夹就遍历。输出保存时把干净图和掩码都存下来,方便对比。
import torch import cv2 import numpy as np from models.unet import UNetErase def infer(model_path, img_path, out_path, img_size=256): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = UNetErase(base=32).to(device) model.load_state_dict(torch.load(model_path, map_location=device)) model.eval() img = cv2.imread(img_path) h, w = img.shape[:2] inp = cv2.resize(img, (img_size, img_size)) inp = torch.from_numpy(inp[:, :, ::-1].copy()).permute(2, 0, 1).float() / 255.0 inp = inp.unsqueeze(0).to(device) with torch.no_grad(): out_img, out_mask = model(inp) # 还原到原尺寸 clean = out_img.squeeze(0).permute(1, 2, 0).cpu().numpy() clean = (clean * 255).astype(np.uint8) clean = cv2.resize(clean, (w, h)) cv2.imwrite(out_path, clean[:, :, ::-1]) if __name__ == '__main__': infer('weights/best.pth', 'test/sample.png', 'test/clean.png')推理时注意model.eval()和torch.no_grad()都要加,前者关掉 BatchNorm 的训练行为,后者省显存。输入尺寸和训练时保持一致,否则 BatchNorm 的统计量对不上,输出会发灰。如果原图分辨率很高,直接缩到 256 会丢细节,可以分块推理再拼接,但块与块之间要做重叠融合,避免接缝。
4. 避坑与排查:合成数据、训练和推理里最容易翻车的几件事
4.1 合成手写太规整,模型在真实卷面上失效
现象:训练集上 PSNR 很高,拿真实拍摄的试卷一测,手写擦不干净,印刷体还被抹掉一块。原因是合成时手写都是正着贴的,颜色单一,背景干净,模型学到了「深色像素就是手写」这个捷径。真实卷面有阴影、折痕、拍照畸变,深色区域不一定是手写。
解决:合成时加大扰动幅度,旋转角度放到 ±30 度,颜色范围拉宽,给背景图加高斯噪声和亮度变化,手写笔迹做弹性形变模拟书写抖动。另外可以在训练后期混入少量真实标注样本做微调,哪怕只有几十张,也能明显改善泛化。
4.2 掩码和重建图两个分支互相拖累
现象:训练时重建 loss 下降但掩码 IoU 上不去,或者反过来。两个分支共享编码器,梯度方向冲突时会互相拉扯。原因是损失权重没调好,或者掩码监督太强,模型把精力都放在分类像素上,忽略了重建质量。
解决:先固定权重比 1:0.5 跑一轮,看两个指标是否同步上升。如果掩码 IoU 高但重建差,把掩码权重降到 0.2;如果掩码一直上不去,检查掩码标签是不是有噪声,比如合成时阈值选得不好导致笔迹边缘没标全。也可以让掩码分支只在训练前期参与,后期冻结,让重建分支主导。
4.3 显存不够导致 batch size 只能设 1
现象:8GB 显存跑 256×256 输入,batch size 设 4 就 OOM。原因是 U-Net 在瓶颈层的特征图通道数很大,加上跳跃连接拼接后通道翻倍,显存占用比想象中高。
解决:把base从 64 降到 32 甚至 16,通道数减半显存占用大约降到四分之一。输入尺寸从 512 降到 256。开启混合精度训练,用torch.cuda.amp能把显存再压三成左右。如果还不够,用梯度累积模拟大 batch,累积 4 步相当于 batch size 乘 4,但显存不变。
scaler = torch.cuda.amp.GradScaler() for i, (noisy, clean, mask) in enumerate(loader): with torch.cuda.amp.autocast(): pred_img, pred_mask = model(noisy) loss = l1_loss(pred_img, clean) + 0.5 * bce_loss(pred_mask, mask) scaler.scale(loss).backward() if (i + 1) % 4 == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()4.4 推理结果整体偏灰或偏色
现象:推理出来的干净图颜色发灰,或者整体偏蓝偏红。原因是输入归一化方式和训练不一致,或者 OpenCV 的 BGR 和 RGB 转换漏了。也有可能是 BatchNorm 在 eval 模式下用了训练集的统计量,而推理图的分布和训练集差太远。
解决:检查推理脚本里的归一化和训练脚本是否完全一致,包括除以 255 的顺序、通道转换的位置。如果颜色偏,把输入输出都打印出来对比。BatchNorm 的问题可以在训练时用更大的数据集覆盖更多分布,或者推理时改用 InstanceNorm,它对单张图的统计更鲁棒。
4.5 文档说明写成流水账,答辩被追问细节答不上
现象:文档说明里只写了「用了 U-Net」「跑了 100 轮」,没有参数表、没有消融实验、没有失败案例分析。答辩老师一问「为什么用 L1 不用 L2」「掩码分支的作用是什么」就卡住。
解决:文档里至少要有三张表——模型结构参数表、训练超参表、不同配置的指标对比表。再配几张图:合成样本示例、训练 loss 曲线、推理结果对比。把踩过的坑和对应的解决也写进去,这比堆砌公式更能体现你真的动手做过。
5. 把擦除模型用出花:掩码后处理、分块推理与效果验证
跑通基线之后,想让效果再上一个台阶,有几个具体技巧值得试。第一个是掩码后处理。模型输出的掩码是概率图,直接二值化会有毛刺,用形态学开运算去掉小噪点,再对边缘做一次高斯模糊,把掩码当软权重去融合原图和重建图,能减少擦除区域的生硬边界。具体做法是:final = noisy * (1 - mask) + pred_img * mask,mask 经过模糊后边缘过渡自然,印刷体不会被硬切。
第二个是分块推理。试卷扫描件动辄 2000×3000 像素,直接缩到 256 推理会丢小字。把大图切成 512×512 的块,块间重叠 64 像素,每块单独推理后按重叠区域加权融合。权重可以用余弦窗,越靠块中心权重越高,这样接缝几乎看不出来。代价是推理时间线性增加,但毕业设计的演示场景完全够用。
第三个是效果验证。光看 PSNR 和 SSIM 不够,这两个指标对文字边缘的锐利度不敏感。我一般会额外算一个「印刷体保留率」:在干净图上用 OCR 检测印刷体文字框,对比擦除前后这些框内的文字识别置信度,如果置信度没掉,说明印刷体没被误伤。再算一个「手写残留率」:在掩码区域内统计还有多少深色像素,残留越少越好。这两个指标比 PSNR 更贴近实际需求。
| 验证指标 | 计算方式 | 合格线参考 |
|---|---|---|
| PSNR | 重建图与干净图的峰值信噪比 | 30 dB 以上 |
| SSIM | 结构相似度 | 0.95 以上 |
| 掩码 IoU | 预测掩码与真值掩码交并比 | 0.85 以上 |
| 印刷体保留率 | 擦除前后 OCR 置信度比值 | 0.95 以上 |
| 手写残留率 | 掩码区域内深色像素占比 | 5% 以下 |
最后说个我自己的习惯:每次改完模型或数据,先拿同一张测试图跑一遍,把输入、输出、掩码三张图并排存下来,肉眼过一遍再去看指标。指标涨了但图看着别扭的情况太常见了,肉眼是最快的 sanity check。这个方向不难,难的是把每个环节的细节抠到位,从合成数据的扰动幅度到推理时的归一化,任何一处偷懒都会在最终效果上放大。希望帮到你。
本文还有配套的精品资源,点击获取