
简介手写文字擦除赛题第1名方案完整提供Python源码、数据模型及说明文档面向从事OCR、文档图像处理或深度学习修复的开发者与竞赛选手。方案基于开源EraseNet改造为Paddle实现并通过与PERT的对比实验验证了在本批次数据上的优势包含数据划分策略、mask生成逻辑按RGB平均差值生成软掩码以及多分支多阶段网络结构、感知损失与GAN损失等关键细节。针对红黑蓝多色手写字、印刷字重叠、试卷污渍等难点方案在训练数据与网络设计上均有对应处理。压缩包共30个文件以22个py源码、3个sh训练/测试脚本、2个readme及模型zip、说明txt/md为主整体仅98KB轻量便于快速部署复现从数据预处理到模型训练、测试与模型转换均有覆盖目录结构清晰适合作为复现与二次开发的基线。该资源已有694人学习适合希望借鉴冠军思路、加速自研手写擦除与文字背景修复的读者。1. 手写文字擦除为什么“第1名方案”也要自己重新调数据手写文字擦除这活儿看着像图像修复实际上比普通修复难一截印刷体是规则的手写体的粗细、倾斜、压线毫无规律擦多一步就会把印刷体带走擦少一步又残留半截笔画。所谓“第1名方案”本质上也不是一个模型打天下而是把数据模型、掩码策略和损失权重抠到了细节训练时怎么配对掩码要不要膨胀损失怎么加权每一项都影响最终榜单上的分数。下面按一套常见的Python实现路径来拆解从数据组织到训练推理再到落地避坑让新手上手就能跑通也让做OCR预处理的老手有可直接抄作业的参数和边界。2. 手写文字擦除的任务拆解与模型选型不是所有修复模型都适合文档图像2.1 手写文字擦除的三个子问题检测、掩码、修复先把问题拆成三个环节。第一个环节是检测也就是判断图像里哪些像素属于手写区域第二个环节是掩码生成把“要擦掉的内容”转成一张和原图同尺寸的0/1图第三个环节才是修复用周围印刷体和纸张纹理把掩码区域填充回合理内容。很多人一上来就跳到最后一步直接跑通用GAN修复结果模型不知道手写在哪儿把印刷体当噪声一起抹了。我一般会把检测和掩码合并成一步先训练一个轻量分割头输出每个像素属于手写笔迹的概率然后根据置信度阈值生成掩码。这个做法比“整画幅修复”慢一点但召回率稳定尤其是在试卷扫描这类背景复杂的图像里手写和印刷体重叠时分开检测比端到端生成方式更好调。这里再说一句“数据模型”的含义。在这类方案里数据模型不只是“图像加标签”还包括三张配套图干净印刷体图、带手写原图、手写掩码图。干净图用于监督掩码图用于告诉模型哪里需要重画带手写原图用于输入。三张图缺一张训练就很容易滑向一边——要么把整张纸擦成白板要么干脆不擦。很多刚接触python源码项目的人第一反应是去找更深的模型但在手写文字擦除这个任务里数据模型的组织方式往往比模型结构更早决定成败。2.2 为什么U-Net系加修复头是常见底座榜单里那些第一梯队的方案绝大多数不是从零造模型而是在编码器解码器结构上做文章。U-Net因为有跳跃连接能把低层细节和高层语义同时送到解码端特别适合像素级回归任务。手写文字擦除要求保留印刷体边缘和纸张底纹这就非常吃低层细节U-Net系的优势就在这儿。常见做法是在U-Net后面再接一个修复头修复头可以选带掩码的部分卷积也可以选门控卷积。部分卷积的好处是每次卷积都会更新掩码让边界像素逐渐从“未知”变成“已知”修复出来的纹理连续性更好门控卷积则能用数据学出每个位置该信多少输入特征。我偏向部分卷积加一个轻量Transformer分支用Transformer捕捉笔画的长程连贯性解决手写长笔画穿过印刷体段落时的断裂问题。不过要泼一盆冷水底座模型只决定上限决定能不能上榜的是训练数据里的“难例”比例。如果训练集里全是打印稿上随便划两道模型当然很快收敛一旦出现手写压网格线、手写压在印刷行距中间、彩色纸张底纹这些难例才是拉开分数差距的地方。榜单方案的人力大部分花在构造这种难例上而不是在调网络深度。2.3 基于OpenCV的快速基线形态学与像素插值能不能打在进入深度模型前先给一个可以用python入门快速实现的OpenCV基线。它的作用是当对照实验和速度基准不是拿来打榜。很多场景里只要手写和印刷体在灰度上分得开这个基线就能对付一半的数据。import cv2 import numpy as np def remove_handwriting_opencv(img_path: str, min_area: int 20, method: int cv2.INPAINT_TELEA) - tuple: # 灰度化手写笔迹和印刷体灰度有差异但差异不稳定 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f无法读取图像: {img_path}) # 大津法二值化拿到前景候选区。 # 这里不使用固定阈值因为扫描件的光照经常不均匀。 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 开运算先腐蚀后膨胀去掉孤立噪点同时保留长笔画 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) cleaned cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 找轮廓按面积过滤噪点矩形框近似手写区域 contours, _ cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) mask np.zeros_like(img) for c in contours: if cv2.contourArea(c) min_area: continue x, y, w, h cv2.boundingRect(c) cv2.rectangle(mask, (x, y), (x w, y h), 255, -1) # TELEA 基于邻域像素加权NS 基于偏微分方程小洞用 TELEA 更快 result cv2.inpaint(img, mask, 3, method) return result, mask这个基线的几个参数值得说清楚。min_area设小了印刷体上的句号、逗号会被当成手写点擦掉设大了细手写笔画的尾巴会留在画面上。对A4扫描件我一般从20开始调具体看扫描分辨率300DPI下20像素能压住大多数噪点但彩色底纹场景要用更小的值。method用TELEA还是NS肉眼差距不大TELEA在CPU上快大约1.5倍批量处理时优先选它。这个基线最大的问题是没有区分印刷体和手写体一旦手写压在印刷体上或者字体笔画较粗轮廓会把两者算成一个连通域一起去擦。所以它只适合做快速筛选和问题发现真正的生产级结果还是得交给深度模型。放在这里也是为了给后续深度方案做个“兜底”有些内网部署环境加载不了大模型OpenCV基线至少能保证流程不断。3. 用Python把数据模型跑通手写文字擦除的最小训练与推理流程3.1 数据模型的组织方式图像对与掩码的目录结构跑这套python源码前先把python环境和vscode配置python弄干净依赖无非是torch、torchvision、opencv-python和pillow。python基础语法里的文件遍历和路径拼接会反复出现别用硬编码绝对路径直接用Pathlib。项目里“数据模型”说白了就是一套固定的文件组织规则我用的目录结构是这样的dataset/ train/ input/ # 带手写原图命名统一为 00001.png target/ # 干净印刷体图对应同编号 00001.png mask/ # 手写掩码白色区域是需要擦除的部分 val/ input/ target/ mask/ meta.csvinput、target、mask三张图必须严格同步命名且像素尺寸一致。meta.csv里记录每张图的手写颜色、笔画粗细、来源扫描设备这些字段在后续做难例挖掘时特别有用。很多人只存input和target不存mask结果回炉训练时想调节损失权重只能重新找人来标浪费的时间足够把数据管线重写一遍。掩码的标注常见做法有两种。一种是用标注工具人工把手写区域涂成白色准确但贵另一种是先用干净的target和带手写的input做差差值大于阈值的位置就当成手写区域。第二种做法在手写下方没有印刷体时非常准但手写压字时会漏掉重叠部分必须人工补。榜单方案的掩码通常是两者的结合自动差分生成初稿人工修正重叠区域。3.2 生成训练掩码的Python脚本差分与膨胀下面这段脚本是数据准备阶段最常用的一个函数给干净印刷体和带手写原图产出掩码图。逻辑不复杂但膨胀这一步很容易被忽略。import cv2 import numpy as np def build_mask_from_difference(clean: np.ndarray, input_img: np.ndarray, thr: int 25, dilate_iter: int 1) - np.ndarray: clean: 干净印刷体灰度图 (H, W) input_img: 带手写原图灰度图 (H, W) thr: 像素差阈值超过则认为属于手写区域 dilate_iter: 膨胀次数把边缘往外扩给修复留过渡带 diff cv2.absdiff(input_img, clean) _, mask cv2.threshold(diff, thr, 255, cv2.THRESH_BINARY) # 手写边缘和印刷体边缘在差分图上会形成环形边 # 只保留面积较大的连通域过滤掉单像素噪声 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(mask) out np.zeros_like(mask) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] 8: continue out[labels i] 255 if dilate_iter 0: kernel np.ones((3, 3), np.uint8) out cv2.dilate(out, kernel, iterationsdilate_iter) return out参数说明thr25在300DPI扫描件上能压住纸张纹理噪声但手机拍照图上建议提高到35到45因为手机成像有更重的噪声和压缩伪影。dilate_iter1是把掩码向外扩一个像素扩得太少修复边缘会缺纹理扩得太多会把相邻印刷体字脚卷进去。如果你并不缺训练数据更稳的做法是把这个函数当数据增强的一部分用不同的thr和dilate_iter生成多份掩码让模型见过各种边缘过渡。3.3 训练与推理的最小代码从U-Net到损失加权训练部分给一个最小可跑的PyTorch示例骨架刻意保持简单重点说损失函数的写法。import torch import torch.nn as nn class MinimalInpaintNet(nn.Module): 最小修复网络4通道输入(RGB掩码)3通道输出 def __init__(self, in_ch4, out_ch3): super().__init__() self.encoder nn.Sequential( nn.Conv2d(in_ch, 32, 3, stride2, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.Conv2d(32, 64, 3, stride2, padding1), nn.BatchNorm2d(64), nn.ReLU(), ) self.decoder nn.Sequential( nn.ConvTranspose2d(64, 32, 3, stride2, padding1, output_padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.ConvTranspose2d(32, out_ch, 3, stride2, padding1, output_padding1), nn.Sigmoid(), ) def forward(self, x, mask): # mask 是单通道拼接后作为第4通道输入 x torch.cat([x, mask], dim1) return self.decoder(self.encoder(x)) def mask_weighted_loss(pred, target, mask, region_weight1.3): # 在手写区域把损失放大背景区域保持原权重 diff torch.abs(pred - target) weighted torch.mean(diff * (1.0 region_weight * mask)) return weighted这个网络的encoder和decoder都很浅跑通依赖没问题但要复现榜单质量至少要把卷积层换成ResBlock并在编码器最深层加入自注意力。mask_weighted_loss里的region_weight1.3是个经验值意思是手写区域损失比背景区域多承担30%的梯度压力。如果训练过程中发现印刷体细节丢失就把这个值降到1.0附近如果发现手写没擦干净就往上提到1.5到2.0。这里有个从图像到训练对齐的细节输入图像和掩码必须同时做随机裁剪、翻转和旋转绝对禁止只预处理输入图而忘记同步变换掩码。最方便的做法是在Dataset类的__getitem__里用同一个随机种子生成几何变换参数或者在自定义transform里把三张图input/target/mask组织成一个字典再统一处理。翻车现场最常见的错误就是掩码和图像错位模型训练几十个epoch后仍然学不会回填纹理。推理时不再需要target只需要input和mask因此生产代码里的入口函数接收一张原图和一张掩码输出修复后的图。注意推理时的掩码可以使用稍微膨胀的版本因为训练时掩码相对准确推理时预测掩码往往偏小不膨胀就会漏掉边缘残迹。4. 手写文字擦除落地排查5个反复翻车的坑与解法4.1 手写压线模型把印刷体和下划线一起擦了现象原图里手写内容压在印刷体行距或下划线网格线上修复后印刷体字脚消失出现整片空白。原因掩码生成阶段用了简单差分手写和印刷体重叠部分被判为同一个连通域掩码把印刷体也框了进去模型学到“掩码区域全部替换”于是把印刷体一起覆盖成背景纹理。解决把掩码生成改成语义分割输出不只看色差还要看局部纹理结构。具体做法是先训练一个二分类分割头预测“手写笔迹区域”再用融合策略两者都判定为手写才擦只有差分判定为手写就保留。另外在训练损失上给掩码边界外的印刷体加保护带保护带内l1 loss权重设为0逼迫模型只在具体手写区域输出生成内容。4.2 修复边缘出现水痕像钢笔水洇开的印子现象擦除结果整体不错但掩码边缘有一圈半透明的灰白色痕迹放大看是像素值起了过渡带。原因训练时掩码边缘是硬切的0/1边界模型在边界位置学到的是“既要淡又要浅”的折中策略。推理时掩码稍微偏大或偏小就会在边缘画出模糊的半透明带。解决训练和推理两端同时处理。训练端把掩码边缘做高斯模糊把掩码从0/1改成分数权重边界像素的梯度压力逐渐衰减推理端对掩码做3x3的均值滤波后再归一化给修复网络一个过渡带。不要把滤波做得太强否则完全擦除的区域会保留一层淡淡的手写残影。4.3 训练集全是平扫图模型在手机拍照图上直接翻车现象模型在扫描仪图像上PSNR和SSIM都很好看换到手机拍的试卷照片上擦除区域出现彩色斑块背景底纹也被误擦。原因这是典型的域偏移。平扫图光照均匀、无透视变形手机图有透视畸变、色温偏移、阴影遮挡和JPEG压缩噪声模型在训练集里没见过这些噪声模式判别不出来什么是纸张底纹什么是手写噪声。解决这个问题要在训练数据增强里强制加入场景扰动def warp_and_color_aug(img, mask, angle_mean3): h, w img.shape[:2] angle np.random.normal(angle_mean, 1.5) M cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) img_w cv2.warpAffine(img, M, (w, h), flagscv2.INTER_LINEAR) # mask 用最近邻插值避免插值产生半透明边界 mask_w cv2.warpAffine(mask, M, (w, h), flagscv2.INTER_NEAREST) # 色温抖动在 RGB 通道上整体平移 offset np.random.randint(-10, 10, size(3,)) img_w np.clip(img_w.astype(np.int16) offset.reshape(1, 1, 3), 0, 255).astype(np.uint8) return img_w, mask_w我一般会在val集里专门留10%的手机实拍图每个epoch做一次人工抽查。指标看着再好看也要以这10%的图片为准。如果项目资源够手机实拍图应该占训练集的5%到10%只靠仿真增强永远补不齐真实手机的光学短板。4.4 数据模型里只存PNG不存标注想二次训练才发现没有掩码现象项目跑完一轮发现精度卡在目标分数的90%左右想继续训练翻开数据目录只找到input和target手写掩码一张都没保存。原因掩码在训练时是实时生成的没有落盘。当时图快把中间结果丢了等到要调试损失函数的权重或者要换模型结构才发现掩码是黑匣子没法排查。解决数据准备阶段就把掩码固化到磁盘至少要保存“最终训练用掩码”和“原始差分掩码”两份。最终训练用掩码用于复跑实验原始差分掩码用于分析模型在哪些结构上容易失误。存储开销大约只比原图多30%到50%对现代磁盘完全可以接受。这也是“数据模型”这个词里最容易被低估的部分文件组织本身就是模型结构的一部分。4.5 擦除结果整体偏灰纸张底色和原图对不上现象修复区域颜色和原图纸面差异明显偏灰偏暗像贴了块补丁OCR识别率上来了但人眼一眼看出处理痕迹。原因训练loss在RGB空间做平均模型学到的是“均值最小”而不是“颜色最接近”。RGB空间里纸面浅黄色和背景白色的欧氏距离比深色手写小得多模型就会把浅色区域拉向白把修出来的内容整体提亮颜色发灰。解决损失计算之前把输入和target从RGB转到YUV空间对亮度分量单独做一次L1彩度分量用较小权重。U-Net输出的颜色通道在收敛中后期容易出现“均值灰”漂移可以在后处理阶段做色彩对齐用原图的非掩码区域统计RGB均值修复区域按比例映射把输出图的平均颜色拉回原图的纸张底色。这一步不改善结构但人眼观感和最终交付质量会有明显提升。5. 手写文字擦除增效合成数据、后处理与验收指标5.1 合成手写数据用字体与随机抖动生成训练对如果真实标注数据不足最划算的做法是合成。别小看这条路真实手写的光滑程度、连笔和笔锋用随机字体加抖动可以模拟出七八分效果而且掩码天然已知不用人工标。from PIL import Image, ImageDraw, ImageFont, ImageFilter import numpy as np def render_handwrite_text(text, font_path, canvas_size(512, 512), angle_range(-10, 10), font_size_range(26, 40)): # 创建白底灰度图 img Image.new(L, canvas_size, 255) draw ImageDraw.Draw(img) font_size np.random.randint(*font_size_range) font ImageFont.truetype(font_path, font_size) # 随机角度旋转不要超过12度太夸张会脱离真实书写习惯 angle np.random.uniform(*angle_range) # 随机放置位置模拟字迹偏移 base np.random.randint(20, 90, size(2,)) draw.text((base[0], base[1]), text, fill0, fontfont) img img.rotate(angle, center(canvas_size[0] // 2, canvas_size[1] // 2), resampleImage.BICUBIC, fillcolor255) # 加一点高斯柔化模拟笔迹边缘的浸墨 img img.filter(ImageFilter.GaussianBlur(radius0.5)) return img关键参数角度范围建议控制在正负10度以内真实手写整体行的倾斜度不会超过12度太大旋转会让模型学到“越斜越要擦”的假规律。字体大小随机化能提高泛化能力但如果训练集和测试集的扫描DPI不一致这个参数的分布要按DPI换算。合成数据一定要和真实数据混着用只拿合成数据训练的模型在真实笔迹的间断笔画上会非常犹豫。我一般会用多套手写字体构造字符池再对每个字符做“断笔”处理随机把二值笔迹切成2到3段模拟连笔断墨的效果。这个操作对下游OCR识别率提升非常显著因为真实手写里断笔远比完美连笔多。5.2 后处理掩码膨胀、引导滤波与色彩对齐推理输出不是终点。修复结果在进入人眼验收前还要过一道后处理。第一步掩码膨胀。在推理端统一把模型预测掩码膨胀1到2像素避免边缘残留。第二步引导滤波。用原图的灰度图作为引导图对修复结果做引导滤波修复区域和原图纸张纹理能有更自然的融合。引导图最好用原图而不用修复图这样纸面纤维纹理能迁移到修复区域。第三步色彩对齐。统计原图非掩码区域的平均亮度修复结果乘以亮度比调整到一致。import cv2 import numpy as np def post_process_inpaint(result, origin, mask, guided_radius4): mask cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations1) guided cv2.ximgproc.guidedFilter( guideorigin, srcresult, radiusguided_radius, eps1e-2) # 颜色对齐只在修复区域做防止改变原图内容 out origin.copy() out[mask 0] guided[mask 0] return outguidedFilter来自opencv-contrib-python如果环境没装用cv2.bilateralFilter替代也能有七分效果。radius参数对A4扫描图我一般设4对手机图要调到6到8因为手机图上纸张纹理颗粒更粗。eps1e-2是像素深度的相对值单位是8bit灰度平方改用float归一化时要重新调。最后效果好不好不要凭一张截图判断。要输出“修复前的带手写图”“修复后的图”和“掩码图”三张并排对比确认边缘有没有吃印刷体颜色有没有偏再用OCR指标下结论。5.3 验收指标PSNR、SSIM、OCR可读率单独看哪个都没用榜单排名常用PSNR和SSIM但这些指标在文档图像上有严重的盲区。PSNR对整张图的噪声敏感手写区域只占全图5%修复再好PSNR也只从42变成43区分度不够SSIM侧重结构相似性对颜色漂移近乎失明修复结果偏灰它也照样给高分。所以我的做法是三分法。指标计算范围作用手写区域PSNR只在掩码区域内计算判断擦除干净程度印刷体保留率掩码外侧3像素环带判断有没有误伤印刷体OCR可读率修复后全图字符识别准确率判断业务链路真实收益OCR可读率有一个更细的做法用同一个OCR引擎分别识别干净原图和修复图统计新增的错误字符数。纯粹看修复图准确率意义不大因为模型可能把难字整块擦成空格OCR“准确”了业务上却是灾难。只有当“修复图错误数减去干净图错误数”接近于0才说明修复没有顺手把印刷体带走。6. 手写文字擦除接进OCR流水线一个批量处理脚本的边界设置末尾补一个具体的批量处理技巧把单张推理服务包成队列并按OCR反馈结果动态调整掩码膨胀次数。import queue import threading import cv2 def inference_worker(in_queue, out_queue, model): while True: item in_queue.get() if item is None: break img_path, mask_path item img, mask cv2.imread(img_path), cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) pred model.inpaint(img, mask) out_queue.put((img_path, pred))更值得说的一句经验是手写擦除接OCR流水线时不要在输入阶段做大尺寸归一化。OCR引擎吃的是原始分辨率修复模型训练的输入尺寸是512或640推理时先用模型输出再做比例放大回原分辨率最后送到OCR。顺序反了OCR对修复区域的小字号印刷体会出现识别阈值偏移白折腾一遍。我自己的习惯是每批数据跑完随机抽5张失败样例把掩码叠在修复结果上保存成对比图放进产物的目录。这样即使不看指标也能在肉眼层面快速确认是新数据分布问题还是模型退化。手写文字擦除这行指标好看不是终点能把交付图片干净地送进下一道OCR才算真的落地。希望帮到你。本文还有配套的精品资源点击获取