乐于分享
好东西不私藏

AI笔记15-LeNet模型之手势图像识别

AI笔记15-LeNet模型之手势图像识别

大家好,我是AI小白,上节课我们学习了卷积网络的基本组件,也学LeNet模型的示意图和代码展示,今天我们通过手势图像识别实战来学习LeNet模型,下面是我的学习笔记,欢迎大家一起讨论、学习。
大纲主题
  • 手势图像数据集介绍
  • 手势图形识别项目流程
  • 小结
一、手势图像数据集的介绍

通过网盘分享的知识:手势识别

链接: https://pan.baidu.com/s/5IFnxOYrqWSPuV-DVuAtLCg

├── train                   # 训练集    ├── G0                  # 手势0        ├── IMG_1118.jpg    # 手势0的图片        ├── ...    ├── G1                  # 手势1        ├── IMG_1119.jpg        ├── ...    ├── ...    ├── G9                  # 手势9├── test                    # 测试集    ├── G0    ├── ...    ├── G9  

二、手势图形识别项目流程

  • 数据预处理

如何记录标签数据和图片数据:

import osimport randomimport numpy as npimport cv2def load_img_label(root_dir):    """    递归遍历 root_dir 下的所有子文件夹,    将每张图片的完整路径和其所属的文件夹名(标签)分别存入两个列表并返回。    """    img_paths = []   # 存放所有图片的完整路径    labels = []      # 存放每张图片对应的标签(文件夹名)    # 遍历 root_dir 下的直接子项(包括文件夹和文件)    for item in os.listdir(root_dir):        item_path = os.path.join(root_dir, item)        # 忽略隐藏文件或文件夹(如 .DS_Store)        if item.startswith('.'):            continue        # 只处理文件夹(因为我们期望子文件夹代表不同类别)        if os.path.isdir(item_path):            # 遍历该类别文件夹中的每个文件            for img_file in os.listdir(item_path):                img_full_path = os.path.join(item_path, img_file)                # 只把文件(图片)加进去,忽略子文件夹内的子文件夹(如果有)                if os.path.isfile(img_full_path):                    img_paths.append(img_full_path)                    labels.append(item)   # 标签就是文件夹名,如 'G0'    return img_paths, labels# 1. 基础路径root = "gestures"# 2. 加载训练集train_root = os.path.join(root, 'train')train_img, train_label = load_img_label(train_root)# 3. 加载测试集test_root = os.path.join(root, 'test')test_img, test_label = load_img_label(test_root)# 4. 从训练集标签中提取所有类别(去重,排序)label_list = list(set(train_label))   # 原代码中变量名为 lable_list,保留拼写label_list.sort()                     # 按字母/数字顺序排列,保证可重复性# 5. 构建正向和反向的映射字典label2idx = {label: idx for idx, label in enumerate(label_list)}idx2label = {idx: label for idx, label in enumerate(label_list)}# 6. 打印查看映射关系print(label2idx)print(idx2label)

1. 导入必要的库

  • os用于处理文件和目录路径。

  • randomnumpycv2 在后续训练中可能会用到,但当前数据预处理阶段尚未使用,保留以备后续扩展。

2. 定义加载函数 load_img_label(root_dir)

原函数名为 load_img_label,参数名为 train_root,但实际功能通用,因此改为 root_dir 更清晰。

函数内部逻辑:

  • 创建两个空列表 img_paths 和 labels,分别存放图片路径和对应的标签。

  • 使用 os.listdir(root_dir) 获取根目录下的所有条目(包括文件夹和隐藏文件)。

  • 跳过隐藏条目if item.startswith('.') 排除 .DS_Store 等系统文件,避免报错。

  • 仅处理文件夹if os.path.isdir(item_path) 确保只进入类别文件夹(如 G0G1……)。

  • 遍历该类别文件夹下的所有文件:for img_file in os.listdir(item_path)

  • 构造完整图片路径:img_full_path = os.path.join(item_path, img_file)

  • 确保是文件if os.path.isfile(img_full_path) 忽略可能存在的子文件夹。

  • 添加到列表:img_paths.append(img_full_path) 和 labels.append(item)。注意这里的 item 就是类别文件夹名(如 'G0'),作为标签。

  • 最后返回两个列表。

3. 加载训练集和测试集

