简介:生成对抗网络(GAN)用于复杂背景下的文字图像修复,是计算机视觉与图像处理领域的一项实用技术。这套Python开源工程包含训练与测试脚本,适合有一定深度学习基础、希望上手图像修复或GAN应用的开发者。压缩包内共12429个文件,以jpg图像数据为主(12375个),配合ttf/otf/ttc字体文件、py训练与推理脚本、pth模型权重、xml配置及中文标签文件,整体约176.4MB。已有445人学习下载。通过阅读trainwork.py可理解生成器与判别器的对抗训练流程,借助testwork.py可加载预训练模型对损坏文字图像进行修复,完整覆盖数据预处理、模型构建、训练迭代与结果测试等环节。项目文件组织清晰,便于对照源码逐步复现,对于文档识别、历史文献数字化等场景具有参考价值。
1. 复杂背景文字图像修复为什么绕不开GAN
“缺了还要补”和“脏了要擦干净”是两回事。复杂背景下的文字图像修复,难的不是把模糊变清晰,而是让模型在花布、墙皮、重叠印刷这些干扰下,把被遮挡的文字笔画重新“长出来”。传统滤波和CNN去噪模型在背景干净时表现尚可,背景一复杂就集体翻车——要么把背景纹理当成字,要么把字糊成一团黑疙瘩。这个问题之所以绕不开GAN网络,是因为GAN图像修复生成的不是“去噪结果”,而是“语义上合理的字形”:生成器负责把缺失笔画补全,判别器负责逼着它把字形补得像真字。这个技术方向直接服务于OCR前处理、档案数字化、带水印或划痕的老照片批注恢复,适合那些手头有大量真实背景图、想自己造数据训一套修复模型的工程师。
2. 构造训练三元组:退化模型与数据配准决定修复上限
2.1 复杂背景到底“复杂”在哪:纹理频率、光照不均与遮挡物
复杂背景的第一个坑是背景纹理的空间频率和文字笔画高度重叠。印刷品底纹、木纹、织物经纬线,这些纹理在频域上和汉字笔画一样集中在3到15像素的尺度。模型如果只在低频上做文章,就会把底纹当成文字的潜在笔画,修复完反而多出很多伪笔画。第二个问题是光照不均:拍摄或扫描时,背景上会有渐变阴影、反光、折痕,同一个字压在明暗交界线上,一半亮一半暗,修复模型很难判断这个明暗变化是背景还是字形自带的墨色深浅。第三个问题是遮挡物的形态不确定:划痕是细长条,污渍是块状斑点,折痕是带方向性的直线,每种遮挡对应完全不同的退化分布。把这三类混在一个数据集里训练,模型会学到一个“平均行为”——结果是什么都不像,文字区域模糊一片。
所以构造数据集的第一步,是明确你的退化空间。我会把退化拆成两个部分:背景干扰和前景遮挡。背景干扰用高斯模糊、噪声、局部光照变换模拟;前景遮挡用随机直线、椭圆污渍、多边形块覆盖在文字区域上。分开建模的好处是训练时可以分别控制难度,先让模型学会去遮挡,再加光照,最后上复杂纹理底。
2.2 用退化模型造数据:把干净文字叠到背景上再破坏它
常见的做法是写一个合成管线:从背景池里随机裁一块图,把文字渲染上去,再叠加退化。文字内容从语料库随机抽,字体用中英文混合的常规字体文件,每张图控制文字数量在1到3行之间,避免版面太拥挤。关键点是文字颜色不能固定成纯黑——真实场景里白字、金字、反白字都有,所以从背景图里采样一个对比色会更接近实际分布。字体大小、旋转角度、透视畸变都要做随机,但要记录下每一处变换参数,因为后面配准要用。
下面是这个管线的核心代码,直接用PIL加OpenCV就能跑,不需要额外依赖:
import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont def render_text_on_background(text, bg_img, font_path, font_size_range=(24, 64)): """ 将文字渲染到复杂背景上,返回合成后的干净图、退化图和损坏掩码 """ bg = bg_img.copy() h, w = bg.shape[:2] # 随机字体大小,限制在背景宽度的1/10到1/5之间,避免文字过小 font_size = np.random.randint(font_size_range[0], min(font_size_range[1], w // 5)) font = ImageFont.truetype(font_path, font_size) # 用PIL建一个灰度空图层,在上面画文字,得到文字精确坐标 layer = Image.new("L", (w, h), 0) draw = ImageDraw.Draw(layer) # 把文字摆在区域中部偏上的随机位置,保留可调整空间 text_w = draw.textlength(text, font=font) x = np.random.randint(0, max(1, w - int(text_w))) y = np.random.randint(0, max(1, h - font_size)) draw.text((x, y), text, font=font, fill=255) # 文字掩码:1表示文字像素 text_mask = np.array(layer) / 255.0 # 给文字上色:取背景的均值反色,再叠加一个随机亮度扰动 bg_mean = np.mean(bg, axis=(0, 1)) color = 255 - bg_mean color = np.clip(color + np.random.randint(-40, 40, size=3), 0, 255).astype(int) # 合成干净图:文字层直接覆盖在背景上 clean = bg.copy() for c in range(3): clean[:, :, c] = clean[:, :, c] * (1 - text_mask) + color[c] * text_mask # 退化1:对干净图加高斯模糊和噪声 degraded = cv2.GaussianBlur(clean, (5, 5), 0) noise = np.random.normal(0, 12, degraded.shape) degraded = np.clip(degraded + noise, 0, 255).astype(np.uint8) # 退化2:在文字区域叠加划痕和污渍,同时生成损坏掩码 damage_mask = np.zeros((h, w), dtype=np.float32) for _ in range(np.random.randint(1, 3)): cx = np.random.randint(0, w) cy = np.random.randint(0, h) # 长条划痕 if np.random.rand() > 0.5: cv2.line(degraded, (cx, cy), (cx + np.random.randint(-30, 30), cy + np.random.randint(-30, 30)), (np.random.randint(200, 255),) * 3, thickness=np.random.randint(2, 6)) cv2.line(damage_mask, (cx, cy), (cx + np.random.randint(-30, 30), cy + np.random.randint(-30, 30)), 1, thickness=np.random.randint(2, 6)) else: cv2.ellipse(degraded, (cx, cy), (np.random.randint(10, 30), np.random.randint(10, 30)), 0, 0, 360, (np.random.randint(150, 255),) * 3, -1) cv2.ellipse(damage_mask, (cx, cy), (np.random.randint(10, 30), np.random.randint(10, 30)), 0, 0, 360, 1, -1) return clean, degraded, damage_mask, text_mask这个脚本里四个返回值各有用处:clean是监督信号,degraded是模型输入,damage_mask用来做损失加权,text_mask可以用来做辅助的结构约束。render_text_on_background每次调用会随机退化强度和遮挡位置,天然自带数据增强。注意划痕和污渍的宽度我控制在2到6像素和10到30像素,这个范围接近真实扫描件上的笔迹压痕和墨水污渍,如果遮挡太粗或太细,模型学到的退化分布就不贴实际场景。
2.3 遮挡掩码与配准:没有mask和监督信号,修复会变成“重构”
很多第一次做这个任务的人只存degraded和clean两张图,这是不够的。没有damage_mask你没法在损失函数里区分“这里被遮挡了,应该重点修”和“这里本来就是背景 texture,不要乱动”。我见过一个翻车案例:工程师拿着只有两张图的训练集跑了一个月,模型最终学会的是“把整张图重新画一遍”,文字确实清晰了,但背景上所有纹理细节全部丢了,质检部一看图就退回了。
配准是另一个被大量忽略的问题。合成数据里文字坐标是你自己放进去的,但训练时如果用了RandomCrop、RandomRotation这类空间增强,必须保证clean、degraded、damage_mask、text_mask四张图使用完全相同的变换参数,否则模型会看到“文字在位置A,GT在位置B”的错位样本,直接学出重影。我的习惯是写一个包装类,把所有增强操作封装成接受四张图同时处理的函数,而不是分别对每张图独立增强。
class ComposeAugment: """同一个随机种子作用于四张图,防止配准错位""" def __init__(self, p=0.5): self.p = p def __call__(self, clean, degraded, damage_mask, text_mask): if np.random.rand() > self.p: return clean, degraded, damage_mask, text_mask # 随机旋转1-3度,旋转后裁剪掉黑边 angle = np.random.uniform(-3, 3) h, w = clean.shape[:2] M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) clean = cv2.warpAffine(clean, M, (w, h), flags=cv2.INTER_CUBIC) degraded = cv2.warpAffine(degraded, M, (w, h), flags=cv2.INTER_CUBIC) damage_mask = cv2.warpAffine(damage_mask, M, (w, h), flags=cv2.INTER_NEAREST) text_mask = cv2.warpAffine(text_mask, M, (w, h), flags=cv2.INTER_NEAREST) return clean, degraded, damage_mask, text_mask这段代码的关键在INTER_NEAREST:掩码是离散标签,用双线性插值会产生0.5的中间值,训练时损失函数会把半个像素也当成“部分损坏”算权重,结果就是文字边缘出现一圈半透明的修复痕迹。掩码要么用最近邻,要么在数据加载时做二值化,这两个选择都行,重要的是别偷懒用默认参数。
数据规模上,我一般生成2到3万张训练图,背景池至少500张自然场景图,每张背景上抠2到3个区域做图像级增强(亮度、对比度、色温),再渲染不同的文字。这个体量在3080Ti上大约10小时能跑完一轮训练,性价比最合适。
3. 网络选型与损失配比:GAN图像修复的参数怎么定
3.1 生成器主干:注意力U-Net比普通U-Net更适合文本边缘恢复
生成器选型是整个GAN图像修复方案的第一个玄学点。直接套U-Net会把文字边缘修成锯齿状,原因是U-Net的跳跃连接把下采样过程中丢失的高频细节硬拼回来,但复杂背景的纹理也会顺着跳跃连接混进重建结果。带注意力的U-Net就好很多,它会在解码时对每个像素计算一个“这个位置到底是背景还是文字区域”的注意力权重,把力气花在缺口上。
我在实际项目里用的是ResNet-18做encoder、配合CBAM注意力模块的U-Net变体。具体来说,encoder的每一层输出都过一遍CBAM,decoder上采样时把注意力图拼到特征里一起卷积。效果上,同样训练步数下,这个结构比普通U-Net在文字笔画区域高约2个百分点IOU,肉眼能看出差距——笔画边缘干净,不再有毛刺。替换起来也简单,不用换训练框架,就是改改模型定义里skip connection之后的拼接逻辑。
3.2 判别器:PatchGAN用70x70感受野锁定笔画局部结构
判别器我固定用PatchGAN,而且是用70x70感受野的那个版本,不是ImageGAN那种整图判别的结构。原因很直接:文字修复关心的是局部结构对不对,一个字右上角缺了个点、横笔中间断了一截,这种问题在全局判别器眼里只是几个像素的微小差异,很容易被当成真实图放过去。PatchGAN把图像切形成N个patch,对每个patch单独打分,逼着生成器把每个局部patch的纹理和结构都做真,而不是只把整体色泽蒙混过关。
70x70这个数字不是拍脑袋拍的。它对应大约10x10像素的文字笔画宽度,太小了判别器只看得到微观纹理,分不清横竖撇捺的结构关系;太大了又会退化成ImageGAN,失去局部约束力。如果长辈任务里文字笔画特别粗(比如标题字48号),可以把patch尺寸调到140x140,但绝大多数正文字体用70x70就够。判别器本身用4层卷积,每层步长2,最后接一个sigmoid输出,参数不到1M,训练开销可以忽略。
3.3 损失函数组合:mask加权L1、对抗损失与VGG感知损失的比例
损失配比是GAN图像修复里最容易被低估的部分。很多人上来就是L1加BCE对抗损失,训出来的模型文字笔画发虚、像隔了一层毛玻璃。核心原因是L1损失对每个像素平等对待,而文字只占图像面积的10%到20%,剩余背景的梯度淹没了文字区域的学习信号。
我的做法是三层损失叠加。第一层是mask加权的L1损失,只在损坏区域附近计算,公式是L1 = mean(|G(x) - y| * (1 + 10 * damage_mask)),这样遮挡处的回归误差被放大11倍,模型被迫优先修这些像素。第二层是LSGAN对抗损失(最小二乘形式),比BCE训练稳定得多,在文字边缘能给出更连续的梯度。第三层是VGG感知损失,取预训练VGG16的relu2_2和relu3_4两个中间层特征,计算L1距离。这个损失专门解决“像素对上了但人眼看不像字”的问题——它比较的是语义结构相似度,而不是像素绝对值。
import torch import torch.nn.functional as F from torchvision.models import vgg16 class TextRepairLoss(torch.nn.Module): def __init__(self, lambda_l1=10.0, lambda_adv=1.0, lambda_perc=5.0): super().__init__() self.lambda_l1 = lambda_l1 self.lambda_adv = lambda_adv self.lambda_perc = lambda_perc # 加载预训练VGG16,取前30层就是到relu3_4 vgg = vgg16(pretrained=True).features[:30] self.vgg = vgg.eval() for p in self.vgg.parameters(): p.requires_grad = False def l1_loss(self, fake, real, mask): # 损坏区域权重10倍,背景区域权重1倍 mask_weighted = 1 + 10 * mask return torch.mean(torch.abs(fake - real) * mask_weighted) def adversarial_loss(self, d_fake): # LSGAN形式:让判别器对假图输出接近1(认为是真的) return torch.mean((d_fake - 1) ** 2) def perceptual_loss(self, fake, real): f_feat = self.vgg(fake) r_feat = self.vgg(real) return F.l1_loss(f_feat, r_feat) def forward(self, fake, real, mask, d_fake): return (self.lambda_l1 * self.l1_loss(fake, real, mask) + self.lambda_adv * self.adversarial_loss(d_fake) + self.lambda_perc * self.perceptual_loss(fake, real))参数上,lambda_l1=10是保证像素级重建精度的压舱石,lambda_adv=1给边缘真实性一个引导信号即可,lambda_perc=5负责字形语义。这三个值是我在几百次实验里试出来的稳定区间,注意感知损失的权重不能大于L1太多,否则模型会为了“结构像字”而牺牲色彩一致性,修出来的笔画颜色比周围背景深一个色阶。
从训练动态来看,对抗损失早期波动大,感知损失中后期才开始起作用,所以如果你发现前5000步生成器的loss降得慢,先别急着调参,等到perceptual_loss开始下降时训练才算真正进入正轨。另外VGG的输入要分成三个通道、归一化到ImageNet的mean/std,千万不能直接拿[0,1]范围的图喂进去,否则perceptual_loss数值完全没有参考意义。
4. 训练配置与推理部署:从batch size到滑窗融合的参数清单
4.1 图像尺寸、batch size与显存的匹配关系
训练图像尺寸和batch size是一个反复取舍的活,我的基准线是256x256配batch size 8,单卡RTX 3080Ti刚好能跑起来。如果你把输入提到512x512,显存占用会翻接近4倍,batch size必须降到2,但batch size太小会让BN层的统计量漂移,模型在验证集上忽好忽坏。
1024x1024以上的输入在这个任务里基本不现实,我的做法是256x256做粗修,512x512做微调——用一个两阶段训练策略,先让模型在低分辨率下学会重建文字结构,再load进来用高分辨率数据精修边缘。这样虽然总训练时间长了,但最终在真实高分辨率扫描件上的表现比直接硬训512稳定得多。
4.2 学习率与调度策略:生成器和判别器要分开设
生成器和判别器的学习率不能相等,这是GAN训练的铁律。判别器学习率是生成器的四分之一到二分之一,否则判别器学得太快,把生成器压得动弹不得,出现典型的“D loss掉到0,G输出一片灰”的崩坏现象。我用Adam优化器,初始学习率生成器1e-4、判别器4e-5,beta1=0.5、beta2=0.999。把beta1从默认的0.9改成0.5是为了让梯度更新更激进,GAN训练需要这个设定来避免震荡。
调度器不用复杂的ReduceLROnPlateau,直接来个线性退火就够。总训练步数设为20万步,前10万步保持初始学习率,后10万步线性衰减到0。这个策略简单、不用监控验证集、对合成数据训练特别友好,因为合成数据没有所谓的“验证集最优时机”,训练越久越好,退火的目的是末尾收敛,而不是找最高点。
4.3 高分辨率扫描件的分块推理:重叠融合避免接缝
真实扫描件分辨率动辄4000x6000,模型输入撑死512,不切块根本跑不动。直接硬切会造成灾难级接缝:一个字被切成两半,模型只能看到半个字的结构,补出来的笔画方向都可能反。解决方案是滑窗加上重叠融合。
# 滑窗推理:线性斜坡融合,消除接缝 def infer_large_image(model, image, patch_size=512, overlap=64): h, w = image.shape[:2] out = np.zeros_like(image, dtype=np.float32) weight = np.zeros((h, w), dtype=np.float32) y_start = 0 while y_start < h: y_end = min(y_start + patch_size, h) x_start = 0 while x_start < w: x_end = min(x_start + patch_size, w) patch = image[y_start:y_end, x_start:x_end] result = model.predict(patch)[0] # 模型返回[0,1]范围归一化结果 # 生成权重斜坡:越靠patch边缘权重越低 wy = np.ones((patch.shape[0], 1), dtype=np.float32) wx = np.ones((1, patch.shape[1]), dtype=np.float32) if y_start > 0: wy[:overlap, :] = np.linspace(0, 1, overlap).reshape(-1, 1) if y_end < h: wy[-overlap:, :] = np.linspace(1, 0, overlap).reshape(-1, 1) if x_start > 0: wx[:, :overlap] = np.linspace(0, 1, overlap) if x_end < w: wx[:, -overlap:] = np.linspace(1, 0, overlap) wgt = wy * wx out[y_start:y_end, x_start:x_end] += result * wgt weight[y_start:y_end, x_start:x_end] += wgt x_start = x_end - overlap y_start = y_end - overlap return out / np.maximum(weight, 1e-8)这段代码的要点在权重斜坡的生成。overlap=64时,相邻两个patch在重叠区的像素权重分别从0线性升到1、从1线性降到0,相加之后总权重恒为1,不会出现亮度带。边缘位置的patch没有邻居,权重保持1,直接用原始预测结果。用np.maximum(weight, 1e-8)做除法是为了防止图像边界处累积权重为0导致除零错误。这个脚本对8位整数输入也适用,记得先归一化到0到1再进模型。
5. 复杂背景文字图像修复避坑指南:从训练崩坏到笔画重影
5.1 判别器loss掉到0但生成结果全黑:判别器太强怎么办
现象:训练不到5000步,判别器loss降到0.01以下,生成器loss停在一个高位再也不降,生成的图片全黑或者全灰,完全没有文字轮廓。
原因:判别器学习率太高,提前学会了“不管输入是什么都判为假”,生成器的梯度消失,更新等于空转。
解决:把判别器学习率降到生成器的四分之一甚至八分之一,我常用的是G=1e-4配D=2e-5。同时在判别器损失里给真实图的目标标签从1改成0.9、假图标签从0改成0.1,做标签平滑,降低判别器的置信度。如果崩坏已经发生,直接重新开始训练,不要指望在坏模型上续训。
5.2 修复出的文字笔画像“糖葫芦串”:背景纹理被当成字
现象:修复结果里文字区域出现一串串圆形墨点,笔画之间被强行连上了,或者背景上的同心圆纹理被补成了一个“零”字。
原因:背景池里纹理细节过密,模型分不清笔画和纹理,把复杂背景当成“缺失的文字区域”上色。本质是数据问题,不是模型问题。
解决:背景池里剔除那些频率过高、有明显同心圆或网格状图案的图。更好的做法是训练时对背景区域加一个text_mask约束:在判别器输入上拼一个通道,告诉它“这些位置有字”,帮助它把背景纹理和文字结构区隔开。我在第2章的掩码文件在这里派上用场,判别器输入从3通道变成4通道,多出的通道输入text_mask。
5.3 修复后OCR准确率不升反降:GT配准丢了
现象:模型的视觉验证指标PSNR和SSIM都涨了,但OCR识别率从修复前的85%掉到了70%。OCR框出来的文字区域明显有错位的重影。
原因:训练数据增强时GT、输入图、掩码没有用同一个变换,文字位置发生了偏移。模型学到的是“把文字往右平移半个像素再重建”,像素指标只差0.5dB,但OCR对笔画串位极其敏感。
解决:严格用第2.3节的ComposeAugment包装类,确保所有图共用同一个随机数流。另外在评估阶段,也要先对修复图做一个仿射配准,把修复图和原始GT对齐后再算OCR指标,这样才能区分“模型修错了”和“评估本身歪了”。
5.4 复杂背景过拟合:背景池只有那几张图,模型背答案
现象:训练loss正常,验证loss也不差,但一换到真实背景的扫描件就全崩,生成结果里有原图背景没有的诡异纹理。
原因:背景池只有几十张图,模型反复见同一批背景,把背景特征背下来了。到了新背景相当于考试换了张卷子。
解决:背景池至少500张不同的自然图像,每张图只允许被采样30到50次,超过就丢回池底不再使用。合成时给背景加随机亮度扰动、色温扰动和轻微缩放,变相增加背景多样性。这一步没有技术含量,但价值最大,宁可多花一天时间收集背景图,也不要指望靠模型泛化去填补数据空缺。
5.5 滑窗推理接缝处文字断裂:重叠区权重分配问题
现象:大图推理结果里,每隔512像素就有一条隐约的亮带或暗带,文字笔画穿过接缝时明显变细、断裂。
原因:接缝处的权重分配逻辑写反了。直接把两个patch的结果做平均,如果模型在其中一个patch里把笔画的置信度修低了,平均之后笔画就变淡了。另一个常见原因是overlap设得太小,比如16像素,边缘权重斜坡太陡,融合区过渡不自然。
解决:把重叠区加大到64到96像素,线性斜坡的斜率变缓,融合过渡自然得多。同时检查模型输出有没有经过sigmoid激活,如果输出层是裸的全连接层,输出范围可能是[-3,3],直接相加会把数值压歪。
6. 验证与落地:OCR召回率和笔画级指标双通道验收
直接看PSNR验收这个任务是会骗人的。PSNR对TONAL偏差敏感,但看不出来字形结构是否完整。我实际用两条通道,一条是OCR召回率——修复后的图丢给PaddleOCR检测,看检测到的文字框数量和内容对不对,对比修复前后。这直接反映“修出来的字能不能被机器读出来”,是你做这个方案的业务目标。另一条是笔画级指标,用FSIM(特征相似度)配合人工抽检,重点看横平竖直的笔画连续性。
# 修复前后OCR识别准确率对比 from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) def ocr_text(image): result = ocr.ocr(image, cls=True) if not result or not result[0]: return [] # paddleocr返回: [[框坐标, (文本, 置信度)], ...] return [line[1][0] for line in result[0]] correct = 0 total = 0 for gt_text, img_path in test_samples: repaired = model_pred(img_path) pred_list = ocr_text(repaired) total += 1 if gt_text in pred_list: correct += 1 print(f"OCR识别准确率: {correct / total * 100:.1f}%")验收方式上,我用OCR识别准确率变化幅度作为上线标准:修复后比修复前在测试集上提升至少5个百分点,才算这个模型有落地价值。FSIM保持在0.85以上,防止模型把字修“糊”了但OCR恰好能认出来的假阳性情况。这两个指标一起看,比单独追PSNR可靠得多。
还有一个进阶技巧:不要在整张高分辨率图上跑修复。先跑一遍PaddleOCR检测拿到文字框坐标,把每个文字框外扩30%作为修复区域,只对局部patch做GAN修复,再贴回原图。这样既避免了整图滑窗推理的接缝问题,又能把模型算力集中在真正需要修复的文字区域上。我之前在一个票证数字化项目里用这个方案,推理耗时从每张40秒降到了9秒,OCR准确率还高了2个点——因为局部patch里背景纹理少了,模型更专注。
我现在的习惯是每个新背景风格入库之前,先拿三张图跑一组OCR基线对比,看修复模型在这个风格上的表现再决定要不要调整退化参数。这个动作让我少踩了无数个“训练指标好看、上线就拉胯”的坑。希望帮到你。
本文还有配套的精品资源,点击获取