简介:在图像处理中,如何将复杂的前景信息分离是经典难题。传统算法依赖颜色、梯度等低层特征,面对手写笔迹与印刷体混杂的试卷图像时往往束手无策。基于深度学习的图像翻译技术,将此类擦除任务转化为端到端的生成问题,模型可直接学习从“带字图像”到“干净图像”的映射。UNet结构凭借编码器-解码器与跳连接设计,在保留印刷体纹理细节的同时有效抹除手写痕迹。针对配对数据稀缺的痛点,合成数据结合半监督迭代训练提供了切实可行的方案。这项技术在OCR前处理、文档去噪、试卷清理等场景具有广泛应用价值。本文从UNet原理出发,结合PyTorch实现,系统分享试卷手写文字擦除的完整训练、推理与调参经验,帮助开发者绕过常见工程坑点。

1. 项目概述与核心痛点

先说结论:这是一份我好不容易从某个付费群里蹲来的学习资源,标题写着“基于深度学习的试卷手写文字擦除源码+模型+文档说明.zip”,压缩包里大概包含了完整的Python训练代码、预训练权重文件以及一份PDF格式的说明文档。我折腾了一整个周末,从解压、复现到微调,踩了不少坑,这篇博文就是把整个过程的细节、原理和排错笔记整理出来,希望能帮同样想做这个方向的人少走弯路。

先解释一下这个项目到底在解决什么问题。大家应该都有过这种经历:手头有一张做过的试卷,想再刷一遍,可上面全是红笔黑笔留下的答案和批改痕迹,用橡皮擦也擦不干净,扫描成电子版之后那些笔迹依然顽固地压在题目上,用普通图像处理工具很难干净地去掉。传统的去噪算法(比如OpenCV里的快速去噪、大津二值化、形态学操作)对大面积、高对比度、颜色各异的手写笔迹基本无能为力,因为手写笔迹和试卷印刷体都属于前景信息,单纯靠颜色、梯度这些低层特征很难区分开。而深度学习方法把这个问题转化成“图像到图像”的翻译任务,可以用一个神经网络模型直接从“带手写笔迹的试卷图像”预测“干净的无字试卷图像”,不需要人工设计复杂的特征规则,只要数据够多、模型结构合理,就能自动学到印刷体本身的先验结构(比如印刷体笔画更规整、边缘更锐利、布局更一致),把手写笔迹从图中分离并抹除。

这个资源包的适用人群非常明确:图像处理方向的初学者、考研或期末备考时需要整理真题卷的学生党、教育信息化领域的开发者,以及任何想做OCR前处理(先把卷面洗干净再识别)的人。从我个人的角度看,它最大的价值不在于“一键擦除”这个功能本身,而在于它把完整的训练闭环摆在你面前:数据怎么构造、模型怎么选、损失函数怎么设计、后处理怎么做,这些在论文里往往语焉不详的坑,源码里全都摊开了。这篇文章我就按自己动手复现的顺序,把整个过程拆开讲清楚。

2. 整体设计思路与模型选型逻辑

2.1 手写擦除的本质:这是一道图像翻译题

刚开始接触这个课题的时候,我总想着能不能用图像分割的思路:先写一个模型把手写笔迹的像素位置分割出来,再把对应位置的像素填成纸面背景色。但真做起来问题很多,比如笔迹压线时分割标注很难画准;像素级的填充容易产生硬边;更麻烦的是如果擦除区域下面还有印刷体文字,单纯用背景色填充会把印刷体也抹掉。所以后来我转换了思路——应该让网络直接预测干净的图像,而不是预测一个擦除区域。换句话说我需要的是端到端的生成模型,输入一张脏卷子,输出一张对应的干净卷子,所有擦除逻辑全部由网络隐式学习。这个思路本质上是图像翻译(Image-to-Image Translation)领域的经典设定,跟超分辨率、去雨、去雾、图像修复属于同一个技术谱系。

