- 🍨 本文为🔗365天深度学习训练营 中的学习记录博客
- 🍖 原作者:K同学啊
目录
一、项目简介
车牌识别通常可以拆分为两个任务:
- 车牌检测:从完整车辆图像中定位车牌区域;
- 车牌识别:读取车牌区域中的省份简称、字母和数字。
当前项目的数据已经是裁剪后的车牌小图,因此它解决的是第二个任务,也就是车牌字符识别,并不包含车牌检测。
项目的基本流程如下:
车牌图片
↓
Resize + Normalize
↓
四层卷积提取图像特征
↓
全连接层一次输出 7 个字符
↓
逐位置选取概率最大的字符
↓
拼接为完整车牌号码
与“先切割字符、再逐个识别”的方案相比,这个项目采用端到端的多字符分类方式:输入一张完整车牌图,网络同时输出 7 个位置的分类结果,流程更加直接。
二、项目结构与运行环境
2.1 项目结构
当前项目结构非常精简:
license_plate_recognition/
├── 015_licence_plate/ # 车牌图片数据集
├── data/ # 当前为空的预留目录
├── license_plate_recognition.py # 数据处理、建模与训练脚本
└── 基于PyTorch的CNN中文车牌识别项目实战.md
所有核心逻辑都集中在 license_plate_recognition.py 中,主要包括:
- 读取图片路径并从文件名提取标签;
- 将字符标签转换为模型可以处理的数值形式;
- 实现 PyTorch 自定义数据集;
- 划分训练集与测试集;
- 定义带 Batch Normalization 的 CNN;
- 使用 Adam 优化器完成 30 轮训练;
- 记录测试损失并绘制 Loss 曲线。
2.2 依赖环境
项目用到的主要依赖如下:
Python
PyTorch
torchvision
NumPy
Pillow
Matplotlib
torchsummary
可以在虚拟环境中安装:
pip install torch torchvision numpy pillow matplotlib torchsummary
代码会自动判断 CUDA 是否可用:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
有 NVIDIA 显卡并正确安装 CUDA 版 PyTorch 时,程序会使用 GPU;否则自动回退到 CPU。
三、数据集分析
3.1 数据规模
对项目中的 015_licence_plate 目录进行统计,可以得到:
| 项目 | 数值 |
|---|---|
| 图片总数 | 13,675 张 |
| 图片尺寸 | 全部为 226 × 72 |
| 单个标签长度 | 全部为 7 个字符 |
| 训练集比例 | 80% |
| 测试集比例 | 20% |
| 训练集数量 | 10,940 张 |
| 测试集数量 | 2,735 张 |
数据量不算特别大,但足够完成一个入门级的端到端车牌识别实验。
3.2 文件名就是标签
数据集没有单独的 CSV 或 JSON 标注文件,而是把真实车牌号直接写进文件名。例如:
000000000_藏WP66B0.jpg
000000001_沪E264UD.jpg
000000002_浙E198UJ.jpg
以下划线分割文件名后,最后一段去掉扩展名就是标签:
000000001_沪E264UD.jpg → 沪E264UD
这种组织方式读取方便,但解析时应使用 pathlib.Path.stem,避免手动按 / 分割路径导致 Windows 与 Linux 不兼容:
from pathlib import Path
data_dir = Path("./015_licence_plate")
data_paths = sorted(data_dir.glob("*.jpg"))
class_names = [path.stem.split("_")[-1] for path in data_paths]
3.3 数据样例
原脚本使用 Matplotlib 一次展示 18 张图片:
plt.figure(figsize=(14, 5))
plt.suptitle("数据示例", fontsize=15)
for i in range(18):
plt.subplot(3, 6, i + 1)
image = plt.imread(data_paths_str[i])
plt.imshow(image)
plt.axis("off")
plt.show()
在正式训练之前先观察样本很有必要,可以快速发现图片损坏、方向错误、标签与内容不一致等问题。
四、车牌标签编码
4.1 构造字符表
项目将所有可能出现的字符分成三类:
char_enum = [
"京", "沪", "津", "渝", "冀", "晋", "蒙", "辽", "吉", "黑",
"苏", "浙", "皖", "闽", "赣", "鲁", "豫", "鄂", "湘", "粤",
"桂", "琼", "川", "贵", "云", "藏", "陕", "甘", "青", "宁",
"新", "军", "使"
]
number = [str(i) for i in range(10)]
alphabet = [chr(i) for i in range(65, 91)]
char_set = char_enum + number + alphabet
字符表一共有:
33 个中文或特殊字符 + 10 个数字 + 26 个大写字母 = 69 类
每张车牌有 7 个字符,因此识别任务可以理解为 7 个位置、每个位置 69 分类。
4.2 原项目的 One-Hot 编码
原脚本把每个标签编码为 [7, 69] 的 One-Hot 矩阵:
def text2vec(text):
vector = np.zeros([label_name_len, char_set_len])
for i, char in enumerate(text):
index = char_set.index(char)
vector[i][index] = 1.0
return vector
以 沪E264UD 为例,7 行分别代表车牌的 7 个位置,每一行只有真实字符对应的位置为 1。
4.3 更适合交叉熵的整数编码
如果使用 nn.CrossEntropyLoss,更推荐把标签直接保存为字符索引:
char_to_index = {char: index for index, char in enumerate(char_set)}
def text2index(text):
return torch.tensor(
[char_to_index[char] for char in text],
dtype=torch.long
)
编码后的单条标签形状为 [7],数据类型为 torch.long。这种写法与交叉熵的目标格式天然一致,也节省了 One-Hot 矩阵占用的空间。
五、自定义数据集与数据预处理
5.1 自定义 Dataset
项目通过继承 torch.utils.data.Dataset 组织图片和标签:
class MyDataset(torch.utils.data.Dataset):
def __init__(self, labels, image_paths, transform=None):
self.labels = labels
self.image_paths = image_paths
self.transform = transform
def __len__(self):
return len(self.labels)
def __getitem__(self, index):
image = Image.open(self.image_paths[index]).convert("RGB")
label = self.labels[index]
if self.transform:
image = self.transform(image)
return image, label
__len__ 返回样本总数,__getitem__ 根据索引读取图片和标签。将图片统一转换为 RGB,可以避免灰度图或带透明通道的图片造成输入通道数不一致。
5.2 图像预处理
原项目的数据变换如下:
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]
)
])
各步骤的作用是:
Resize([224, 224]):统一输入尺寸,方便批量训练;ToTensor():将图片转换为[C, H, W]格式的张量,并把像素缩放到[0, 1];Normalize():按通道标准化,使用的是 ImageNet 常用均值和标准差。
需要注意的是,原始车牌比例约为 226:72,直接拉伸成正方形会改变字符比例。后续可以尝试按比例缩放后补边,例如统一为 224 × 72 或 256 × 80,通常更符合车牌的几何结构。
5.3 划分训练集与测试集
原项目按 8:2 随机划分:
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]
)
为了让每次实验得到相同的数据划分,建议固定随机种子:
generator = torch.Generator().manual_seed(42)
train_dataset, test_dataset = torch.utils.data.random_split(
total_data,
[train_size, test_size],
generator=generator
)
数据加载器可以这样设置:
train_loader = DataLoader(
train_dataset,
batch_size=16,
shuffle=True
)
test_loader = DataLoader(
test_dataset,
batch_size=16,
shuffle=False
)
训练集打乱有助于减少样本顺序带来的影响;测试集不需要打乱,便于复现实验和定位错误样本。
六、CNN 网络结构设计
6.1 网络定义
项目实现了一个包含 4 个卷积层的网络,每个卷积层后加入 Batch Normalization 和 ReLU:
class NetworkBN(nn.Module):
def __init__(self, plate_length=7, num_classes=69):
super().__init__()
self.conv1 = nn.Conv2d(3, 12, kernel_size=5)
self.bn1 = nn.BatchNorm2d(12)
self.conv2 = nn.Conv2d(12, 12, kernel_size=5)
self.bn2 = nn.BatchNorm2d(12)
self.pool = nn.MaxPool2d(2, 2)
self.conv3 = nn.Conv2d(12, 24, kernel_size=5)
self.bn3 = nn.BatchNorm2d(24)
self.conv4 = nn.Conv2d(24, 24, kernel_size=5)
self.bn4 = nn.BatchNorm2d(24)
self.classifier = nn.Linear(
24 * 50 * 50,
plate_length * num_classes
)
self.plate_length = plate_length
self.num_classes = num_classes
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.bn3(self.conv3(x)))
x = F.relu(self.bn4(self.conv4(x)))
x = self.pool(x)
x = torch.flatten(x, 1)
x = self.classifier(x)
# CrossEntropyLoss 需要类别维位于第 2 维
x = x.view(-1, self.plate_length, self.num_classes)
return x.permute(0, 2, 1) # [N, 69, 7]
6.2 特征图尺寸变化
输入为 [N, 3, 224, 224] 时,尺寸变化如下:
| 层 | 输出通道 | 输出尺寸 |
|---|---|---|
| 输入 | 3 | 224 × 224 |
| Conv1,5×5 | 12 | 220 × 220 |
| Conv2,5×5 | 12 | 216 × 216 |
| MaxPool,2×2 | 12 | 108 × 108 |
| Conv3,5×5 | 24 | 104 × 104 |
| Conv4,5×5 | 24 | 100 × 100 |
| MaxPool,2×2 | 24 | 50 × 50 |
| Flatten | - | 60,000 |
| Linear | - | 483 |
| Reshape + Permute | - | 69 × 7 |
最后的 483 来自:
7 个字符位置 × 69 个候选字符 = 483
6.3 Batch Normalization 的作用
Batch Normalization 会对一个批次内的中间特征进行标准化,主要作用包括:
- 缓解不同层特征分布变化;
- 让梯度传播更加稳定;
- 通常可以使用更合适的学习率;
- 对小型 CNN 的收敛速度有一定帮助。
这个网络约有 29,006,799 个可训练参数,其中约 2,898 万个参数来自全连接层。也就是说,绝大多数参数并不在卷积特征提取部分,而集中在最后的分类器中,这也是后续最值得优化的位置。
七、模型训练与测试
7.1 优化器与损失函数
原项目使用 Adam 优化器:
optimizer = torch.optim.Adam(
model.parameters(),
lr=1e-4,
weight_decay=1e-4
)
loss_fn = nn.CrossEntropyLoss()
参数含义如下:
- 学习率
1e-4:控制单次参数更新幅度; weight_decay=1e-4:提供 L2 正则化,减轻过拟合;CrossEntropyLoss:用于每个字符位置的 69 分类任务。
当模型输出为 [N, 69, 7]、标签为 [N, 7] 时,可以直接计算:
loss = loss_fn(outputs, labels)
7.2 训练函数
一个更完整的训练函数如下:
def train_one_epoch(model, data_loader, loss_fn, optimizer, device):
model.train()
running_loss = 0.0
for images, labels in data_loader:
images = images.to(device)
labels = labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = loss_fn(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item() * images.size(0)
return running_loss / len(data_loader.dataset)
训练阶段的固定顺序是:
清空梯度 → 前向传播 → 计算损失 → 反向传播 → 更新参数
7.3 测试函数
测试时应使用 model.eval(),并关闭梯度计算:
def evaluate(model, data_loader, loss_fn, device):
model.eval()
total_loss = 0.0
char_correct = 0
plate_correct = 0
char_total = 0
with torch.no_grad():
for images, labels in data_loader:
images = images.to(device)
labels = labels.to(device)
outputs = model(images) # [N, 69, 7]
loss = loss_fn(outputs, labels)
predictions = outputs.argmax(dim=1)
total_loss += loss.item() * images.size(0)
char_correct += (predictions == labels).sum().item()
plate_correct += (predictions == labels).all(dim=1).sum().item()
char_total += labels.numel()
sample_total = len(data_loader.dataset)
return {
"loss": total_loss / sample_total,
"char_accuracy": char_correct / char_total,
"plate_accuracy": plate_correct / sample_total,
}
7.4 完整训练循环
原项目设置了 30 个 Epoch,并记录测试损失。建议同时记录训练损失、字符准确率和整牌准确率:
epochs = 30
history = []
for epoch in range(epochs):
train_loss = train_one_epoch(
model, train_loader, loss_fn, optimizer, device
)
metrics = evaluate(model, test_loader, loss_fn, device)
history.append({
"epoch": epoch + 1,
"train_loss": train_loss,
**metrics,
})
print(
f"Epoch {epoch + 1:02d}/{epochs} | "
f"train_loss={train_loss:.4f} | "
f"test_loss={metrics['loss']:.4f} | "
f"char_acc={metrics['char_accuracy']:.2%} | "
f"plate_acc={metrics['plate_accuracy']:.2%}"
)
当前项目目录中没有保存好的模型权重、训练日志或实际曲线图片,因此本文不填写未经运行验证的准确率。完成训练后,应以终端日志和测试集评估结果为准。
八、结果可视化
8.1 绘制准确率与损失曲线
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime
current_time = datetime.now() # 获取当前时间
x = [i for i in range(1,31)]
plt.plot(x, test_loss_list, label="Loss", alpha=0.8)
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title(current_time) # 打卡请带上时间戳,否则代码截图无效
plt.tight_layout()
plt.legend()
plt.show()