root = "gestures"train_root = os.path.join(root, 'train')train_img, train_label = load_img_label(train_root)test_root = os.path.join(root, 'test')test_img, test_label = load_img_label(test_root)
  • gestures,然后分别拼接 train 和 test 子目录。

  • 调用同一个函数,得到训练集的图片路径列表 train_img 和标签列表 train_label,测试集同理。

4. 构建标签与索引的映射

label_list = list(set(train_label))   # 原码中变量名为 lable_listlabel_list.sort()
  • set(train_label) 去重,得到所有出现过的标签(例如 {'G0','G1',...,'G9'})。

  • 转为列表并排序,保证每次运行得到的顺序一致(字典构建的索引顺序固定)。

5. 构建字典映射

label2idx = {label: idx for idx, label in enumerate(lable_list)}idx2label = {idx: label for idx, label in enumerate(lable_list)}
  • enumerate 为每个类别分配一个整数索引(从0开始)。

  • label2idx:标签 → 索引,例如 {'G0':0, 'G1':1, ...}

  • idx2label:索引 → 标签,例如 {0:'G0', 1:'G1', ...}

如何将图片和标签数据打包成适合训练的格式:

import torchfrom torch.utils.data import Datasetimport cv2import numpy as npclass GesturesDataset(Dataset):    """    自定义手势数据集,用于加载图片路径和对应标签,    并在 __getitem__ 中完成图像预处理和标签映射。    """    def __init__(self, X, y):        """        初始化数据集        Args:            X: list of str, 图片的完整路径列表            y: list of str, 对应的原始标签(如 'G0')列表        """        self.X = X        self.y = y    def __len__(self):        """返回数据集样本总数"""        return len(self.X)    def __getitem__(self, idx):        """        根据索引 idx 返回一个样本 (image_tensor, label_tensor)        """        # 获取图片路径和原始标签        img_path = self.X[idx]        img_label = self.y[idx]        # 1. 读取图像 (cv2 默认 BGR 格式)        img = cv2.imread(img_path)        # 可选:若图像读取失败,可做保护,此处保持原意不添加额外处理        # 2. 将图像缩放到 32x32 像素(注意:cv2.resize 默认使用双线性插值)        img = cv2.resize(img, (3232))        # 3. 将图像转为 numpy 数组(原代码此处多余,因为 cv2 读取后已经是 numpy 数组)        #    但为了保持原意,保留该操作(np.array(img) 实际不会改变类型)        img = np.array(img)        # 4. 数据规范化到 [-1, 1] 区间        #    - 先将像素值 [0, 255] 缩放到 [0, 1]        #    - 再通过 (x - 0.5)/0.5 映射到 [-1, 1]        img = img / 255.0        img = (img - 0.5) / 0.5        # 5. 转为 PyTorch 张量,数据类型为 float32        img = torch.tensor(img, dtype=torch.float32)        # 6. 调整维度顺序:PyTorch 模型期望输入通道在前,即 [C, H, W]        #    原图像形状为 [H, W, C],所以需交换维度        img = img.permute(201)   # 原为 [H, W, C] -> [C, H, W]        # 7. 标签转换为数字索引:使用外部的 label2idx 字典        label = label2idx[img_label]   # 例如 'G0' -> 0        label = torch.tensor(label, dtype=torch.long)        return img, label

1. 导入必要的库

import torchfrom torch.utils.data import Datasetimport cv2import numpy as np
  • torch 和 Dataset:用于构建 PyTorch 自定义数据集。

  • cv2:OpenCV,用于读取图像和调整大小。

  • numpy:用于数组操作(虽然 cv2 已返回 numpy 数组,但原代码显式调用了 np.array,所以保留)。

2. 类定义 GesturesDataset(Dataset)

继承 torch.utils.data.Dataset,必须实现 __len__ 和 __getitem__ 方法。

3. 初始化方法 __init__(self, X, y)

self.X = X      # 保存图片路径列表self.y = y      # 保存原始标签列表(字符串,例如 'G0')

4. __len__(self)

返回 len(self.X),即样本总数。假设 X 和 y 长度相同。

5. __getitem__(self, idx) — 核心数据预处理逻辑

步骤 0:获取原始数据

img_path = self.X[idx]img_label = self.y[idx]

