2026-06-05

基于图卷积网络的实时手势识别系统项目讲解

✍️ LiGuiyu & 桂鱼养的龙虾🦞
手势识别图卷积网络GCNMediaPipeCNN人机交互深度学习空中手写天目启航NUAAPyTorch特征工程

天目启航:基于图卷积网络的实时手势识别系统

🖐️ 摄像头 + 图神经网络 = 用手势控制电脑。这是我在南航「天目启航」专项中完成的实时手势识别项目——对着摄像头比手势,电脑就能实时识别并响应,还支持 Apple Vision Pro 风格的捏合空中书写。

项目代码:GestureInteractionSystem · 技术栈:Python + PyTorch + MediaPipe + OpenCV · 94K 参数 · 96.81% 识别准确率 · 30+ FPS 实时推理


这篇文章分两篇:上篇用大白话讲给完全不懂 AI 的人听,下篇用公式和推导讲给想深挖数学原理的人看。你可以按需跳读。


上篇 · 项目算法详解

本文档面向几乎不懂深度学习的读者,用通俗类比解释项目中每个算法和模型的原理、结构与运行逻辑。


1. 整体流程概览

摄像头画面
    │
    ▼
MediaPipe 手部检测 → 21个关键点的(x,y,z)坐标
    │
    ▼
1 Euro Filter → 消除抖动,让坐标丝滑
    │
    ▼
GCN 手势分类 → "这是 OK 手势"(10类选1)
    │
    ▼
屏幕显示 + 交互响应

如果你在书写,还会额外走一条路:右手捏合画出的轨迹 → 自适应裁剪 → CNN 识别 → "这是数字 3"。


2. MediaPipe:摄像头怎么"看到"手

MediaPipe 是 Google 开源的一个机器学习框架。它里面有一个专门的手部检测模型,叫 Hand Landmarker。

它的工作原理可以这样理解:

想象你有一张手的 X 光片。医生能在上面标出 21 个关键位置:手腕 1 个 + 每根手指 4 个关节 = 21 个点。MediaPipe 做的事情完全一样——只不过它不需要 X 光,只需要一张普通 RGB 摄像头的照片。

21 个关键点都是什么?

编号 名称 位置
0 WRIST 手腕
1-4 THUMB_* 拇指:根部→掌指关节→指间关节→指尖
5-8 INDEX_* 食指:根部→PIP→DIP→指尖
9-12 MIDDLE_* 中指:同上
13-16 RING_* 无名指
17-20 PINKY_* 小指

每个点的输出是三个数字:(x, y, z)。x 和 y 是画面中的位置(0 到 1 之间,1 表示画面最右/最下),z 是相对于手腕的深度。

为什么用 MediaPipe 而不是自己训练?

训练一个手部检测模型需要几万张标注好的手部图片。MediaPipe 已经在数百万张图片上训练好了,直接拿来用就行——这叫做"预训练模型"。


3. 1 Euro Filter:让抖动消失的魔法

问题: MediaPipe 每帧独立检测,相邻帧之间同一根手指的位置会有微小跳动(1-2 像素的随机抖动)。画面上看起来就像手在"颤抖"。

解决思路: 不是直接相信 MediaPipe 给的坐标,而是把它和"上一帧的坐标"做个加权平均——这个操作叫做"滤波"。

1 Euro Filter 的聪明之处:

普通的均值滤波有一个问题:如果手在快速移动,加权平均会让检测结果"跟不上"真实的手——产生拖影。

1 Euro Filter 通过一个巧妙的设计解决这个问题:

  • 静止时 → 截止频率低 → 强平滑 → 彻底消除抖动
  • 快速移动时 → 截止频率自动升高 → 弱平滑 → 紧跟真实手的位置

它只靠两个参数控制这个行为:

  • min_cutoff(最小截止频率):手静止时的平滑强度。值越小越平滑,但太大会有延迟。
  • beta(速度系数):控制"多快的运动算快"。值越大对手速越敏感。

我们项目里设 min_cutoff=6.5, beta=0.002,经验证明这个组合在丝滑和跟手之间取得了很好的平衡。

为什么左右手各一套滤波器?

如果左右手共用一套滤波器,那么左手的坐标变化会"污染"右手的滤波器状态,导致右手的位置跳到左手附近。左右手独立滤波 = 互不干扰。


4. GCN 手势分类模型

4.1 图是什么

图(Graph)由两种东西组成:

  • 节点(Node):一个个独立的对象。在手中,就是 21 个关键点。
  • 边(Edge):节点之间的连接线。在手中,就是骨骼连接(比如手腕连到食指根部)。

普通的神经网络(比如 CNN)处理的是"网格状"的数据——图片就是像素排列成的规则网格。但手的 21 个点不是规则排列的——它们通过骨骼连接形成一个"树状"结构。用一个方形的卷积核去扫这些不规则排列的点,就像用棋盘去下围棋——工具和数据不匹配。

图卷积网络(GCN)就是专门为这种"图结构数据"设计的神经网络。它的核心思想是:每个节点的新特征 = 它邻居节点特征的加权平均

4.2 图卷积:邻居之间互相传话

想象 21 个同学围坐在一个教室里,每个人只知道自己的位置(x, y, z 坐标)。老师让大家做一个手势,然后要求每个人猜"现在大家做的是什么手势"。

第一轮(第一层 GCN):每个人跟直接相邻的同学(通过骨骼连接的邻居)交流一下——"你的手指是弯的还是直的?""你的位置在哪?"。交流完后,每个人不仅知道自己的状态,还大致知道邻居的状态。

第二轮(第二层 GCN):再次交流。这一次,"邻居"已经包含了邻居的邻居的信息。所以每个人能感知到两跳以外的关节状态。

