手势识别数据集处理和模型训练文档

手势识别数据集处理和模型训练文档

数据预处理实现

图像预处理核心代码

1
2
3
4
5
6
7
8
9
10
11
12
def preprocess_image(image_path):
"""
图片预处理函数:
1. 读取图片并转灰度
2. resize到96x96
3. 归一化到[0,1]
"""
img = cv2.imread(image_path)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
resized = cv2.resize(gray, TARGET_SIZE)
normalized = resized.astype('float32') / 255.0
return normalized

这个预处理函数实现了多个关键功能:

  1. 使用cv2.imread读取图片时返回BGR格式,需要转换为灰度图。选择灰度图而不是彩色图有两个原因:首先,手势识别主要依赖形状特征而不是颜色信息;其次,单通道输入可以显著减少模型参数量和计算量。

  2. resize到96x96是在效果和性能间的平衡。过大的尺寸会增加计算量但边缘细节过多可能引入噪声,过小的尺寸会丢失重要特征。经过实验,96x96是一个比较好的平衡点。

  3. 归一化使用了float32而不是float64,这是深度学习中的最佳实践:

    • float32提供足够的精度
    • 相比float64可以节省一半的内存和带宽
    • 现代GPU对float32的计算做了优化

数据集构建与加载

1
2
3
4
5
6
7
8
9
10
11
12
13
class GestureDataset(Dataset):
def __init__(self, X, y, transform=None):
# 确保数据格式正确,添加通道维度
self.X = torch.FloatTensor(X).unsqueeze(1)
self.y = torch.LongTensor(y)
self.transform = transform

def __getitem__(self, idx):
image = self.X[idx]
label = self.y[idx]
if self.transform:
image = self.transform(image)
return image, label

这个Dataset类的设计体现了几个重要的技术考量:

  1. 继承PyTorch的Dataset类,这样可以利用PyTorch的DataLoader进行高效的数据加载:

    • 支持多进程加载,提高数据读取效率
    • 自动处理batch划分
    • 支持打乱数据顺序
    • 支持内存固定(pin_memory),提高GPU传输效率
  2. unsqueeze(1)操作在实现中非常关键:

    • 原始图片数据形状为(H,W)
    • CNN需要的输入形状是(C,H,W)
    • unsqueeze(1)在第1维度(通道维度)增加一个维度
    • 这样就从(H,W)变成了(1,H,W),符合CNN的输入要求
  3. transform的设计采用了可选参数模式:

    • 训练集需要数据增强,会传入transform
    • 验证集和测试集不需要数据增强,transform为None
    • 这种设计让一个Dataset类可以同时用于训练和评估

模型架构实现

倒残差块(Inverted Residual Block)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class InvertedResidual(nn.Module):
def __init__(self, in_c, out_c, stride, expand_ratio):
super().__init__()
hidden_dim = in_c * expand_ratio
self.use_res = stride == 1 and in_c == out_c

layers = []
if expand_ratio != 1:
# 升维卷积
layers.extend([
nn.Conv2d(in_c, hidden_dim, 1, bias=False),
nn.BatchNorm2d(hidden_dim),
nn.ReLU6(inplace=True)
])

layers.extend([
# 深度可分离卷积
nn.Conv2d(hidden_dim, hidden_dim, 3, stride, 1,
groups=hidden_dim, bias=False),
nn.BatchNorm2d(hidden_dim),
nn.ReLU6(inplace=True),
# 降维卷积
nn.Conv2d(hidden_dim, out_c, 1, bias=False),
nn.BatchNorm2d(out_c)
])
self.conv = nn.Sequential(*layers)

倒残差块是MobileNetV2中提出的创新结构,其设计包含多个精妙之处:

  1. expand_ratio控制特征通道的扩展:

    • 常规残差块是先降维再升维
    • 倒残差块反其道而行之,先升维再降维
    • 升维可以提供更大的特征空间,有利于特征提取
    • 主要计算量在中间层,通过分组卷积降低计算复杂度
  2. 深度可分离卷积的实现:

    • groups=hidden_dim使每个通道独立卷积
    • 相比常规3x3卷积可以降低9倍计算量
    • 在精度损失很小的情况下大幅提升效率
  3. bias=False的使用:

    • 卷积层后接BatchNorm时可以省略偏置项
    • BatchNorm会学习偏置的作用
    • 减少参数量并提供更好的正则化效果
  4. ReLU6的选择:

    • 相比普通ReLU,ReLU6在6处截断
    • 适合低精度量化部署
    • 有更好的数值稳定性

完整网络结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class LightGestureNet(nn.Module):
def __init__(self, num_classes=8):
super().__init__()

# 第一层卷积
self.first = nn.Sequential(
nn.Conv2d(1, 16, 3, 2, 1, bias=False),
nn.BatchNorm2d(16),
nn.ReLU6(inplace=True)
)

# 主干网络:4个倒残差块
self.layers = nn.Sequential(
InvertedResidual(16, 24, 2, 6), # stride=2
InvertedResidual(24, 24, 1, 6), # stride=1
InvertedResidual(24, 32, 2, 6), # stride=2
InvertedResidual(32, 32, 1, 6) # stride=1
)

# 分类头
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(32, num_classes)
)