根据索引取出路径和原始标签。

步骤 1:读取图像

img = cv2.imread(img_path)
  • cv2.imread 会以 BGR 顺序的三维 numpy 数组形式读取图像,形状为 [H, W, C],像素值范围 0~255。

  • 原代码没有处理读取失败的情况(如路径错误),改写也保持原意(后续 resize 时会报错)。

步骤 2:缩放到 32×32

img = cv2.resize(img, (3232))
  • cv2.resize 默认使用双线性插值,目标尺寸为 (宽度, 高度) = (32, 32),因此输出形状为 [32, 32, C](通道数不变)。

步骤 3:转换为 numpy 数组

img = np.array(img)
  • 实际上 img 已经是 numpy.ndarray,此操作会创建一个副本。原代码这样写可能是为了强调显式转换,我们保留它。

步骤 4:数据归一化到 [-1, 1]

img = img / 255.0                # 从 [0,255] -> [0,1]img = (img - 0.5) / 0.5          # 从 [0,1] -> [-1,1]
  • 除以 255:将像素值缩放到 0~1 之间。

  • (img - 0.5) / 0.5 等价于 2*img - 1,将 0→-1, 0.5→0, 1→1。这是常见的将输入归一化到对称区间的做法,有利于神经网络训练。

步骤 5:转为 PyTorch 张量

img = torch.tensor(img, dtype=torch.float32)
  • 将 numpy 数组转换为 torch.Tensor,并指定数据类型为 torch.float32(即 float)。原代码未指定 device,默认在 CPU 上。

步骤 6:转换维度顺序

img = img.permute(201)   # [H, W, C] -> [C, H, W]
  • yTorch 的卷积层通常要求输入张量的形状为 (batch, channels, height, width)。每个样本的形状应为 (C, H, W)

  • 原始 img 的形状是 (32, 32, C),通过 permute(2, 0, 1) 将通道维(索引 2)移到第一维,高度(索引 0)、宽度(索引 1)依次后移。

步骤 7:标签映射为数字并转为张量

label = label2idx[img_label]   # 使用外部字典将 'G0' 映射为整数label = torch.tensor(label, dtype=torch.long)
  • label2idx 必须在类外部定义好(例如之前预处理代码生成的字典)。假设 label2idx = {'G0':0, 'G1':1, ...}

  • 分类任务中标签通常用 torch.long 类型(即整型),因为交叉熵损失要求目标为长整型。

步骤 8:返回样本

return imglabel

返回一个元组,包含处理后的图像张量和标签张量。

  • 模型搭建

在上节课我们已经学习到了LeNet网络结构,代码如下:

import torchfrom torch import nnclass ConvBlock(nn.Module):    """    一层卷积块:        - 卷积层        - 批规范化层        - 激活层    """    def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1):        super().__init__()        self.conv = nn.Conv2d(in_channels=in_channels, out_channels=out_channels,                             kernel_size=kernel_size, stride=stride, padding=padding)        self.bn = nn.BatchNorm2d(num_features=out_channels)  # 批规范化层        self.relu = nn.ReLU()  # ReLU激活函数    def forward(self, x):        x = self.conv(x)  # 卷积操作        x = self.bn(x)  # 批规范化操作        x = self.relu(x)  # 激活操作class LeNet(nn.Module):    def __init__(self):        super().__init__()        # 特征提取部分        self.feature_extractor = nn.Sequential(            # 第一层卷积块            ConvBlock(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=0),            nn.MaxPool2d(kernel_size=2, stride=2, padding=0),  # 最大池化层            # 第二层卷积块            ConvBlock(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=0),            nn.MaxPool2d(kernel_size=2, stride=2, padding=0)  # 最大池化层        )        # 分类部分        self.classifier = nn.Sequential(            nn.Flatten(),  # 展平操作            nn.Linear(in_features=400, out_features=120),  # 第一个全连接层            nn.ReLU(),  # ReLU激活函数            nn.Linear(in_features=120, out_features=84),  # 第二个全连接层            nn.ReLU(),  # ReLU激活函数            nn.Linear(in_features=84, out_features=10)  # 输出层        )    def forward(self, x):        # 1. 特征提取        x = self.feature_extractor(x)        # 2. 分类        x = self.classifier(x)        return x
将上述代码封装为model.py文件,方便后续直接import。
from models import LeNetmodel = LeNet()
  • 筹备训练

