PyTorch+CRNN实战火车车厢号识别:从训练到部署
发布时间:2026/10/1 3:27:37来源:尧图网络
简介这是一套基于PyTorch框架的OCR深度学习项目核心实现火车车厢编号的自动检测、识别与提取替代传统人工抄录方式适用于铁路货运管理、物流追踪与智能交通场景的开发者、研究人员及计算机视觉学习者。资源共41个文件压缩包约86KB其中25个Python脚本构成代码主体覆盖CTPN文本检测、CRNN识别、STN空间变换网络及训练测试与工具函数11个TXT文档提供部署与模块说明另含字符映射用的pkl字典文件、DOCX附赠说明、JSON配置及MD项目文档便于快速复现与二次开发。目前已有31人学习下载。包内结构清晰附训练代码、验证码生成器、演示脚本与checkpoints模块可支撑从数据准备、模型训练到端到端推理的全流程实践适合希望掌握OCR文本识别技术并将其落地到工业场景的进阶开发者。1. 火车车厢号识别为什么值得用 PyTorch OCR 来做铁路货运场的车厢号识别看起来是个小任务做起来才知道有多磨人车皮表面是弧形钢面号码是喷漆或者钢印日晒雨淋之后底色花、字体残缺、反光严重再加上拍摄时列车在动、角度是斜的传统 OCR 拿到的几乎都是“半残废”图像。这时候靠模板匹配或者通用 OCR 库识别率很难稳定在 90% 以上。火车车厢号识别系统的核心就是用 PyTorch 搭一套 OCR 深度学习模型把“车厢图像 - 车厢编号文本”这条路走通专门为铁路货运管理、物流追踪和智能交通系统处理大批量车厢图像。适合谁手上有成百上千张车厢图像、需要自动化录入车号、又不想靠人工逐张核对的人。这套方案能解决的核心问题是鲁棒性用卷积网络提特征、序列模型建模文本、CTC 做对齐在模糊、倾斜、低对比度的图上也把车号读出来。2. OCR 路线选型为什么是 PyTorch CRNN而不是 Tesseract 或模板匹配2.1 通用 OCR 在车厢号场景的边界先聊选型。很多人第一反应是 Tesseract开源、装起来快、文档多。但 Tesseract 是针对扫描文档和印刷体优化的遇到弧形钢面、强反光、笔画残缺的喷漆字识别率掉得很快。车厢号还有一个特点它是“短文本 强结构化”通常是一个车种字母加 67 位数字比如 C70、P62NK 这类车种代码加上数字编号字符集基本锁死在数字和有限字母里。这种场景用通用 OCR 是杀鸡用牛刀而且牛刀还杀不准。模板匹配更不用提。车厢号的字体不是统一标准喷印不同厂家的车、不同年代的刷涂字形差异很大再加钢印有凹凸阴影同一个字符在不同光照下长成两个样子。模板匹配对刚性的形状变化毫无抵抗力。做 OCR 选型有一条经验图像条件受控、文本格式固定才考虑传统方案图像条件不可控直接上深度学习。2.2 CRNN 这条链路是怎么把图像变成文本的现在主流的轻量级 OCR 路线是 CRNN 结构CNN 负责抽特征RNN 负责按序列建模最后用 CTC 做对齐。它不要求先切出单个字符而是把整行文本当作一个序列来识别——这正好对付车厢号的另一个痛点字符间距不均、有些字符粘连、有些字符被铆钉或锈迹盖住。CRNN 的处理流程可以拆成三步。第一步CNN 把输入图像从高 32、宽 W 的尺寸压成高 1、宽约 W/16 的特征序列相当于把图像按列切成了若干个“时间片”。第二步双向 LSTM 读这个特征序列输出每个时间片属于某个字符的概率分布。第三步CTC 把这些时间片的预测结果合并成最终的字符串处理重复字符和空白。这里最核心的思想是我不需要精确告诉模型“哪个像素对应哪个字符”只需要给它“图像 整串文本”的标注CTC 自己学会对齐。用 PyTorch 实现一个可跑的简化版 CRNN关键代码长这样import torch import torch.nn as nn from torchvision.models import resnet18 class CRNN(nn.Module): def __init__(self, num_classes, lstm_hidden256): super().__init__() # 用 resnet18 的前几层做特征提取器去掉最后的全局池化和全连接 backbone resnet18(weightsNone) self.cnn nn.Sequential(*list(backbone.children())[:-2]) # 图像高 32经过 resnet18 下采样后特征图高变为 2展平后就是 2*512 self.linear nn.Linear(512 * 2, lstm_hidden) self.lstm nn.LSTM( input_sizelstm_hidden, hidden_sizelstm_hidden, num_layers2, bidirectionalTrue, batch_firstTrue, ) # 双向 LSTM 输出维度是 hidden*2映射到字符类别数 self.fc nn.Linear(lstm_hidden * 2, num_classes) def forward(self, x): # x: (batch, 1, 32, W) feat self.cnn(x) # (batch, 512, 2, W/16) b, c, h, w feat.shape feat feat.view(b, c * h, w) # 把高和通道合并得到序列 feat feat.permute(0, 2, 1) # (batch, seq_len, features) feat torch.relu(self.linear(feat)) out, _ self.lstm(feat) # (batch, seq_len, hidden*2) logits self.fc(out) # (batch, seq_len, num_classes) # PyTorch 的 CTCLoss 要求输入维度是 (seq_len, batch, num_classes) return logits.permute(1, 0, 2)逻辑说明CNN 部分用了 resnet18 的卷积层下采样倍数决定了序列长度。输入宽 W128 时序列长度大约 8W256 时序列长度大约 16。序列长度不需要太长但要大于文本最大长度CTC 才有足够的“时间片”去铺字符。LSTM 双向是必须的——车厢号两侧的字符受透视畸变影响单向 LSTM 只看左边上下文右侧信息缺失时尾字符容易丢。num_classes记得把 CTC 的 blank 位留出来通常是字符集大小加 1。参数说明lstm_hidden256是经验值字符集只有 40 个以内时 256 足够再大收益不明显反而拖慢训练backbone里 resnet18 的weightsNone是跳过预训练加载实际项目建议用weightsResNet18_Weights.IMAGENET1K_V1虽然车厢号和 ImageNet 分类任务差得远但底层边缘纹理特征迁移是有效的。2.3 PyTorch 在货运场景里的落地优势选 PyTorch 不是因为它最潮而是落地链路顺。一是调试序列模型方便——CRNN 中间那几步 tensor 维度变换容易写错换成静态图框架报错信息能把人绕晕PyTorch 里print(feat.shape)就能定位问题。二是训练流水线省事Dataset DataLoader torchvision.transforms一套组合拳批量读图、多进程加载、数据增强全都能在一个框架里解决不依赖额外的数据流水线组件。三是部署出口宽torch.onnx.export导出 ONNX 后可以交给 ONNX Runtime 做 CPU 推理也可以转 TensorRT 上 GPU货运场机房不一定有 GPU 环境这个灵活性很关键。补充一个很多人忽略的点PyTorch 环境搭建本身就能劝退一批人。如果本地只有 CPU装 CPU 版 PyTorch 也能把 CRNN 训练流程跑通就是慢有 N 卡就按 CUDA 版本装对应 wheel装上后用torch.cuda.is_available()验证别急着开训先确认 PyTorch 真的检测到了 GPU这一步翻车的人最多。训练阶段用 CPU 不是不行但要意识到收敛时间可能是 GPU 的十倍以上如果图像量上千强烈建议租 GPU 实例或者找一台带 N 卡的机器跑训练。3. 数据处理透视矫正、标注规范和增强策略识别率的一半看这里3.1 透视矫正把斜着拍的车厢号拉正车厢号识别最大的输入干扰不是噪声是角度。列车进港时拍摄位置固定但车厢长度不同、相机安装高度不同拍出来的号码区域总是带透视形变。不矫正直接丢给 CRNN模型也能勉强学但字符纵向笔画会被压成斜的识别率上限被卡死。常见做法是先做车厢号区域检测可以用 YOLO 一类检测器定位号码区域也可以用固定 ROI 框选然后对检测到的四边形做透视矫正。OpenCV 的标准流程是四点变换import cv2 import numpy as np def warp_corners(image, src_points, dst_width256, dst_height32): src_points: 原图中号码区域四个角点顺序为左上、右上、右下、左下 输出: 拉正后的灰度图用于后续识别 dst_points np.array([ [0, 0], [dst_width - 1, 0], [dst_width - 1, dst_height - 1], [0, dst_height - 1], ], dtypenp.float32) matrix cv2.getPerspectiveTransform(src_points.astype(np.float32), dst_points) warped cv2.warpPerspective(image, matrix, (dst_width, dst_height)) return cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)逻辑说明src_points的来源有两种——如果检测器输出的是旋转框把旋转框的四个角点按顺序排好传进来如果是人工标定 ROI就用固定坐标。关键是角点顺序必须和dst_points一致顺序错了图像会翻转或扭曲。dst_width256, dst_height32是 CRNN 的常见输入尺寸宽度和高度比例大约是 8:1适配车厢号这种单行短文本。参数说明目标高度 32 不是拍脑袋定的。CRNN 里的 CNN 下采样倍数约 16高度 32 时特征图高度正好是 2既保留了纵向笔画的空间信息又不会让特征图太高导致序列维度爆炸。目标宽度要看号码最长长度——7 位数字加车种字母约 10 个字符256 像素够用。这里有个细节透视矫正时两侧多留 5%10% 的余量别把字符裁到边缘。车号区域两端的字符在矫正后经常因为插值被拉伸变形留余量能让模型少踩一个坑。3.2 标注格式与字符编码别用文件夹名当标签标注是 OCR 项目里最不能省的一环。车厢号数据集的标注格式建议用最朴素的“路径 标签”文本文件每行一个样本images/001.jpg C70 4578321 images/002.jpg P62NK 3456789 images/003.jpg GQ70 1029384两点要注意第一标签里的空格是车种和编号之间的分隔训练时要么去掉、要么当成一个字符建模建议去掉因为 CRNN 对空格的处理容易和 CTC blank 混淆第二字符集要提前定死车厢号里出现的字符就是 0-9 和有限的几组字母C、P、G、N、K、X 等提前统计全部训练数据的字符分布把字符表固定下来中途加字符会让之前的模型白训。字符编码用最简单的查表法char_list list(0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ) char_to_index {c: i 1 for i, c in enumerate(char_list)} # 0 留给 CTC blank这里手动把索引 0 空出来给 CTC 的 blank是所有 CTC 训练代码里的一个隐藏约定——模型输出的num_classes等于len(char_list) 1解码时去掉索引 0 的预测即可。忘记这个偏移训练 loss 看起来正常但解码结果全是重复字符是常见翻车点。3.3 数据增强模拟现场光、锈和运动模糊车厢号图像的实际退化主要是三类光照不均阴影、逆光、表面退化锈迹、漆皮脱落、运动模糊列车未完全停稳。用 Albumentations 做增强比 torchvision 自带的 transform 灵活得多可以按区域做随机遮挡import albumentations as A train_aug A.Compose([ A.RandomBrightnessContrast(brightness_limit0.3, contrast_limit0.3, p0.8), A.HueSaturationValue(hue_shift_limit5, sat_shift_limit20, val_shift_limit20, p0.5), A.GaussNoise(var_limit(10, 50), p0.4), A.CoarseDropout(max_holes8, max_height3, max_width5, fill_value0, p0.4), A.MotionBlur(blur_limit(3, 7), p0.3), A.ShiftScaleRotate(shift_limit0.05, scale_limit0.1, rotate_limit5, p0.5), ])逻辑说明RandomBrightnessContrast模拟早晚光线变化CoarseDropout模拟锈迹和漆块脱落——这组增强对车厢号尤其重要因为真实样本里字符正好被锈迹盖住的比例不低模型要在训练时见过这种局部缺失才能学会靠上下文猜字符。MotionBlur的 blur_limit 不要给太大3 到 7 像素够模拟低速运动太糊会让模型去学“猜字”而不是“认字”。参数说明ShiftScaleRotate的 rotate_limit 只给 5 度因为透视矫正已经把大角度拉正了剩下的小角度是矫正误差旋转过大反而破坏了矫正结果。这里有个经验值增强的强度按“在线增强后人工看 50 张图觉得像真实拍摄现场”为准增强过头会导致训练 loss 降不下去。如果真实数据量不足 1000 张还有一个常见做法是合成数据用真实车厢背景图 数字/字母渲染成号码贴上去但合成数据只用来预热模型随后必须用真实数据微调否则模型对喷漆字迹的质感没概念。4. PyTorch 训练配置数据加载器、CTC 损失与调参顺序4.1 数据加载器动态 padding 解决图像宽度不一致车厢号区域检测出来后图像宽度不统一但 CRNN 要求输入在同一 batch 内宽度一致或接近一致否则 GPU 的矩阵运算没法并行。做法是在 collate_fn 里把一批图 pad 到同一宽度import torch from torch.utils.data import Dataset, DataLoader from PIL import Image class CarNumberDataset(Dataset): def __init__(self, ann_file, char_to_index, img_height32, max_width256): self.samples [] with open(ann_file, r) as f: for line in f: path, label line.strip().split() self.samples.append((path, label)) self.char_to_index char_to_index self.img_height img_height self.max_width max_width def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(L) # 等比缩放到固定高度宽度按比例算 ratio self.img_height / img.height new_w min(int(img.width * ratio), self.max_width) img img.resize((new_w, self.img_height), Image.BILINEAR) img torch.from_numpy(np.array(img, dtypenp.float32)) / 255.0 img img.unsqueeze(0) # (1, H, W) # 标签编码 target [self.char_to_index[c] for c in label] return img, torch.tensor(target, dtypetorch.long) def collate_fn(batch): imgs, targets zip(*batch) max_w max(img.shape[2] for img in imgs) batch_imgs torch.zeros(len(imgs), 1, imgs[0].shape[1], max_w) for i, img in enumerate(imgs): batch_imgs[i, :, :, :img.shape[2]] img target_lens torch.tensor([t.shape[0] for t in targets], dtypetorch.long) targets_cat torch.cat(targets) return batch_imgs, targets_cat, target_lens逻辑说明__getitem__里把图像高度统一到 32宽度按原图比例缩放超过max_width的截断。等比缩放很重要——如果直接拉伸到固定宽高字符会横向变形CRNN 学到的是“变形后的字形”而不是“正常的字形”。collate_fn里的targets_cat把不同长度的标签首尾拼成一个一维向量这是 CTCLoss 要求的输入格式它会依靠target_lens把每个样本的标签切回来。参数说明max_width对识别率影响很大。设小了长车号被截断尾字符丢失设大了短车号被 pad 出一大片空白区域浪费算力。先统计训练集中矫正后图像的宽度分布取 p95 分位数当max_width最合理。Image.BILINEAR放大比双三次快缩小时抗锯齿效果也能接受工业场景图量大速度优先。4.2 训练循环梯度裁剪是必须的CTC 训练最常见的问题是 loss 前期波动大、然后突然 NaN。原因多半是 LSTM 梯度爆炸解决方案是梯度裁剪。训练循环按标准写法import torch.nn.functional as F model CRNN(num_classeslen(char_to_index) 1) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, patience5, factor0.5) criterion torch.nn.CTCLoss(blank0, zero_infinityTrue) def train_one_epoch(dataloader, model, optimizer, criterion): model.train() total_loss 0 for imgs, targets, target_lens in dataloader: # 序列长度: 图像宽度经过 CNN 下采样 16 倍 input_lens torch.full((imgs.size(0),), imgs.size(3) // 16, dtypetorch.long) logits model(imgs) # (seq_len, batch, num_classes) seq_len logits.size(0) input_lens torch.clamp(input_lens, maxseq_len) loss criterion(logits, targets, input_lens, target_lens) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)逻辑说明input_lens是每个样本的序列长度这里直接拿图像宽度整除 16 得到——CNN 下采样 16 倍宽度 256 的图像序列长度就是 16。用clamp防止取整后序列长度超过实际输出维度导致维度不匹配。clip_grad_norm_这行不能省双向 LSTM 的梯度范数在训练初期经常超过 5不裁剪的话一个 batch 就能把参数冲飞。参数说明lr1e-3对 Adam 来说是常规起点但 CRNN 这种 CNNLSTM 的组合建议降到 5e-4 起步。zero_infinityTrue是 CTC loss 的一个兜底参数当某个样本的路径概率下溢成 0 时 loss 变成无穷大这个参数把无穷大转成 0避免一个样本毁掉整个训练。ReduceLROnPlateau的 patience 给 5 个 epochOCR 任务的 loss 曲线波动本来就大patience 太小会过早降低学习率。4.3 三个必调参数batch_size、图像高度、字符集第一个必调参数是batch_size。CRNN 的显存占用跟序列长度和 batch 成正比图像宽 256、高 32 时batch 32 在 8G 显存上已经比较紧batch 64 建议用 16G 显存。显存不够时优先减 batch而不是减图像高度——高度低于 24 后字符笔画的垂直信息损失严重。第二个必调参数是图像高度前面提过默认 32但它其实要和字符高度匹配。车厢号喷漆字符的粗细比例和印刷体不同如果原始图像里字符高度撑不满整个 ROI反而应该把高度微调到 48让字符上下留一点空间。判断标准是预处理后人工看一批图字符高度占图像高度的 60%80% 最理想。第三个必调参数是字符集范围。很多人的字符集里多放了一堆根本不会出现的符号导致num_classes变大模型需要区分的类变多收敛变慢。车厢号的真实字符集就 10 个数字加 20 个以内的大写字母统计一遍训练数据把实际出现过的字符全部列出来没出现过的坚决不放。另外验证集必须按“车厢”而不是按“图片”划分。同一节车厢在不同角度拍了 5 张图如果这些图同时出现在训练和验证集里验证指标虚高部署时换个角度立刻现原形。5. 避坑指南四个高频翻车现象与排查顺序5.1 9 和 g、0 和 O 混认现象训练 loss 正常收敛但验证时“9”频繁被识别成“g”“0”和“O”分不清。原因这类形状相近字符在训练集里样本数量不平衡。车厢号里数字占绝大多数某些字母样本可能只有几十张模型对少样本类别的特征刻画不充分。另外 CTC 的 blank 会吸收掉一部分弱特征让相近字符的边界更模糊。解决先统计训练集每个字符的出现频次对低频字符做针对性增强——把这些字符所在的样本复制多份配合 CoarseDropout 和光照变化做差异化增强而不要简单地整图复制。如果增强后还是混再把“9/g 混淆对”单独挑出来人工看标注经常是标注本身错了模型学到了错误的对应关系。5.2 矫正后图像两端字符出现拉伸畸变现象透视矫正后中间字符识别都正常首字符和末字符经常出错特别是车号开头的字母。原因透视变换的插值在图像边缘最严重四边形的角点只要偏差两三个像素边缘字符就会被横向拉伸或压缩。而且 LSTM 对序列首尾的上下文建模天然比中间弱。解决矫正时把目标宽度从按字符数计算改成增加 20% 的余量让字符区域只占矫正后图像的中部 80%两侧留给畸变区。另外一个有效做法是训练时随机把整张图左右平移几个像素让模型见过“字符不总是从第一个像素开始”的情况。5.3 显存够但训练速度突然变慢现象前几十个 step 速度正常之后训练变慢甚至 CPU 占用飙升GPU 利用率掉到 50% 以下。原因如果图像宽度不统一且没做动态 padding而是在__getitem__里把每个样本 pad 到固定最大宽度那所有短样本都在算无意义的空白区域。更隐蔽的原因是 DataLoader 的num_workers设置过小预处理成了瓶颈。解决用动态 padding 而不是全 pad 到固定宽度然后把num_workers调到 CPU 核心数的一半左右pin_memoryTrue打开。如果 GPU 利用率还是上不去把persistent_workersTrue加上避免每个 epoch 重新创建 worker 进程。5.4 这个模型在白天好的很傍晚逆光就翻车现象训练集里光照均匀模型在测试集光照均匀的图像上识别率 98%换到逆光、阴影环境立刻降到 85% 以下。原因数据增强里只加了亮度扰动没模拟逆光产生的对比度倒转和局部过曝。车厢号区域经常有钢架阴影横跨字符一部分在阴影里、一部分在阳光里这种情况亮度扰动模拟不出来。解决增强里加入局部光照遮罩——生成一个随机的暗色带状区域叠在图上模拟阴影扫过字符的效果。另外把训练图按光照条件分桶确保每个 batch 里逆光样本和正常样本混合避免模型在一轮迭代里被某一类光照主导。6. 部署提速与验收转 ONNX 批量推理和一套可量化的验证指标训练完的模型要进生产环境常见做法是先转 ONNX再用 ONNX Runtime 做 Python 或 C 推理。车厢号识别是典型的批量任务一次来几百张图吞吐量比单张延迟更关键。ONNX 导出时用动态 batch 轴model.eval() dummy_input torch.randn(1, 1, 32, 256) torch.onnx.export( model, dummy_input, crnn_carnum.onnx, opset_version11, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {1: batch}}, )导出后用 ONNX Runtime 跑推理注意 CTCLoss 的输入转换在导出后不再需要解码直接用贪心策略取每帧概率最大的索引合并连续重复删掉 blank。批量推理提速有两个实际技巧一是把图像按宽度分桶把宽度接近的图像放进同一个 batch减少 padding 浪费二是用 torch 的torch.no_grad()和 ONNX Runtime 的IoSession复用避免每次推理重新建 session。验证指标建议三个字符准确率预测正确的字符数 / 总字符数、整串准确率全对才算对、以及 p95 单张推理耗时。字符准确率是排查问题的主指标整串准确率是业务验收指标——货运系统的车号错了就要重录整串准确率至少要 95% 才敢上线。我自己养成的习惯是每次训练完先不看不测整体指标随机抽 30 张错样本看原因是标注问题、增强问题还是模型结构问题先归因再动参数这个习惯帮我少走了很多弯路——很多“模型问题”最后查出来是标注文件里混了空格。这套方案做到位车厢号识别从采集到出文本的自动化是可以稳稳落地的希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网