资讯详情 从分割到擦除:手写试卷图像处理的完整深度学习落地流程
📅 2026/10/14 3:42:43
简介手写试卷擦除工具是一套开箱即用的深度学习图像修复方案基于Python实现集成BiSeNetV2、NAFA、SA-GAN等轻量级语义分割与修复模型可自动识别并擦除试卷手写笔迹同时保留印刷体内容。适用于计算机、人工智能、电子信息等专业本科生毕设、课程设计也是深度学习图像修复方向的入门实战案例。压缩包共69个文件以45个Python脚本为核心覆盖训练、推理、评估、数据加载、损失函数及ONNX模型导出等完整流程另有Shell脚本、Markdown说明文档与示例图片整体约472KB目录结构清晰各模块职责明确。已有74人学习下载内容经过本地验证可运行。配套项目说明文档详细梳理了环境配置、数据准备、训练命令、推理示例和常见问题解答代码便于替换主干网络、接入新数据集或优化擦除边缘效果适合二次开发与答辩展示。1. 手写试卷擦除工具先解决“哪里是手写”再谈“怎么擦”手写试卷擦除这类需求这几年在题库整理、试卷重做、错题归档场景里越来越常见。很多人第一反应是找图像去水印或者 OCR 识别文字实际做下来会发现都不对去水印算法会把印刷字一起抹掉OCR 只能识别文字、没法把卷面变回空白。这个工具的核心思路是反过来的先训练一个分割模型把手写笔迹的区域找出来再用图像修复或像素替换把这些区域清掉印刷体和纸张网格完整保留。整个过程用 Python 就能闭环训练、测试、导出 ONNX 再接入桌面端或移动端是一个非常适合作为深度学习落地入门的方向。这篇文章会从数据合成、网络选型、训练评估、常见坑点到 ONNX 部署按完整流程讲清楚适合已经会用 PyTorch 基础 API、想独立做完一个端到端视觉项目的开发者也适合要评估这个方案值不值得投入的团队。2. 造数据是第一步合成手写样本、掩码与纸张背景2.1 为什么要合成数据真实手写样本的根本缺口想训练一个能区分“手写笔迹”和“印刷体”的模型最理想的数据是大量带像素级标注的真实试卷图。但真实场景里没人给你标——试卷是扫描件或手机拍照逐像素勾手写区域的成本太高而且手写压着印刷字、有红笔批改、有铅笔痕迹标注标准很难统一。我一般会先放弃追求真实数据用合成数据把模型的“基础能力”打牢再手动采集几十张真实样本做微调。合成数据的核心是把“写”这个动作拆成可编程的变量字体、颜色、宽度、旋转、噪声、背景。用一个手写字体库在目标区域渲染文字渲染的同时直接把文字的 alpha 通道保存下来天生就有像素级掩码不用标注。常见做法是用开源手写字体配合矢量渲染工具每个样本随机变换字体大小和倾斜角度叠到扫描纸张纹理上就得到一组原图、掩码训练对。2.2 从渲染到训练对三套合成脚本的骨干逻辑这部分我给出一套我实际用过的合成流程。整体分三块渲染手写文字、合成试卷底图、扰动增强。先看文字渲染脚本的核心逻辑# render_handwriting.py from PIL import Image, ImageDraw, ImageFont import numpy as np # 1. 透明画布上渲染手写文字拿到 alpha 作为手写掩码 def render_text_alpha(text, font_path, font_size, rotation, canvas_size(512, 512)): canvas Image.new(RGBA, canvas_size, (0, 0, 0, 0)) draw ImageDraw.Draw(canvas) font ImageFont.truetype(font_path, font_size) bbox draw.textbbox((0, 0), text, fontfont) x (canvas_size[0] - (bbox[2] - bbox[0])) // 2 - bbox[0] y (canvas_size[1] - (bbox[3] - bbox[1])) // 2 - bbox[1] draw.text((x, y), text, fontfont, fill(255, 255, 255, 255)) alpha canvas.rotate(rotation, expandFalse, resampleImage.BICUBIC) return alpha.split()[3] # 直接得到 alpha 通道就是掩码 # 2. 先给出掩码再生成对应原图掩码永远是“第一手数据”而不是事后标注 def compose_sample(alpha, paper_img, ink_color, blur_radius1.5, noise_std8): ink Image.new(RGBA, paper_img.size, ink_color (255,)) # alpha 转换为 PIL 灰度图作为透明度蒙版 mask alpha.point(lambda p: 255 if p 60 else 0) composed Image.composite(ink, paper_img, mask) composed composed.filter(ImageFilter.GaussianBlur(blur_radius)) arr np.array(composed).astype(np.float32) arr np.random.randn(*arr.shape[:2], 1) * noise_std return Image.fromarray(arr.clip(0, 255).astype(np.uint8)), mask这里最关键的设计是让掩码由渲染过程直接产出而不是从最终合成图里反推。顺序是先渲染文字得到 alpha再通过 alpha 把墨迹贴到纸张上。这样掩码和原图天然对齐永远不会出现标注错位问题。参数上有两个值得注意的地方point(lambda p: 255 if p 60 else 0)是 binarize 掩码的阈值太大会丢掉浅色笔触太小会把墨迹边缘的“毛刺”大面积圈进来blur_radius1.5模拟真实书写时笔迹边缘的渗墨这个值对训练效果影响很小但对测试阶段做擦除边界有影响。2.3 纸张背景越像真实卷面模型越不容易翻车手写文字是前景纸张背景是容易被忽略但决定成败的部分。纯白背景训练的模型一到真实扫描卷上就水土不服网格纸的横线被当成前景、答题卡的小方块被误认为文字、灰底扫描件对比度过低导致笔迹漏检。我的做法是把背景渲染做成一个分层模板每层都能随机替换# background.py def make_background(base_texture, grid_typenone, grid_color(200, 200, 200)): bg base_texture.convert(RGB).copy() draw ImageDraw.Draw(bg) if grid_type lines: # 横线卷面 for y in range(0, bg.height, 36): draw.line([(0, y), (bg.width, y)], fillgrid_color, width1) elif grid_type dotted: # 点阵答题卡 for y in range(0, bg.height, 28): for x in range(0, bg.width, 28): draw.point((x, y), fillgrid_color) elif grid_type answer_zone: # 答题区色块 for y in range(80, 500, 60): draw.rectangle([50, y, 462, y 52], outlinegrid_color, width1) return bg背景合成里我一般保持 4 类纯白、浅灰网格、横线作文纸、答题卡色块区。每类占 25%保证模型不会对某一种纸张特征过拟合。纸张要加轻微噪声和光照不均匀否则真实扫描图上那些色偏会让模型给出“背景也是文字”的幻觉。2.4 网络选型U-Net 锚点与两个实用改动分割模型这块U-Net 是最稳妥的起点。它不是性能最强的但是是最容易让损失下降、最容易导出 ONNX 的结构。编码器用预训练的 ResNet 或 MobileNetV3 都可以后者在部署时体积更小。在试卷擦除这个任务里我建议做两个改动。第一输出通道从 1 改成 2用二分类交叉熵处理“背景/手写”而不是直接回归灰度值。这样模型学习目标更清晰掩码边缘也更锐利。第二输入分辨率固定在 512×512 而不是 256×256——手写字迹的笔画往往只有 2 到 4 像素宽低分辨率下细笔迹会被下采样抹掉模型学到的掩码会断成一截一截。# model.py import torch.nn as nn # 简化版 U-Net 骨架示例只展示输入输出部分 class SegNet(nn.Module): def __init__(self, encodermobilenetv3): super().__init__() # 用预训练分类网络做编码器去掉最后的分类头 # 解码器为 4 次双线性上采样 3x3 卷积步长补到 512 输入 self.head nn.Conv2d(64, 2, kernel_size1) # 2 类背景、手写 def forward(self, x): feats self.encoder(x) # 实际代码里替换为特征金字塔取多尺度 out self.up(feats) # 上采样, 缩放回 512x512 return self.head(out) # (B, 2, 512, 512)这里补充一个从实战中得到的观点不要在骨干网络上堆太多参数量。手写分割本质上像素级二分类卷积复杂度不高。用轻量编码器推理速度快一倍ONNX 模型体积小擦除质量损失很小。真正影响质量的不是网络宽度而是掩码的边界准不准。3. 训练与测试最小可复现流程与评估指标3.1 训练配置loss、优化器与增强项怎么设训练阶段做的事其实很常规但几个参数直接决定掩码质量。先给一张我常用的训练配置表配置项推荐值说明输入尺寸512×512保留细笔迹低于 384 建议直接放弃Batch Size8 16根据显存调整FP16 可以到 16损失函数0.6×Dice 0.4×BCEDice 处理前景背景不平衡BCE 保证梯度稳定优化器AdamW, lr1e-4预热 3 个 epoch 再到 1e-3 后余弦衰减训练轮数60 epoch 左右合成数据量大40 轮后验证 loss 基本平了数据增强随机仿射 / 颜色扰动 / 随机遮挡仿射角度 ±10°平移 ±20px训练脚本的核心结构并不复杂真正影响结果的是 loss 的写法直接看实现# train.py 片段 def dice_bce_loss(pred, target): # pred: (B, 2, H, W) 的 logits, target: (B, 1, H, W) 的 0/1 掩码 bce nn.functional.cross_entropy(pred, target.squeeze(1).long()) prob torch.softmax(pred, dim1)[:, 1, :, :] # 手写类别的概率 smooth 1.0 intersect (prob * target.float()).sum() dice 1 - (2.0 * intersect smooth) / (prob.sum() target.sum() smooth) return 0.6 * dice 0.4 * bce这个 loss 组合在笔迹分割上的表现很稳。只用 BCE细笔迹会被忽略因为背景像素占比超过 90%模型学一个“全输出背景”也能把 loss 压得很低只用 Dice训练前期波动大容易卡在局部最优。两者混合后Dice 项保证了前景召回BCE 保证了每个像素的微调方向。优化器上用 AdamW 配固定 lr 1e-4 即可不需要花哨的调度策略。数据增强里需要注意一点旋转角度别超过 ±10°因为试卷照片本身是正放的过大的旋转会引入无意义的任务难度。3.2 测评指标别只看 mask 的 IOU很多人训练结束只看 mask IOU觉得 0.92 就完事了实际一擦就露馅。原因很简单真实试卷上的手写笔迹常与印刷体或网格线有重叠mask 层面的一点偏差映射到像素替换结果上就变成“一块白斑”。测评应该分两层。第一层是 mask 指标Dice、IOU、召回率。第二层是擦除结果指标要在最终图像上算我一般用这三项指标含义好的范围目标区 PSNR擦除区域与原空白区域的峰值信噪比高于 32dB 基本看不出痕迹印刷体保留率印刷字像素中被保留的比例高于 0.99残留笔迹面积占比预测 mask 之外残留的手写像素比例低于 5%测试时最容易被骗的坑是拿测试集的“合成数据”做测评。合成数据里的纸张背景、笔迹颜色都是已知的模型很容易拟合出高分。真实测评至少要有几十张不同光线、不同手机角度拍的真实试卷否则所有指标都只能用来观察模型有没有过拟合。3.3 测试流程先出掩码再做擦除测试阶段其实是一个完整 pipeline图像对齐预处理 → 模型预测掩码 → 掩码后处理 → 擦除合成。前两步没有任何特殊之处重点是后两步。# infer.py import cv2 import numpy as np def erase_paper(image, mask): # image: BGR图像 # mask: 模型输出的 0-1 掩码float32 kernel np.ones((3, 3), np.uint8) mask_dilate cv2.dilate(mask, kernel, iterations1) # 膨胀避免笔迹边缘残留 mask_inpaint (mask_dilate 0.5).astype(np.uint8) * 255 # 用 inpaint 只处理手写区域参数 3 是修复半径大小与笔画粗细成正比 result cv2.inpaint(image, mask_inpaint, 3, cv2.INPAINT_TELEA) # 对印刷体区域做保护不在掩码内的像素直接保留原图 safe (mask_inpaint 0).astype(np.uint8) output result * safe[:, :, None] image * (1 - safe[:, :, None]) return output这段代码里cv2.dilate和cv2.inpaint是两步关键后处理。膨胀解决笔迹浅色边缘漏擦的问题inpaint 修复半径则要根据实际笔画宽度来调。笔画宽约 2-3 像素时半径 3 即可如果是毛笔字、标记笔笔迹较粗半径要调到 5-7否则会出现明显的“补丁块”。擦除逻辑里最重要的是保护印刷体。直接用 inpaint 全图处理会让印刷字边缘也受损所以最后一步用掩码取交集只有掩码区域采用 inpaint 结果其余区域直接保留原图。这一步能有效把印刷体保留率拉回 0.99 以上。4. ONNX 转换导出、校验与边缘设备部署4.1 导出前的模型准备固定动态轴还是静态轴PyTorch 模型直接部署到桌面端或移动端不太现实ONNX 是这个场景下的通用中间格式。转换前要做一个决策输入尺寸是否固定。如果只处理扫描整页试卷固定 512×512 就行导出最简单推理也更快。但实际照片输入比例不一直接 resize 会拉伸错位所以我会先固定宽高为 512同时接受轻微比例失真。要处理任意尺寸导出时指定动态轴ONNX Runtime 推理时再指定输入 shape。python -m onnxruntime.tools.convert_onnx_models_to_ort model.onnx4.2 导出命令与校验两处最容易翻车的地方用 PyTorch 的torch.onnx.export导出时我推荐 opset 设为 17版本太老会对一些算子兼容性差。核心代码import torch # model 是训练好的 SegNeteval 模式先把权重加载进来 model.eval() x torch.randn(1, 3, 512, 512, devicecpu) torch.onnx.export( model, x, paper_erase.onnx, input_names[input], output_names[mask_logits], opset_version17, do_constant_foldingTrue, dynamic_axes{input: {0: batch, 2: height, 3: width}, mask_logits: {0: batch, 2: height, 3: width}} )动态轴这行是最容易踩坑的地方。如果模型内部有需要固定输入尺寸的算子比如 AdaptiveAvgPool 或 F.interpolate size 参数导出不会报错但推理时换尺寸会出现形状不匹配运行时错误。这类问题必须在导出前排查把网络里的nn.AdaptiveAvgPool2d换成普通nn.AvgPool2d把size参数改为scale_factor才能顺利支持动态尺寸。导出完不要直接拿去部署要跑一遍输出校验。用同一张输入图分别跑 PyTorch 和 ONNX Runtime对比输出张量的最大差值import onnxruntime as ort import numpy as np x np.random.randn(1, 3, 512, 512).astype(np.float32) sess ort.InferenceSession(paper_erase.onnx, providers[CPUExecutionProvider]) onnx_out sess.run([mask_logits], {input: x})[0] with torch.no_grad(): torch_out model(torch.from_numpy(x)).numpy() diff np.abs(onnx_out - torch_out).max() print(max abs diff:, diff) # 小于 1e-3 基本没问题4.3 部署推理的性能调优FP16 与算子融合选项ONNX Runtime 默认跑 FP32。CPU 上如果觉得速度不够可以把模型转成 FP16然后用ORT_ENABLE_FP161运行GPU 上用 TensorRT 或 DirectML 后端做算子融合速度比单跑 ORT 快 2-3 倍。部署端对延迟敏感时我的建议顺序是先把预处理放到 ONNX 模型外面用 OpenCV 做一次 resize 和归一化再把后处理 mask 的膨胀修复逻辑留给宿主语言不要在模型内部做矩阵形态学运算。5. 手写试卷擦除落地的 5 个常见坑现象、原因与解法5.1 坑一浅色铅笔笔迹完全漏删现象模型对铅笔写的内容几乎不出掩码擦除后留下一片灰影。原因合成数据里只用了深色墨迹黑色、蓝色没有浅色铅笔样本铅笔灰度接近背景纸张模型学到的就是“背景”。解决在合成阶段把文字渲染颜色加入灰色系灰度值 80-180 随机同时把背景纸张噪声调高模拟真实扫描的颗粒感。若真实场景中铅笔占比高可以单独用灰度通道做一份只含铅笔的合成数据微调。5.2 坑二掩码膨胀过度印刷字被擦出白边现象一句印刷体题目旁边出现白色虚影笔画边缘像被啃过。原因后处理里无条件膨胀掩码手写笔迹和印刷字贴得近时膨胀区域越过边界覆盖到印刷字上。解决膨胀前先判断掩码区域是否和印刷字相邻方法是用 Canny 检测印刷字边缘把边缘像素从 mask 中减去一段距离再膨胀。或者简单一点把膨胀核从 3×3 降为 2×2并且只在笔画方向上做各向异性膨胀。5.3 坑三合成数据训练效果好真实卷面一塌糊涂现象合成测试集 IOU 0.93真实试卷上召回率掉到 0.7。原因合成数据的字体、纸张和真实拍摄环境差异大真实试卷存在的阴影、折痕、光照不均合成样本里完全没有。解决用真实试卷图做背景层叠加合成笔迹重新生成一个“半合成”测试集再采集 10-20 张真实试卷人工标注做 finetune。这是最有效的一条没有捷径。5.4 坑四ONNX 动态轴导出后运行时 shape 报错现象导出成功推理时传入 800×600 报错“Resize: input shape doesnt match”。原因模型内部某层使用了基于固定尺寸计算的累加上采样次数动态轴只改动了外部输入内部计算得到的特征图尺寸锚定在默认值。解决仔细排查解码器里的nn.Upsample(size...)全部改为scale_factor2如果改完仍有问题直接放弃动态轴把输入固定为 512×512这是稳定性和灵活性之间的权衡。5.5 坑五红笔批改痕迹要么擦不净要么整块变灰现象带有红色批改痕迹的试卷红笔区域出现灰绿色斑块或者完全没被识别。原因红笔颜色虽然在 RGB 空间与黑色笔迹差异大但模型是在三通道混合特征里学习的红色通道的响应权重不够且红笔与印刷体叠加时掩码判别混乱。解决把输入图像转换到 HSV 空间增加一个基于色相通道的红色笔迹掩码作为辅助监督信号在训练阶段对红笔样本额外做 1.5 倍上采样。实际处理时可以在预处理阶段用cv2.inRange提取红色区域并直接叠进 mask 里不需要全部依赖模型。6. 验证方法如何判断这个模型真的能交付跑完训练和 ONNX 导出后别急着宣布完成。我习惯做三轮验证每一轮都在真实数据上执行而不是看指标曲线。第一轮是肉眼盲测。挑 50 张试卷按“完全不处理、模型擦除、人工精修擦除”三组顺序打乱让三位同事或者用户分别打分关注点是“有没有明显擦痕”“印刷字有没有被误伤”“残笔面积是否可接受”。分数比 PSNR 更有说服力因为 PSNR 对与人眼感知不敏感的像素偏移不敏感。第二轮是掩码可视化。把模型输出的掩码叠加在原图上检查边缘是否沿着笔迹轮廓走而不是像水彩一样晕开。如果掩码边缘出现锯齿或膨胀说明后处理参数没调好。这一轮里我最常发现的是大笔画中间出现“空洞”即两笔画在 x 方向距离很近模型把中间空隙也判成文字导致印刷字被啃掉一块。第三轮是滑窗批测。用一张超大尺寸的题库扫描图切成 512×512 的块逐块推理再拼接统计拼接处的接缝是否会出现掩码断裂。如果断裂频繁把滑窗步长设为 460重叠 52 像素重叠区域取平均可以明显缓解。做完这三轮再回头决定是否要调整数据比例或者后处理参数。这套流程走了几次之后我形成的习惯是永远保留一份真实场景的“盲测集”每次参数调整都重新跑一遍盲测而不是只看 loss 曲线。测试集指标最终应该起到的是辅助作用不能替代人眼判断。希望这个方案能帮到你让你在试卷图像处理方向上少踩一些坑。本文还有配套的精品资源点击获取