那用什么结构来实现这个“翻译”呢?我当时在几个方向之间犹豫过:基于UNet的纯卷积网络、基于GAN的对抗生成网络、以及基于ViT的Transformer结构。UNet的优势是结构成熟、参数量适中、对训练数据量要求没有那么苛刻,即使不用ImageNet预训练权重,从头开始训练也能收敛;GAN的生成效果往往更“锐利”、更真实,但训练不稳定,需要同时训一个判别器,调参难度上升一个量级;Transformer的全局建模能力强,但实际上手写擦除是一个高频细节敏感的任务,卷面上印刷体的笔锋、字间距、标点这些高频成分很容易在自注意力计算中被平滑掉,而且ViT很难在普通的单卡上训起来。综合数据量、硬件条件、结果稳定性这几个因素,我最终判断这个项目的核心生成器大概率是UNet类结构,并在复现时确实验证了这一点。

2.2 为什么选择UNet这种编码器-解码器结构

UNet的名字来源于它的U形结构:左边是一条逐步下采样的编码器路径,用来逐层压缩空间尺寸、提取越来越抽象的特征;右边是一条逐步上采样的解码器路径,用来把抽象特征还原回原始分辨率;中间通过跳连接把编码器每一层的特征图拼接到解码器对应的层级上。为什么跳连接在擦除任务中这么关键?因为手写擦除要求输出图像既要“干净”,又要保持原本印刷体内容的准确结构。浅层特征图分辨率高、保留了边缘和纹理信息,深层特征图语义强、能判断哪些区域属于手写笔迹。如果没有跳连接,解码器只能拿到极度抽象的高级特征,生成结果往往模糊,印刷体的细节直接糊成一团;有了跳连接,解码器每一层都能参考对应分辨率的原始细节,相当于一边看全局语义、一边对照局部纹理来恢复图像,效果会扎实很多。

一个典型的UNet卷积块就是“两次3×3卷积 + ReLU激活 + 批量归一化”,下采样用步长为2的卷积或2×2最大池化,上采样用转置卷积或双线性插值。实际复现时我看到源码里把普通的卷积替换成了带残差连接的卷积块,还在每个Block后面加了空间注意力模块,让网络更关注手写笔迹所在的区域。实际测试下来,这种设计能让损失下降更快,而且对低对比度红色笔迹的效果有可见提升。

2.3 数据构造才是真正的核心壁垒

模型结构再花哨,没有合适的训练数据也是白搭。我在复现时最头疼的就是数据从哪来。真正的“同一张试卷的脏版和干净版”配对数据,现实中几乎没有现成数据集,网上能找到的HWR数据集大多是手写识别方向的,跟这个任务不匹配。我的做法和大多数开源项目的做法一致:合成配对数据。方法是先准备一批干净的试卷PDF(从开源题库里批量导出),渲染成图片作为“干净标签”;再用文字渲染库模拟手写笔迹,把模拟笔迹叠加在干净图片上生成“输入图片”。叠加时要随机选颜色(黑色、蓝色、红色)、随机位置、随机旋转、随机透明度,才能尽量逼近真实试卷的杂乱程度。

这一步看起来简单,实际很多细节会直接影响模型上限。比如如果只用一种字体模拟手写,模型很容易学到“把所有该字体区域都抹除”的捷径,等遇到真实手写字节时效果雪崩。我建议至少准备5种以上风格差异较大的手写字体,同时叠加真实照片里常见的干扰(比如纸面阴影、折痕、轻微的透视畸变)来增加泛化能力。另外训练时我用了一半合成数据加一半真实标注数据,真实数据是从网上找的带批注的试卷图片,先用一个初始模型自动生成擦除结果,再人工挑质量好的作为伪标签,相当于半监督迭代训练。这个方法虽然麻烦,但对真实场景效果的提升非常明显。

3. 环境准备、源码结构与训练实操

3.1 拿到zip包之后的第一件事:测毒+看文档