代码如下:

import torchimport torch.nn as nn# ----- 设备设置 -----# 检测 CUDA 是否可用,若可用则使用第 0 块 GPU,否则使用 CPUdevice = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")# 将模型移动到指定设备(内存 → GPU 或 CPU 显存)model = model.to(device)# ----- 超参数配置 -----epochs = 80          # 训练总轮数learning_rate = 1e-3 # 学习率# ----- 损失函数与优化器 -----# 使用交叉熵损失(适用于多分类任务)criterion = nn.CrossEntropyLoss()# 使用随机梯度下降(SGD)优化器,传入模型参数和学习率optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

1. 导入必要模块

import torchimport torch.nn as nn
  • torch:PyTorch 主库,提供设备管理、张量操作等。

  • nn:神经网络模块,包含 CrossEntropyLoss 等常用层和损失函数。

2. 设备选择(Device)

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
  • 如果有 CUDA GPU,则使用第一块 GPU(cuda:0),否则回退到 CPU

3. 将模型移动到指定设备

model = model.to(device)
  • 调用 model.to(device=device) 将模型参数和缓冲区迁移到 GPU 或 CPU 内存

4. 设置训练轮数

epochs = 80

5. 设置学习率

learning_rate = 1e-3

6. 定义损失函数

criterion = nn.CrossEntropyLoss()

7. 定义优化器

optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
  • 模型评估

# 准确率计算def get_acc(data_loader):    accs = []    model.eval()    with torch.no_grad():        for X, y in data_loader:            X = X.to(device=device)            y = y.to(device=device)            y_pred = model(X)            y_pred = y_pred.argmax(dim=-1)            acc = (y_pred == y).to(torch.float32).mean().item()            accs.append(acc)    final_acc = round(number=sum(accs) / len(accs), ndigits=5)    return final_acc
  • 实现训练过程

代码如下:

# 训练过程def train():    train_accs = []    test_accs = []    cur_test_acc = 0    # 1,训练之前,检测一下准确率    train_acc = get_acc(data_loader=train_dataloader)    test_acc = get_acc(data_loader=test_dataloader)    train_accs.append(train_acc)    test_accs.append(test_acc)    print(f"训练之前:train_acc: {train_acc},test_acc: {test_acc}")    # 每一轮次    for epoch in range(epochs):        # 模型设置为 train 模式        model.train()        # 计时        start_train = time.time()        # 每一批量        for X, y in train_dataloader:            # 数据搬家            X = X.to(device=device)            y = y.to(device=device)            # 1,正向传播            y_pred = model(X)            # 2,计算损失            loss = loss_fn(y_pred, y)            # 3,反向传播            loss.backward()            # 4,优化一步            optimizer.step()            # 5,清空梯度            optimizer.zero_grad()        # 计时结束        stop_train = time.time()        # 测试准确率        train_acc = get_acc(data_loader=train_dataloader)        test_acc = get_acc(data_loader=test_dataloader)        train_accs.append(train_acc)        test_accs.append(test_acc)        # 保存模型        if cur_test_acc < test_acc:            cur_test_acc = test_acc            # 保存最好模型            torch.save(obj=model.state_dict(), f="lenet_best.pt")        # 保存最后模型        torch.save(obj=model.state_dict(), f="lenet_last.pt"        # 格式化输出日志        print(f"""        当前是第 {epoch + 1} 轮:        ------------------------------------------------------------        | 训练准确率 (train_acc) | 测试准确率 (test_acc) | 运行时间 (elapsed_time) |        ------------------------------------------------------------        | {train_acc:<18} | {test_acc:<17} | {round(number=stop_train - start_train, ndigits=3)} 秒    |        ------------------------------------------------------------        """)    return train_accs, test_accs
  • 开始训练

train_accs, test_accs = train()
  • 图形化监控数据

plt.plot(train_accs, label="train_acc")plt.plot(test_accs, label="train_acc")plt.legend()plt.grid()plt.xlabel(xlabel='epoch')plt.ylabel(ylabel="acc")plt.title(label="LeNet Training Process")

运行结果:

  • 模型预测

