news 2026/9/16 3:50:18

试卷手写擦除全流程:数据合成、U-Net训练与OCR评估

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
试卷手写擦除全流程:数据合成、U-Net训练与OCR评估

简介:基于深度学习的试卷手写文字擦除毕业设计资料包,面向计算机视觉方向学生与毕业设计开发者,聚焦去除图像中的手写文字并保留背景信息。整套资源共29个文件,以22个Python源码为核心,覆盖模型结构(如SA-GAN、BiSeNetV2)、数据加载、损失函数、训练/测试/预测等完整流程;另有3个Shell脚本便于执行,2个README与1份Markdown文档用于说明原理与使用,并附带1个zip提交包。压缩包整体仅94KB,轻量易部署,已有122人学习。项目基于U-Net及对抗生成网络实现手写擦除,文档对研发背景、技术方案和实验结果有系统说明,既能作为毕业设计核心参考,也可作为计算机视觉课程设计或论文复现的切入点,并为后续文本检测、图像修复等方向提供思路与代码基础。

1. 拿到“试卷手写文字擦除”项目,先看数据而不是模型

试卷手写文字擦除要解决的,是扫描或拍照的卷面上学生手写、老师批注与印刷体文字叠在一起时,把笔迹抹掉又尽量不伤印刷字形的图像修复问题。OCR 之前的这一道预处理,直接影响识别率;很多个人开发者第一反应是找一个“能去手写的模型”,但这类任务的可用数据远比网络结构稀缺。

一张真实卷面里,印刷体与手写体共享纸张纹理和高对比度边缘,纯粹的噪点模型会把印刷体也当成瑕疵修掉。所以常见做法是把任务拆成两个监督信号:检测手写笔迹的掩码分支,与重建干净卷面的生成分支,一起训练。下面按一个标准深度学习交付物的构成展开:数据、模型、训练、文档与导出。

2. 训练数据做主战场:合成手写笔迹与掩码标签的组织

2.1 为什么一开始就造数据:成对样本从哪里来

试卷手写擦除属于有监督的图像到图像翻译,训练时每一张输入图都要对应一张“没有手写的干净图”。真实场景里同时采集同一张卷面的脏版和净版几乎不可能,人工用 Photoshop 擦出成对数据又太贵,单张标注成本高于绝大多数毕业设计的时间预算。因此最可靠的起步方式是合成:拿扫描或打印出来的印刷卷面当干净图,在上面叠加模拟手写笔迹,得到脏图,同时自然拿到像素级掩码。

合成数据还有一个额外好处:掩码是生成过程中顺带产出的,不需要人工标注。这样数据集的规模只受印刷卷面数量和机器性能限制,几十张模板就能扩出上万对样本。比例上我一般会把合成样本和真实手工标注样本按 9:1 到 4:1 混合,真实样本只用来做微调和验证,避免模型过度适应合成笔迹的均匀灰度。

2.2 用 OpenCV 生成第一版训练对:可直接跑的代码

先用最简单的随机线段模拟笔道,跑通整个训练流程再说质量。核心生成逻辑是:在干净图上画若干条随机角度、随机长度的线段,把这些线段的位置同步记录为掩码。

import numpy as np import cv2 def gen_stroke_mask(w, h, count=12): """ 生成笔迹掩码:count 控制笔画条数,返回 0/255 的二值图。 这里用线段近似手写笔道,后续可换成真实笔迹贴图。 """ mask = np.zeros((h, w), dtype=np.uint8) for _ in range(count): x0, y0 = np.random.randint(0, w), np.random.randint(0, h) angle = np.random.uniform(0, np.pi) length = np.random.randint(40, 160) radius = np.random.randint(2, 6) x1 = int(x0 + length * np.cos(angle)) y1 = int(y0 + length * np.sin(angle)) cv2.line(mask, (x0, y0), (x1, y1), 255, radius) return mask def make_pair(clean): h, w = clean.shape[:2] mask = gen_stroke_mask(w, h, count=np.random.randint(8, 24)) dirty = clean.copy() ink = np.random.randint(60, 150) # 笔迹的灰度值,模拟不同深浅 dirty[mask > 0] = ink return dirty, mask

