Touch 手写数字识别

[English]

Touch 原理与数据采集

Touch 原理

采用 ESP_Touch_Kit_Touchpad 作为 Touch 触摸板。该触摸板有 6*7 的 Touch 通道组成。

../_images/touch_kit_pad.png

Touch 触控板实物图

当手指在 Touch 触摸板上移动时,会改变 Touch 通道的电容值,这时候我们就可以通过检测电容值的变化来判断手指的位置。

检测算法

  1. 因为硬件差异,各个通道触发的最大值和最小值不同,所以需要对各个通道的电容值进行归一化处理。通过手指在上面滑动,来记录各个通道的最大值和最小值。

  2. 在手指滑动的过程中,会有一路通道的电容值变化率最大,这时候再取相邻变化率最大的通道,可以获取到两条相邻的通道。

  3. 将两个通道的值进行差比和运算,可以获取到手指在该方向上的相对坐标(较两通道中心点的偏移量)。

\[x = \frac{Fa - Fb}{Fa + Fb}\]
  1. 通过上述步骤,可以获取到手指在两个方向上的相对坐标,从而可以确定手指的位置。

备注

需要设定合适的触发阈值,来判断手指是否在 Touch 触摸板上移动。

  1. 当检测到手指抬起,即可将绘制的数据进行保存。

数据采集

由于在实际的 Touch 触控板绘制数字时,其最终的图像并非类似于 MNIST 数据集中的手写数字,导致直接使用 MNIST 数据集训练的模型在实际应用中效果不佳。因此,需要采集实际的 Touch 触控板绘制数字的数据集,并进行训练。

../_images/touch_hw_real_data.png

基于 Touch 触控板绘制的真实数据集

备注

经过插值后的手写数据图像尺寸为 30x25

点击此处下载本示例中使用的数据集:touch_dataset.zip

模型训练与部署

模型搭建

基于 PyTorch 框架,搭建适用于 Touch 手写数字的网络模型,结构如下:

class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.model = torch.nn.Sequential(
            torch.nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, stride=1, padding=1),
            torch.nn.ReLU(),
            torch.nn.MaxPool2d(kernel_size=2, stride=2),

            torch.nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, stride=1, padding=1),
            torch.nn.ReLU(),
            torch.nn.MaxPool2d(kernel_size=2, stride=2),

            torch.nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, stride=1, padding=1),
            torch.nn.ReLU(),

            torch.nn.Flatten(),
            torch.nn.Linear(in_features=7 * 6 * 64, out_features=256),
            torch.nn.ReLU(),
            torch.nn.Dropout(p=0.5),
            torch.nn.Linear(in_features=256, out_features=10),
            torch.nn.Softmax(dim=1)
        )

    def forward(self, x):
        output = self.model(x)
        return output

模型训练

模型的训练过程包括数据集的加载与预处理,模型训练参数的设置,模型训练过程的监控与保存。

数据加载与预处理

将不同数字对应的图像分别放置在 dataset/extra 文件夹中,以数字作为文件夹名称,并使用 transforms.Compose 对图像进行预处理,包括灰度化、随机旋转平移与标准化。随后使用 ImageFolder 加载整个数据集,并按 8:2 的比例划分为训练集与测试集,最后通过 DataLoader 构建批量加载器以供后续模型训练和评估使用。

import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms

transform = transforms.Compose([
    transforms.Grayscale(num_output_channels=1),
    transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)),
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,)),
])

dataset = datasets.ImageFolder(root='./dataset/extra', transform=transform)

train_size = int(0.8 * len(dataset))
test_size = len(dataset) - train_size
train_dataset, test_dataset = random_split(dataset, [train_size, test_size])

train_loader = DataLoader(dataset=train_dataset, batch_size=32, shuffle=True)
test_loader = DataLoader(dataset=test_dataset, batch_size=32, shuffle=False)

模型训练参数设定

模型训练参数包括学习率、优化器、损失函数等。实际训练过程中采用交叉熵作为损失函数,并使用 Adam 优化器进行模型参数的更新:

device = "cuda:0" if torch.cuda.is_available() else "cpu"
model = Net().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

模型训练与保存

设置训练轮数为 100 轮,并使用训练集和测试集分别进行训练和评估。训练过程中使用训练集进行模型参数的更新,并使用测试集进行模型性能的评估。训练结束后,将模型参数保存为 ./models/final_model.pth 文件。

def train_epoch(model, train_loader, criterion, optimizer, device):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0

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

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

        running_loss += loss.item()
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

    epoch_loss = running_loss / len(train_loader)
    epoch_acc = 100 * correct / total
    return epoch_loss, epoch_acc