第三轮(第三层 GCN):继续交流。现在每个节点已经能感知到整只手的姿态了——指尖节点"知道"手腕在干什么,手腕节点"知道"小指在干什么。

这个过程用数学表达就是图卷积公式

新特征 = 激活函数(归一化邻接矩阵 × 旧特征 × 权重矩阵)
  • 归一化邻接矩阵:决定了"谁跟谁交流"以及"交流时每个人说话的分量"(度数高的节点分到更小的权重,避免信息被稀释)
  • 权重矩阵:神经网络需要学习的参数——"交流时重点听哪些信息"
  • 激活函数:给网络加入非线性能力,让它能表达"如果食指直且中指弯且拇指成圈 → 这是 OK"

4.3 输入特征详解

每个手部关键点不只有 (x, y, z) 坐标。我们额外加了 6 个维度的信息:

骨骼向量(4 维)

对于每条骨骼连接(比如从手腕到食指根部),计算:

  • 方向向量 (dx, dy, dz):这根骨头指向哪个方向?这是一个单位向量(长度为 1),编码了方向信息。
  • 长度 len:这根骨头有多长?长度可以反映手离摄像头的远近。

每个节点收集以它为 dst(目标节点)的骨骼边信息,取平均(scatter-mean)。比如食指根部(index_mcp,节点5)只收到来自手腕 (0,5) 的一条骨骼边;食指 PIP(index_pip,节点6)才收到来自食指根部的 (5,6) 边信息。

关节角度(1 维)

在手指弯曲的地方(比如食指 PIP 关节),有两条相邻的骨骼边(根部→PIP 和 PIP→DIP)。这两条边之间的夹角直接反映了手指的弯曲程度。

我们计算夹角的余弦值:cos(θ) = (u·v) / (|u|×|v|)

  • cos ≈ 1:两条骨骼方向几乎一致 → 手指伸直
  • cos ≈ 0:两条骨骼垂直 → 手指弯了 90°
  • cos ≈ -1:两条骨骼反向 → 手指完全折过来了

余弦值的最大优点是完全不受手旋转的影响。无论手怎么转,食指弯 90° 的 cos 值始终接近 0。这就是"旋转不变性"。

每个节点收集以它为目标节点的骨骼边信息(scatter-mean 到 dst 方向),取平均作为该节点的骨骼特征。例如节点 5(食指根部/index_mcp)只收到来自手腕 (0,5) 的一条骨骼边输入,而节点 6(食指 PIP/index_pip)则收到来自食指根部的 (5,6) 边信息。

4.4 残差连接:防止"传话传到忘词"

GCN 有一个天然的短板叫"过平滑":随着层数加深,所有节点的特征会越来越像——因为每轮都在取邻居的平均。层数多了以后,每个节点都变成了"全班平均",失去了自己的个性。

残差连接的解法非常简单:

本层输出 = 图卷积(输入) + 输入本身

也就是说,在做完邻居交流之后,还要把交流之前的"自己"加回去。这样即使经过了多层,节点仍然保留了一部分最初的原始信息。

这就像是:每轮交流完后,每个人在笔记本上写"我学到了什么新东西",但同时保留上一轮的笔记。几轮下来,笔记越来越厚,但最初的观察永远不会丢失。

4.5 完整模型结构

输入: 每节点 9 维特征 [xyz(3) + 手标签(1) + 骨骼(4) + 角度(1)]
         │
    ┌────▼────┐
    │ GCN 层1 │  9 → 160 维  (升维,提取丰富特征)
    │ +LayerNorm + ReLU + Dropout
    └────┬────┘
         │
    ┌────▼────┐
    │ 残差块1 │  160 → 160  (邻居交流 + 加上自己)
    │ LN→GCN→ReLU→Drop + 自身
    └────┬────┘
         │
    ┌────▼────┐
    │ 残差块2 │  160 → 160
    └────┬────┘
         │
    ┌────▼────┐
    │ 残差块3 │  160 → 160
    └────┬────┘
         │
    全局平均池化  (把 21 个节点的特征取平均 → 1 个 160 维向量)
         │
    Linear(160→80) + ReLU + Dropout
         │
    Linear(80→10)    (输出 10 个数字,每个代表一种手势的"得分")
         │
    softmax → 概率分布  (比如 "OK: 87%, 点赞: 8%, ...")

关键数字: 总参数约 94,000 个,单帧推理约 0.15 毫秒。


5. CNN 数字识别模型

手势分类用 GCN,但空中手写的数字识别用的是一种更经典的网络:卷积神经网络(CNN)。

5.1 卷积:滑动的小窗口

想象你在看一张 32×32 像素的数字图片。你怎么判断它是"3"还是"8"?

你可能会先扫描它的边缘(看有没有弧线),再看它的拐角(看有没有闭合的圈),最后综合这些局部特征做出判断。

CNN 中的"卷积"就是模拟这个过程:

  • 一个 3×3 的小窗口(叫"卷积核"或"滤波器")在图片上滑动
  • 每滑到一个位置,窗口内的 9 个像素与卷积核的 9 个权重相乘并求和
  • 这个和代表"这个位置有没有我要找的特征"

一个卷积核专门找一种特征(比如"横线"),32 个卷积核就能同时找 32 种不同的特征(横线、竖线、斜线、弧线、拐角...)。

为什么叫"卷积"? 因为它是一种带权重的滑动窗口操作,数学上叫卷积运算。但在直觉层面,理解成"扫描仪在图片上找特征"就够了。

5.2 池化:压缩但不丢信息

