资讯详情

基于深度学习的试卷手写擦除:从U-Net源码到模型部署的完整链路

📅 2026/10/1 5:42:24 | 华诺云谱 👁 阅读
基于深度学习的试卷手写擦除:从U-Net源码到模型部署的完整链路
简介本资源为基于深度学习的试卷手写文字擦除毕业设计完整项目包面向计算机视觉方向的高校学生与深度学习入门开发者可用于毕业设计参考、图像修复课题研究及教育文档数字化场景。包内共29个文件以22个Python源码文件为核心涵盖模型定义、损失函数、数据加载与训练测试脚本另含3个Shell脚本用于训练与转换流程以及README与Markdown文档说明压缩包约94KB结构紧凑便于快速上手。项目围绕手写文字擦除任务涉及U-Net类分割网络、生成对抗网络、注意力机制与ONNX模型转换等模块并配有训练权重与测试入口可帮助读者理解从数据预处理到模型部署的完整链路。目前已有124人学习下载适合需要完整毕设方案、算法实现细节与排错思路的读者参考借鉴。1. 试卷手写擦除到底在做什么从一张答题卡到干净卷面的技术链路带过毕业设计的人大概都有体会题目里只要出现「基于深度学习」六个字真正难的不是模型结构而是把一份能跑通的源码、一个训好的模型和一份说得清的文档说明凑齐。试卷手写文字擦除这个方向恰好卡在一个很实用的场景上——把学生写在印刷体试卷上的手写笔迹抹掉只留下原本的题目印刷内容还原出一张干净的空白卷。它和照片修复模型、人声抑制这类任务在思路上是相通的都是把图像里某一类「叠加层」分离出去保留底层的结构信息。这个方向适合两类人一类是正在找毕业设计选题、需要一份完整源码加模型加文档说明的同学另一类是已经入门深度学习、想找一个图像到图像转换任务练手的工程师。它不需要你从零标注数据公开的手写数据集和合成方案足够撑起一个能答辩、能复现的项目。下面按「这是什么 → 怎么做 → 坑在哪」的顺序把整条链路拆开讲清楚。2. 试卷手写擦除的技术选型为什么是图像到图像转换而不是 OCR 后重排2.1 擦除任务的本质是像素级回归不是文字识别很多人第一反应是先 OCR 识别手写内容定位坐标再把那块区域涂白。这条路在真实试卷上几乎必翻车。原因在于手写笔迹和印刷体经常粘连、重叠OCR 的检测框会把手写和印刷体一起框进去涂白的时候连题目文字一起抹掉。而且试卷版式多样填空、解答、批注混在一起检测框的粒度根本对不上。擦除任务的正确建模方式是图像到图像转换输入一张带手写的试卷图输出一张只有印刷体的干净图。模型学的是「哪些像素属于手写层」这个映射而不是「手写写了什么字」。这也是它和照片修复模型、去噪任务共享同一套技术底座的原因——都是编码器提取特征、解码器重建目标图中间靠跳跃连接保留结构细节。从算法族看主流有三条路线。第一条是纯 CNN 的编码解码结构比如基于 U-Net 的变体实现简单、显存占用低适合毕业设计的算力条件。第二条是生成对抗网络用判别器逼着生成结果更接近真实干净卷面效果更锐利但训练不稳定。第三条是近几年热起来的 Transformer 类结构全局建模能力强但对数据量和显存要求高低显存运行模型时容易 OOM。对毕业设计来说我一般建议从 U-Net 起步跑通之后再考虑加判别器或换注意力模块。2.2 数据从哪来合成方案比真实采集更可控真实的手写试卷涉及隐私公开数据集很少直接可用。常见做法是合成拿干净的印刷体试卷图作为背景把手写笔迹数据集里的字随机贴上去同时生成对应的掩码图。这样一对训练样本就是「带手写图 干净图」掩码图还能作为辅助监督信号。手写笔迹来源可以用公开的手写数字、手写汉字数据集也可以自己写一批字扫描进去。关键是贴的时候要做随机旋转、缩放、颜色扰动和透明度混合让合成分布尽量接近真实拍摄的试卷。下面是一段合成脚本的核心逻辑用 Python 和 OpenCV 实现。import cv2 import numpy as np import random def synthesize_sample(clean_img, handwriting_imgs, num_strokes8): clean_img: 干净印刷体试卷图 (H, W, 3) handwriting_imgs: 手写笔迹图列表每张是单通道或三通道 返回: 带手写图, 干净图, 掩码图 h, w clean_img.shape[:2] noisy clean_img.copy() mask np.zeros((h, w), dtypenp.uint8) for _ in range(num_strokes): hw random.choice(handwriting_imgs) # 随机缩放模拟不同字号 scale random.uniform(0.5, 1.5) nh, nw int(hw.shape[0] * scale), int(hw.shape[1] * scale) hw_resized cv2.resize(hw, (nw, nh)) # 随机位置保证不越界 if nh h or nw w: continue y random.randint(0, h - nh) x random.randint(0, w - nw) # 随机旋转 angle random.uniform(-15, 15) M cv2.getRotationMatrix2D((nw / 2, nh / 2), angle, 1.0) hw_rot cv2.warpAffine(hw_resized, M, (nw, nh), borderValue(255, 255, 255)) # 提取笔迹区域假设背景为白笔迹为深色 gray cv2.cvtColor(hw_rot, cv2.COLOR_BGR2GRAY) _, stroke_mask cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV) # 随机颜色扰动模拟不同笔的颜色 color np.array([random.randint(0, 80) for _ in range(3)]) roi noisy[y:ynh, x:xnw] roi[stroke_mask 0] color mask[y:ynh, x:xnw][stroke_mask 0] 255 return noisy, clean_img, mask这段代码的逻辑是遍历每一张手写笔迹图随机缩放、旋转、定位然后通过阈值提取笔迹的掩码区域把该区域像素替换成随机深色同时更新全局掩码图。参数num_strokes控制每张图叠加的手写数量一般设 5 到 15 之间太少模型学不到密集笔迹的擦除太多会让印刷体被大面积遮挡训练信号变差。scale范围对应真实试卷上不同字号的手写angle模拟书写倾斜。掩码图在训练时可以作为额外输出分支的监督让模型更明确地学到「哪里是手写」。注意合成时手写颜色不要只用纯黑真实笔迹有蓝、黑、红多种颜色扰动能让模型对笔色更鲁棒。2.3 模型结构怎么搭U-Net 的编码器、解码器与跳跃连接选 U-Net 作为基线是因为它在小数据集上收敛快、结构清晰、方便在文档说明里画图讲原理。编码器用几层卷积加下采样逐步提取语义特征解码器用上采样加卷积逐步恢复空间分辨率跳跃连接把编码器同层的特征拼到解码器保留边缘和纹理细节。对擦除任务来说跳跃连接尤其重要因为印刷体的笔画边缘很细没有它解码器很难重建出锐利的文字。下面是一个精简版 U-Net 的实现输入输出都是三通道图像中间加一个掩码预测分支。import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.block(x) class UNetErase(nn.Module): def __init__(self, base32): super().__init__() # 编码器 self.enc1 ConvBlock(3, base) self.enc2 ConvBlock(base, base * 2) self.enc3 ConvBlock(base * 2, base * 4) self.pool nn.MaxPool2d(2) # 瓶颈 self.bottleneck ConvBlock(base * 4, base * 8) # 解码器 self.up3 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.dec3 ConvBlock(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.dec2 ConvBlock(base * 4, base * 2) self.up1 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.dec1 ConvBlock(base * 2, base) # 输出头干净图 掩码 self.out_img nn.Conv2d(base, 3, 1) self.out_mask nn.Conv2d(base, 1, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) b self.bottleneck(self.pool(e3)) d3 self.dec3(torch.cat([self.up3(b), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return torch.sigmoid(self.out_img(d1)), torch.sigmoid(self.out_mask(d1))base控制基础通道数毕业设计场景下 32 或 64 都够用显存紧张就降到 16。编码器每下采样一次通道翻倍解码器每上采样一次通道减半跳跃连接用torch.cat在通道维拼接。输出两个头out_img是重建的干净图out_mask是手写区域掩码。训练时干净图用 L1 或 L2 损失掩码用二值交叉熵两者加权求和。掩码分支的作用是给模型一个显式的「手写在哪」的监督实践中能明显加快收敛。2.4 训练配置损失函数、学习率与显存权衡损失函数我一般用 L1 加掩码 BCE 的组合权重比 1:0.5 左右。L1 比 L2 在图像重建任务上更容易产生锐利边缘不会像 L2 那样倾向于输出模糊的平均值。学习率用 1e-4 配 Adam训练中期如果 loss 平台期就降到 1e-5。batch size 在 8GB 显存下用 4 到 8输入尺寸裁到 256×256 或 512×512太大显存扛不住。训练轮数看数据量合成样本一万对左右跑 50 到 100 个 epoch 基本收敛。每个 epoch 后在验证集上看重建图和掩码的指标重建图用 PSNR 和 SSIM掩码用 IoU。如果 PSNR 上不去但掩码 IoU 很高说明模型定位到了手写但重建质量差这时候要检查解码器的跳跃连接有没有正常拼接或者损失权重是不是掩码占太大。3. 从源码到可运行环境搭建、数据管线与推理脚本3.1 环境依赖与目录结构拿到一份「源码模型文档说明」的压缩包第一件事不是急着跑而是先看目录结构和依赖清单。典型的项目结构长这样data/放合成脚本和样本models/放网络定义train.py和infer.py是入口weights/存训好的模型docs/放文档说明。依赖一般是 PyTorch、OpenCV、NumPy、Pillow版本不要追新PyTorch 1.13 到 2.x 都能跑CUDA 版本和显卡驱动对上就行。# 建议用 conda 建独立环境避免和系统 Python 冲突 conda create -n paper_erase python3.9 -y conda activate paper_erase # 安装 PyTorch按自己的 CUDA 版本选对应命令 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python numpy pillow tqdm tensorboard装完之后先跑一个最小验证python -c import torch; print(torch.cuda.is_available())输出 True 说明 GPU 可用。如果 False检查 CUDA 版本和 PyTorch 是否匹配这是新手最容易卡住的地方。3.2 数据加载与增强的工程细节数据管线用 PyTorch 的Dataset和DataLoader封装。合成样本可以离线生成存成图片对也可以在线生成。离线生成的好处是训练时 IO 快坏处是占磁盘在线生成省空间但每个 epoch 的样本都不同相当于做了数据增强。毕业设计建议离线生成一批固定样本保证实验可复现。from torch.utils.data import Dataset, DataLoader import cv2 import numpy as np import os class EraseDataset(Dataset): def __init__(self, root, img_size256): self.root root self.img_size img_size self.names [f for f in os.listdir(os.path.join(root, noisy)) if f.endswith(.png)] def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] noisy cv2.imread(os.path.join(self.root, noisy, name)) clean cv2.imread(os.path.join(self.root, clean, name)) mask cv2.imread(os.path.join(self.root, mask, name), 0) # 统一尺寸 noisy cv2.resize(noisy, (self.img_size, self.img_size)) clean cv2.resize(clean, (self.img_size, self.img_size)) mask cv2.resize(mask, (self.img_size, self.img_size)) # 归一化到 [0,1]转 CHW noisy torch.from_numpy(noisy[:, :, ::-1].copy()).permute(2, 0, 1).float() / 255.0 clean torch.from_numpy(clean[:, :, ::-1].copy()).permute(2, 0, 1).float() / 255.0 mask torch.from_numpy(mask).unsqueeze(0).float() / 255.0 return noisy, clean, mask loader DataLoader(EraseDataset(data/train), batch_size8, shuffleTrue, num_workers4, pin_memoryTrue)num_workers设成 CPU 核数的一半左右太多反而拖慢。pin_memoryTrue在 GPU 训练时能加速数据传输。注意 OpenCV 读进来是 BGR转 RGB 用[:, :, ::-1]这个细节漏了会导致颜色整体偏模型学出来的干净图颜色不对。3.3 推理脚本与批量处理训练完之后推理脚本要能处理单张图和整个文件夹。单张图直接前向文件夹就遍历。输出保存时把干净图和掩码都存下来方便对比。import torch import cv2 import numpy as np from models.unet import UNetErase def infer(model_path, img_path, out_path, img_size256): device torch.device(cuda if torch.cuda.is_available() else cpu) model UNetErase(base32).to(device) model.load_state_dict(torch.load(model_path, map_locationdevice)) model.eval() img cv2.imread(img_path) h, w img.shape[:2] inp cv2.resize(img, (img_size, img_size)) inp torch.from_numpy(inp[:, :, ::-1].copy()).permute(2, 0, 1).float() / 255.0 inp inp.unsqueeze(0).to(device) with torch.no_grad(): out_img, out_mask model(inp) # 还原到原尺寸 clean out_img.squeeze(0).permute(1, 2, 0).cpu().numpy() clean (clean * 255).astype(np.uint8) clean cv2.resize(clean, (w, h)) cv2.imwrite(out_path, clean[:, :, ::-1]) if __name__ __main__: infer(weights/best.pth, test/sample.png, test/clean.png)推理时注意model.eval()和torch.no_grad()都要加前者关掉 BatchNorm 的训练行为后者省显存。输入尺寸和训练时保持一致否则 BatchNorm 的统计量对不上输出会发灰。如果原图分辨率很高直接缩到 256 会丢细节可以分块推理再拼接但块与块之间要做重叠融合避免接缝。4. 避坑与排查合成数据、训练和推理里最容易翻车的几件事4.1 合成手写太规整模型在真实卷面上失效现象训练集上 PSNR 很高拿真实拍摄的试卷一测手写擦不干净印刷体还被抹掉一块。原因是合成时手写都是正着贴的颜色单一背景干净模型学到了「深色像素就是手写」这个捷径。真实卷面有阴影、折痕、拍照畸变深色区域不一定是手写。解决合成时加大扰动幅度旋转角度放到 ±30 度颜色范围拉宽给背景图加高斯噪声和亮度变化手写笔迹做弹性形变模拟书写抖动。另外可以在训练后期混入少量真实标注样本做微调哪怕只有几十张也能明显改善泛化。4.2 掩码和重建图两个分支互相拖累现象训练时重建 loss 下降但掩码 IoU 上不去或者反过来。两个分支共享编码器梯度方向冲突时会互相拉扯。原因是损失权重没调好或者掩码监督太强模型把精力都放在分类像素上忽略了重建质量。解决先固定权重比 1:0.5 跑一轮看两个指标是否同步上升。如果掩码 IoU 高但重建差把掩码权重降到 0.2如果掩码一直上不去检查掩码标签是不是有噪声比如合成时阈值选得不好导致笔迹边缘没标全。也可以让掩码分支只在训练前期参与后期冻结让重建分支主导。4.3 显存不够导致 batch size 只能设 1现象8GB 显存跑 256×256 输入batch size 设 4 就 OOM。原因是 U-Net 在瓶颈层的特征图通道数很大加上跳跃连接拼接后通道翻倍显存占用比想象中高。解决把base从 64 降到 32 甚至 16通道数减半显存占用大约降到四分之一。输入尺寸从 512 降到 256。开启混合精度训练用torch.cuda.amp能把显存再压三成左右。如果还不够用梯度累积模拟大 batch累积 4 步相当于 batch size 乘 4但显存不变。scaler torch.cuda.amp.GradScaler() for i, (noisy, clean, mask) in enumerate(loader): with torch.cuda.amp.autocast(): pred_img, pred_mask model(noisy) loss l1_loss(pred_img, clean) 0.5 * bce_loss(pred_mask, mask) scaler.scale(loss).backward() if (i 1) % 4 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()4.4 推理结果整体偏灰或偏色现象推理出来的干净图颜色发灰或者整体偏蓝偏红。原因是输入归一化方式和训练不一致或者 OpenCV 的 BGR 和 RGB 转换漏了。也有可能是 BatchNorm 在 eval 模式下用了训练集的统计量而推理图的分布和训练集差太远。解决检查推理脚本里的归一化和训练脚本是否完全一致包括除以 255 的顺序、通道转换的位置。如果颜色偏把输入输出都打印出来对比。BatchNorm 的问题可以在训练时用更大的数据集覆盖更多分布或者推理时改用 InstanceNorm它对单张图的统计更鲁棒。4.5 文档说明写成流水账答辩被追问细节答不上现象文档说明里只写了「用了 U-Net」「跑了 100 轮」没有参数表、没有消融实验、没有失败案例分析。答辩老师一问「为什么用 L1 不用 L2」「掩码分支的作用是什么」就卡住。解决文档里至少要有三张表——模型结构参数表、训练超参表、不同配置的指标对比表。再配几张图合成样本示例、训练 loss 曲线、推理结果对比。把踩过的坑和对应的解决也写进去这比堆砌公式更能体现你真的动手做过。5. 把擦除模型用出花掩码后处理、分块推理与效果验证跑通基线之后想让效果再上一个台阶有几个具体技巧值得试。第一个是掩码后处理。模型输出的掩码是概率图直接二值化会有毛刺用形态学开运算去掉小噪点再对边缘做一次高斯模糊把掩码当软权重去融合原图和重建图能减少擦除区域的生硬边界。具体做法是final noisy * (1 - mask) pred_img * maskmask 经过模糊后边缘过渡自然印刷体不会被硬切。第二个是分块推理。试卷扫描件动辄 2000×3000 像素直接缩到 256 推理会丢小字。把大图切成 512×512 的块块间重叠 64 像素每块单独推理后按重叠区域加权融合。权重可以用余弦窗越靠块中心权重越高这样接缝几乎看不出来。代价是推理时间线性增加但毕业设计的演示场景完全够用。第三个是效果验证。光看 PSNR 和 SSIM 不够这两个指标对文字边缘的锐利度不敏感。我一般会额外算一个「印刷体保留率」在干净图上用 OCR 检测印刷体文字框对比擦除前后这些框内的文字识别置信度如果置信度没掉说明印刷体没被误伤。再算一个「手写残留率」在掩码区域内统计还有多少深色像素残留越少越好。这两个指标比 PSNR 更贴近实际需求。验证指标计算方式合格线参考PSNR重建图与干净图的峰值信噪比30 dB 以上SSIM结构相似度0.95 以上掩码 IoU预测掩码与真值掩码交并比0.85 以上印刷体保留率擦除前后 OCR 置信度比值0.95 以上手写残留率掩码区域内深色像素占比5% 以下最后说个我自己的习惯每次改完模型或数据先拿同一张测试图跑一遍把输入、输出、掩码三张图并排存下来肉眼过一遍再去看指标。指标涨了但图看着别扭的情况太常见了肉眼是最快的 sanity check。这个方向不难难的是把每个环节的细节抠到位从合成数据的扰动幅度到推理时的归一化任何一处偷懒都会在最终效果上放大。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