def test_epoch(model, test_loader, criterion, device):
    model.eval()
    running_loss = 0.0
    correct = 0
    total = 0

    with torch.no_grad():
        for inputs, labels in test_loader:
            inputs, labels = inputs.to(device), labels.to(device)

            outputs = model(inputs)
            loss = criterion(outputs, labels)

            running_loss += loss.item()
            _, predicted = torch.max(outputs.data, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    epoch_loss = running_loss / len(test_loader)
    epoch_acc = 100 * correct / total
    return epoch_loss, epoch_acc

num_epochs = 100
train_acc_array = []
test_acc_array = []
for epoch in range(num_epochs):
    train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer, device)
    test_loss, test_acc = test_epoch(model, test_loader, criterion, device)

    print(f'Epoch [{epoch + 1}/{num_epochs}], '
        f'Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}%, '
        f'Test Loss: {test_loss:.4f}, Test Acc: {test_acc:.2f}%')
    train_acc_array.append(train_acc)
    test_acc_array.append(test_acc)

torch.save(model.state_dict(), './models/final_model.pth')

模型训练过程中,训练集与测试集的准确率变化曲线如下:

../_images/touch_train_acc.png

训练集与测试集的准确率变化曲线

模型部署

ESP-PPQ 环境配置

ESP-PPQ 是一种基于 ppq 的量化工具。请使用以下命令安装 ESP-PPQ

pip uninstall ppq
pip install git+https://github.com/espressif/esp-ppq.git

模型量化与部署

参考 How to quantize model 实现对模型量化与导出。若需要导出适用于 ESP32P4 的模型,请将 TARGET 设置为 esp32p4

import torch
from PIL import Image
from ppq.api import espdl_quantize_torch
from torch.utils.data import Dataset
from torch.utils.data import random_split
from torchvision import transforms, datasets

DEVICE = "cpu"

class FeatureOnlyDataset(Dataset):
    def __init__(self, original_dataset):
        self.features = []
        for item in original_dataset:
            self.features.append(item[0])

    def __len__(self):
        return len(self.features)

    def __getitem__(self, idx):
        return self.features[idx]


def collate_fn2(batch):
    features = torch.stack(batch)
    return features.to(DEVICE)


if __name__ == '__main__':
    BATCH_SIZE = 32
    INPUT_SHAPE = [1, 25, 30]
    TARGET = "esp32s3"
    NUM_OF_BITS = 8
    ESPDL_MODEL_PATH = "./s3/touch_recognition.espdl"

    transform = transforms.Compose([
        transforms.Grayscale(num_output_channels=1),
        transforms.ToTensor(),
        transforms.Normalize((0.5,), (0.5,)),
    ])

    dataset = datasets.ImageFolder(root="../dataset/extra", transform=transform)
    train_size = int(0.8 * len(dataset))
    test_size = len(dataset) - train_size
    train_dataset, test_dataset = random_split(dataset, [train_size, test_size])

    image = Image.open("../dataset/extra/9/20250225_140331.png").convert('L')
    input_tensor = transform(image).unsqueeze(0)
    print(input_tensor)

    feature_only_test_data = FeatureOnlyDataset(test_dataset)

    testDataLoader = torch.utils.data.DataLoader(dataset=feature_only_test_data, batch_size=BATCH_SIZE, shuffle=False,
                                                collate_fn=collate_fn2)

    model = Net().to(DEVICE)
    model.load_state_dict(torch.load("./final_model.pth", map_location=DEVICE))
    model.eval()

    quant_ppq_graph = espdl_quantize_torch(
        model=model,
        espdl_export_file=ESPDL_MODEL_PATH,
        calib_dataloader=testDataLoader,
        calib_steps=8,
        input_shape=[1] + INPUT_SHAPE,
        inputs=[input_tensor],
        target=TARGET,
        num_of_bits=NUM_OF_BITS,
        device=DEVICE,
        error_report=True,
        skip_export=False,
        export_test_values=True,
        verbose=1,
        dispatching_override=None
    )

为了便于调试模型,ESP-DL 提供了在量化期间添加测试数据并在 PC 端查看推理结果的功能。在上述过程中,image 被加载至 espdl_quantize_torch 中被用于测试。在模型转换结束后,测试数据的推理结果将在以 *.info 为后缀的文件中保存:

test outputs value:
%23, shape: [1, 10], exponents: [0],
value: array([9.85415445e-34, 1.92874989e-22, 7.46892081e-43, 1.60381094e-28,
    3.22134028e-27, 1.05306175e-20, 4.07960022e-41, 1.42516404e-21,
    2.38026637e-26, 1.00000000e+00, 0.00000000e+00, 0.00000000e+00],
    dtype=float32)

重要

在模型量化与部署过程中,请将 torch.utils.data.DataLoader 中的 shuffle 参数设置为 False

端侧推理

参考 How to load test profile modelHow to run model 实现模型的加载与推理。

需要注意的是,在本例中,Touch 驱动上报的按下与未按下的状态为 1 与 0,而模型的输入为标准化后的图像数据,因此需要对 Touch 驱动上报的数据进行预处理:

for (size_t i = 0; i < m_feature_size; i++) {
    int8_t value = (input_data[i] == 0 ? -1 : 1);
    quant_buffer[i] = dl::quantize<int8_t>((float)value, m_input_scale);
}

完整的项目工程请参考: ai/esp_dl/touchpad_digit_recognition