参数说明:count控制每张图的笔画数量,太小学不到密集书写场景,太大训练数据全是大面积涂抹;radius是笔宽,2 到 6 像素对应签字笔和粗记号笔的区别,实际做的时候可以按 2、3、5 三种规格分别生成;ink取 60 到 150 之间的随机灰度,避开纯黑和接近纸张的浅灰,否则模型会倾向输出极端值。掩码建议保存为 PNG 或 BMP,不要用 JPEG,压缩会破坏边缘,导致训练时掩码和图形貌不一致。这套代码生成的是完整可用的训练三元组:dirty进模型,clean是重建目标,mask给掩码分支做监督。

提示:随机线段画出的笔迹颜色均匀,和真实手写的笔压变化差别很大。第一版用它跑通管线没问题,中期迭代时可以把真实手写笔迹抠图随机贴到卷面上,掩码同步记录贴图位置,精度会有明显提升。

2.3 掩码的三种组织方式与边缘处理

训练时掩码不只是“给模型看的标签”,它的组织方式直接影响重建质量。我用过三种组织方式,各有用途。

掩码类型生成方式用途
硬掩码笔迹区域标 1,其余标 0监督掩码分支的二值分类
软掩码硬掩码做高斯模糊,边缘渐变对重建损失做加权,减少边缘伪影
膨胀后掩码硬掩码做形态学膨胀 3~5 像素防止模型只擦掉笔画主干,留下浅色墨迹残留

第三种方式最容易忽略。手写笔迹和纸张接触处往往有一圈渗墨,二值掩码只覆盖了深色中心,网络学出来的擦除结果会在笔画周围留下一圈淡影。我在生成掩码后会做一次cv2.dilate,把掩码向外扩几个像素,让模型把渗墨区也纳入学习目标。边缘处如果直接拿硬掩码约束,重建图经常出现明显的方框感,所以训练时重建损失用软掩码加权,掩码分支损失仍然用硬掩码,两者分工明确。

3. 模型结构选型:U-Net 做骨架,掩码头引导擦除

3.1 为什么通用去噪模型解决不了试卷手写擦除

去噪模型的基本假设是噪声独立随机、没有语义,而手写笔迹和印刷文字一样都携带明显结构,既有笔画的连续性,又有方向的连贯性。如果拿普通去噪网络处理,它要么认为印刷体也是噪声一起抹掉,要么因为找不到噪声的统计规律而输出一坨糊状纹理。试卷手写擦除实际是条件图像修复问题,难点不在“去”,而在“补”:擦掉之后的区域要重建出纸张纹理和可能被遮挡的印刷笔画。

因此模型必须同时做两件事:定位手写区域,以及生成该区域的合理内容。一个自然的做法是让网络长出两个头,一个头输出笔画概率掩码,另一个头输出重建后的干净图。掩码头迫使编码器提取笔迹特有的语义特征,重建头则利用这些特征指导生成,两个任务共享底层特征,训练时互相约束,比单纯端到端回归稳定得多。

3.2 一个能跑通的双头 U-Net 基线(PyTorch)

先放一个结构完整、可以直接放进训练脚本的基线模型。它采用三次下采样三次上采样的紧凑 U-Net,通道数控制在 64 到 512 之间,8G 显存也能跑 512×512 输入。

import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class EraseNet(nn.Module): def __init__(self): super().__init__() self.e1 = DoubleConv(3, 64) self.e2 = DoubleConv(64, 128) self.e3 = DoubleConv(128, 256) self.pool = nn.MaxPool2d(2) self.bridge = DoubleConv(256, 512) self.up = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False) self.d2 = DoubleConv(256 + 512, 256) self.d1 = DoubleConv(128 + 256, 128) self.d0 = DoubleConv(64 + 128, 64) self.recon = nn.Conv2d(64, 3, 1) self.mask_head = nn.Conv2d(64, 1, 1) def forward(self, x): e1 = self.e1(x) e2 = self.e2(self.pool(e1)) e3 = self.e3(self.pool(e2)) b = self.bridge(self.pool(e3)) d2 = self.d2(torch.cat([self.up(b), e3], dim=1)) d1 = self.d1(torch.cat([self.up(d2), e2], dim=1)) d0 = self.d0(torch.cat([self.up(d1), e1], dim=1)) recon = self.recon(d0) mask = torch.sigmoid(self.mask_head(d0)) return recon, mask

