
简介生成对抗网络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, fontfont) 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, fontfont, fill255) # 文字掩码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, size3), 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), dtypenp.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, thicknessnp.random.randint(2, 6)) cv2.line(damage_mask, (cx, cy), (cx np.random.randint(-30, 30), cy np.random.randint(-30, 30)), 1, thicknessnp.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四张图使用完全相同的变换参数否则模型会看到“文字在位置AGT在位置B”的错位样本直接学出重影。我的习惯是写一个包装类把所有增强操作封装成接受四张图同时处理的函数而不是分别对每张图独立增强。class ComposeAugment: 同一个随机种子作用于四张图防止配准错位 def __init__(self, p0.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), flagscv2.INTER_CUBIC) degraded cv2.warpAffine(degraded, M, (w, h), flagscv2.INTER_CUBIC) damage_mask cv2.warpAffine(damage_mask, M, (w, h), flagscv2.INTER_NEAREST) text_mask cv2.warpAffine(text_mask, M, (w, h), flagscv2.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的每一层输出都过一遍CBAMdecoder上采样时把注意力图拼到特征里一起卷积。效果上同样训练步数下这个结构比普通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_l110.0, lambda_adv1.0, lambda_perc5.0): super().__init__() self.lambda_l1 lambda_l1 self.lambda_adv lambda_adv self.lambda_perc lambda_perc # 加载预训练VGG16取前30层就是到relu3_4 vgg vgg16(pretrainedTrue).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_l110是保证像素级重建精度的压舱石lambda_adv1给边缘真实性一个引导信号即可lambda_perc5负责字形语义。这三个值是我在几百次实验里试出来的稳定区间注意感知损失的权重不能大于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掉到0G输出一片灰”的崩坏现象。我用Adam优化器初始学习率生成器1e-4、判别器4e-5beta10.5、beta20.999。把beta1从默认的0.9改成0.5是为了让梯度更新更激进GAN训练需要这个设定来避免震荡。调度器不用复杂的ReduceLROnPlateau直接来个线性退火就够。总训练步数设为20万步前10万步保持初始学习率后10万步线性衰减到0。这个策略简单、不用监控验证集、对合成数据训练特别友好因为合成数据没有所谓的“验证集最优时机”训练越久越好退火的目的是末尾收敛而不是找最高点。4.3 高分辨率扫描件的分块推理重叠融合避免接缝真实扫描件分辨率动辄4000x6000模型输入撑死512不切块根本跑不动。直接硬切会造成灾难级接缝一个字被切成两半模型只能看到半个字的结构补出来的笔画方向都可能反。解决方案是滑窗加上重叠融合。# 滑窗推理线性斜坡融合消除接缝 def infer_large_image(model, image, patch_size512, overlap64): h, w image.shape[:2] out np.zeros_like(image, dtypenp.float32) weight np.zeros((h, w), dtypenp.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), dtypenp.float32) wx np.ones((1, patch.shape[1]), dtypenp.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)这段代码的要点在权重斜坡的生成。overlap64时相邻两个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停在一个高位再也不降生成的图片全黑或者全灰完全没有文字轮廓。原因判别器学习率太高提前学会了“不管输入是什么都判为假”生成器的梯度消失更新等于空转。解决把判别器学习率降到生成器的四分之一甚至八分之一我常用的是G1e-4配D2e-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_clsTrue, langch, show_logFalse) def ocr_text(image): result ocr.ocr(image, clsTrue) 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(fOCR识别准确率: {correct / total * 100:.1f}%)验收方式上我用OCR识别准确率变化幅度作为上线标准修复后比修复前在测试集上提升至少5个百分点才算这个模型有落地价值。FSIM保持在0.85以上防止模型把字修“糊”了但OCR恰好能认出来的假阳性情况。这两个指标一起看比单独追PSNR可靠得多。还有一个进阶技巧不要在整张高分辨率图上跑修复。先跑一遍PaddleOCR检测拿到文字框坐标把每个文字框外扩30%作为修复区域只对局部patch做GAN修复再贴回原图。这样既避免了整图滑窗推理的接缝问题又能把模型算力集中在真正需要修复的文字区域上。我之前在一个票证数字化项目里用这个方案推理耗时从每张40秒降到了9秒OCR准确率还高了2个点——因为局部patch里背景纹理少了模型更专注。我现在的习惯是每个新背景风格入库之前先拿三张图跑一组OCR基线对比看修复模型在这个风格上的表现再决定要不要调整退化参数。这个动作让我少踩了无数个“训练指标好看、上线就拉胯”的坑。希望帮到你。本文还有配套的精品资源点击获取