第 P10 周:Pytorch 实现车牌识别
👉声明
- 本文为「365 天深度学习训练营」内部学习记录。
- 本文参考 K 同学啊课程内容完成,仅用于个人学习与交流。
- 数据集为车牌识别数据集,请勿对外公开传播。
- 本篇为个人在 P10 关卡上的实践记录。
第 P10 周:Pytorch 实现车牌识别
在之前的案例中,我们多使用 datasets.ImageFolder 直接导入已经按类别整理好的数据集。但车牌识别数据集中,每张图片的标签写在文件名里,并不是一个类别一个文件夹,因此本周需要自定义 Dataset,从图片路径中提取车牌字符串,再将标签数字化后完成识别任务。
本周目标:
- 使用 Python 整理
第15天-没有加密版本.zip数据集; - 自定义
MyDataset加载车牌图片和标签; - 搭建输出形状为
[batch, 7, 69]的卷积神经网络; - 补充测试阶段 ACC 统计;
- 实现单张车牌预测。
🏡 我的环境
- 语言环境:Python 3.8(我的本机实跑为 3.11,流程一致)
- 编译器:Jupyter Notebook / Jupyter Lab
- 深度学习环境:PyTorch(教程参考
torch==1.10.0+cu113,torchvision==0.11.1+cu113) - 数据集:
第15天-没有加密版本.zip
一、导入数据
1) 导入库并设置设备
from torchvision.transforms import transforms
from torch.utils.data import DataLoader
from torchvision import datasets
import torchvision.models as models
import torch.nn.functional as F
import torch.nn as nn
import torch
import torchvision
import os
import shutil
import zipfile
import pathlib
import random
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from PIL import Image
from torch.autograd import Variable
from torch.utils.data import Dataset
import torch.utils.data as data
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device
为了让实验结果更稳定,可以固定随机种子:
def seed_everything(seed=42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
seed_everything(42)
2) 解压并整理数据集
压缩包内部只有一个目录:015_licence_plate/,图片文件名格式类似:
015_licence_plate/000000000_藏WP66B0.jpg
015_licence_plate/000000001_津D8Z15T.jpg
015_licence_plate/000000002_陕Z813VB.jpg
在 Linux/WSL 中直接解压中文文件名可能出现乱码,所以这里使用 zipfile 手动按 GBK 修复文件名后再写入磁盘。
# 这里重复导入标准库,是为了该单元可以单独重新运行。
import pathlib
import shutil
import zipfile
zip_path = pathlib.Path("第15天-没有加密版本.zip")
data_dir = pathlib.Path("015_licence_plate")
assert zip_path.exists(), f"未找到数据压缩包:{zip_path}"
if not data_dir.exists() or len(list(data_dir.glob("*.jpg"))) == 0:
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
raw_name = info.filename
try:
fixed_name = raw_name.encode("cp437").decode("gbk")
except UnicodeEncodeError:
fixed_name = raw_name
target_path = pathlib.Path(fixed_name)
if info.is_dir():
target_path.mkdir(parents=True, exist_ok=True)
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target_path, "wb") as dst:
shutil.copyfileobj(src, dst)
data_paths = sorted(data_dir.glob("*.jpg"))
data_paths_str = [str(path) for path in data_paths]
classeNames = [path.stem.split("_", 1)[1] for path in data_paths]
len(data_paths_str), classeNames[:10]
说明:
data_paths_str保存所有图片路径;classeNames保存从文件名中提取出的车牌号;- 每个标签长度为 7,例如
藏WP66B0。
3) 数据可视化
plt.figure(figsize=(14, 5))
plt.suptitle("数据示例", fontsize=15)
for i in range(18):
plt.subplot(3, 6, i + 1)
plt.xticks([])
plt.yticks([])
image = plt.imread(data_paths_str[i])
plt.imshow(image)
plt.title(classeNames[i], fontsize=9)
plt.show()
4) 标签数字化
车牌标签包含省份简称、数字和大写字母。模型需要输出 7 个位置,每个位置在 69 个候选字符中选择一个。
char_enum = [
"京", "沪", "津", "渝", "冀", "晋", "蒙", "辽", "吉", "黑", "苏", "浙", "皖", "闽", "赣", "鲁",
"豫", "鄂", "湘", "粤", "桂", "琼", "川", "贵", "云", "藏", "陕", "甘", "青", "宁", "新", "军", "使"
]
number = [str(i) for i in range(0, 10)]
alphabet = [chr(i) for i in range(65, 91)]
char_set = char_enum + number + alphabet
char_set_len = len(char_set)
label_name_len = len(classeNames[0])
def text2vec(text):
vector = np.zeros([label_name_len, char_set_len], dtype=np.float32)
for i, c in enumerate(text):
idx = char_set.index(c)
vector[i][idx] = 1.0
return vector
def vec2text(vec):
char_indices = np.argmax(vec, axis=1)
return "".join(char_set[i] for i in char_indices)
all_labels = [text2vec(name) for name in classeNames]
char_set_len, label_name_len, vec2text(all_labels[0])
5) 自定义 Dataset
class MyDataset(data.Dataset):
def __init__(self, all_labels, data_paths_str, transform=None):
self.img_labels = all_labels
self.img_dir = data_paths_str
self.transform = transform
def __len__(self):
return len(self.img_labels)
def __getitem__(self, index):
image = Image.open(self.img_dir[index]).convert("RGB")
label = torch.tensor(self.img_labels[index], dtype=torch.float32)
if self.transform:
image = self.transform(image)
return image, label
train_transforms = transforms.Compose([
transforms.Resize([224, 224]),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225],
),
])
total_data = MyDataset(all_labels, data_paths_str, train_transforms)
total_data
6) 划分数据集
train_size = int(0.8 * len(total_data))
test_size = len(total_data) - train_size
train_dataset, test_dataset = torch.utils.data.random_split(total_data, [train_size, test_size])
train_size, test_size
train_loader = torch.utils.data.DataLoader(
train_dataset,
batch_size=16,
shuffle=True,
)
test_loader = torch.utils.data.DataLoader(
test_dataset,
batch_size=16,
shuffle=True,
)
print("The number of images in a training set is: ", len(train_loader) * 16)
print("The number of images in a test set is: ", len(test_loader) * 16)
print("The number of batches per epoch is: ", len(train_loader))
for X, y in test_loader:
print("Shape of X [N, C, H, W]: ", X.shape)
print("Shape of y: ", y.shape, y.dtype)
break
二、自建模型
本案例的模型输出不是普通分类任务中的 [batch, num_classes],而是 [batch, 7, 69]:
7表示车牌字符长度;69表示每个位置可选字符数量;batch会随批大小变化。
class Network_bn(nn.Module):
def __init__(self):
super(Network_bn, self).__init__()
self.conv1 = nn.Conv2d(in_channels=3, out_channels=12, kernel_size=5, stride=1, padding=0)
self.bn1 = nn.BatchNorm2d(12)
self.conv2 = nn.Conv2d(in_channels=12, out_channels=12, kernel_size=5, stride=1, padding=0)
self.bn2 = nn.BatchNorm2d(12)
self.pool = nn.MaxPool2d(2, 2)
self.conv4 = nn.Conv2d(in_channels=12, out_channels=24, kernel_size=5, stride=1, padding=0)
self.bn4 = nn.BatchNorm2d(24)
self.conv5 = nn.Conv2d(in_channels=24, out_channels=24, kernel_size=5, stride=1, padding=0)
self.bn5 = nn.BatchNorm2d(24)
self.fc1 = nn.Linear(24 * 50 * 50, label_name_len * char_set_len)
self.reshape = Reshape([label_name_len, char_set_len])
def forward(self, x):
x = F.relu(self.bn1(self.conv1(x)))
x = F.relu(self.bn2(self.conv2(x)))
x = self.pool(x)
x = F.relu(self.bn4(self.conv4(x)))
x = F.relu(self.bn5(self.conv5(x)))
x = self.pool(x)
x = x.view(-1, 24 * 50 * 50)
x = self.fc1(x)
x = self.reshape(x)
return x
class Reshape(nn.Module):
def __init__(self, shape):
super(Reshape, self).__init__()
self.shape = shape
def forward(self, x):
return x.view(x.size(0), *self.shape)
device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device))
model = Network_bn().to(device)
model
try:
import torchsummary
torchsummary.summary(model, (3, 224, 224))
except ModuleNotFoundError:
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total params: {total_params:,}")
print(f"Trainable params: {trainable_params:,}")
📮提问:[-1, 7, 69] 中的 -1 是什么意思?
在 PyTorch 中,-1 表示该维度由系统自动推断。这里通常对应 batch 维度,因为训练和测试时的 batch size 可能不同。7 表示 7 个车牌字符位置,69 表示每个位置的候选字符类别数。
三、模型训练
1) 优化器与损失函数
optimizer = torch.optim.Adam(
model.parameters(),
lr=1e-4,
weight_decay=0.0001,
)
loss_model = nn.CrossEntropyLoss()
CrossEntropyLoss 默认要求类别维度在第 2 维。模型输出为 [batch, 7, 69],因此计算 loss 时需要改成 [batch, 69, 7],标签从 one-hot 转为 [batch, 7] 的类别索引。
def sequence_cross_entropy(pred, y):
target = y.argmax(dim=2).long()
return loss_model(pred.permute(0, 2, 1), target)
2) 补充 ACC 统计
本周任务要求补充测试阶段 ACC 统计。这里使用“整张车牌准确率”:只有 7 个字符全部预测正确,才算该样本预测正确。
def test(model, test_loader, loss_model):
size = len(test_loader.dataset)
num_batches = len(test_loader)
model.eval()
test_loss, correct = 0, 0
with torch.no_grad():
for X, y in test_loader:
X, y = X.to(device), y.to(device)
pred = model(X)
test_loss += sequence_cross_entropy(pred, y).item()
pred_labels = pred.argmax(dim=2)
true_labels = y.argmax(dim=2)
correct += (pred_labels == true_labels).all(dim=1).type(torch.float).sum().item()
test_loss /= num_batches
test_acc = correct / size
print(f"Test Error: \n Accuracy: {(100 * test_acc):>0.1f}%, Avg loss: {test_loss:>8f} \n")
return test_acc, test_loss
def train(model, train_loader, loss_model, optimizer):
model = model.to(device)
model.train()
for i, (images, labels) in enumerate(train_loader, 0):
images = Variable(images.to(device))
labels = Variable(labels.to(device))
optimizer.zero_grad()
outputs = model(images)
loss = sequence_cross_entropy(outputs, labels)
loss.backward()
optimizer.step()
if i % 1000 == 0:
print("[%5d] loss: %.3f" % (i, loss))
3) 模型训练
test_acc_list = []
test_loss_list = []
epochs = 30
for t in range(epochs):
print(f"Epoch {t + 1}\n-------------------------------")
train(model, train_loader, loss_model, optimizer)
test_acc, test_loss = test(model, test_loader, loss_model)
test_acc_list.append(test_acc)
test_loss_list.append(test_loss)
print("Done!")
四、结果分析
1) Loss 曲线
from datetime import datetime
current_time = datetime.now()
x = [i for i in range(1, epochs + 1)]
plt.plot(x, test_loss_list, label="Loss", alpha=0.8)
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title(current_time)
plt.legend()
plt.show()
2) ACC 曲线
plt.plot(x, test_acc_list, label="Accuracy", alpha=0.8)
plt.xlabel("Epoch")
plt.ylabel("Accuracy")
plt.title(current_time)
plt.legend()
plt.show()
五、单张车牌识别
拔高要求是对单张车牌进行识别。这里封装一个预测函数,输入图片路径,输出模型识别到的车牌字符串。
def predict_one_image(model, image_path, transform):
model.eval()
image = Image.open(image_path).convert("RGB")
input_tensor = transform(image).unsqueeze(0).to(device)
with torch.no_grad():
pred = model(input_tensor)
pred_indices = pred.argmax(dim=2).squeeze(0).cpu().numpy()
return "".join(char_set[i] for i in pred_indices)
sample_path = data_paths_str[0]
true_label = classeNames[0]
pred_label = predict_one_image(model, sample_path, train_transforms)
print("真实车牌:", true_label)
print("预测车牌:", pred_label)
plt.imshow(plt.imread(sample_path))
plt.title(f"True: {true_label} | Pred: {pred_label}")
plt.axis("off")
plt.show()

