简介:面向大学生竞赛与深度学习实践者的手写文字擦除一等奖方案完整资源包。该方案针对试卷扫描图中手写红黑蓝笔迹、手画线段、污渍脏点与印刷字重叠等复杂场景,提供从数据划分、模型训练到测试推理的全流程实现。官方训练集共1081对,方案另划出1000张训练、81张验证,并包含测试集A/B处理逻辑。包内含22个Python脚本、3个Shell脚本、2个readme及说明文档等30个文件,压缩包约95KB,覆盖loss设计、PSNR指标计算、模型结构(如BiSeNetV2、SA-GAN、NAFA)与ckpt转换、ONNX导出等模块,目录结构清晰,便于按功能快速定位。已有337人学习,适合想要复现榜首思路、改进图像修复算法的参赛者与研究入门者。下载即得可直接运行的源码、配套数据调用说明与模型转换文档,能够在本地快速跑通整体流程,并为后续针对性调优提供可靠起点。
1. 手写文字擦除:为什么这个下载即用方案绕开了通用修复模型的坑
扫描件或照片上压着手写批注,想在不伤底图的前提下把字迹抹掉,这是文档数字化的高频需求。通用 inpainting 在移除路人、水印这类独立对象时表现尚可,但遇到手写笔迹就露馅:字迹往往是密集、细长、不均匀的连通区域,被覆盖的背景又是打印体文字和纸张纹理,模型一旦感受野不够或结构先验不足,就会在擦除区域里“补出”一团模糊或不存在的笔画。所谓“第 1 名方案”,通常不是单一模型多牛,而是数据构造、掩码生成、模型结构和训练策略四个环节都针对手写笔迹做了专门设计,最后被打包成 python 源码 + 数据 + 模型 + 文档说明的工程目录。这篇博文就从模型选型讲起,逐步拆到数据管线、训练推理和评估验证,让你拿到这类下载即用项目时能真正改得动、跑得通、调得准。
2. 手写文字擦除方案拆解:为什么泛化修复模型撑不住这个场景
2.1 文字区域与自然破损区的本质差异:结构先验从哪来
自然图像修复面对的是被遮挡的物体,比如移除前景人物后要补出的背景相对连续、低频成分多。手写文字擦除面对的是另一类情况:被擦除区域的周围往往紧挨着印刷体文字行,笔画间距均匀、横竖撇捺有规律,而掩码本身是极其不规则的细长条。
如果只用 U-Net + 普通卷积的修复结构,每个像素的感受野有限,模型只能“看到”掩码附近十几个像素的信息。手写笔迹的轨迹是长程相关的,比如一个横跨 200 像素的长横线,中间被掩码切断后,模型必须在两端记忆之间建立连接。这就是为什么早期用 Partial Convolution 做文字擦除时,长笔画区域经常出现断点或残留。这类方案在处理大面积矩形掩码时有效,但面对不规则笔画掩码时,有效卷积区域会被掩码吃掉大半,注意力机制又无法覆盖全局。
所以榜单上跑在前面的文字擦除方案,几乎都把“全局上下文建模”放在第一位,而不是只在通道数或层数上堆砌。常见做法是引入具有大感受野的视觉主干,或在网络内部加频谱变换分支,让模型对掩码两端的长程依赖更敏感。
2.2 榜单级方案的关键组件:全局依赖加结构保持的搭配
以 OpenMMLab 等仓库中出现频次较高的修复结构为参考,这类方案的主干通常包括一个带残差连接和空洞卷积的编码器、一个负责全局上下文建模的替换卷积模块,以及一个多尺度解码器。核心模块的伪代码结构如下:
import torch import torch.nn as nn import torch.nn.functional as F class FourierConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Conv2d(in_ch, out_ch, 3, 1, 1) self.spectral = nn.Sequential( nn.Conv2d(in_ch, in_ch, 1), nn.ReLU(inplace=True), nn.Conv2d(in_ch, in_ch, 1), ) def forward(self, x, mask): b, c, h, w = x.shape # 低频全局建模:整图的 DFT 频谱参与修复,掩码区域也能获得远端信息 x_fft = torch.fft.rfft2(x, norm="ortho") x_fft = torch.view_as_real(x_fft).permute(0, 3, 1, 2) x_freq = self.spectral(x_fft) x_freq = torch.view_as_complex(x_freq.permute(0, 2, 3, 1).contiguous()) x_global = torch.fft.irfft2(x_freq, s=(h, w), norm="ortho") x_local = self.conv(x) # 掩码区域由全局频谱分支主导,非掩码区域由局部卷积主导 x_out = x_local * (1 - mask) + x_global * mask return x_out这段代码覆盖了这类方案最核心的替换策略:局部卷积负责保持邻近像素的一致性,频谱变换分支负责从整幅图像的频域信息中还原掩码区域的低频结构。mask在这里是归一化到 0 到 1 的掩码,1 代表待擦除区域,送入网络前要乘到输入图像上或作为额外通道拼接。
参数上的要点是频谱分支的隐藏通道保持输入通道的 1/4 左右即可,过大会带来大量全局计算开销。掩码区域占比超过 30% 时,x_global的权重可以适度调高到 0.7,否则修复结果容易出现“糊成一片”的低频假象。
2.3 训练策略与损失函数怎么配
只靠 L1 或 L2 损失训练修复模型,结果总是偏模糊,因为像素级损失鼓励模型取所有可能输出的平均。手写文字擦除更需要锐利的笔画边界和被遮盖的印刷体边缘,所以损失函数基本是组合拳。常见配置如下:
| 损失项 | 权重范围 | 作用 |
|---|---|---|
| L1 / Huber 损失 | 1.0 | 约束整体像素亮度,稳定训练 |
| 感知损失(LPIPS) | 0.5~1.0 | 在 VGG 特征空间比较,保持结构 |
| 对抗损失(PatchGAN) | 0.1~0.3 | 提升局部纹理真实感,消除模糊 |
| 风格损失(Gram 矩阵) | 0.1 | 保持纸张纹理一致性 |
训练时我一般会把感知损失的权重先固定到 1.0,对抗损失的权重从 0.1 起步,每 5 个 epoch 观察一次验证集输出。如果重建区域过于平滑,优先加对抗损失权重而不是盲目加大感知损失,因为感知损失过大会让模型过度关注高频细节,反而在平整背景下产生不自然的纹理。
2.4 显存与速度权衡的工程取舍
这类带频谱分支的模型在 512x512 分辨率下单卡训练显存占用约 8~12GB。如果只有一块 6GB 显存的卡,常见做法是切 patch 训练,把输入裁成 256x256 的随机区域,同时保证掩码在 patch 内的面积占比控制在 10% 到 40% 之间。推理时再切换到全分辨率,并开启混合精度:
python train.py --img-size 256 --batch-size 8 --mixed-precision混合精度训练在这里不是可选项而是默认项,因为频谱分支的傅里叶变换在 FP16 下能显著降低显存占用,同时保持精度。需要注意torch.fft在 FP16 下会自动提升到 FP32 计算,实际显存节省主要在卷积层,所以别指望整体显存减半。
3. 手写文字擦除的数据集怎么来:合成管线与掩码构造
3.1 真实标注数据为什么贵:透过 OCR 辅助标注看成本
手写文字擦除需要成对的“带字图 + 干净背景图”。真实场景下收集一批带手写批注的试卷或合同,再用图像编辑工具逐张擦除手写笔迹,效率极低,一小时最多标注几张,而且不同人的标注风格会影响模型泛化。另一个思路是用 OCR 检测手写笔迹的包围框,再辅助人工微调得到掩码,但框是矩形,直接作为掩码会覆盖大量无辜背景,导致模型学到“把整块内容抹掉”而不是“擦除笔迹保留底图”。
所以榜单方案的训练数据几乎都以合成为主:用干净文档图像做底图,在上面叠加渲染出的手写笔迹,同时自动生成精确到像素的笔迹掩码。
3.2 用 python 代码合成手写笔迹数据
合成管线需要四类素材:底图(可以是扫描文档、打印文本渲染图或自然纸纹理)、手写风格字体文件、随机语料和背景噪声。核心逻辑是把手写文字渲染到透明图层,再经过随机变换叠加到底图上:
import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont def render_handwriting(bg: np.ndarray, text: str, font_path: str) -> tuple[np.ndarray, np.ndarray]: # 随机手写笔迹的颜色:蓝色钢笔、黑色签字笔、红色批注笔 color = np.random.choice([(20, 30, 140), (30, 30, 30), (0, 0, 200)]) h, w = bg.shape[:2] overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) font_size = np.random.randint(24, 60) font = ImageFont.truetype(font_path, font_size) # 随机位置、随机旋转角度,模拟真实批注的随意性 x, y = np.random.randint(0, int(w * 0.3)), np.random.randint(0, int(h * 0.5)) angle = np.random.uniform(-8, 8) draw.text((x, y), text, font=font, fill=(*color, 200)) overlay = overlay.rotate(angle, center=(x, y), resample=Image.BICUBIC) hand = np.array(overlay)[..., :3].astype(np.float32) alpha = np.array(overlay)[..., 3:4].astype(np.float32) / 255.0 # 笔画抖动:沿随机方向平移 1-2 像素,模拟运笔不稳定 kernel = np.random.randint(5, 10) * 2 + 1 alpha = cv2.GaussianBlur(alpha, (kernel, kernel), 0) fused = bg.astype(np.float32) * (1 - alpha) + hand * alpha mask = (alpha > 0.5).astype(np.uint8) * 255 return fused.astype(np.uint8), maskrender_handwriting把文字渲染到 RGBA 图层后做旋转和抖动模糊,alpha通道同时作为融合权重和掩码来源。注意两个细节:模糊核大小必须和字体大小联动,字体越大模糊核越大,否则合成的笔迹边缘要么过硬要么虚散;掩码阈值取 0.5 而不是 0,能避免半透明抗锯齿像素被错误归入掩码区域造成训练时标签不一致。
3.3 掩码是训练成败的分水岭
合成数据生成的掩码天然是笔画级。实际推理时掩码来源不同:要么由用户手动涂抹,要么由文本检测模型生成,这两类掩码都比合成掩码粗糙,笔画边界通常在 2~3 个像素的误差。训练时如果只用精确掩码,推理时遇到膨胀或收缩的掩码就会退化。解决办法是训练时做掩码增强:
def augment_mask(mask, erosion_range=(0, 2), dilation_range=(0, 3)): kernel_sizes = list(range(erosion_range[0] * 2 + 1, dilation_range[1] * 2 + 2, 2)) k = np.random.choice(kernel_sizes) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (k, k)) if np.random.rand() < 0.5: return cv2.erode(mask, kernel, iterations=1) else: return cv2.dilate(mask, kernel, iterations=1)每次迭代随机对掩码做一次腐蚀或膨胀,让模型见过不同松紧程度的掩码边界。我在实际训练中验证过,加了这一行随机增强后,推理时用不够精确的手绘掩码也能保持擦除效果稳定。掩码膨胀比腐蚀更常见,因为用户涂抹时倾向盖住完整笔迹,腐蚀到笔画内部反而会留下残边。
3.4 数据规模与混合配比建议
合成数据做主力,真实数据做微调。一般合成数据 10 万张级别起步,真实标注数据几百到一千张就足够让模型适应真实扫描件的色彩分布。合成语料选择手写风格文本,不要用印刷体随机字符串;底图要覆盖空白纸、横线纸、有打印文字的页面三种类型,比例约 3:3:4。空白纸上擦除手写笔迹最简单,打印文字页上最难,因为掩码和底图文字叠加后模型要区分“哪些笔画是被盖住的、哪些是底图原有的”。
4. 源码、模型与下载即用:从零跑通推理和训练
4.1 requirements 与目录结构
下载即用的项目包通常按以下结构组织,拿到手先花两分钟核对目录,确认权重文件和文档描述一致:
text_eraser/ ├── checkpoints/ │ └── text_eraser_v1.pth ├── data/ │ ├── train/ │ │ ├── images/ │ │ └── masks/ │ └── val/ ├── src/ │ ├── models/ │ ├── datasets/ │ └── utils/ ├── scripts/ │ ├── train.py │ └── inference.py ├── docs/ │ └── README.md └── requirements.txt环境配置建议 python 3.9 或 3.10,torch与torchvision用 2.x 的稳定版。requirements.txt里核心依赖是 opencv-python、pillow、numpy、timm(部分模型用)、lpips。先执行pip install -r requirements.txt,再单独确认 torch 版本和本机 CUDA 匹配,torch装错版本是这类项目最常见的启动失败原因。
4.2 单张图片推理脚本:加载模型与预处理
推理脚本的核心流程是:读图、做掩码、归一化、模型前向、后处理。掩码如果没有现成的分割结果,可以用 OpenCV 的createFastLineDetector或直接让用户涂抹生成。这里给出加载模型和推理的完整链路:
import cv2 import torch from src.models import build_model model = build_model("text_eraser_v1", pretrained="checkpoints/text_eraser_v1.pth") model.eval().cuda() img = cv2.imread("input.jpg") # BGR, HxWx3 mask = cv2.imread("mask.png", 0) # 单通道, 0 或 255 img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 缩放到 8 的倍数,这是频谱分支的约束条件 pad_h = (8 - img.shape[0] % 8) % 8 pad_w = (8 - img.shape[1] % 8) % 8 img_pad = cv2.copyMakeBorder(img_rgb, 0, pad_h, 0, pad_w, cv2.BORDER_REPLICATE) mask_pad = cv2.copyMakeBorder(mask, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=0) t_img = torch.from_numpy(img_pad).float().permute(2, 0, 1).unsqueeze(0) / 127.5 - 1 t_mask = torch.from_numpy(mask_pad).float().unsqueeze(0).unsqueeze(0) / 255.0 with torch.no_grad(): out = model(t_img.cuda(), t_mask.cuda())[0] # 输出会同时返回图像和残差掩码 out_img = (out[0].clamp(-1, 1).cpu().permute(1, 2, 0).numpy() + 1) * 127.5 out_img = out_img[: img.shape[0], : img.shape[1]] out_bgr = cv2.cvtColor(out_img.astype("uint8"), cv2.COLOR_RGB2BGR) cv2.imwrite("output.jpg", out_bgr)这里有两个容易被忽略的参数:BORDER_REPLICATE而不是BORDER_CONSTANT,是因为频谱分支会取整图变换,边缘补零会产生高频振铃;掩码归一化到 0~1,因为模型在训练时对掩码做了同样的缩放。输出裁剪回原始尺寸时,务必使用切片而不是resize,resize会引入插值误差让边缘区域出现色差。
4.3 训练启动与断点续训的细节
训练脚本一般支持从命令行指定数据路径、图片尺寸和恢复点,典型启动命令如下:
python scripts/train.py \ --data-dir data/train \ --val-dir data/val \ --img-size 256 \ --batch-size 8 \ --lr 1e-4 \ --epochs 60 \ --ckpt checkpoints/text_eraser_v1.pth训练时学习率用 1e-4 起步,每 20 个 epoch 乘以 0.1 做一次衰减。60 个 epoch 中前 10 个 epoch 建议冻结频谱分支只训练普通卷积层,等局部重建稳定后再放开全局分支,否则一开始频谱分支的梯度噪声会打乱底层特征。Windows 环境下注意data_dir路径分隔符和num_workers设置,num_workers在 Windows 上超过 4 容易出现内存复制错误,改为 2 即可。
5. 手写文字擦除效果验证与三个常见踩坑点
5.1 定量评估:PSNR 之外还应该看什么
PSNR 和 SSIM 只能反映像素级重建质量,无法衡量手写文字是否擦干净。两个常见做法:一是用 OCR 工具分别识别原图和擦除后的图像,对比字符错误率变化;二是用深度特征相似度 LPIPS 评估感知一致性。手写字擦除的专有指标是把掩码区域的 OCR 置信度变化作为辅助参考。对一个下载即用项目最快验证方法是:
python scripts/eval.py --data-dir data/val --metric psnr ssiam lpips只看 PSNR 会踩坑:PSNR 高但输出整体模糊,因为模糊图像和原图对应区域像素值接近。打印几张样本目检,重点关注三种区域——长横线附近的背景、两个笔画的交叉点、笔画紧贴打印文字边缘的位置。
5.2 三个高频坑:残影、整体模糊、纹理被抹平
第一,擦除后残留笔迹边缘,原因是掩码比实际笔画细,导致模型没有获得完整遮挡信息。解决方法是推理前对输入掩码做一次 3x3 膨胀,让掩码完全覆盖笔迹;这一操作在实际项目中几乎总能提升视觉干净度。第二,输出区域平滑如磨皮,常见原因是对抗损失权重过低,把adv_loss_weight从 0.1 提到 0.3 并延长训练即可。
第三,纸张纹理被过度修复成了纯色块。这是感知损失权重过大的典型症状,感知损失会强拉重建区域的高频特征和原图一致,而纸张的随机噪点在特征空间里占据显著位置,模型为了匹配这种高频就产生了过度平滑或过度锐化。优先调低感知损失到 0.5,而不是简单调大对抗损失。
5.3 一个偷懒但有效的验证脚本思路
如果你只想快速验证一个下载即用项目是否靠谱,不需要完整训练,可以手工合成一组带有手写笔迹的测试图,固定掩码后分别用不同掩码膨胀系数推理三次,观察三张输出图的重建稳定性。稳定性的意义在于:掩码有小幅变化时,擦除结果不该出现整段重建区域的闪烁。如果不同膨胀系数下重建内容差异巨大,说明模型对掩码边界过度敏感,这种方案在真实场景中几乎不可用。把这条验证逻辑写进 eval 脚本里,比单跑一次推理更能判断方案的工程成熟度。
本文还有配套的精品资源,点击获取