import streamlit as stimport torchimport osimport numpy as npfrom PIL import Imagefrom models import LeNet# 标签索引映射字典(与训练时一致)idx2label = {    0'G0'1'G1'2'G2'3'G3'4'G4',    5'G5'6'G6'7'G7'8'G8'9'G9'}def infer(img_path, model, device, idx2label):    """    对单张图片进行推理,返回预测的类别标签(如 'G3')    参数:        img_path: str, 图片文件路径        model: torch.nn.Module, 已加载权重的模型        device: torch.device, 推理设备(cuda/cpu)        idx2label: dict, 索引到类别名的映射    返回:        label: str, 预测的类别名称    """    # 1. 检查图片文件是否存在    if not os.path.exists(img_path):        raise FileNotFoundError(f"图片文件不存在: {img_path}")    # 2. 读取图像(使用 PIL 保持与训练时一致,训练时用了 cv2,但 resize 和归一化逻辑相同)    img = Image.open(img_path)    # 3. 预处理:缩放到 32x32    img = img.resize((3232))    # 4. 转为 numpy 数组,形状 [H, W, C],像素值 0~255    img = np.array(img)    # 5. 归一化到 [-1, 1]:先 [0,255] -> [0,1],再 [0,1] -> [-1,1]    img = img / 255.0    img = (img - 0.5) / 0.5    # 6. 转为 PyTorch 张量,float32    img = torch.tensor(img, dtype=torch.float32)    # 7. 调整维度顺序:PIL 读入为 [H, W, C] -> 转换为 [C, H, W]    img = img.permute(201)    # 8. 增加 batch 维度: [C, H, W] -> [1, C, H, W]    img = img.unsqueeze(0)    # 9. 将数据移动到指定设备    img = img.to(device)    # 10. 设置模型为评估模式(关闭 dropout 等)    model.eval()    # 11. 不计算梯度,加快推理并节省内存    with torch.no_grad():        # 12. 正向传播,得到 logits        y_pred = model(img)   # 修正:使用传入的 model,而不是全局 m1        # 13. 取概率最大的类别索引        pred_idx = y_pred.argmax(dim=-1).item()        # 14. 通过映射字典得到类别标签        label = idx2label.get(pred_idx, "未知")    return label# ========== Streamlit UI 部分 ==========def main():    # 设置页面标题    st.title("手势识别应用 - LeNet")    # 1. 检测并显示计算设备    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")    st.write(f"当前推理设备: {device}")    # 2. 加载模型(只加载一次,利用 Streamlit 的缓存机制)    @st.cache_resource    def load_model(device):        model = LeNet()                     # 实例化模型        model.to(device)                   # 移动到设备        # 加载训练好的权重(文件 lenet_best.pt 应位于当前目录)        state_dict = torch.load("lenet_best.pt", map_location=device)        model.load_state_dict(state_dict, strict=False)        model.eval()                       # 设为评估模式        return model    try:        model = load_model(device)        st.success("模型加载成功!")    except Exception as e:        st.error(f"模型加载失败: {e}")        st.stop()    # 3. 文件上传组件    uploaded_file = st.file_uploader("上传一张手势图片"type=["png""jpg""jpeg"])    if uploaded_file is not None:        # 3.1 将上传的文件保存为临时文件(便于传给 PIL 和 infer 函数)        temp_path = "temp_img.jpg"        with open(temp_path, "wb"as f:            f.write(uploaded_file.getvalue())        # 4. 显示上传的图片        img = Image.open(temp_path)        st.image(img, caption="您上传的图片", use_column_width=True)        # 5. 执行推理并显示结果        if st.button("开始识别"):            with st.spinner("识别中..."):                label = infer(temp_path, model, device, idx2label)            st.success(f"预测结果: **{label}**")        # 可选:推理完成后删除临时文件(此处省略)if __name__ == "__main__":    main()
  • 小结

1. 手势图像数据集的介绍与下载

2.深度学习的流程:

数据预处理->批量化数据打包->模型搭建->训练模型->模型评估->模型预测

以上就是我们这节课的全部内容了,这节课主要通过实战训练,回顾了一遍深度学习的流程,下节课我们继续通过手势图像来学习Vgg16和ResNet,希望大家和我一起共同学习、一起进步,欢迎点赞、转发,也请关注我的公众号。