简介:面向计算机视觉方向学习与毕业设计需求的手写文字擦除方案,以Python和OpenCV为基础,结合深度学习模型实现图像中手写笔迹的自动去除与背景修复。资源包含完整工程代码、已训练模型参数、训练与预测脚本、数据加载与掩码生成模块,以及详细项目说明文档,方便二次开发或直接复现。压缩包共32个文件,以Python脚本(22个)为主体,辅以Shell运行脚本、模型权重文件(.pdparams)、说明文档(md/txt/readme)等,整体约150.71MB,结构清晰,能覆盖从模型训练到推理评估的完整流程。工程中涉及SA-GAN、BiSeNetV2、非局部注意力等典型网络结构模块,并配有PSNR损失等评价指标计算代码,适合作为毕业设计、课程设计或科研入门参考。目前已有91人学习浏览,遇到配置或运行问题,还可联系作者远程协助调试,上手门槛相对可控。
1. 手写文字擦除,先分清是去噪还是生成
从扫描作业、合同单据或老照片上去掉手写批注,直觉上应该先试 OpenCV 阈值加连通域分析。实际工程里,手写笔迹的压感导致颜色深浅不均,与印刷字体在灰度直方图上经常重叠,传统图像处理做一次擦除需要同时调四五组参数,换个扫描仪就得重新调一遍。这套方案把“擦除”定义为图像恢复任务:输入带手写的页面,输出尽可能接近原始干净排版的结果。项目代码同时包含 Python + OpenCV 做前后处理、GAN 做核心重建、数据集与 dataloader 做监督训练,还附带 compute_mask.py、PSNRLoss.py、EMA 和两个 checkpoints 权重。用到 STE_idr_best.pdparams 和 STE_str_best.pdparams 两种权重时,你会发现损失策略对最终笔迹残留的影响比调网络深度更明显。适合课程设计和毕业设计直接参考,也适合做文档 OCR 预处理的人借鉴——把“擦除”做成一个模块挂在识别链路前面。
2. 项目结构与生成器判别器的分工
2.1 文件结构的关键判定
动 train.py 之前,先把根目录的文件职责摸清。整个项目能跑通的核心文件如下表所示:
| 文件 | 作用 | 改动频率 |
|---|---|---|
| data/dataloader.py | 读入“含手写/干净”图对,执行裁剪与归一化 | 低 |
| compute_mask.py | 生成手写区域二值掩码,用于区域加权损失 | 中 |
| Loss.py、PSNRLoss.py、losses.py | 实现 L1、PSNR、感知等多项损失组合 | 中 |
| sa_gan.py、idr.py、sa_aidr.py、networks.py | 生成器主网络及参考结构定义 | 中 |
| BiSeNetV2.py、non_local.py | 分割骨干与全局上下文模块 | 低 |
| discriminator.py | 判别器,对生成图与干净图做真假判别 | 低 |
| ema.py | 指数移动平均,平滑训练中的权重波动 | 低 |
| train.py、test.py、predict.py | 训练、批量评估、单图推理 | 中 |
| convert_onnx.py | 将训练好的模型转为 ONNX 格式 | 低 |
checkpoints 目录里放置的是 .pdparams 后缀的飞桨格式权重。这套代码需要跑在 PaddlePaddle 环境里,不能用 PyTorch 的 torch.load 直接加载。如果你从别处拿到的预训练权重是 .pth 后缀,要用 ckpt_convert.py 先做张量名和维度顺序的转换,否则第一个 batch 就会出现 NaN,而且很难从日志里定位是数据问题还是权重加载问题。
2.2 dataloader 在线合成训练对
data 目录下 dataloader.py 的作用是把原始扫描页切成固定尺寸 patch,并提供监督信号。数据集中没有配对标注时,常见做法是离线合成:先准备一张无手写的干净页面作为底图,再随机位置叠加上手写笔画,形成“脏图 + 干净图”的成对样本。dataloader 中典型的一段逻辑如下:
class PairLoader(Dataset): def __init__(self, root, crop=256): self.pairs = [] for d, c in zip(os.listdir(root + '/dirty'), os.listdir(root + '/clean')): self.pairs.append((os.path.join(root, 'dirty', d), os.path.join(root, 'clean', c))) self.crop = crop def __getitem__(self, idx): dirty = cv2.imread(self.pairs[idx][0], cv2.IMREAD_GRAYSCALE) clean = cv2.imread(self.pairs[idx][1], cv2.IMREAD_GRAYSCALE) h, w = dirty.shape y = random.randint(0, h - self.crop) x = random.randint(0, w - self.crop) dirty = dirty[y:y + self.crop, x:x + self.crop] clean = clean[y:y + self.crop, x:x + self.crop] return (dirty / 255.0 - 0.5, clean / 255.0 - 0.5)这段代码做三件事。第一,以灰度模式读图,不让生成器去学习纸张本身的 RGB 色彩分布,除非你确实想连纸张颜色一起还原;第二,随机裁剪到 256×256,避免整页输入超出显存并保持 batch 内尺寸一致;第三,把像素归一化到 [-0.5, 0.5],这个线性映射让后续损失函数的梯度在取值范围内保持连续,不会像 [0,1] 那样在两端饱和。实测中 crop=256 对侧页的长手写笔画有截断风险,如果生成器频繁出现半截文字消失,把 crop 调到 512 并同步把 batch 从 8 降到 4 是更稳的组合。
2.3 生成器与判别器的装配
生成器主体由 sa_gan.py 和 BiSeNetV2.py 共同支撑,理解上不要把它们看成并列模型,而是一条 pipeline 的两段。BiSeNetV2 原本是实时语义分割网络,低分辨率分支负责提取大范围文本行分布,高分辨率分支保留笔画边缘;在擦除任务里,这两路特征融合后正好给出“哪里是手写、哪里是背景结构”。sa_gan.py 在其上叠加自注意力层,让修复区域能从画面远处借纹理。non_local.py 给出了非局部注意力的一种实现,常见写法如下:
def non_local_block(x, inter_channels=32): b, c, h, w = x.shape theta = conv1x1(x, inter_channels).view(b, inter_channels, -1) theta = theta.permute(0, 2, 1) phi = conv1x1(x, inter_channels).view(b, inter_channels, -1) attn = torch.softmax(torch.bmm(theta, phi), dim=-1) g = conv1x1(x, inter_channels).view(b, inter_channels, -1) out = torch.bmm(g, attn.permute(0, 2, 1)) out = out.view(b, c, h, w) return out + x注意力图 attn 的空间维度是 (hw)×(hw)。如果直接对 512×512 的特征图做全局注意力,矩阵会变成 262144×262144,显存立刻爆掉。所以实际使用中都会先把空间分辨率压缩到 32×32 或 64×64,再做全局上下文聚合。判别器由 discriminator.py 定义,输入生成图与干净参考图,训练时判别器与生成器交替更新,这类对抗机制正是产生清晰边缘的关键。
3. 掩码计算与损失函数的设计
3.1 compute_mask.py 提供区域监督
compute_mask.py 的目标是把手写区域标出来,供损失函数按区域加权。常见做法是从像素差异出发,将带手写图和干净图逐像素做差,灰度差超过阈值的点记为 1,再用膨胀操作把笔迹边缘扩一圈,避免网络只在笔锋内部做修正:
def compute_mask(dirty, clean, thr=30, dilate=2): diff = cv2.absdiff(dirty, clean) mask = (diff > thr).astype(np.uint8) mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=dilate) return mask膨胀参数 dilate 很重要。手写笔迹边缘存在半透明过渡,灰度差常常达不到阈值,膨胀的作用就是把这个过渡地带也纳入监督范围,让生成器不只是处理高置信笔画,还要处理颜色残留。另一种常见的掩码形态是背景掩码,把印刷体文字区域也置为需要重建的对象,此时网络要对整块内容做生成,损失权重必须和前景掩码模式区分开,否则会出现印刷体被过度平滑的问题。
3.2 PSNR 损失与 L1 损失各管一段
Loss.py 和 losses.py 负责把多种损失汇总到一起,其中 PSNRLoss.py 看起来是在算图像质量,实际是把这个指标改造成了可训练的回归损失。PSNR 由 MSE 推导而来:mse = mean((pred - gt)^2),psnr = 10 * log10(max^2 / mse)。把 PSNR 直接作为损失项时,网络会优先降低 MSE 大的区域,对手写笔迹这种局部高误差像素非常敏感。相较单纯 L1,PSNR 损失强调逐像素平均正确,L1 则对异常值更宽容。两者配合的典型写法是:
def hybrid_loss(pred, target, weight_l1=1.0, weight_psnr=0.1): l1 = torch.mean(torch.abs(pred - target)) mse = torch.mean((pred - target) ** 2) psnr = 10 * torch.log10(1.0 / (mse + 1e-8)) return weight_l1 * l1 - weight_psnr * psnr注意 psnr 项前面是负号。训练目标是让 PSNR 更大,损失项需要取相反数才能和 L1 一起朝减小的方向优化。weight_psnr=0.1 是一个起步值,若擦除结果过度平滑,说明 PSNR 权重压制了结构项,调到 0.03 左右再对比一次。
3.3 多损失与 EMA 的配合
项目中存在的损失组合可归纳为下表:
| 损失项 | 监督目标 | 常见权重 |
|---|---|---|
| L1 损失 | 逐像素绝对误差 | 1.0 |
| PSNR 损失 | 回归平均误差 | 0.03~0.1 |
| 对抗损失 | 整体观感真实性 | 0.1 |
| 感知损失 | 高层语义特征一致性 | 0.05 |
如果只用 L1,恢复出的背景文字边缘会发糊;如果让对抗损失比例过高,可能会连印刷体一起“擦”掉。感知损失隔着预训练网络中间层对比特征,让输出不再逐像素对齐而是整体结构对齐,对保持文字骨架特别有效。EMA 则维护一份历史权重的滑动平均,在评估时用小方差版本代替临时权重,能明显压低训练后期损失曲线的抖动幅度。
4. 模型训练与预测的具体跑法
4.1 环境搭建与 OpenCV 版本
项目依赖飞桨和 OpenCV。搭环境时,Python 版本建议固定在 3.8 到 3.10 之间,太新的解释器可能碰到 PaddlePaddle 预编译包尚未适配的情况。OpenCV 使用 4.x 即可,不需要特殊版本,主要承担图片读取、缩放和形态学操作:
conda create -n erase python=3.8 -y conda activate erase pip install paddlepaddle-gpu==2.5.2 pip install opencv-python==4.8.0.74 numpy==1.24.3如果只需 CPU 调试,把 paddlepaddle-gpu 换成 paddlepaddle 即可,但训练建议还是用 GPU。OpenCV 安装完可以用cv2.getBuildInformation()验证编译信息,确认读图模块正常,避免后面 predict.py 在 imread 阶段静默返回 None。
4.2 train.sh 与 test.sh 的启动方式
train.sh 内部最终调用的是 train.py,test.sh 对应 test.py。启动一个完整训练回合的常见参数组合如下:
python train.py \ --train_root ./data/train \ --val_root ./data/val \ --batch_size 8 \ --crop_size 256 \ --lr 2e-4 \ --epochs 200 \ --save_dir ./checkpoints参数含义:train_root 指向包含 dirty 和 clean 两个子目录的根路径,batch_size 根据显存调整,crop_size 决定输入 patch 大小,lr 是生成器初始学习率。判别器的学习率一般设置为生成器的十分之一,代码中通常存在 discriminator 独立的学习率字段,没找到就在 train.py 搜索lr_d或d_lr。跑 test.sh 时,实际执行的是:
python test.py --resume ./checkpoints/STE_idr_best.pdparams --data_dir ./data/test注意权重路径指向的是 STE_idr_best 还是 STE_str_best,两者效果差异主要在边缘残留分布上。前者对全局版面更敏感,后者对单笔画更敏锐。拿到新的扫描件做推理时,优先用 STE_str_best 看主观效果。
4.3 ONNX 导出与二次集成
convert_onnx.py 用于把训练好的飞桨模型导出为标准 ONNX,方便脱离飞桨环境部署。导出时常见问题是动态尺寸导致 ONNX Runtime 报错,建议固定输入分辨率:
import paddle model = build_generator() model.set_state_dict(paddle.load('STE_str_best.pdparams')) model.eval() dummy = paddle.zeros([1, 1, 256, 256]) paddle.onnx.export( model, 'erase.onnx', input_spec=[paddle.static.InputSpec( shape=[-1, 1, 256, 256], dtype='float32')] )导出的 erase.onnx 可以交给 ONNX Runtime 或 OpenCV 的 DNN 模块加载。OpenCV 的 cv2.dnn.readNetFromONNX 在 4.5 版本后对常见算子支持已经比较完整,但自注意力里的 reshape 和 transpose 组合偶发不支持,这时需要回退到原版飞桨推理。
5. 用 OpenCV 做边缘修复与结果验证
预测输出往往不是完美的干净页面,生成器在手写笔迹边缘会留下浅色残影。用 OpenCV 做一层轻量后处理,能显著提升视觉效果。我常用的做法是先膨胀再腐蚀,把离散的残影点闭合成小块,再通过轮廓面积过滤掉孤立噪声:
import cv2 import numpy as np out = cv2.imread('output.png', cv2.IMREAD_GRAYSCALE) kernel = np.ones((3, 3), np.uint8) closed = cv2.morphologyEx(out, cv2.MORPH_CLOSE, kernel, iterations=1) med = cv2.medianBlur(closed, 3) cv2.imwrite('output_clean.png', med)闭运算能合并非连续的残影,中值滤波在保留笔画结构的同时抑制孤立的椒盐噪声。但不能对整张图做多次闭运算,否则印刷字体笔画会被加粗,反而降低后续 OCR 的置信度。
验证清除效果时,不要只看整图 PSNR。用手写掩码加权计算局部 PSNR 和 SSIM 更有意义:掩码区域残影越少,局部 PSNR 提升越明显。把生成的掩码与预测图对应区域拿出来单独统计,比较 STE_idr_best 和 STE_str_best 在该区域上的数值差异,就是两个权重文件最直观的区分方式。训练集里类似 DEHW 这类场景文本擦除数据的分布,也会直接影响这个局部指标的表现。
本文还有配套的精品资源,点击获取