输出的recon是 3 通道干净图,mask是经过 sigmoid 的笔画概率图,范围 0 到 1,可以直接算 BCE 损失。pytorch 环境配置方面,这段代码只需要torchtorch.nn,没有额外依赖,适配常见的 CUDA 版本即可。输入尺寸要求宽高都是偶数,实践中固定成 512×512 或 768×768 就行。

如果显存不够,优先把第一层通道从 64 降到 32,模型参数量和显存占用立刻下降一截。训练时如果把batch_size设成 1,BatchNorm 的统计量会非常不稳,换成GroupNorm更稳妥,这个坑在文档图像的细节纹理任务里很常见。

3.3 注意力与 Transformer 的边界:什么时候值得换

Transformer 结构在长距离依赖建模上有优势,但直接拿 ViT 做高分辨率文档图像修复,显存和训练成本都很高。手写擦除需要的语义范围有限:一条笔画左右延伸几十像素,印刷字笔画之间的距离也就在这个尺度,数百像素之外的全局关系对重建结果帮助不大。所以基线阶段不建议上完整的 Transformer 模型。

如果要在结构里引入注意力,我会把轻量模块插在桥接层之后,而不是替换整个编码器。比如在d2输出后加一个 SE 模块或一个窗口大小为 7 的 Swin Block,让特征在 1/4 分辨率下做局部窗口交互,既能增强笔画间的上下文关联,显存增量也只有几十兆。下表是三种路线的适用判断。

结构路线显存开销细节恢复适用阶段
纯 U-Net 基线中上先跑通流程,验证数据有效性
U-Net + SE/CBAM默认推荐,性价比最高
U-Net + Swin Block中高复杂笔迹更好真实样本充足、追求指标时

一个反直觉的结论是,在合成线段数据上,带注意力的模型提升往往不明显,因为合成笔迹的随机线段之间本来就没有复杂语义关联。等混入真实手写数据后再加注意力,才会看到效果释放。

4. 训练参数与评估:从 epoch 设置到 OCR 回归指标

4.1 损失函数组合:别只算 L1

试卷这类文档图像要求重建结果既平滑又保留印刷体的锐利边缘,单一损失很难兼顾。L1 损失对像素绝对值敏感,能稳住整体灰度;SSIM 损失约束结构相似,让印刷笔画的骨架不塌;感知损失则提升纹理真实感,防止输出“过光滑”。掩码分支单独用 BCE,它和重建损失共享特征但不参与像素重建。

损失计算对象推荐权重
Mask BCE预测掩码 vs 硬掩码1.0
L1重建图 vs 干净图1.0
SSIM重建图 vs 干净图0.2
感知损失VGG 特征层0.1(数据量大后可关)

加对抗损失时要注意平衡:生成器想尽办法把擦除区域填平,判别器又会挑剔纸张纹理的真实度,训练容易摇摆。毕业设计或工程预研阶段先不加,等 L1+SSIM+BCE 组合跑稳之后再尝试。

4.2 训练超参与最小训练循环

把训练脚本最核心的一段放在下面。这里用自动混合精度省显存,200 个 epoch 起步,验证集上取 PSNR 最高的权重保存。

scaler = torch.cuda.amp.GradScaler() opt = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=140, eta_min=1e-6) for epoch in range(200): model.train() for clean, dirty, mask in loader: clean, dirty, mask = clean.cuda(), dirty.cuda(), mask.cuda() opt.zero_grad() with torch.cuda.amp.autocast(): pred, pred_mask = model(dirty) loss_l1 = torch.abs(pred - clean).mean() loss_ssim = 1 - ssim(pred, clean) # 使用 pytorch_msssim loss_mask = torch.nn.functional.binary_cross_entropy(pred_mask, mask) loss = loss_l1 + 0.2 * loss_ssim + loss_mask scaler.scale(loss).backward() scaler.step(opt) scaler.update() sched.step() if (epoch + 1) % 10 == 0: # 验证集上计算指标,保存最优权重 check_and_save(model, epoch)

