第P10周:Pytorch实现车牌识别

0 阅读11分钟

目录


一、项目简介

车牌识别通常可以拆分为两个任务:

  1. 车牌检测:从完整车辆图像中定位车牌区域;
  2. 车牌识别:读取车牌区域中的省份简称、字母和数字。

当前项目的数据已经是裁剪后的车牌小图,因此它解决的是第二个任务,也就是车牌字符识别,并不包含车牌检测。

项目的基本流程如下:

车牌图片
   ↓
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 × 72256 × 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] 时,尺寸变化如下:

输出通道输出尺寸
输入3224 × 224
Conv1,5×512220 × 220
Conv2,5×512216 × 216
MaxPool,2×212108 × 108
Conv3,5×524104 × 104
Conv4,5×524100 × 100
MaxPool,2×22450 × 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()

image.png