这个资源包是zip格式,很多人下载完的第一反应是双击解压,然后发现报错或者解出来的文件损坏。我遇到的第一个坑就是Linux服务器上解压时直接提示“file is not a zip file”,后来用file命令看了下真实类型,发现后缀是zip但实际是个HTML网页(下载时被网关拦了,给了一个错误页)。所以不管从哪下载的资源包,第一步一定要确认它真的是一个完整的zip归档,不是错误页、不是压缩包一半没传完。检查的方式很简单,在Linux终端里执行:

file 试卷手写文字擦除源码+模型+文档说明.zip
unzip -t 试卷手写文字擦除源码+模型+文档说明.zip

第一条命令查看文件真实类型,第二条测试归档完整性。如果提示“invalid zip archive: could not find eocd”,基本就是压缩包被截断了,EOCD(End of Central Directory)记录在文件末尾,文件不完整的话找不到这条记录,自然无法解压。也有可能是文件本身没有下载完全,可以重新下载后再校验一次。确认文件没问题之后,再解压:

unzip 试卷手写文字擦除源码+模型+文档说明.zip -d handwriting_eraser/

解压完成后,正常会看到这几个典型的目录结构:src/放训练和推理脚本、models/放预训练权重、data/放样例数据、docs/放说明文档。我建议先花半小时把说明文档读完再做任何操作,而不是直接跑训练脚本。这个项目的文档写得还算详细,里面包含环境依赖版本、数据目录格式、训练命令示例,这三个信息缺一不可,否则很容易在环境配置上干耗几个小时。

3.2 搭建深度学习环境与依赖版本对应关系

深度学习项目最烦的就是版本不匹配。这个项目的代码基于PyTorch框架,我看了一眼依赖列表,要求Python 3.8+、PyTorch 1.10以上、OpenCV、NumPy、tqdm等。如果你用的是自己的电脑,建议用conda新建一个独立环境,不要直接装在基础环境里,免得跟其他项目冲突:

conda create -n eraser python=3.8
conda activate eraser
pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 -f https://download.pytorch.org/whl/torch_stable.html
pip install opencv-python numpy tqdm pillow tensorboard

这里特别提醒一点:如果机器上的NVIDIA驱动版本比较老,不要盲目装最新版PyTorch,否则会报CUDA版本不匹配的错。可以用nvidia-smi命令查看驱动支持的CUDA版本,然后倒推合适的PyTorch版本。我自己的机器驱动是470系列,最高支持CUDA 11.4,所以装的是11.3的PyTorch,跑得很稳。如果你是Ubuntu 22.04或者24.04的系统,还要额外注意系统自带的显卡驱动经常“安装了没反应”,建议先把系统驱动卸载干净,再手动从NVIDIA官网装驱动,装完重启再验证一遍nvidia-smi是否正常输出,再继续搭环境。

3.3 训练流水线拆解:从数据加载到损失函数

把这个项目的训练主循环梳理了一遍,本质上是一个常规的监督学习流程,但有几个设计点值得展开讲。

数据加载环节用的是PyTorch的Dataset和DataLoader接口。Dataset类里负责从文件夹读取配对图片,每对图片是“带字试卷”和“干净试卷”的同名文件;在读取之后会做一系列在线数据增强,包括随机裁剪到256×256、随机水平翻转、颜色抖动(亮度、对比度、饱和度)。我特意测试过,不做颜色抖动的情况下,模型对红色笔迹的擦除效果明显变差,这很好理解——训练集里红笔样本不足,做颜色增强相当于变相扩充了红笔笔迹的多样性。DataLoader的batch size设为8,num_workers设为4,用多进程加载数据,否则GPU会频繁等待CPU喂数据,训练速度慢得让人崩溃。