卷积之后,图片尺寸还是很大。池化(Pooling)用来压缩尺寸:

  • 把相邻的 2×2 区域合并成 1 个像素
  • 取最大值(MaxPooling)或平均值(AvgPooling)
  • 尺寸减半,但保留了最大/平均的特征响应

这就像把一本书的每一页拍成照片:你不需要每个字的超高清细节,只要能看清字的大致形状就够了。

经过 3 轮"卷积→池化":

32×32 → (Conv1+Pool) → 16×16 → (Conv2+Pool) → 8×8 → (Conv3+Pool) → 4×4

最后 4×4×128=2048 个数字被展开成一长条,经过全连接层映射到 10 个数字类别。

5.3 完整模型结构

输入: 32×32×3 的图片(灰度图复制为 RGB 三通道)
         │
    ┌────▼────┐
    │ Conv1   │  3→32 个滤波器, 3×3
    │ +BatchNorm + ReLU + MaxPool(2×2)  → 16×16×32
    └────┬────┘
         │
    ┌────▼────┐
    │ Conv2   │  32→64 个滤波器, 3×3
    │ +BN + ReLU + MaxPool(2×2)  → 8×8×64
    └────┬────┘
         │
    ┌────▼────┐
    │ Conv3   │  64→128 个滤波器, 3×3
    │ +BN + ReLU + MaxPool(2×2)  → 4×4×128
    └────┬────┘
         │
    展开为 2048 维向量
         │
    Linear(2048→256) + ReLU + Dropout
         │
    Linear(256→10)
         │
    softmax → "数字 3: 99%"

关键数字: 约 621,000 个参数。QMNIST 数据集(6 万张手写数字图片)训练,识别准确率 99.31%。


6. EMA 时序平滑:手势不再闪烁

问题: 摄像头每秒钟处理约 30 帧。MediaPipe 每帧独立检测 → GCN 每帧独立分类。由于检测噪声,连续两帧的分类结果可能从"OK"跳到"点赞"再跳回来。画面上显示的手势标签就会疯狂闪烁。

解决方案:指数移动平均(EMA)

不是直接显示当前帧的分类结果,而是:

显示的概率 = 75% × 当前帧概率 + 25% × 上一帧的显示概率

这意味着:

  • 如果连续 10 帧都是"OK",显示的概率逐步累积到接近 100%
  • 如果某帧突然跳成"点赞"(噪声),显示的概率只会轻微波动,不会跳变
  • 如果手势真的变了(连续多帧都是新手势),显示会平滑地过渡到新手势

为什么系数是 0.75? 这个选择意味着当前帧占 75% 权重,历史占 25%。EMA 的半衰期约 0.5 帧($k = \log 0.5 / \log 0.25$),即历史权重极快地衰减。0.75 在"更相信当前帧"和"保留一点历史平滑"之间取得了平衡。太高(比如 0.99)会导致响应迟钝——手势换了但显示还是旧的。太低(比如 0.3)会导致滤波效果太弱——闪烁依旧。


7. 训练过程详解

7.1 什么是"训练"

"训练"可以类比为学生做练习题:

  1. 模型 = 学生:他有一套可调整的"知识参数"(GCN 的权重矩阵)。
  2. 训练数据 = 练习题:每道题是一帧手部关键点坐标,标签是正确的手势名称。
  3. 前向传播 = 做题:学生读题 → 用当前知识参数计算 → 给出答案。
  4. 损失函数 = 老师打分:比较学生的答案和正确答案,给出一个"错误分数"。
  5. 反向传播 = 改错:根据"错误分数"往回追溯,调整每层参数,让下一次答对。

重复步骤 3-5 几千次,错误分数越来越低 → 学生的知识参数越来越准 → 模型训练完成。

7.2 损失函数:老师打分

我们用的损失函数叫交叉熵损失(Cross-Entropy Loss)

假设模型预测:[OK: 0.7, 点赞: 0.2, 拳头: 0.1],正确答案是 OK。

交叉熵损失的核心逻辑:

  • 如果模型把正确答案的分数打得高(0.7 → 不错),损失较小
  • 如果模型把正确答案的分数打得低(0.1 → 惨),损失很大
  • 如果模型极度确信错误答案(其他 0.99, OK 0.01),损失爆炸

我们还加了标签平滑(Label Smoothing, ε=0.05):不是让正确答案硬卡在 1.0,而是让它"接近 1.0 但留一点裕量"。这防止模型"过度自信"——在训练数据上拿满分,但遇到没见过的数据就懵了。

7.3 优化器:学生改错

我们用的优化器叫 AdamW

这个名字不需要记。只需要知道它做了什么:

  • 每个参数有自己的"学习速度"——容易调的就大步走,难调的就小步走
  • 带"动量"——像滚雪球一样,如果连续多次往同一个方向调整,就加速
  • Weight Decay:每轮训练后,所有参数略微缩小一点(×0.99999),防止某些参数"膨胀"得太厉害

7.4 学习率调度:从大步到小步

学习率(Learning Rate)控制每次改错时"步子迈多大"。

  • 训练初期(第 1-10 轮):步子大(lr=2e-3),快速靠近正确答案。
  • 训练中期(第 11-50 轮):验证准确率不再明显提升 → 步子减半。
  • 训练后期(第 50+ 轮):继续减半,精雕细琢。

我们用的 ReduceLROnPlateau 策略自动完成这个过程:监控验证损失,25 轮不降就自动把学习率打五折。

7.5 数据增强:一本变十本

我们只有 15,990 帧手部关键点数据。如果只练这 15,990 遍,模型会过于依赖这些特定帧的特征。