六、本周总结
这一周主要完成了一个车牌识别任务。相比之前普通的图像分类任务,这次最大的不同是:标签不是来自文件夹名,而是藏在图片文件名里,所以需要自己提取标签,并手动构建数据集。
在这个过程中,我主要学到了以下几点:
- 如何自定义 Dataset
当数据集不是标准文件夹分类结构时,就不能完全依赖 ImageFolder,而是需要自己写 Dataset 来读取图片和标签。 - 理解车牌识别的输出形式
本次模型输出为 [batch, 7, 69],表示每张图片有 7 个字符位置,每个位置从 69 个候选字符中选择一个。 - 掌握多字符分类任务的 loss 写法
因为 CrossEntropyLoss 对输入维度有要求,所以需要将模型输出从 [batch, 7, 69] 调整为 [batch, 69, 7],同时把 one-hot 标签转成类别索引。 - 补充了整张车牌准确率统计
这里采用的是比较严格的统计方式:只有 7 个字符全部预测正确,才算整张车牌预测正确。 - 实现了单张图片预测
最后封装了一个预测函数,可以输入任意一张车牌图片,并输出模型识别到的车牌字符串。
整体来说,这一周的重点不是单纯搭建 CNN,而是理解“图片到序列标签”的完整流程。后续如果继续优化,可以尝试加入数据增强、改进模型结构、减少全连接层参数量,或者引入更强的 CNN Backbone。同时,也可以同时统计字符级准确率和整牌准确率,这样能更细致地分析模型到底错在什么地方。
更多推荐


所有评论(0)