模型构建环节,我看到的源码把UNet封装成类,输入是3通道RGB图像,输出也是3通道RGB图像。编码器部分包含5个下采样stage,通道数从64开始逐层翻倍,到最深一层是512;解码器部分也是5个上采样stage,每次上采样后把对应编码器层的特征图沿通道维度拼接起来,再接卷积块。最后一层用1×1卷积把通道数映射回3,激活函数用Tanh,把输出范围约束到[-1, 1]之间,和输入图像的归一化范围保持一致。权重初始化用的是Kaiming初始化,这是ReLU系网络的标准配置。

优化器和损失函数这块,源码里用的是Adam优化器,初始学习率设置成0.0002,并且用了余弦退火学习率调度器。损失函数是L1损失和感知损失的加权组合。L1损失就是“输出图像和干净标签逐像素绝对误差的均值”,它能约束像素级别的颜色接近度,保证擦除区域的颜色和纸面背景一致;感知损失则不是直接比像素值,而是把输出图像和标签图像分别送入一个预训练的VGG16网络,取中间几层特征图计算L1距离,这样网络会去对齐高级语义特征,生成的文字结构更真实。两个损失的权重大概是L1占0.6、感知损失占0.4,这个比例调过几次,纯L1损失的结果偏模糊,纯感知损失的结果容易出现奇怪的纹理畸变。

3.4 训练过程实录:配置、监控和断点续训

我在单张NVIDIA RTX 3060 12G显卡上从头训练,256×256分辨率、batch size 8,显存占用大概6G左右,训练450个epoch大约花了14个小时。训练日志每100个iteration打印一次当前loss,同时把生成的样例图写到TensorBoard里,用来直观观察擦除效果。从loss曲线来看,前50个epoch下降非常快,从0.3降到0.08左右;到200个epoch之后基本进入平台期,loss在小范围内波动,这说明模型已经收敛得差不多了。但我个人的习惯是就算loss平台了,也再让它多跑一段时间,因为视觉质量很多时候和loss变化并不同步,有时候loss没变,但生成图片的笔画残留明显变少。所以建议每训练完100个epoch就手动跑一次推理,看真实效果,不要只看数字。

训练过程中还有一个容易忽略的点:中间保存的checkpoint不要只保存最后一份。项目源码里用的方式是每10个epoch存一个ckpt,文件名带epoch编号。这个策略非常实用,因为后期如果发现模型过拟合或者对某类笔迹效果差,可以用之前某个epoch的权重重新微调,而不是从头再来。模型的保存格式有两种,一种是只保存模型参数:

torch.save(model.state_dict(), "checkpoints/epoch_300.pth")

另一种是连同优化器状态、学习率调度器状态一起保存:

torch.save({
    "epoch": epoch,
    "model": model.state_dict(),
    "optimizer": optimizer.state_dict(),
    "scheduler": scheduler.state_dict()
}, "checkpoints/ckpt_latest.pth")

复现训练时尽量用后一种方式保存,因为一旦训练中途断了,可以直接从断点恢复,优化器的动量信息和学习率调度器的当前位置都不会丢,省去重新调整的过程。

4. 推理部署与效果优化细节

4.1 加载模型跑通推理流程

训练好之后,真正要拿去做擦除的是推理脚本。第一次跑推理的流程大致是:读取图片、resize到模型输入尺寸(这个项目是256×256)、做和训练一样的归一化、喂给模型前向传播、把输出反归一化回0到255、再resize回原图尺寸保存。

代码核心部分大概长这样:

import torch
import cv2
from model import UNet

model = UNet(in_channels=3, out_channels=3)
ckpt = torch.load("models/best.pth", map_location="cpu")
model.load_state_dict(ckpt["model"] if isinstance(ckpt, dict) else ckpt)
model.eval()

image = cv2.imread("input/test.png")
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
origin_h, origin_w = image.shape[:2]
image_resized = cv2.resize(image_rgb, (256, 256))
input_tensor = torch.from_numpy(image_resized).permute(2, 0, 1).unsqueeze(0).float() / 127.5 - 1.0