数据增强的做法是:每次喂给模型的数据,都做微小的随机变换。

  • 绕 z 轴旋转 ±5°:模拟手轻微倾斜
  • 缩放 ±3%:模拟手离摄像头远近稍变
  • 加微小高斯噪声:模拟摄像头画质波动

因为这些变换不改变"手势是什么"——一个旋转了 3° 的 OK 手势仍然是 OK 手势——所以标签不变。

这样一来,虽然原始数据只有 15,990 帧,但每轮训练模型看到的都是'略有不同'的版本,等于无形中把数据集扩大了无数倍。


8. 关键概念速查表

概念 一句话解释 在我们项目里的作用
MediaPipe Google 的预训练手部检测模型 从摄像头画面提取 21 个关键点坐标
图卷积 (GCN) 每个节点聚合邻居信息来更新自己 识别手势类别(OK/点赞/拳头...)
卷积 (CNN) 滑动窗口扫描图片找特征 识别空中手写的数字(3/8/...)
1 Euro Filter 自适应低通滤波器 消除手骨架的像素级抖动
EMA 概率向量的指数移动平均 防止手势标签闪烁
残差连接 每层输出 = 图卷积结果 + 原始输入 防止网络加深后信息丢失
Dropout 训练时随机关闭一部分神经元 防止模型死记硬背(过拟合)
LayerNorm 把每层输出标准化到同一尺度 让训练更稳定、收敛更快
交叉熵损失 衡量"模型预测"和"真实标签"的差距 告诉模型哪里错了、错得多严重
AdamW 自适应学习率的优化器 高效地调整模型参数
学习率调度 训练后期自动减小学习率 从"大步快跑"过渡到"小步精调"
数据增强 给数据加微小随机变换 用有限数据训练出更好的泛化能力
过拟合 模型在训练数据上表现好但新数据上表现差 用 Dropout、Weight Decay、数据增强对抗
早停 (Early Stop) 验证准确率不再提升就停止训练 避免无效训练和过拟合

下篇 · 项目算法数学原理详解

本文档深入讲解项目中所有涉及到的数学公式、算法原理和推导过程。面向希望理解底层数学机制的读者。


1. 图卷积网络 (GCN)

1.1 图的数学定义

一个图 $\mathcal{G}$ 由节点集合 $\mathcal{V}$ 和边集合 $\mathcal{E}$ 组成:

$$\mathcal{G} = (\mathcal{V}, \mathcal{E})$$

在手部关键点图中:

  • $|\mathcal{V}| = N = 21$(21 个关键点)
  • $|\mathcal{E}| = 21$(21 条骨骼边)
  • 每条边 $(u, v) \in \mathcal{E}$ 表示节点 $u$ 和 $v$ 之间存在骨骼连接

1.2 邻接矩阵

邻接矩阵 $A \in \mathbb{R}^{N \times N}$ 编码了图的连接关系:

$$A_{ij} = \begin{cases} 1 & \text{如果节点 } i \text{ 和 } j \text{ 之间存在骨骼边} \\ 0 & \text{否则} \end{cases}$$

由于骨骼连接是无向的(如果 $i$ 连到 $j$,那么 $j$ 也连到 $i$),$A$ 是对称矩阵:$A_{ij} = A_{ji}$。

加入自环后的邻接矩阵:

$$\hat{A} = A + I_N$$

其中 $I_N$ 是 $N \times N$ 的单位矩阵。自环确保每个节点在消息传递时也保留自己的信息。

1.3 度矩阵

节点 $i$ 的度 $d_i$ 是与它相连的边的数量(包括自环):

$$\hat{D}{ii} = \sum{j=1}^{N} \hat{A}_{ij}$$

度矩阵 $\hat{D}$ 是对角矩阵,非对角元素为 0。例如手腕节点连接了 5 条骨骼,它的 $d_i$ 较大;指尖节点只连接 1 条骨骼,$d_i$ 较小。

1.4 对称归一化与图卷积公式

图卷积层的核心公式:

$$H^{(l+1)} = \sigma\left(\hat{D}^{-\frac{1}{2}} \hat{A} \hat{D}^{-\frac{1}{2}} H^{(l)} W^{(l)}\right)$$

逐项分解:

$H^{(l)} \in \mathbb{R}^{N \times d_l}$ — 第 $l$ 层的节点特征矩阵。$N=21$ 行对应 21 个节点,$d_l$ 列对应每个节点的特征维度。

  • 输入层 $l=0$:$H^{(0)}$ 是原始 9 维特征(坐标+手标签+骨骼向量+角度)

$W^{(l)} \in \mathbb{R}^{d_l \times d_{l+1}}$ — 可学习的权重矩阵。它将每个节点的 $d_l$ 维特征线性变换为 $d_{l+1}$ 维。例如第一层 $W^{(0)} \in \mathbb{R}^{9 \times 160}$。

$\hat{D}^{-\frac{1}{2}} \hat{A} \hat{D}^{-\frac{1}{2}}$ — 对称归一化的邻接矩阵。这个操作的物理意义是"按度数加权平均邻居特征"。

为什么需要归一化? 如果不归一化,高度数节点(如手腕,连接 5 条边)聚合了来自 5 个邻居的信息后,特征值的量级会比低度数节点(如指尖,连接 1 条边)大很多。归一化使每个邻居的贡献除以 $\sqrt{d_i \cdot d_j}$,补偿了度数差异。

逐元素展开归一化邻接矩阵:

$$\left(\hat{D}^{-\frac{1}{2}} \hat{A} \hat{D}^{-\frac{1}{2}}\right){ij} = \frac{\hat{A}{ij}}{\sqrt{\hat{D}{ii} \cdot \hat{D}{jj}}}$$

