ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

深度学习字符验证码识别CNN逐位分类Python实战

深度学习字符验证码识别CNN逐位分类Python实战 简介面向深度学习图像识别初学者、安全方向开发者及课程设计学习者的字符型验证码识别实践包围绕数字与字符验证码的卷积神经网络、循环神经网络建模、训练与预测流程展开可用于入门项目复现、算法练手和验证码识别方案调研。压缩包共1210个文件约9.58MB其中978张png、202张jpg及少量bmp、jpeg图片构成验证码样本17个py脚本覆盖数据处理、模型构建、训练预测等环节另含txt说明、rst文档、zip压缩包与模型文件等辅助内容。已有1542人学习下载具备一定参考热度。读者可从中获得从样本归一化、数据增强、one-hot编码到CNN/RNN模型搭建、交叉熵训练与结果组合的完整源码脉络并借助示例图片和脚本理解字符序列识别、过拟合监控及多字符输出思路便于改造成自己的验证码识别项目。1. 从一批糊掉的字符图说起这个基于深度学习的字符识别项目到底能干什么前阵子有人拿着一批自己生成的四位字符图来找我图上压着干扰线和噪点人眼扫过去都得愣一下。他要的不是接个现成接口敷衍过去而是基于深度学习的字符型图片数字验证码识别——从零训一个模型出来。这份资源就是那条完整链路的 Python 实现数据生成、图像预处理、网络定义、训练收敛、推理解码一步不落源码能直接跑起来。它的适用面比看上去宽。想完整走一遍「图像输入到字符串输出」流程的深度学习新手可以拿它当第一个真正跑通的实战做 OCR、票据编号、工牌数字、集装箱号这类字符识别落地的开发者可以把它当成一个能直接改的基线。字符集小、长度固定、单通道灰度图训练成本低到一张消费级显卡几小时就出结果这才是它适合入门的核心原因。2. 目录结构与技术选型一个字符识别项目该有的骨架先别急着看模型。字符识别这类项目跑不起来的原因九成不在网络结构而在目录没分好、配置散在各处、训练和推理各写一套预处理。这套源码的目录划分是我比较认可的一种改起来不牵一发动全身。2.1 六个文件撑起整条链路captcha_rec/ ├── config.py # 训练/推理共用配置超参只在这里改 ├── charset.py # 字符集与索引映射训练推理共用同一份 ├── data/ │ ├── gen_captcha.py # 自建数据集生成器 │ ├── dataset.py # Dataset 与预处理流水线 │ ├── train.txt # 每行 路径\t标签 │ └── val.txt ├── models/ │ └── cnn.py # CNN 主干 逐位分类头 ├── train.py # 训练循环、验证、保存权重 ├── predict.py # 单图/批量推理与解码 └── utils/ ├── metric.py # 整串准确率、单字符准确率 └── seed.py # 固定随机种子 └── requirements.txt这个划分的关键在于三个「单一出口」config.py管参数、charset.py管字符和索引、dataset.py管预处理。很多人在自己写的时候把预处理写在训练脚本里推理时又抄一遍抄错一个cv2.resize的插值参数线上准确率立刻掉十几个点这种血泪经验我在别人的项目里见过不止一次。工程约定上train.txt和val.txt用制表符分隔路径和标签不用 CSV因为图片路径里偶尔会出现逗号。标签一律小写不做大小写混排——字符集里0和O、1和l这类形近字符如果都保留模型在低分辨率下几乎必然混淆除非你的业务真的需要区分。charset.py是全篇最容易翻车的地方把它单独拎出来是值得的# charset.py 训练和推理都必须 import 这一份禁止在别处重写 CHARSET 0123456789abcdefghijklmnopqrstuvwxyz # 0 号位固定留给 CTC 的 blank逐位分类时从 1 开始用也不会浪费多少参数 CHAR2IDX {c: i 1 for i, c in enumerate(CHARSET)} IDX2CHAR {i 1: c for i, c in enumerate(CHARSET)} BLANK 0 NUM_CLASSES len(CHARSET) 1 # 36 blankNUM_CLASSES加 1 是为了兼容后面第 6 章要讲的 CTC 改造逐位分类时多出来的那一类永远学不会对结果没有影响。参数说明CHAR2IDX的索引从 1 开始BLANK0是 CTC 的硬性约定改不了。IDX2CHAR必须和CHAR2IDX严格互逆我一般写个断言assert len(IDX2CHAR) len(CHAR2IDX)挡住手抖。config.py里我把所有数字集中了# config.py 所有超参的唯一来源 IMG_H, IMG_W 60, 160 # 统一尺寸宽高比 8:3 比较接近 4 位字符的排版 MAX_LEN 4 # 固定长度变长方案见第 6 章 BATCH_SIZE 64 EPOCHS 40 LR 1e-3 WEIGHT_DECAY 1e-4 DEVICE cuda # 没有显卡就写 cpu训练慢但能跑 TRAIN_LIST data/train.txt VAL_LIST data/val.txt CKPT ckpt/best.pthIMG_H和IMG_W不是随便定的。4 位字符横排宽度至少要是单字符宽度的 4 倍还留出间隙60 高度能保住字符的笔画细节又不至于让全连接层参数爆炸。把IMG_W提到 320 而模型不变Flatten后的维度翻倍全连接层参数量直接四倍小数据集上过拟合会来得非常快。2.2 技术选型为什么先做逐位分类而不是一上来就 CRNN 配 CTC字符识别主流两条路一条是固定长度逐位分类一条是 CRNN CTC 的序列识别。这个项目选的是前者理由很实在固定长度场景下逐位分类的训练信号更直接。逐位分类把「识别 4 个字符」拆成 4 个独立的分类任务每个位置一个 softmax损失是 4 个交叉熵的均值。梯度回传路径短收敛快20 到 30 个 epoch 就能把整串准确率推到很高的水位。CTC 则是把模型输出当成一条时间序列靠动态规划把所有可能的对齐方式加起来它对齐能力更强、支持变长但训练早期收敛慢标签长度和输入长度不匹配时还会直接给你inf损失。对比项逐位分类本项目CRNN CTC字符长度必须固定任意长度收敛速度快几十 epoch 见效慢常要上百 epoch实现复杂度低多一个 view 就行中要处理 input_lengths对字符粘连的鲁棒性一般靠位置先验硬扛较好典型翻车点字符集顺序错乱标签长度 时间步导致 inf选逐位分类不等于偷懒。字符长度固定的业务场景——四位验证码、六位工单号、固定位数的批次号——占了实际需求的大头用 CTC 属于杀鸡用牛刀还多背一份对齐逻辑的维护成本。真到了变长那天主干网络和数据集代码基本不用动只换分类头和损失函数第 6 章会给具体的改法。主干网络用的是最朴素的四段卷积。这套结构在字符识别上有个隐含优势四段MaxPool把 60×160 压到 2×10特征图上横向还剩 10 个位置刚好够 4 个字符各自找到自己的感受野又不会因为压得太狠把相邻字符糊在一起。换成三段卷积特征图剩 7×20全连接层参数量涨到四倍小数据集上直接过拟合。2.3 环境和依赖别在版本上栽跟头依赖不多但锁版本这件事别省# requirements.txt torch2.0,3.0 torchvision0.15 opencv-python4.8 numpy1.24 pillow10.0 tqdm4.66opencv-python和opencv-python-headless千万别同时装装重了cv2.imread在无图形界面的服务器上会报libGL.so.1找不到。这种错误看起来像系统环境问题实际是依赖装串了浪费半天时间排查。我一般在服务器上只装 headless 版本。charset.py里的字符集顺序一旦定下来就不能随便动。改了字符集顺序而不重训模型输出的索引全部错位表现是「训练时好好的推理出来全是乱码」这个坑在第 5 章会单独展开。种子也要固定utils/seed.py里把random、numpy、torch三家的种子一起设复现实验才有意义。3. 数据管线从字符图片到能喂进网络的张量模型再漂亮数据管线错了全白搭。这一章拆的是data/gen_captcha.py和data/dataset.py两个文件前者负责造数据后者负责把图片变成张量。3.1 自建数据集字符图怎么生成、怎么标注没有现成数据集的时候自己生成是最快的路。真实业务里的字符图片通常来自截图或扫描件但练手阶段用 PIL 合成完全够用关键是干扰要加到「人眼勉强能认」的程度太干净学不到东西太脏模型直接放弃。# data/gen_captcha.py 生成一张四位字符图并写入标注文件 import random from PIL import Image, ImageDraw, ImageFont, ImageFilter def gen_one(text, size(160, 60), font_pathfonts/mono.ttf): img Image.new(RGB, size, (255, 255, 255)) draw ImageDraw.Draw(img) font ImageFont.truetype(font_path, 34) cell size[0] // len(text) for i, ch in enumerate(text): # 每个字符独立抖动坐标模拟真实场景的排版漂移 x i * cell random.randint(-2, 4) y random.randint(0, 8) color tuple(random.randint(0, 90) for _ in range(3)) draw.text((x, y), ch, fontfont, fillcolor) # 干扰线1~3 条颜色比字符略浅避免完全遮住笔画 for _ in range(random.randint(1, 3)): x1, y1 random.randint(0, size[0]), random.randint(0, size[1]) x2, y2 random.randint(0, size[0]), random.randint(0, size[1]) draw.line((x1, y1, x2, y2), fill(random.randint(120, 200),) * 3, width1) # 椒盐噪点密度控制在千分之五左右 for _ in range(int(size[0] * size[1] * 0.005)): draw.point((random.randint(0, size[0] - 1), random.randint(0, size[1] - 1)), fill(random.randint(0, 255),) * 3) return img.filter(ImageFilter.GaussianBlur(radius0.6))参数说明cell是单个字符分到的横向格子宽度x的抖动范围[-2, 4]让字符略微越界模拟真实排版里的挤压fill取0~90的深色而不是纯黑是因为纯黑在二值化之后对比过强模型会过度依赖笔画粗细这个捷径特征。噪点密度0.005是我试下来比较平衡的值提到0.02之后整串准确率会掉但单字符准确率还在说明模型开始靠猜。生成完记得写标注文件路径和标签用制表符隔开一次写一行训练集和验证集用不同的随机种子分开生成——这一步是很多人的翻车点同一个种子生成两批图验证集里混进和训练集几乎一模一样的样本准确率虚高得吓人。3.2 预处理流水线灰度、去噪、归一化与尺寸统一预处理的目标只有一个让训练和推理走完全相同的代码路径。dataset.py里这段是核心# data/dataset.py 预处理 张量化训练和推理共用 import cv2 import torch from torch.utils.data import Dataset class CaptchaDataset(Dataset): def __init__(self, list_file, char2idx, img_w160, img_h60): self.items [] with open(list_file, encodingutf-8) as f: for line in f: path, label line.rstrip(\n).split(\t) if len(label) ! 4: # 长度对不上直接丢掉别让它污染训练 continue self.items.append((path, label)) self.char2idx char2idx self.img_w, self.img_h img_w, img_h def __len__(self): return len(self.items) def __getitem__(self, idx): path, label self.items[idx] img cv2.imread(path, cv2.IMREAD_GRAYSCALE) # 单通道省显存也省参数 if img is None: raise FileNotFoundError(path) img cv2.resize(img, (self.img_w, self.img_h), interpolationcv2.INTER_AREA) img cv2.medianBlur(img, 3) # 3x3 中值滤波压椒盐噪点 img img.astype(float32) / 255.0 # 归一到 0~1 img (img - 0.5) / 0.5 # 拉到 -1~1 tensor torch.from_numpy(img).unsqueeze(0) # (1, H, W) target torch.tensor([self.char2idx[c] for c in label], dtypetorch.long) return tensor, target逻辑说明先 resize 再滤波是有讲究的先滤波再 resize 会让噪点在插值时被拉伸成条状反而更难去掉。INTER_AREA用于缩小是最不容易产生摩尔纹的插值方式换成INTER_LINEAR在 160 宽度下问题不大但换成INTER_CUBIC会引入振铃边缘出现假的笔画。参数说明medianBlur的核大小必须是奇数且大于 13 是最小有效值5 会开始吃掉细笔画。归一化用(x - 0.5) / 0.5拉到[-1, 1]配合 BatchNorm 收敛更快如果网络里全是 ReLU 而没有 BN用[0, 1]也能跑差别不大但别两套混用。DataLoader那边两个参数值得调from torch.utils.data import DataLoader train_loader DataLoader( CaptchaDataset(data/train.txt, CHAR2IDX), batch_size64, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue, # 最后一批不足 batch_size 时丢掉 )drop_lastTrue在 BatchNorm 上很关键。最后一批如果只剩 1 张图BN 在单个样本上算方差结果接近 0反向传播时对这两层参数的影响是灾难性的表现出来就是「每个 epoch 最后 loss 尖刺一下」。我见过有人为此把学习率砍半其实是数据加载的问题。num_workers4在机械硬盘上开太高反而更慢开了pin_memory才真正省下主机到显存的那段拷贝时间。3.3 数据增强的边界能加什么不能加什么字符识别的增强比自然图像窄得多。可以加轻微旋转±3 度、小幅平移±5%、对比度抖动、随机加噪点。不能加水平翻转字符会变成镜像、大角度旋转超出手写体常识、随机裁剪会切掉字符。import random def augment(img): # 只在训练集上调用验证集和推理一律走原图 if random.random() 0.3: img cv2.convertScaleAbs(img, alpharandom.uniform(0.8, 1.2), betarandom.randint(-20, 20)) if random.random() 0.2: h, w img.shape[:2] M cv2.getRotationMatrix2D((w / 2, h / 2), random.uniform(-3, 3), 1.0) img cv2.warpAffine(img, M, (w, h), borderValue255) return img增强函数里最忌讳的是「训练加了、验证也加了」。验证集的唯一职责是反映真实分布一旦对它做随机变换准确率曲线就失去了参考价值你根本不知道模型是在进步还是在适应增强。这条我在自己项目里定成硬规则augment只在trainTrue的分支里调用。4. 网络与训练CNN 逐位分类头的完整实现前面铺垫的数据和结构到这里收口。这一章拆三段代码网络定义、训练循环、推理解码每一段都能直接抄进自己的项目改。4.1 CNN 主干与逐位分类头网络结构上没有花活四段卷积加两层全连接重点是最后一层的形状处理。# models/cnn.py import torch.nn as nn class CaptchaCNN(nn.Module): def __init__(self, num_classes, max_len4): super().__init__() self.max_len max_len self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 60x160 - 30x80 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # - 15x40 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # - 7x20 nn.Conv2d(128, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d((3, 2)), # - 2x10 ) self.head nn.Sequential( nn.Flatten(), nn.Linear(128 * 2 * 10, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(512, max_len * num_classes), ) def forward(self, x): x self.features(x) x self.head(x) # 关键一步把一维输出拆回 (batch, 字符位置, 类别) return x.view(-1, self.max_len, self.num_classes)逻辑说明四次池化把空间维度压到2×10Flatten后的长度是 2560接 512 维隐层。最后的view是整个设计的枢纽——如果不拆模型输出是个 144 维向量没法逐位算交叉熵拆开之后logits[:, i, :]就是第 i 个字符位置的分类分布损失可以直接逐位相加。参数说明Dropout(0.3)放在两层全连接之间字符识别这种小数据集上 0.3 到 0.5 都试过0.5 会欠拟合0.3 比较稳。最后一个池化用(3, 2)是为了把 7 压成 2、20 压成 10非对称池化在这种宽扁输入上比强行方形池化更合理。num_classes传NUM_CLASSES含 blankmax_len传MAX_LEN两个都从config.py来。还有一点容易忽略输入必须是(B, 1, H, W)且 H、W 和训练时完全一致。动态尺寸的网络不是不能写但全连接层会立刻锁死尺寸后面改图片宽高就得重训所以一开始统一到 60×160 是省事的做法。4.2 训练循环损失、优化器和学习率调度训练循环里最值得讲的是损失怎么算以及为什么必须做梯度裁剪。# train.py节选 import torch import torch.nn.functional as F def train_one_epoch(model, loader, optimizer, device): model.train() loss_sum, char_correct, char_total 0.0, 0, 0 for imgs, targets in loader: imgs, targets imgs.to(device), targets.to(device) logits model(imgs) # (B, L, C) # 逐位交叉熵取平均 loss 0.0 for i in range(model.max_len): loss loss F.cross_entropy(logits[:, i, :], targets[:, i]) loss loss / model.max_len optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() loss_sum loss.item() * imgs.size(0) char_correct (logits.argmax(-1) targets).sum().item() char_total targets.numel() return loss_sum / len(loader.dataset), char_correct / char_total逻辑说明F.cross_entropy内部已经做了log_softmax加负对数似然不要在它前面再手动过一遍 softmax否则等于算了两遍梯度会被压扁到几乎不动。逐位求和再除以max_len而不是直接相加是为了让损失量级和单个分类任务对齐学习率不用跟着字符长度重新调。参数说明clip_grad_norm_的max_norm5.0是经验值字符识别里梯度爆炸大多来自 BatchNorm 在小 batch 上的方差抖动裁剪能挡住绝大部分尖刺。训练循环外配套调度器optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience3 ) # 每个 epoch 结束用验证集整串准确率驱动 scheduler.step(val_acc)patience3表示连续 3 个 epoch 验证准确率不涨就把学习率砍半。这个策略比固定步长衰减更适合小数据集因为收敛曲线经常是阶梯式的卡住三个 epoch 之后往往还能再上一个台阶。weight_decay1e-4对 Adam 来说相当于 L2 正则在三千张左右的训练集上能明显压住过拟合。验证函数里我只算两个指标——整串准确率和单字符准确率torch.no_grad() def evaluate(model, loader, device): model.eval() full_ok, full_total 0, 0 char_ok, char_total 0, 0 for imgs, targets in loader: imgs, targets imgs.to(device), targets.to(device) pred model(imgs).argmax(-1) # (B, L) full_ok (pred targets).all(dim1).sum().item() full_total imgs.size(0) char_ok (pred targets).sum().item() char_total targets.numel() return full_ok / full_total, char_ok / char_total关键在all(dim1)只有四个位置全对才算整串正确这个指标才是业务真正关心的。单字符准确率 95% 听起来漂亮四个字符乘起来只剩 81%整串准确率才是能拿去交付的数字。两个指标一起看还能定位问题——单字符高、整串低说明模型在个别位置上系统性混淆通常是对应字符样本不足。4.3 推理解码把 logits 还原成字符串推理代码短但坑集中。# predict.py节选 import cv2 import torch from charset import IDX2CHAR torch.no_grad() def predict_one(model, path, device, img_w160, img_h60): model.eval() img cv2.imread(path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (img_w, img_h), interpolationcv2.INTER_AREA) img cv2.medianBlur(img, 3) img img.astype(float32) / 255.0 img (img - 0.5) / 0.5 x torch.from_numpy(img).unsqueeze(0).unsqueeze(0).to(device) # (1,1,H,W) ids model(x).argmax(-1)[0].cpu().tolist() return .join(IDX2CHAR[i] for i in ids)逻辑说明unsqueeze用了两次第一次补通道维、第二次补 batch 维最后得到(1, 1, 60, 160)和训练时DataLoader吐出来的形状完全对齐。model.eval()不能漏它把 BatchNorm 从按 batch 统计切到用滑动平均值漏掉这一行单张推理时 BN 会拿自己这一张图的方差去归一化输出会飘得非常离谱——这个现象很像「模型没训好」实际只是忘了切模式。参数说明img_w、img_h必须和config.py里的IMG_W、IMG_H一致建议直接从 config import 而不是写默认值写死默认值是迟早要出事的。IDX2CHAR从charset.py来和训练时的CHAR2IDX同源这是保证索引不乱的根本。批量推理时把循环换成DataLoader再把join换成列表推导即可速度能快三到五倍。如果要导出 ONNX 给别的语言用torch.onnx.export的输入形状要写死成(1, 1, 60, 160)动态 batch 虽然支持但很多推理引擎对动态轴的处理不一致反而增加调试成本。5. 常见问题排查训练不收敛、准确率虚高、显存爆掉的真实踩坑下面五条是这类项目里出现频率最高的问题每条按现象、原因、解决写。看的时候对照自己的日志别急着改模型结构。5.1 loss 一路降到 0.1 就卡死验证整串准确率停在 30% 左右现象训练损失很漂亮前 10 个 epoch 从 4.0 掉到 0.1之后纹丝不动。验证集整串准确率在 30% 上下徘徊单字符准确率能到 70%。原因最典型的是学习率太大导致模型掉进一个「只认部分位置」的局部解。四个位置里有三个位置猜得准第四个位置全错整串准确率就恒定在低位。另一种可能是最后一个位置的样本分布和前面不同——比如生成数据时第四个字符的坐标抖动范围更小模型学到的特征没有泛化。解决先把学习率从1e-3降到3e-4重跑看曲线形状有没有变化。还是卡就打印每个位置的单独准确率定位到具体是哪个位置在拖后腿。如果确实是某个位置的问题检查数据生成代码里那个位置的坐标范围、颜色范围是不是和其他位置不一致。我遇到过一次是生成脚本里random.randint(0, 6)写成了random.randint(0, 60)字符被抖到画布外模型学不到任何东西。提示逐位打印准确率的代码只有五行加上去比盲猜快得多。5.2 训练集 99%验证集 40%但两批图明明是分开生成的现象训练集准确率一路飙到 99% 以上验证集卡在 40% 不动损失曲线在两边分叉得越来越大。原因数据泄漏。最常见的形式是训练集和验证集用了同一个随机种子生成或者生成完之后按文件顺序切分导致高度相似的样本两边都有。另一种隐蔽的情况是标签写错——验证集的标注文件里有一批标签和实际图片对不上模型输出其实是对的评测脚本判它错。解决先把验证集里挑 20 张图人眼看一遍确认标签没错。然后算一下两批图的哈希md5重复的直接揪出来。生成脚本里训练集和验证集必须用不同的种子并且切分时按标签分层抽样别让某个字符在验证集里完全缺席。我一般会在切分后打印两边字符频率的分布做一次比对这一步花不了一分钟能省下几小时的怀疑人生。5.3 换了字符集之后模型输出固定几个字符怎么训都一样现象把字符集从小写字母扩到大小写加数字重新训练之后模型输出永远是0000或者aaaa。原因CHAR2IDX改了但IDX2CHAR没同步改或者推理脚本里 import 的是另一份旧的映射表。训练时标签编码用的是新表解码用的是旧表两边一错位输出看起来就像模型啥也没学会。解决字符映射表只能存在一个地方训和推理都从charset.pyimport。加一个自检函数训练启动时跑一遍from charset import CHARSET, CHAR2IDX, IDX2CHAR assert all(IDX2CHAR[CHAR2IDX[c]] c for c in CHARSET), 字符映射不互逆 assert len(CHARSET) len(set(CHARSET)), 字符集有重复字符第二行断言防的是字符集里混进重复字符比如013456789里漏看了个3映射表的长度和实际类别数对不上模型最后一层维度会算错但报错信息很难指向根因。5.4 把 batch_size 从 64 调到 8loss 直接变成 NaN现象为了在显存小的机器上跑把 batch 改小训练几百步之后损失变成nan之后再也回不来。原因BatchNorm 在极小 batch 上方差估计极不稳定。batch 为 8 时每个通道只有 8 个样本参与统计方差可能接近 0除下来数值爆炸反向传播时梯度变成inf参数被污染后整个训练崩掉。解决三条路按优先级排。一是保持 batch 不小于 32显存不够就降分辨率或者减小通道数二是把 BatchNorm 换成 GroupNorm它对 batch 大小不敏感代价是收敛稍慢一点三是用梯度累积optimizer.step()每累积 4 个小 batch 调一次等效于把 batch 放大四倍但 BN 统计仍然是按小 batch 算的所以必须配合方案二才彻底。# 梯度累积 GroupNorm 的组合写法 loss loss / 4 loss.backward() if step % 4 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() optimizer.zero_grad()5.5 训练时准确率很好换了批图推理结果全错现象验证集上整串准确率 90%拿几张新生成的图去推理输出几乎是随机的。原因推理脚本的预处理和训练不一致。常见差异包括训练用cv2.imread读灰度推理用 PIL 读 RGB 再手动转灰度两个库的灰度转换系数不同训练做了中值滤波推理忘了训练归一化到[-1,1]推理只除以 255。这些差异单看都微不足道叠加起来足以让模型失效。解决把预处理抽成一个函数训练和推理都调它。参数从同一个 config 读别在推理脚本里写默认值。我习惯在训练开始前随手存一张预处理后的中间结果到磁盘推理时拿同样一张图跑一遍两张图逐像素比对差超过 1e-6 就说明路径不一致。这个检查不用改任何业务代码两分钟能做完。6. 二次开发换字符集、改变长、上部署的改造路径真拿这个项目去接需求改动能落到三个地方按改动成本从低到高排。6.1 换字符集与变长输出的具体改法换字符集是最简单的一种。只改charset.py的CHARSET字符串和config.py的MAX_LEN然后从头重训。别试图在旧权重上继续训类别数一变最后一层全连接的维度就变了加载权重必然报 shape 不匹配硬改strictFalse会丢掉整个分类头等于重训还多了个错误来源。字符长度不固定时把逐位分类头换成 CTC。主干网络和数据集基本不动改三处import torch.nn as nn # 1) 分类头输出时间步而非字符位置把最后的 view 改成 # return x.view(-1, self.num_classes, T) 然后 permute # 2) 损失换成 CTCLossblank 必须是 0 ctc nn.CTCLoss(blank0, zero_infinityTrue) log_probs model(imgs).log_softmax(2).permute(1, 0, 2) # (T, B, C) input_lengths torch.full((B,), T, dtypetorch.long) loss ctc(log_probs, targets_flat, input_lengths, target_lengths) # 3) 解码走贪心argmax 之后去重、去 blank def greedy_decode(ids): out, prev [], -1 for i in ids: if i ! prev and i ! 0: out.append(IDX2CHAR[i]) prev i return .join(out)参数说明zero_infinityTrue是必须开的否则当某个样本的标签长度超过时间步 T 时损失会返回inf并污染整个 batch 的梯度。T 的大小由特征图的宽度决定输入 160 宽经过四次池化剩 10 个时间步意味着标签长度最多 10四位字符绰绰有余但如果要识别十几位的长串就得减少池化次数或者加宽输入。这个约束在写网络结构的时候就要算清楚训到一半发现 T 不够只能推倒重来。改造前建议先用小数据集跑通整条链路确认识别正确再上全量。判断标准很直接训练 500 步之后loss应该稳定下降到 3 以下如果一直在 8 附近晃八成是input_lengths或target_lengths传错了单位。6.2 落地前的验证清单上线前我会过一遍这张表每一项都对应过真实的翻车场景。改造点涉及文件验证方法主要风险换字符集charset.py、config.py训练前跑互逆断言新旧映射混用导致乱码改图片尺寸config.py、models/cnn.py打印 Flatten 后维度全连接层维度不匹配换 CTCmodels/cnn.py、train.py单 batch 过拟合测试时间步不足导致 inf导出 ONNXpredict.py与 PyTorch 输出逐元素比对BN 未切 eval 模式加数据增强data/dataset.py验证集不加增强增强污染验证指标单 batch 过拟合测试是我最常用的手段拿 8 张图反复训 200 步如果损失降不到接近 0说明网络容量或者数据管线有问题跟超参无关。这个测试能在十分钟内排掉一大半「训练不收敛」的伪问题。从那以后我每次动数据管线都会先拿 8 张图做一遍过拟合测试确认这条路走得通再去碰全量训练。字符识别这行没有玄学绝大部分「模型学不会」都是数据和预处理在背后捅刀子把这条纪律守住能省下的时间远超想象。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表