with torch.no_grad():
    output_tensor = model(input_tensor)
output_array = ((output_tensor.squeeze(0).permute(1, 2, 0) + 1) * 127.5).numpy()
output_bgr = cv2.cvtColor(output_array, cv2.COLOR_RGB2BGR).astype("uint8")
output_bgr = cv2.resize(output_bgr, (origin_w, origin_h))
cv2.imwrite("output/test_clean.png", output_bgr)

注意这里的model.eval()很重要,它会把Dropout和BatchNorm切到推理模式,不然BatchNorm在推理时还在用当前batch的统计量,结果会很不稳定。另外推理时整个代码块放在with torch.no_grad()里,这是为了关闭梯度计算,节省显存并加速推理。

4.2 后处理技巧:让擦除结果更干净

模型直接输出的结果通常能用,但还存在几个问题:边缘偶尔有模糊色晕、红色笔迹擦除后的区域发灰、纸面背景有一些轻微的网格伪影。我总结了一套后处理流程,效果提升很明显。第一步是用保边滤波,比如双边滤波或引导滤波,把输出图压一遍,能去掉大部分色晕而不损失印刷体的锐利度;第二步是做一次轻度的高斯模糊和Unsharp Mask锐化,让印刷文字边缘更清晰;第三步如果原图背景偏黄,就用白平衡算法把背景校正到白色,进一步突出擦除效果。

这里给出我整理的完整后处理流程:

步骤 方法 参数建议 作用
1 双边滤波 d=5, sigmaColor=50, sigmaSpace=50 去色晕,保边缘
2 Unsharp Mask 高斯核(0,0), sigma=1.0, alpha=0.5 增强印刷体锐度
3 白平衡校正 灰度世界算法 背景变白
4 自适应二值化(可选) OpenCV adaptiveThreshold 适合纯文字卷面

要注意第4步自适应二值化是个双刃剑,如果卷面上有图片、表格、灰色底纹,强行二值化会毁掉这些信息,所以只有在纯白底黑字的场景下才建议启用。

4.3 把擦除能力接入业务场景

跑通单张图片推理之后,自然想把能力扩展成批量处理或者服务化。我后来把模型包成了一个简单的FastAPI接口,外部传一张图片进来,接口返回擦除后的图片。核心思路就是把这套加载模型和推理的代码封装成一个类,在接口启动时加载一次模型,之后每次请求都复用同一个模型实例做推理,而不是每来一次请求就重新加载一次权重,否则并发上来性能会特别差。

如果是批量处理一个文件夹里的图片,用multiprocessing并行处理可以显著提速。每张图的推理在GPU上其实很快,256×256输入大概只要几十毫秒,瓶颈主要在图片解码和保存的IO上,所以多进程处理时尽量让每个进程独立读图和写图,进程之间不要共享变量,避免GIL竞争和内存拷贝开销。

5. 常见问题与排查技巧实录

5.1 解压类报错:file is not a zip file / invalid zip archive

这个问题在热词里出现了不少次,我也实际遇到过。有几个原因:文件下载不完整、文件名伪装、下载过程中被网络中间层替换成了错误响应。排查思路很直接,依次执行三件事:第一看文件大小是否和源站标注一致,偏差超过几KB就有问题;第二用file命令看真实文件类型;第三用zip -T或者unzip -t测试完整性。如果是下载不完整,只能重新下载;如果确认文件完整但zip工具就是解不开,可能是因为压缩包用了特殊编码方式(比如7z、rar格式被改了后缀),用7z命令强行解压试试:

7z x 试卷手写文字擦除源码+模型+文档说明.zip

5.2 训练不收敛或loss持续震荡