$H^{(l)} W^{(l)}$ — 线性变换:将每个节点的特征从 $d_l$ 维投影到 $d_{l+1}$ 维。

乘法顺序解析 $(N \times d_l) \times (d_l \times d_{l+1}) = (N \times d_{l+1})$。

$\hat{D}^{-\frac{1}{2}} \hat{A} \hat{D}^{-\frac{1}{2}} (H^{(l)} W^{(l)})$ — 邻居聚合: 对于节点 $i$,其新特征的每一维是所有邻居(包括自己)对应维度的加权平均:

$$(\text{GCN}(H))i = \frac{1}{\sqrt{\hat{D}{ii}}} \sum_{j \in \mathcal{N}(i) \cup {i}} \frac{1}{\sqrt{\hat{D}_{jj}}} (H_j W)$$

其中 $\mathcal{N}(i)$ 是 $i$ 的邻居集合。

$\sigma(\cdot)$ — 非线性激活函数。我们使用 ReLU:$\sigma(x) = \max(0, x)$。没有非线性的话,多层 GCN 等价于单层(矩阵乘法满足结合律,多层线性变换退化为一个线性变换)。

1.5 多层 GCN 的感受野

第 $k$ 层 GCN 之后,每个节点聚合了其 $k$ 跳范围内所有邻居的信息:

  • 第 1 层:1 跳邻居(直接连接的关节)
  • 第 2 层:2 跳邻居(邻居的邻居)
  • 第 3 层:3 跳邻居 → 指尖能感知到手腕的信息

本项目的 4 层 GCN(1 层输入投影 + 3 层残差块)意味着每个节点最多能感知 4 跳范围内的信息,覆盖整只手的全部 21 个节点。

1.6 过平滑问题与残差连接

过平滑:随着 GCN 层数增加,所有节点的特征趋同——因为每层都是邻居的加权平均。极限情况下,所有 21 个节点的特征变成完全一样。

残差连接的数学形式

$$H_{\text{out}} = \text{GCN}(\text{LayerNorm}(H_{\text{in}})) + H_{\text{in}}$$

残差块内部流程:$\text{LayerNorm} \to \text{GCN} \to \text{ReLU} \to \text{Dropout} \to \text{加残差输入}$

残差连接的作用:即使 GCN 层的输出因过度平均而趋同,原始输入 $H_{\text{in}}$ 仍然保留了每个节点的独立特征。这使网络可以在"邻居信息"和"自身原始信息"之间取得平衡。

1.7 LayerNorm

LayerNorm 对每个节点的特征向量独立进行标准化:

$$\text{LayerNorm}(h_i) = \gamma \cdot \frac{h_i - \mu_i}{\sigma_i} + \beta$$

其中 $\mu_i = \frac{1}{d}\sum_{k=1}^{d} h_{i,k}$(节点 $i$ 的特征均值),$\sigma_i = \sqrt{\frac{1}{d}\sum_{k=1}^{d}(h_{i,k} - \mu_i)^2}$(标准差),$\gamma$ 和 $\beta$ 是可学习的缩放和平移参数。

与 BatchNorm 的区别:BatchNorm 对同一特征维度跨 batch 标准化,LayerNorm 对同一个样本的所有特征标准化。在 GCN 中,LayerNorm 更合适,因为不同节点的特征分布可能差异很大(手腕 vs 指尖)。


2. 特征工程

2.1 每轴独立归一化

原始坐标 $(x, y, z)$ 的问题:

  • $x, y \in [0, 1]$(图像坐标系)
  • $z$ 的量级远小于 $x, y$(手腕为参考原点,z ≈ -0.05 到 +0.05)
  • 手在不同位置时同一手势的 $x, y$ 绝对值不同

归一化步骤:

先将所有坐标平移到以手腕为原点:

$$\tilde{p}_i = p_i - p_0 \quad (i = 0, 1, \ldots, 20)$$

然后对每个坐标轴独立除以自身的跨度:

$$\tilde{p}_i^{(k)} = \frac{\tilde{p}_i^{(k)}}{\max_j(\tilde{p}_j^{(k)}) - \min_j(\tilde{p}_j^{(k)})}, \quad k \in {x, y, z}$$

为什么每轴独立归一化比 3D 归一化好?

3D 归一化(除以手腕到中指尖的欧氏距离 $|p_{12} - p_0|_2$)的问题:

$$|p_{12} - p_0|2 = \sqrt{(x{12}-x_0)^2 + (y_{12}-y_0)^2 + (z_{12}-z_0)^2}$$

由于 $(x_{12}-x_0)^2 + (y_{12}-y_0)^2 \gg (z_{12}-z_0)^2$(z 轴跨度仅为 x 轴的 40-50%),3D 范数由 x, y 主导。用这样的范数除 z 轴分量,z 值被压缩到接近 0,模型几乎无法利用深度信息。

每轴独立归一化后,三个维度的量级相当,均位于 $[-1, 1]$ 区间。

2.2 骨骼向量特征

对每条骨骼边 $(u, v)$,计算:

方向单位向量:

$$\vec{d}_{uv} = \frac{p_v - p_u}{|p_v - p_u|_2} = \left(\frac{x_v-x_u}{l}, \frac{y_v-y_u}{l}, \frac{z_v-z_u}{l}\right)$$

骨骼长度:

$$l_{uv} = |p_v - p_u|_2$$

骨骼特征向量:

$$b_{uv} = [d_{uv}^{(x)}, d_{uv}^{(y)}, d_{uv}^{(z)}, l_{uv}] \in \mathbb{R}^4$$

节点聚合(Scatter-Mean):

