
简介一份基于Python、MediaPipe与OpenCV实现的手势识别系统课程设计源码包代码附带超详细注释定位清晰适合计算机、人工智能、电子信息等相关专业的学生作为课程设计、毕业设计或项目初期演示的参考也适合刚入门计算机视觉的开发者对照学习。资源共有18个文件核心为10个Python脚本按功能拆分出手部关键点检测、动态手势逻辑、拖拽操作、控制计算机等模块另含7张示例图片便于直观查看运行效果1份Markdown说明文档用于梳理整体工程结构可在短时间内抓住实现思路。压缩包约2.58MB代码体量适中便于快速下载和上手。目前已有790人学习浏览代码经测试运行成功读者配置好依赖后即可直接体验手势控制效果。通过阅读注释清晰的源码既能掌握MediaPipe关键点提取与OpenCV图像处理的基本流程也能在此基础上扩展更多手势指令是兼顾学习、课设与实战的优质参考。1. 从摄像头到手势指令为什么选 Python MediaPipe 做识别手势识别的常规思路是先做手部检测、再提取关键点、最后按几何特征分类。传统做法里手部分割和关键点检测是最容易翻车的环节肤色模型扛不住光照变化背景稍复杂一点误检率就上去了自己从头训练一个手部关键点检测器光是标注数据就够写一篇论文。MediaPipe 把这一步封装成了开箱即用的解决方案输入一帧 RGB 图像就能返回 21 个手部关键点的归一化坐标OpenCV 负责摄像头采集和图像绘制Python 负责把坐标变成业务逻辑。这套组合对课程设计来说最友好代码量集中在特征设计和分类逻辑上而不是底层算法复现。本文按“环境搭建 → 数据流动 → 特征工程 → 分类实现 → 性能调优”的顺序展开前面几章偏原理和实现最后一章给出一组可以直接用到课程设计报告里的调优建议。目标读者是已经写过一些 Python、接触过 OpenCV 基本操作、但第一次把手势识别做成完整系统的开发者全程不涉及训练深度学习模型。2. 搭建手势识别开发环境手部检测管线里的 3 个核心依赖2.1 版本选型和安装顺序MediaPipe 的 Python 包迭代节奏比较快不同版本的 API 差异不小。课程设计项目里推荐先固定版本再写代码否则照着网上的代码抄经常遇到接口签名对不上的问题。比较稳妥的组合是 Python 3.8 到 3.10MediaPipe 0.10.xOpenCV 4.8 以上。这个组合在 Windows 和 Linux 上都有预编译的 wheel 包不需要自己编译 C 扩展。# 建议在虚拟环境里操作避免污染全局 Python python -m venv gesture_env source gesture_env/bin/activate # Windows 下用 gesture_env\Scripts\activate pip install mediapipe0.10.14 pip install opencv-python4.9.0.80 pip install numpy1.26.4安装顺序上先装 MediaPipe 再装 OpenCV 比较省事。MediaPipe 会带上 protobuf 和 absl-py 这些依赖OpenCV 后装不会覆盖掉它们。如果安装 OpenCV 时提示ERROR: pips dependency resolver通常不是版本冲突只是 pip 在提醒你某个间接依赖的版本有更新不用慌。验证环境是否就绪用一段最简导入代码import mediapipe as mp import cv2 import numpy as np print(MediaPipe:, mp.__version__) print(OpenCV:, cv2.__version__)能打印出版本号说明环境没问题。如果在这里报错先检查虚拟环境是否激活再检查 Python 位数是不是 64 位MediaPipe 没有 32 位版本。2.2 读懂 MediaPipe Hands 的输入输出契约MediaPipe Hands 的 pipeline 分两段第一段用 palm detection 在整帧里找手掌区域第二段在手部 ROI 内做 landmark 回归输出 21 个关键点。这个设计直接决定了它的行为特点——手掌是定位单元所以手背朝镜头时检测率会下降手指并拢时 landmark 依然稳定因为 palm detector 只看手掌区域。理解这个契约后面调参才有的放矢。import mediapipe as mp mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, # 视频流模式连续帧之间会跟踪 max_num_hands2, # 最多检测 2 只手 model_complexity0, # 模型复杂度0 轻量1 完整 min_detection_confidence0.5, # 检测阶段置信度阈值 min_tracking_confidence0.5 # 跟踪阶段置信度阈值 )参数不是拍脑袋定的。static_image_modeFalse意味着后续帧会复用上一帧的手部 ROI检测更快但偶尔会丢跟踪适合摄像头场景处理单张图片时改成True每帧都做全图检测。model_complexity0在 CPU 上跑能省将近一半的推理时间但手指关节在快速运动时会有点抖1更稳但慢。max_num_hands不是越大越好课程设计里识别单手数字手势居多设 1 或 2 足够。置信度阈值低于 0.5 时背景里的类手物体容易被误检成手。2.3 统一图像颜色空间和坐标系MediaPipe 要求输入 RGB 图像OpenCV 的VideoCapture默认读出来的是 BGR。颜色通道顺序弄反是新手最容易踩的坑表现为手部关键点检测不出来或者检测结果飘忽不定因为 MediaPipe 的模型是在 RGB 数据上训练的喂 BGR 等于喂了分布外数据。import cv2 import mediapipe as mp mp_drawing mp.solutions.drawing_utils mp_hands mp.solutions.hands cap cv2.VideoCapture(0) # 0 表示默认摄像头 hands mp_hands.Hands( static_image_modeFalse, max_num_hands1, model_complexity0, min_detection_confidence0.6, min_tracking_confidence0.5 ) while cap.isOpened(): ret, frame cap.read() if not ret: break # OpenCV 默认 BGR必须转成 RGB 再交给 MediaPipe frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(frame_rgb) # 绘制结果时转回 BGR保证 OpenCV 显示颜色正常 if results.multi_hand_landmarks: for hand_landmarks in results.multi_hand_landmarks: mp_drawing.draw_landmarks( frame, hand_landmarks, mp_hands.HAND_CONNECTIONS) # 在这里访问 hand_landmarks.landmark 拿到 21 个关键点 cv2.imshow(Gesture Recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()results.multi_hand_landmarks是列表每个元素对应一只手的 21 个关键点。HAND_CONNECTIONS是固定连接关系画出来的骨架线就是手指的骨骼结构。这段代码是后面所有功能的地基特征提取、分类、绘制都发生在if results.multi_hand_landmarks这个分支里面。提示如果摄像头画面是镜像的在cv2.imshow之前用cv2.flip(frame, 1)做水平翻转。MediaPipe 的 landmark 坐标不受翻转影响翻转只改变显示效果。3. 从 21 个关键点到鲁棒特征坐标归一化与几何特征计算3.1 landmark 坐标体系与逐帧抖动hand_landmarks.landmark里每个点都有x、y、z三个属性。x和y是相对图像宽高的归一化值范围在 0 到 1 之间z表示关键点相对于手腕的深度值越小离镜头越近。注意z不是真实物理距离它是一个相对深度同一只手的不同点之间才有比较意义。直接拿这些坐标做分类会踩两个坑一是坐标依赖图像尺寸二是手在画面里的位置变化会引起坐标整体偏移。所以特征提取之前必须做归一化。常见做法是以手腕点landmark 0为原点用整只手的包围盒尺寸做缩放把所有坐标映射到一个尺度无关的空间里。import numpy as np def get_hand_features(hand_landmarks): 从 MediaPipe 关键点提取归一化特征 lm hand_landmarks.landmark # 收集所有关键点到 numpy 数组 pts np.array([[p.x, p.y, p.z] for p in lm]) # 以手腕索引 0为基准点做平移 wrist pts[0] pts_centered pts - wrist # 用食指指尖到手腕的距离做缩放基准 scale np.linalg.norm(pts_centered[8]) # landmark 8 是食指指尖 if scale 1e-6: return None # 防止除零同时过滤掉异常帧 pts_normalized pts_centered / scale return pts_normalized.flatten() # 展平成 63 维向量除以食指指尖到手腕的距离而不是包围盒对角线是因为这个距离在大多数手势中都比较稳定而且直接对应手指伸长长度物理意义明确。scale 1e-6这个判断不是多余的手部在画面里特别小或者刚出现时所有点可能挤在一起这时候的特征没有区分度直接跳过这一帧比硬算更有价值。3.2 特征向量设计几何特征比原始坐标更抗干扰归一化后的 63 维坐标向量可以直接喂给分类器但每帧坐标都在微抖分类边界稍微紧一点就容易误判。我一般会在坐标特征之外再叠加两类几何特征手指间的夹角和关键点之间的距离比例。这两种特征跟手的绝对位置、画面尺寸无关只反映手的姿态。手指夹角用三点计算以食指为例取手腕0、食指根部5、食指指尖8三个点用向量点积求夹角。def calc_angle(p1, p2, p3): 计算 p2 为顶点时 p1-p2-p3 的夹角返回角度制 v1 np.array([p1.x - p2.x, p1.y - p2.y]) v2 np.array([p3.x - p2.x, p3.y - p2.y]) cos_theta np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) 1e-6) cos_theta np.clip(cos_theta, -1.0, 1.0) return np.degrees(np.arccos(cos_theta)) def build_feature_vector(hand_landmarks): 构建完整特征向量63 维坐标特征 5 个指尖夹角 尺长比 lm hand_landmarks.landmark # 坐标特征 pts_normalized get_hand_features(hand_landmarks) if pts_normalized is None: return None # 5 个手指的夹角参考点分别是 1, 5, 9, 13, 17 finger_base_ids [1, 5, 9, 13, 17] finger_tip_ids [4, 8, 12, 16, 20] angles [] for base, tip in zip(finger_base_ids, finger_tip_ids): angle calc_angle(lm[0], lm[base], lm[tip]) angles.append(angle) # 特征向量 features np.concatenate([pts_normalized, np.array(angles)]) return featuresnp.clip防止点积结果超出[-1, 1]因为浮点误差可能导致arccos传入越界值返回 NaN。特征向量维度是 63 5 68pts_normalized需要展开成一维再拼接。build_feature_vector是后面分类器和数据采集共用的接口保持特征口径统一训练和推理才不会打架。4. 数字 0 到 9 的识别实现从规则分类器到可采集数据的训练管线4.1 用指尖坐标判断伸屈规则分类器的设计思路课程设计最直接的需求通常是识别 0 到 9 的数字手势。这类手势的区分度主要体现在手指的伸曲状态上比如数字 1 是食指伸直其余手指弯曲数字 5 是五指全部张开。判断一根手指是否伸直最有效的指标不是指尖坐标而是指尖到手掌中心的距离。代码实现里以中指根部landmark 9和手腕landmark 0的中点近似手掌中心计算每根手指指尖到手掌中心的距离再跟该手指根部到手掌中心的距离比较。距离明显更大就判定为伸直。def is_finger_extended(hand_landmarks, finger_id): 判断指定手指是否伸直finger_id: 4-食指, 8-中指, 12-无名指, 16-小指, 20-大拇指 lm hand_landmarks.landmark # 手掌中心手腕(0) 和 中指根部(9) 的中点 palm_center { x: (lm[0].x lm[9].x) / 2, y: (lm[0].y lm[9].y) / 2, z: (lm[0].z lm[9].z) / 2 } # 指尖和手指根部 tip lm[finger_id] base lm[finger_id - 2] # 根部索引在指尖索引 -2 的位置 def dist(p1, p2): return np.sqrt((p1.x - p2.x)**2 (p1.y - p2.y)**2 (p1.z - p2.z)**2) tip_to_center dist(tip, palm_center) base_to_center dist(base, palm_center) return tip_to_center base_to_center * 1.2 # 1.2 是经验阈值finger_id - 2的关系不是巧合MediaPipe 的关键点编号里食指根部是 5、指尖是 8中指根部是 9、指尖是 12以此类推。阈值 1.2 需要根据你的摄像头距离微调手离镜头太近时指尖到手掌中心的距离会被放大1.2可能误判成伸直手离镜头太远时整个手变小1.2又可能不够敏感。由is_finger_extended组合出数字规则def recognize_number_rule(hand_landmarks): 基于手指伸曲状态的规则分类器 fingers { thumb: is_finger_extended(hand_landmarks, 4), index: is_finger_extended(hand_landmarks, 8), middle: is_finger_extended(hand_landmarks, 12), ring: is_finger_extended(hand_landmarks, 16), pinky: is_finger_extended(hand_landmarks, 20) } # 数字 0握拳所有手指弯曲 if not any(fingers.values()): return 0 # 数字 1仅食指伸直 if (fingers[index] and not fingers[middle] and not fingers[ring] and not fingers[pinky]): return 1 # 数字 2食指和中指伸直 if (fingers[index] and fingers[middle] and not fingers[ring] and not fingers[pinky]): return 2 # 数字 3食指、中指、无名指伸直 if (fingers[index] and fingers[middle] and fingers[ring] and not fingers[pinky]): return 3 # 数字 4除拇指外四指伸直 if (fingers[index] and fingers[middle] and fingers[ring] and fingers[pinky] and not fingers[thumb]): return 4 # 数字 5五指全伸 if all(fingers.values()): return 5 return -1 # 未识别的中间状态代码逻辑简单直接每个数字对应一组伸曲状态的组合。但真实场景里手型不是标准模板有人做手势时大拇指会自然外展导致数字 2 被误判成数字 3。这就是规则分类器的天花板状态组合覆盖不全所有情况。想要更稳得上数据驱动。4.2 收集训练数据把特征和标签写成数据集规则分类器适合演示想要数字 6 到 9 的识别能力——这几个手势靠的是指尖之间的接触关系伸曲状态不够区分——就需要机器学习分类器。支持向量机SVM在这种低维度小样本场景下很合适特征维度 68每个类别采集 100 组数据训练时间几乎可以忽略。数据采集要做到“边伸手边采、松开再换手势”是避免脏数据的核心。import csv import os import time def collect_dataset(save_pathhand_gesture_data.csv, num_samples_per_class100): 采集手势数据按键 0-9 切换标签空格键保存一帧 cap cv2.VideoCapture(0) hands mp_hands.Hands(static_image_modeFalse, max_num_hands1) if not os.path.exists(save_path): with open(save_path, w, newline) as f: writer csv.writer(f) header [ff{i} for i in range(68)] [label] writer.writerow(header) label 0 count 0 while cap.isOpened(): ret, frame cap.read() if not ret: break frame cv2.flip(frame, 1) frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(frame_rgb) if results.multi_hand_landmarks: features build_feature_vector(results.multi_hand_landmarks[0]) if features is not None: count 1 with open(save_path, a, newline) as f: writer csv.writer(f) writer.writerow(np.append(features, label)) # 短暂延迟避免同一帧被重复采集 time.sleep(0.05) cv2.putText(frame, fLabel: {label} Count: {count}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(Collect Data, frame) key cv2.waitKey(1) 0xFF if 48 key 57: # 数字键 0-9 label key - 48 count 0 elif key ord(q): break cap.release() cv2.destroyAllWindows()数据采集时每按一次数字键切换标签当前采集了多少帧直接标在画面左上角。每个手势采集 80 到 120 帧比较合理太少会让分类器欠拟合太多会让单类样本主导训练。注意time.sleep(0.05)不是用来偷懒的MediaPipe 在视频流模式下会对同一只手做跟踪去掉延迟会导致同一帧被重复采集数据冗余严重。4.3 训练 SVM 分类器并集成到视频流import pandas as pd from sklearn.model_selection import train_test_split from sklearn.svm import SVC from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler import joblib # 读取数据 df pd.read_csv(hand_gesture_data.csv) X df.iloc[:, :-1].values y df[label].values # 划分训练集和测试集验证集比例 20% X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy) # 标准化 RBF 核 SVM svm_model make_pipeline(StandardScaler(), SVC( kernelrbf, C1.0, gammascale, probabilityTrue)) svm_model.fit(X_train, y_train) print(训练集准确率:, svm_model.score(X_train, y_train)) print(测试集准确率:, svm_model.score(X_test, y_test)) joblib.dump(svm_model, hand_gesture_svm.pkl)SVM 需要标准化输入make_pipeline把StandardScaler和SVC串在一起训练和推理走同一套预处理。stratifyy保证每个数字类别的样本按比例划分不会出现某一类全在训练集而测试集没有的情况这是小数据集上评估模型时容易忽略的细节。核函数选 RBF 而不是线性核是因为数字 6 到 9 的指尖接触关系和坐标特征之间不是线性可分的。训练完成之后把模型加载回手势识别主循环model joblib.load(hand_gesture_svm.pkl) def recognize_number_ml(features): 用训练好的 SVM 模型识别数字返回 (数字, 置信度) if features is None: return -1, 0.0 proba model.predict_proba([features])[0] pred model.predict([features])[0] confidence proba.max() return pred, confidencepredict_proba返回每个类别的概率分布取最大值当置信度。低于 0.7 的预测结果不值得信任宁可不显示也不要频繁跳变。在视频流里显示识别结果时连续三帧取众数可以显著减少误判飘动实现起来很简单维护一个长度为 3 的 deque每次推入当前预测结果取出现次数最多的那个数字显示。提示SVM 的gammascale是 sklearn 根据特征数量自动计算的不需要手动调。C控制误分类惩罚强度课程设计场景下1.0够用调大容易过拟合训练集调小会让模型欠拟合。5. 性能调优与打包发布让手势识别跑得更稳、更像一个交付物5.1 推理速度瓶颈分析与模型复杂度选择先定位瓶颈再调优。用 Python 的time模块分别测量三段时间cap.read()的耗时、hands.process()的耗时、feature classify的耗时。正常配置下hands.process()占比最高在纯 CPU 机器上单帧耗时在 20 到 40 毫秒之间model_complexity1时翻倍。课程设计答辩现场用的电脑性能不可控我一般建议默认用model_complexity0帧率优先。如果 CPU 跑不到 25 FPS优先做降帧处理而不是换模型。每两帧做一次手势识别中间帧直接沿用上一次的识别结果视觉体验几乎没有差异但推理负载减半。5.2 与摄像头距离的鲁棒性验证和 3 个必调参数同一个模型在不同距离下的表现差异会直接在答辩时暴露。手离摄像头 30 厘米和 80 厘米关键点坐标的绝对差异很大。这里有一个即使有五年经验的开发者也会忽略的细节min_detection_confidence在远距离时容易抖因为手掌区域像素少检测置信度天然下降。建议把min_detection_confidence从 0.5 调到 0.7宁可偶尔漏检不要频繁误检。表格里是课程设计答辩前推荐检查的参数参数推荐值作用min_detection_confidence0.7提高检测精度减少背景误检min_tracking_confidence0.5保持跟踪灵敏度手快速移动时不丢目标model_complexity0降低 CPU 占用保帧率max_num_hands1只识别一只手时减小计算量is_finger_extended阈值1.2 → 1.3手离镜头远时调大近时调小如果摄像头分辨率是 1280x720在cap.read()之后加一行frame cv2.resize(frame, (640, 480))推理耗时能下降 60% 以上识别精度损失很小。分辨率太高反而是负担MediaPipe 内部会把图像缩放成统一尺寸处理喂进去的高分辨率信息大部分被丢弃了。5.3 连续帧去抖和输出稳定性手势识别系统的体验差距往往不在单帧准确率而在帧间稳定性。模型偶尔把数字 2 误判成数字 3本身不可怕可怕的是识别结果在 2 和 3 之间来回跳看起来就像系统坏了一样。用一个固定长度的滑动窗口取窗口内的众数作为最终输出。窗口长度为 5 时滞后感不明显窗口长度为 10 时误判几乎消失但切换数字有半秒延迟。课程设计演示场景推荐窗口长度 5。from collections import deque class GestureSmoother: 滑动窗口去抖保证输出数字稳定 def __init__(self, window_size5): self.window deque(maxlenwindow_size) def update(self, prediction, confidence): if confidence 0.65: # 置信度太低时不入窗保留历史结果 return self.get_result() self.window.append(prediction) return self.get_result() def get_result(self): if not self.window: return -1 # 统计窗口中每个数字出现的次数返回最频繁的 return max(set(self.window), keyself.window.count)deque(maxlen5)会在窗口填满之后自动丢弃最旧的数据不需要手动管理队列长度。confidence 0.65时直接返回历史结果而不是输出 -1避免手在切换动作的过渡阶段识别结果突然变成“无”。这种过渡状态每帧都可能出现处理不好会让界面上的数字不停闪烁。真正的手势识别项目里去抖逻辑甚至比分类器本身的调参更重要因为人眼对稳定的错误容忍度高对频繁跳变的正确结果反而接受不了。5.4 课程设计交付时的运行说明课程设计项目通常要求提交源码和演示。把模型文件和依赖清单提交完整。用pip freeze requirements.txt生成依赖列表在 README 里写清楚 Python 版本和安装命令。这是唯一能让老师的人工评测效率翻倍的做法也直接避免了换电脑评测时环境报错导致的分数损失。模型文件hand_gesture_svm.pkl要跟主程序放在同一目录用相对路径加载而不是绝对路径否则换电脑就报FileNotFoundError。数据采集脚本和训练脚本分开保存训练脚本里输出训练集准确率和测试集准确率这两行数字是课程设计报告里最重要的实验数据。最后用 PyInstaller 打包时加一句--hidden-import mediapipe旧版本 PyInstaller 经常漏掉 MediaPipe 的动态依赖导致打包后运行闪退。本文还有配套的精品资源点击获取