网络整体架构经过精心设计,每个部分都有其特定的作用:

  1. 第一层卷积设计:

    • 使用stride=2直接下采样,减少后续计算量
    • 16个输出通道提供基础特征表达
    • 3x3卷积核在第一层提取基础纹理特征
    • padding=1保持特征图尺寸关系简单
  2. 主干网络的设计考量:

    • 采用4个倒残差块,深度适中
    • 通道数从16->24->32逐步上升,符合特征提取的规律
    • stride=2的块实现特征图尺寸降低
    • stride=1的块在相同尺度上精炼特征
    • expand_ratio=6提供了充足的特征变换空间
  3. 分类头的特殊设计:

    • 使用AdaptiveAvgPool2d自适应池化到1x1
    • 这种设计可以适应不同输入尺寸
    • 全局平均池化相比全连接层参数更少
    • 最后一层才使用全连接,减少过拟合风险

训练实现

优化器与学习率设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 优化器配置
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.CosineAnnealingLR(
optimizer,
T_max=num_epochs
)

# 自定义权重初始化
def weight_init(m):
if isinstance(m, nn.Conv2d):
init.kaiming_normal_(m.weight, mode='fan_out')
elif isinstance(m, nn.BatchNorm2d):
init.constant_(m.weight, 1)
init.constant_(m.bias, 0)
elif isinstance(m, nn.Linear):
init.xavier_normal_(m.weight)

model.apply(weight_init)

训练配置的每个选择都经过了仔细考虑:

  1. 优化器选择Adam的原因:

    • 自适应学习率,减少手动调整
    • 动量项帮助跨过局部最优
    • 对学习率不太敏感,便于调试
    • RMSprop和动量的结合提供了良好的收敛性
  2. 余弦退火学习率调度的优势:

    • 前期快速下降探索空间
    • 中期缓慢下降精细搜索
    • 后期小学习率精调模型
    • 周期性调整可能跳出局部最优
  3. 权重初始化的策略:

    • 卷积层用He初始化,适合ReLU激活函数
    • BatchNorm层的gamma初始化为1,beta初始化为0
    • 全连接层采用Xavier初始化,在该层更适合
    • mode=’fan_out’考虑了输出神经元数量

训练循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
for epoch in range(num_epochs):
model.train()
train_loss = 0
train_correct = 0

for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)

optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()

train_loss += loss.item()
_, predicted = outputs.max(1)
train_correct += predicted.eq(labels).sum().item()

# 更新学习率
scheduler.step()

# 验证
model.eval()
val_loss = 0
val_correct = 0

with torch.no_grad():
for images, labels in val_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
val_loss += loss.item()
_, predicted = outputs.max(1)
val_correct += predicted.eq(labels).sum().item()

训练循环实现了多个重要的训练技巧:

  1. 训练和验证的模式切换:

    • model.train()启用BatchNorm和Dropout
    • model.eval()关闭这些随机行为
    • 这对于获得稳定的验证结果很重要
  2. 梯度清零和更新:

    • optimizer.zero_grad()避免梯度累积
    • loss.backward()计算梯度
    • optimizer.step()更新参数
    • 这个顺序保证了正确的参数更新
  3. 验证阶段的优化:

    • with torch.no_grad()避免存储计算图
    • 减少了显存占用
    • 提高了验证速度
  4. 指标统计方法:

    • loss.item()只获取标量值
    • predicted.eq(labels)计算正确预测
    • sum().item()统计正确总数
    • 这些操作都很轻量,不影响训练速度

模型导出

ONNX导出

1
2
3
4
5
6
7
8
9
10
11
dummy_input = torch.randn(1, 1, 96, 96)
torch.onnx.export(model,
dummy_input,
'gesture_model.onnx',
input_names=['input'],
output_names=['output'],
dynamic_axes={
'input': {0: 'batch_size'},
'output': {0: 'batch_size'}
}
)

ONNX导出过程包含几个关键技术点:

  1. dummy_input的设置:

    • 形状必须匹配模型输入要求
    • 使用randn生成随机值
    • 第一维是batch维度
    • 单通道输入所以第二维是1
  2. dynamic_axes的作用:

    • 允许改变batch_size大小
    • 适应不同的推理场景
    • 其他维度保持固定
    • 有利于部署时的灵活性
  3. 命名规范:

    • input_names和output_names要有意义
    • 便于在不同框架中识别
    • 方便推理时的张量绑定

TFLite转换

1
2
3
4
5
6
7
8
9
# 转换为TFLite
converter = tf.lite.TFLiteConverter.from_saved_model('tf_gesture_model')
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.target_spec.supported_types = [tf.float32]
tflite_model = converter.convert()

# 保存模型
with open('gesture_model.tflite', 'wb') as f:
f.write(tflite_model)

TFLite转换涉及一些重要的优化设置:

  1. 优化级别选择:

    • DEFAULT优化是基础优化集合
    • 包括常量折叠、算子融合等
    • 在精度和性能间取得平衡
    • 适合大多数移动端场景
  2. 数据类型设置:

    • 使用float32保持精度
    • 避免量化带来的精度损失
    • 手势识别任务对精度敏感
    • 模型足够小,不需要量化压缩
  3. 部署考虑:

    • TFLite模型可直接在移动端使用
    • 支持CPU、GPU和NPU加速
    • 兼容Android和iOS平台
    • 便于集成到移动应用中

通过这样的导出配置,我们得到了一个既保持了原始精度,又适合移动端部署的模型。整个导出过程充分考虑了实际应用场景的需求,为后续的部署工作打下了良好的基础。


手势识别数据集处理和模型训练文档
https://blakehansen130.github.io/2024/11/29/gesture-recognition-code-docs/
发布于
2024年11月29日
许可协议