每个节点 $i$ 收集所有以 $i$ 为目标节点的入边骨骼特征,取平均:

$$b_i = \frac{1}{|\mathcal{E}{\to i}|} \sum{(u, i) \in \mathcal{E}} b_{ui}$$

其中 $\mathcal{E}_{\to i}$ 是所有指向 $i$ 的边的集合。

为什么骨骼特征有用? 骨骼方向向量直接编码了每根手指的朝向,骨骼长度编码了手的尺度。这使得模型即使在不归一化坐标的情况下也能感知手的三维姿态。

2.3 关节角度特征

对于两个连续的骨骼边 $\vec{b}{ab} = p_b - p_a$ 和 $\vec{b}{bc} = p_c - p_b$,它们的夹角余弦值为:

$$\cos(\theta) = \frac{\vec{b}{ab} \cdot \vec{b}{bc}}{|\vec{b}{ab}| \cdot |\vec{b}{bc}|}$$

余弦值的物理意义:

$\cos(\theta)$ 夹角 手指状态
$\approx 1$ $\approx 0°$ 完全伸直
$\approx 0$ $\approx 90°$ 弯曲 90°
$\approx -1$ $\approx 180°$ 完全折叠

旋转不变性的数学证明:

设有一个三维旋转矩阵 $R \in SO(3)$,$R^T R = I$,$\det(R) = 1$。旋转后的骨骼向量为 $R\vec{b}{ab}$ 和 $R\vec{b}{bc}$。旋转后的余弦值为:

$$\cos(\theta_R) = \frac{(R\vec{b}{ab}) \cdot (R\vec{b}{bc})}{|R\vec{b}{ab}| \cdot |R\vec{b}{bc}|} = \frac{\vec{b}{ab}^T R^T R \vec{b}{bc}}{|\vec{b}{ab}| \cdot |\vec{b}{bc}|} = \frac{\vec{b}{ab} \cdot \vec{b}{bc}}{|\vec{b}{ab}| \cdot |\vec{b}{bc}|} = \cos(\theta)$$

因此关节角度的余弦值在任意旋转下保持不变,是一个真正的旋转不变量。

本项目计算的 11 个关节:

关节 骨骼边对 说明
拇指 MCP (1→2, 2→3) thumb_cmc → thumb_mcp → thumb_ip
拇指 IP (2→3, 3→4) thumb_mcp → thumb_ip → thumb_tip
食指 PIP (5→6, 6→7) index_mcp → index_pip → index_dip
食指 DIP (6→7, 7→8) index_pip → index_dip → index_tip
中指 PIP (9→10, 10→11) 同上模式
中指 DIP (10→11, 11→12)
无名指 PIP (13→14, 14→15)
无名指 DIP (14→15, 15→16)
小指 PIP (17→18, 18→19)
小指 DIP (18→19, 19→20)
食指-中指分叉 (0→5, 0→9) wrist→index_mcp, wrist→middle_mcp

3. 1 Euro Filter 自适应滤波

3.1 问题定义

MediaPipe 输出的关键点坐标 $x_t$ 包含检测噪声:

$$x_t = \hat{x}_t + \varepsilon_t$$

其中 $\hat{x}_t$ 是真实坐标,$\varepsilon_t \sim \mathcal{N}(0, \sigma^2)$ 是高斯噪声。

3.2 一阶低通滤波

标准的一阶低通滤波器:

$$\hat{x}t = \hat{x}{t-1} + \alpha(x_t - \hat{x}_{t-1})$$

其中 $\alpha \in (0, 1)$ 是平滑系数:

  • $\alpha \to 1$:几乎完全相信新观测 → 响应快但噪声大
  • $\alpha \to 0$:几乎完全依赖历史 → 丝滑但延迟大

3.3 自适应截止频率

1 Euro Filter 的核心创新:根据信号速度动态调整 $\alpha$。

首先计算平滑后的速度估计:

$$\dot{\hat{x}}t = \frac{x_t - \hat{x}{t-1}}{\Delta t}$$

其中 $\Delta t$ 是帧间时间间隔。

然后对速度做指数平滑(偏向新观测,系数 0.6/0.4):

$$\dot{x}_t^{\text{smooth}} = 0.6 \cdot \dot{\hat{x}}t + 0.4 \cdot \dot{x}{t-1}^{\text{smooth}}$$

自适应截止频率:

$$f_c = f_{c_{\min}} + \beta \cdot |\dot{x}_t^{\text{smooth}}|$$

  • $f_{c_{\min}}$:最小截止频率(静止时的平滑强度)。本项目设为 6.5 Hz。
  • $\beta$:速度系数(控制对速度的敏感度)。本项目设为 0.002。

$f_c$ 的含义:

  • 手静止时,$|\dot{x}| \approx 0$,$f_c \approx f_{c_{\min}} = 6.5$ Hz → 中等平滑
  • 手快速移动时,$|\dot{x}|$ 增大,$f_c$ 升高 → 弱平滑,跟随更快

3.4 平滑系数的计算

从截止频率推导平滑系数:

$$\tau = \frac{1}{2\pi f_c}$$

$$\alpha = \frac{1}{1 + \tau / \Delta t}$$

代入:

$$\alpha = \frac{1}{1 + \frac{1}{2\pi f_c \Delta t}}$$

直觉解释:

  • $f_c$ 大 → $\tau$ 小 → $\alpha \to 1$ → 相信新观测
  • $f_c$ 小 → $\tau$ 大 → $\alpha \to 0$ → 依赖历史

3.5 滤波器状态重置

当手短暂消失后重新出现(如从画面边缘进入),滤波器状态携带了旧位置的惯性,会导致新位置出现"拖影"。解决方法是当检测到手从"不存在"变为"存在"时,调用 reset() 清空滤波器的历史状态:

def reset(self):
    self.x_prev = None
    self.dx_prev = None
    self.t_prev = None

4. 指数移动平均 (EMA)

4.1 定义

对 GCN 输出的概率向量 $\mathbf{p}_t \in \mathbb{R}^{10}$ 做跨帧平滑:

$$\mathbf{p}_t^{\text{EMA}} = \alpha \cdot \mathbf{p}t + (1-\alpha) \cdot \mathbf{p}{t-1}^{\text{EMA}}$$

其中 $\alpha = 0.75$。

4.2 展开形式

将递推公式展开可以看出,EMA 是对历史所有帧的指数加权平均:

$$\mathbf{p}t^{\text{EMA}} = \alpha \sum{k=0}^{t} (1-\alpha)^k \mathbf{p}_{t-k}$$

权重随帧间隔指数衰减:$t$ 时刻的权重为 $\alpha = 0.75$,$t-1$ 时刻为 $0.75 \times 0.25 = 0.1875$,$t-2$ 时刻为 $0.75 \times 0.25^2 = 0.0469$……

为什么选 $\alpha=0.75$?

$\alpha$ 当前帧权重 半衰期(权重衰减至一半的帧数) 效果
0.9 90% ~0.1 帧 几乎无平滑
0.75 75% ~0.5 帧 适中(本项目)
0.5 50% ~1 帧 较强平滑
0.3 30% ~2 帧 强平滑但响应慢

半衰期由 $(1-\alpha)^k = 0.5$ 解出:$k = \log 0.5 / \log 0.25 = 0.5$ 帧。$\alpha=0.75$ 意味着半衰期约 0.5 帧(约 17ms),当前帧权重远高于历史帧(3:1),在平滑和响应速度之间取得了平衡。


5. 卷积神经网络 (CNN)

5.1 二维卷积的数学定义

对于一个输入图像 $X \in \mathbb{R}^{H \times W \times C}$ 和一个卷积核 $K \in \mathbb{R}^{k \times k \times C}$,二维卷积的输出为:

$$(X * K){i,j} = \sum{a=0}^{k-1} \sum_{b=0}^{k-1} \sum_{c=0}^{C-1} X_{i+a, j+b, c} \cdot K_{a,b,c}$$

本项目使用 $k=3$(3×3 卷积核),padding=1(边缘补零),stride=1。

多通道卷积: 如果有 $C_{\text{out}}$ 个输出通道,就有 $C_{\text{out}}$ 组卷积核,每组包含 $C_{\text{in}}$ 个 $3 \times 3$ 的核。

第一层:$3 \to 32$ 通道 = 32 组滤波器,每组 3 个 $3 \times 3$ 核 = $32 \times 3 \times 9 = 864$ 个参数。

5.2 最大池化

2×2 最大池化:

$$\text{MaxPool}(X){i,j} = \max{a,b \in {0,1}} X_{2i+a, 2j+b}$$

池化的作用:

  1. 降维:尺寸减半,参数量减少 4 倍
  2. 平移不变性:2×2 区域内的小平移不影响池化结果
  3. 增大感受野:下一层卷积核在原图上覆盖了更大的区域

5.3 BatchNorm

$$\text{BN}(x) = \gamma \cdot \frac{x - \mu_B}{\sigma_B} + \beta$$

其中 $\mu_B$ 和 $\sigma_B$ 是当前 mini-batch 的均值和标准差,$\gamma$ 和 $\beta$ 是可学习参数。

BatchNorm 的作用:

  1. 加速训练收敛(每层输入分布稳定)
  2. 轻微正则化效果(batch 统计量的随机性相当于加噪声)

5.4 感受野计算

经过三层卷积+池化后,最后一层每个神经元在原图上的感受野大小:

  • Conv1(k=3, s=1) → 感受野 3×3
  • Pool1(k=2, s=2) → 感受野 4×4
  • Conv2(k=3, s=1) → 感受野 8×8
  • Pool2(k=2, s=2) → 感受野 10×10
  • Conv3(k=3, s=1) → 感受野 18×18
  • Pool3(k=2, s=2) → 感受野 22×22

感受野公式:$RF_{l} = RF_{l-1} + (k_l - 1) \times \prod_{i=1}^{l-1} s_i$

三层之后每个神经元覆盖了 32×32 输入图的绝大部分区域,足够感知数字的全局形状。

5.5 全连接层

从特征图到分类输出的最后一步:

$$\mathbf{z} = W \mathbf{x} + \mathbf{b}$$

其中 $\mathbf{x} \in \mathbb{R}^{2048}$(展平后的特征向量),$W \in \mathbb{R}^{256 \times 2048}$,$\mathbf{b} \in \mathbb{R}^{256}$,输出 $\mathbf{z} \in \mathbb{R}^{256}$。

最后 $W \in \mathbb{R}^{10 \times 256}$,$\mathbf{b} \in \mathbb{R}^{10}$,输出 10 维 logits。


6. 损失函数

6.1 交叉熵损失

对于单个样本,其真标签为类别 $y$(one-hot 向量),模型输出 logits 为 $\mathbf{z}$:

首先将 logits 转为概率:$\hat{y}_i = \text{softmax}(\mathbf{z})i = \frac{e^{z_i}}{\sum{j} e^{z_j}}$

交叉熵损失:$\mathcal{L} = -\sum_{i=1}^{C} y_i \log(\hat{y}_i) = -\log(\hat{y}_y)$

因为 $y$ 是 one-hot 向量,求和后只剩下真标签对应的那一项。

梯度分析: 对 logit $z_i$ 求梯度:

