基于图卷积网络的实时手势识别系统项目讲解
天目启航:基于图卷积网络的实时手势识别系统
🖐️ 摄像头 + 图神经网络 = 用手势控制电脑。这是我在南航「天目启航」专项中完成的实时手势识别项目——对着摄像头比手势,电脑就能实时识别并响应,还支持 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 什么是"训练"
"训练"可以类比为学生做练习题:
- 模型 = 学生:他有一套可调整的"知识参数"(GCN 的权重矩阵)。
- 训练数据 = 练习题:每道题是一帧手部关键点坐标,标签是正确的手势名称。
- 前向传播 = 做题:学生读题 → 用当前知识参数计算 → 给出答案。
- 损失函数 = 老师打分:比较学生的答案和正确答案,给出一个"错误分数"。
- 反向传播 = 改错:根据"错误分数"往回追溯,调整每层参数,让下一次答对。
重复步骤 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}$$
池化的作用:
- 降维:尺寸减半,参数量减少 4 倍
- 平移不变性:2×2 区域内的小平移不影响池化结果
- 增大感受野:下一层卷积核在原图上覆盖了更大的区域
5.3 BatchNorm
$$\text{BN}(x) = \gamma \cdot \frac{x - \mu_B}{\sigma_B} + \beta$$
其中 $\mu_B$ 和 $\sigma_B$ 是当前 mini-batch 的均值和标准差,$\gamma$ 和 $\beta$ 是可学习参数。
BatchNorm 的作用:
- 加速训练收敛(每层输入分布稳定)
- 轻微正则化效果(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)
登录 后即可评论