参数设置参考:输入分辨率 512×512,batch_size8,AdamW 学习率 1e-4,前 60 个 epoch 用固定学习率,之后用余弦退火降到 1e-6。ssim(pred, clean)建议用pytorch_msssim库,输入要先归一化到 0~1,和公式中默认的 data_range 一致。混合精度在文档图像训练里提升明显,8G 显存跑 512 输入不开autocast很容易爆,开启后 batch 可以从 4 提到 8。

epoch 数量上,合成数据足够多,200 个 epoch 通常能看到 PSNR 涨幅趋缓。如果 60 个 epoch 时 PSNR 还在快速上升,说明数据集比预期复杂,可以继续延长时间并加大随机变换;如果验证损失不降反升,先检查训练集和验证集是否存在卷面模板重叠,模板重复会导致模型靠场景记忆而不是笔画语义做重建。

4.3 评估指标:别只看 PSNR,OCR 字准才是业务指标

图像指标和业务效果经常脱节。PSNR 高只能说明像素接近,不能说明印刷字可读性强;SSIM 高也不能保证 OCR 能认出全部文字。所以验证要分两层:像素层指标控制质量下限,OCR 字准率决定最终有没有用。

import torch from skimage.metrics import peak_signal_noise_ratio, structural_similarity model.eval() with torch.no_grad(): pred, pm = model(dirty) pred = pred.clamp(0, 1) pred_np = pred[0].permute(1, 2, 0).cpu().numpy() clean_np = clean[0].permute(1, 2, 0).cpu().numpy() psnr = peak_signal_noise_ratio(clean_np, pred_np, data_range=1.0) ssim = structural_similarity(clean_np, pred_np, channel_axis=-1) pred_bin = (pm[0, 0].cpu().numpy() > 0.5).astype(int) true_bin = (mask[0, 0].cpu().numpy() > 0.5).astype(int) iou = (pred_bin & true_bin).sum() / (pred_bin | true_bin).sum()

掩码 IoU 反映网络有没有准确找到手写位置,低于 0.6 时重建任务基本不可信。做业务评估时,我通常抽 20 张真实试卷,分别对原图和擦除结果跑 OCR,记录识别准确率提升幅度。如果 PSNR 提升但 OCR 准确率不涨,多半是擦除过于保守,手写残影仍然干扰识别;反之 OCR 提升明显而 PSNR 一般,说明模型抓到了真正需要擦除的区域,业务目标已经达成。

5. 可交付的源码包:模型导出、文档要素与三分钟回归验证

5.1 一个能顺利复现的目录与文档要素

压缩包里的源码能不能在别人机器上跑起来,取决于文档和环境说明是否完整。常规交付目录里需要数据、模型、源码、文档四个区域分开,训练和推理脚本单独建目录,避免混合在一起。

erasure/ ├── data/ # 训练图、真实卷面、掩码 ├── models/ # best.pth、erase.onnx ├── src/ # train.py、infer.py、export_onnx.py └── docs/ # README.md、实验记录、演示脚本

文档说明至少要讲清三件事:数据格式是什么(输入图像尺寸、掩码是 0/255 还是 0/1)、训练命令怎么跑、推理结果存到哪里。环境配置容易出问题,源码包建议附带 requirements.txt 和 environment.yml,锁定核心库版本,才能避开复现时的依赖地狱。

5.2 ONNX 导出与输入尺寸约定

模型导出成 ONNX 而不是只给.pth,是为了让对方在没有同版本 torch 环境的机器上也能力推理。导出时把动态维度打开,方便后续接不同分辨率输入。