$$\frac{\partial \mathcal{L}}{\partial z_i} = \hat{y}_i - y_i$$

这个优雅的结果说明:

  • 如果 $\hat{y}_y \to 1$(模型对正确答案很有信心)→ 梯度接近 0,不再更新
  • 如果 $\hat{y}_y \to 0$(模型完全搞错了)→ 梯度为 -1,大幅更新

6.2 标签平滑

标准交叉熵期望 $\hat{y}_y = 1$(100% 确信正确答案)。标签平滑将目标从 $[0,0,1,0,\ldots]$ 改为:

$$y_i^{\text{smoothed}} = \begin{cases} 1 - \varepsilon & i = y_{\text{true}} \\ \frac{\varepsilon}{C-1} & i \neq y_{\text{true}} \end{cases}$$

其中 $\varepsilon = 0.05$,$C = 10$(类别数)。

这意味着模型不需要 100% 确信——95% 就够了。剩余的 5% 均匀分配给其他 9 个类别。这有效防止了模型在训练数据上"过度自信"(这个现象叫 overconfidence)。


7. 优化器:AdamW

7.1 梯度下降基础

标准梯度下降:$\theta_{t+1} = \theta_t - \eta \nabla_{\theta} \mathcal{L}(\theta_t)$

其中 $\eta$ 是学习率,$\nabla_{\theta} \mathcal{L}$ 是损失函数对参数的梯度。

7.2 Adam 的动量与自适应学习率

Adam 维护两个状态:

一阶矩(动量):$m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t$

二阶矩(自适应学习率):$v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2$

其中 $g_t = \nabla_{\theta} \mathcal{L}_t$,$\beta_1 = 0.9$,$\beta_2 = 0.999$。

偏差修正(初期 $m_0 = 0, v_0 = 0$ 会导致估计偏低):

$$\hat{m}_t = \frac{m_t}{1-\beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1-\beta_2^t}$$

参数更新

$$\theta_{t+1} = \theta_t - \eta \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}$$

$\epsilon = 10^{-8}$ 防止除零。

直觉理解:

  • $m_t$:如果连续多次梯度方向一致(如一直往左下走),动量累积 → 加速
  • $v_t$:如果某个参数的梯度一直很大(震荡),$\hat{v}_t$ 很大 → 学习率自动降低
  • $\hat{m}_t / \sqrt{\hat{v}_t}$:相当于每个参数有独立的、自适应的学习率

7.3 Weight Decay(L2 正则化)

AdamW 的 weight decay 是解耦的(与 L2 正则化不等价):

$$\theta_{t+1} = \theta_t - \eta \left(\frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda \theta_t\right)$$

$\lambda = 10^{-5}$(本项目)。每步更新时,所有参数略微缩小(×0.99999),防止某些参数在训练中"膨胀"。


8. 学习率调度

8.1 ReduceLROnPlateau

当验证损失连续 $p$ 个 epoch 不再下降时,学习率乘以衰减因子 $\gamma$:

$$\eta_{\text{new}} = \gamma \cdot \eta_{\text{old}}$$

本项目设置:$p = 25$,$\gamma = 0.5$。

8.2 为什么不用固定的余弦退火?

余弦退火的 T_max 需要预设。如果模型在 T_max 之前就收敛了,后半段学习率太低浪费算力;如果 T_max 不够,模型还没收敛学习率就衰减到 0。

ReduceLROnPlateau 自适应地决定何时降学习率——只在验证损失确实不再下降时才降,不会浪费也不会过早衰减。


9. 标签平滑

详见 6.2 节。


10. 数据增强

10.1 旋转

绕 z 轴(画面内旋转)旋转角度 $\theta \sim U(-5°, 5°)$:

$$\begin{bmatrix} x' \\ y' \end{bmatrix} = \begin{bmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{bmatrix} \begin{bmatrix} x - x_0 \\ y - y_0 \end{bmatrix} + \begin{bmatrix} x_0 \\ y_0 \end{bmatrix}$$

旋转以手腕 $(x_0, y_0)$ 为中心,保证手势语义不变。

10.2 缩放

$$\mathbf{p}' = s \cdot (\mathbf{p} - \mathbf{p}_0) + \mathbf{p}_0, \quad s \sim U(0.97, 1.03)$$

10.3 高斯噪声

$$\mathbf{p}' = \mathbf{p} + \varepsilon, \quad \varepsilon \sim \mathcal{N}(0, \sigma^2 I_3), \quad \sigma = 0.004$$

噪声模拟了 MediaPipe 在不同画质和光照条件下的检测误差,增强模型对输入扰动的鲁棒性。


📦 项目信息

  • 项目名称:GestureInteractionSystem — 基于 MediaPipe 与图卷积网络的实时手势识别系统
  • 技术栈:Python 3.10+ · PyTorch 2.x · MediaPipe 0.10+ · OpenCV
  • 10 类手势:手掌张开 / 拳头 / 食指指出 / 胜利 V / OK / 点赞 / 三指 / 捏合 / 四指 / 拇指向下
  • GCN 模型:94K 参数 · 9 维节点特征 · 3 层残差块 · 96.81% 验证准确率 · 0.15ms/帧
  • CNN 模型:621K 参数 · 三层卷积 · QMNIST 数据集 · 99.31% 测试准确率
  • 运行平台:Intel Ultra 9 285H + RTX 5060 Ti · Windows 11 + WSL2
  • 学校:南京航空航天大学 · 民航学院 · 天目启航专项

如果你对这个项目感兴趣,完整代码和论文在 GitHub 上。欢迎交流。

$$$$$$ $$$$$$

评论 (0)