这个是我实操中花时间最多的一个问题。刚开始训练时loss在0.3附近震荡就是降不下去,后来排查出几个原因:数据加载时配对文件没对齐,模型输入的是错误配对;学习率设置过高导致参数在最优解附近来回跳过。解决办法是先确认数据配对正确——在训练代码里加一个把输入和标签拼在一起保存的调试函数,直接肉眼检查配对;再把学习率从0.001降到0.0002,问题立刻缓解。另外如果是数据量太小,loss就算收敛了也容易过拟合,训练集至少要有5000张以上的配对图,低于这个规模建议从头开始多跑几轮数据增强。

5.3 显存不足的几种解决思路

跑训练脚本时最常见报错就是CUDA out of memory。这个问题的解决顺序一般是:先减小batch size,从8调到4,观察显存占用变化;如果还爆,就降低输入分辨率,从256×256降到192×192,但要注意分辨率降低会影响擦除精细度;再不行就开梯度累积,batch size减半但每两步再更新一次参数,等效于保持原batch size的更新频率:

accumulation_steps = 2
scaler = torch.cuda.amp.GradScaler()

for i, (inputs, labels) in enumerate(dataloader):
    with torch.cuda.amp.autocast():
        outputs = model(inputs)
        loss = criterion(outputs, labels)
    scaler.scale(loss).backward()
    if (i + 1) % accumulation_steps == 0:
        scaler.step(optimizer)
        scaler.update()
        optimizer.zero_grad()

开启混合精度训练后,显存占用能降低约40%,而且速度提升明显,我的3060上训练时间从14小时缩短到10小时。

5.4 模型擦除结果残留或擦过头

如果模型输出图上还残留手写笔迹的淡痕,通常意味着训练数据里该颜色笔迹的样本太少,或者模型容量不够。解决方向是补充对应颜色的训练样本、增加模型通道数(把UNet初始通道数从64调到96)、或者提高感知损失的权重让网络更关注语义一致性。另一种极端情况是擦除区域连印刷体也被抹掉了,这往往是模型过拟合到了“把高对比度区域全抹掉”的捷径,缓解办法是降低输入图片的前景对比度、增加真实手写数据的比例、或者减少背景纹理干扰。

这些问题的排查思路是可以复用的,建个表方便对照:

现象 可能原因 解决方案
解压报错file is not a zip file 文件未下载完整或文件类型错误 重新下载、file命令验证、7z强解
训练loss不降 数据配对错乱、学习率过高 可视化数据对、降低学习率
显存不够 batch size过大 调小batch、降分辨率、混合精度
结果有淡痕 该类笔迹训练样本不足 扩充样本、调整损失权重
印刷体也被擦除 模型学到过度抹除捷径 增加真实数据、平衡正负样本

6. 从复现到迁移:一些零散但实用的补充

整个项目复现完之后,最直观的感受是:手写擦除这个任务,难点真的不在模型结构,而在数据构造和训练细节的把握上。模型选型和调参就算写成论文,读者照着做也可能因为数据差异得到完全不同的结果。

另外这个项目还有很强的迁移意义。它的核心“图像到图像的对抗式/生成式擦除”思路,可以迁移到很多其他场景:文档去噪、古籍修复、表格清理、试卷去标注、甚至印章去除。只要把输入输出对的构造方式换一下,剩下的训练框架完全可以复用。我自己之后就把这套代码改造成了一个旧照片划痕修复的小工具,效果出奇地好,这也算是深度学习项目复现带来的最大红利——学会一个框架,解决一类问题。

还有一点要提醒:这类从网上下载的资源包,文件来源不一定可靠,解压后先杀毒、先检查代码里有没有可疑的恶意逻辑(比如ssh外连、可疑的eval执行),再放到自己的环境里运行。尤其在服务器上,不要用root账号直接跑不明来源的代码。

源码和模型是一回事,真正让技术发挥价值的是你把它跑通之后,对问题本身的理解深度。这个项目我不敢说每一行代码都最优,但整套流程走下来,从解压zip包到训练、推理、后处理、部署,各个环节需要注意的坑,我基本都在上面说了。希望有缘看到这篇文章的人,能少走几天弯路。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