model.eval() dummy = torch.randn(1, 3, 512, 512).cuda() torch.onnx.export( model, dummy, "erase.onnx", input_names=["dirty"], output_names=["recon", "mask"], opset_version=17, dynamic_axes={"dirty": {2: "h", 3: "w"}, "recon": {2: "h", 3: "w"}, "mask": {2: "h", 3: "w"}}, )

导出后直接用 onnxruntime 跑一遍,检查输出的mask是否仍然经过 sigmoid,避免后续接方在推理时还要反推激活函数。动态维度导出后,输入尺寸不能突破训练时的感受野约束,本项目模型在 512 到 1024 像素范围内都能保持稳定。

5.3 三分钟回归验证脚本

交付前建议写一个固定脚本做回归验证:拿 20 张真实卷面,先记录原图 OCR 字准,再记录擦除后 OCR 字准,对比提升幅度。这个脚本不用做得复杂,关键是每次改模型后都能用它裁决效果,避免凭肉眼判断。

from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) def eval_accuracy(img_path): result = ocr.ocr(img_path, cls=True) text = "".join([line[1][0] for line in (result[0] or [])]) return len(remove_space(text))

原图字数记为before,擦除图字数记为after,目标不是让after等于 0,而是在印刷文字齐全的前提下尽量接近 0。如果after明显小于印刷体应有字数的 90%,说明模型误删了印刷区域,需要回看掩码分支的误报样本,把这类图补充进训练集再训一轮。这个回归基线一旦固定,后续调模型、调后处理、换数据增强,都能用同一套指标快速判断优劣。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 3:49:23

UE5编译报错Could not be compiled?从日志到环境全链路排查指南

你肯定见过这个红色大边框提示:“UE5 Could not be compiled. Try rebuilding from source manually.” 不少朋友第一次看到时直接懵了,明明刚才还好好的,怎么突然就编译不过了呢?尤其当你正赶一个移动端策略游戏原型、或者刚从代…

作者头像 李华
网站建设 2026/9/16 3:48:52

Agent插件生态构建指南:从协议设计到安全分发实践

这几年做Agent开发的朋友应该都有同一种体感:单机版Agent已经不够玩了。你把记忆、工具调用、多步规划这些能力全塞进一个Agent里,折腾半天,场景还是那么几个,边界还是卡在那里。真正的分水岭,是当你开始琢磨“怎么让别…

作者头像 李华
网站建设 2026/9/16 3:48:22

ArcGIS栅格裁剪全攻略:从影像到DEM的实操指南

直接进入正题。搞GIS的十有八九都躲不开这活儿:手里拿着研究区的矢量边界,要一批影像或者DEM数据,结果下载下来是全图幅的大文件,动辄几个GB,死活加载不动。这时候就得裁剪。ArcGIS里裁剪栅格数据的方法看着不止一种&a…

作者头像 李华
网站建设 2026/9/16 3:46:47

Spring Boot 2.6.13集成Flowable 6.8.1工作流引擎实战指南

接手老项目,要在 Spring Boot 2.6.13 里把 Flowable 6.8.1 工作流引擎拉进来,做审批流和业务解耦的那套东西。老实说,刚拿到这个任务的时候我心里是有点底的,毕竟 Flowable 在 Java 生态里算老熟人,Spring Boot 集成它…

作者头像 李华
网站建设 2026/9/16 3:46:24

红包一停情感归零:直播运营如何重建用户留存与情感连接

“红包一停情感归零”——这句话虽然扎心,但就是当下直播和短视频圈里最真实的写照。前几天我还跟团队复盘一个叫“元宝”的账号,之前靠着一波接一波的红包雨冲到榜首,在线人数一度破万,热度数据漂亮得让品牌方排队来投。结果运营…

作者头像 李华
网站建设 2026/9/16 3:45:10

Linux设备驱动调试全链路:从RK3568设备树匹配到I2C/CAN probe触发

1. 这不是“写个驱动”那么简单:为什么现代Linux设备驱动开发必须走通这条完整路径你有没有遇到过这样的情况:在RK3568开发板上,照着《Linux设备驱动开发详解》第3章写了个字符设备模块,insmod成功,mknod也做了&#x…

作者头像 李华