1.项目介绍
1.1项目简介
本项目是一个基于YOLOv11手部关键点检测技术的实时猜拳游戏系统,结合行空板M10和物联网技术。系统通过行空板l连接摄像头实时采集玩家手势图像,利用SIoT物联网服务将图像传输到电脑端进行实时推理,识别出"石头"、"剪刀"、"布"三种手势,并随机生成电脑手势进行猜拳比赛。比赛结果通过物联网实时反馈到行空板显示。本项目展示了深度学习模型在嵌入式设备上的应用,以及如何通过物联网实现设备间的协同工作,适用于游戏开发、人机交互、物联网应用开发等场景。
1.2项目效果视频
2.项目制作框架
本项目是YOLO姿态识别技术(YOLO-Pose系列)中的手势关键点检测应用。YOLO Pose 是 YOLO 系列(You Only Look Once)模型中的一个分支,是一项涉及识别图像中特定点(通常称为关键点,如人体各个关节点被称为人体关键点)位置的任务。关键点可以代表物体的各个部分,如关节、其他显著特征等.不同于常见的目标检测输出边框,YOLO Pose 的输出包括:(1)目标边界框(bounding box)(2)类别(如人、手)(3)一组关键点的位置(如人手21个关键点)

我们说hand keypoint detection(手部关键点检测),就是指模型自动识别图像或视频中手部的重要部位位置,如手指关节、手掌中心等,如下图。

本项目通过自训练手势关键点模型,借助行空板M10与摄像头实时采集视频画面,通过物联网与SIoT技术,将行空板M10采集的画面传输到电脑,进行手部姿态可视化推理,并与电脑随机生成的手势比较,完成猜拳游戏输赢的判断。
项目工作流程如下:
1. 行空板采集摄像头图像并通过SIoT发送到电脑端
2. 电脑运行YOLO模型进行手部关键点检测
3. 基于关键点判断手势类型(石头/剪刀/布)
4. 电脑随机生成手势并判断胜负
5. 结果通过SIoT发回行空板显示
6. 电脑端实时显示识别过程和游戏状态
在本项目中,行空板M10与电脑通过局域网及 SIoT 服务连接,构成了“采集 + 推理 + 执行”的典型智能系统。行空板M10作为移动终端,能够外接传感器与执行器完成数据感知与执行控制。电脑作为外接算力,完成模型训练、部署与推理反馈。通过物联网实现电脑与行空板M10的实时通信,通过“AI + IoT”打造终端感知+一端推理的协同架构能整合边缘感知、智能推理与实时执行等多个优势。
3.软硬件环境准备
3.1软硬件器材清单

3.2软件环境准备
由于我们使用电脑训练水果检测模型,因此需要在电脑端安装相应的库。
首先按下win+R,输入cmd进入窗口。


在命令行窗口中依次输入以下指令,安装ultralytics库
pip install ultralytics
pip install onnx==1.16.1
pip install onnxruntime==1.17.1
输入之后会出现以下页面。

当命令运行完成,出现以下截图表示安装成功。
3.3硬件环境准备
在本项目中我们要通过SIoT服务实现行空板M10与电脑的通讯,因此需要将行空板M10自带的siot升级至siot2.0版本。详情参考本链接:https://mindplus.dfrobot.com.cn/dashboard#
使用USB数据线连接电脑与行空板,打开Mind+,选择Python模式。,等待行空板屏幕亮起表示行空板开机成功。运行“升级行空板SIOTV2_1113”程序(程序见最后附件)
保持Mind+中行空板的连接,打开浏览器,输入“10.1.2.3”,进入行空板官方页面。

在siot页面输入账号和密码,点击登录。

点击“新建主题”,创建一个名为”siot/摄像头“的主题,用于接收行空板M10摄像头采集的视频画面。 |
|
依次创建如下的多个主题,用于行空板和电脑端的数据通信。
4. 制作步骤
4.1数据集准备
为了训练手势识别模型,我们需要准备COCO格式的数据集。一个标准的目标检测数据集包括训练集和验证集,每个集都包含图像和标注文件(txt文件)。标注文件能提供目标的位置和类别信息。
标准的用于姿态识别的数据集的格式如下。

我们使用的数据集总共包含不同姿态的手部图片和手部边界框和21个关键点(手腕、各指关节)的位置信息。
dataset/ //数据集
│
├── train/ // 训练集,使用训练集的数据进行模型训练
│ ├── images/ // 训练集的图片文件夹
│ └── labels/ // 训练集中图片的标注信息文件夹,作用是提供图像中目标的位置和类别信息。
│
├── val/ // 验证集,验证集用于评估模型在未见过的数据上的表现
│ ├── images/ // 验证集的图片文件夹
│ └── labels/ // 验证集图片的标注信息文件夹
│
└── data.yaml // 数据集配置文件,用于定义数据集的参数,通常是一个 YAML 文件。
其中YAML文件一个对于目标检测模型训练很重要的文件。YAML 文件通常包含数据集的路径信息,这些路径告诉模型训练脚本在哪里找到训练集和验证集的图像和标注文件。除此之外,YAML 文件定义了类别索引与类别名称之间的映射关系。这对于模型在训练和推理过程中正确识别和分类目标至关重要。如下图,是手势识别模型的YAML文件。

本项目中,我们使用到了yolo官方给出的手部关键点数据集,在此基础上做了数据集的轻量化处理。
https://docs.ultralytics.com/zh/datasets/pose/hand-keypoints/#introduction
准备好数据集后,接下来我们就可以进入模型的训练环节了。
4.2模型训练
我们首先要去ultralytics的官方仓库下载YOLO项目文件。链接:https://github.com/ultralytics/ultralytics。如下图,将官方文件夹下载下来,并解压(文件夹已附在在本篇文档的最后)。

将文件放到一个能找到的路径,打开Mind+,选择Python模式下的代码模式,如下图。

在右侧“文件系统”中找到"电脑中的文件",找到此文件夹进行添加。


添加好后可以观察到如此下图。

点击"新建文件夹",在ultralytics文件夹中分别新建三个文件夹,依次命名为"datasets"(用于存放数据集),"yamls"(用于存放数据集对应的yaml文件)"runs"(用于存放训练模型的py文件)。

建好后如下图。

将数据集的训练集和验证集文件夹放入"dataset"文件夹,将手势数据集的yaml文件放入"yamls"文件夹。
接着我们在"runs"文件夹中建立一个叫做"train.py"的文件,在此文件中编写训练YOLO模型的代码。

from ultralytics import YOLO
import time
import os
model = YOLO('yolo11n-pose.pt')
# 加载模型并训练
results = model.train(
data=os.path.join(os.getcwd(), '..', 'yamls', 'data.yaml'), # 数据配置文件路径
# 使用相对路径,确保指向正确的yamls文件夹,data.yaml 文件中定义了数据集的路径、类别数等信息
epochs=50, # 设置训练轮数为50(通常需要更多轮数,这里可能是测试)
imgsz=320, # 设置输入图像大小为320x320,降低图像大小可以减少显存占用,适合资源有限的环境
device='cpu', # 使用CPU进行训练(如果无GPU或CUDA环境)
# 如果有GPU,可以设置为 '0' 或 'cuda'
workers=0, # 数据加载线程数(Windows系统建议设置为0)
# workers=0 表示主进程加载数据,避免多线程问题
batch=2, # 设置批量大小为2,降低批量大小可以减少显存占用,但可能影响训练效率
cache=False # 不使用缓存(避免内存占用过多)
)
# 导出ONNX
model.export(format='onnx')
print("模型已成功导出为onnx格式")
运行时可观察终端,自动下载预训练模型"yolov11n-pose.pt",进行模型的训练。
YOLO模型的训练对电脑的配置要求比较高,使用电脑CPU一般训练时间较长,可以考虑使用GPU或者云端算力进行训练,这里我们使用的时本地电脑的CPU训练方法,操作比较简单,耗时略长。

当训练完成后,我们可以观察"runs"文件夹中自动生成了"pose"文件夹(代表“姿态识别”任务),里面存放了训练模型的数据和训练好的模型文件。


我们训练得到的"best.pt"可以直接用于推理,我们也可以将"best.pt"转成onnx格式的模型文件。ONNX 格式,也是一种模型文件的格式,更加通用,可以与各种推理引擎兼容,提供更高效的推理速度。
4.3模型测评
我们先在电脑端测试一下模型的性能,将训练好的模型拖入Mind+项目中的文件下
再在项目中的文件中新建一个叫做"img_inference.py"(用来测试图片推理效果),同时将测试图片"test.png"也拖入项目中的文件夹下。
测试图片如下图所示:

将以下代码复制到"img_inference.py"文件中进行图片推理测试。
from ultralytics import YOLO
import cv2
import numpy as np
from scipy.spatial import ConvexHull
def determine_gesture(kpts):
"""
改进的手势判断算法,方向无关的特征解决倒置手势误识别
:param kpts: 手部21个关键点坐标 (x,y)
:return: 手势类型 ('rock', 'scissors', 'paper')
"""
# 关键点索引定义
wrist = 0 # 手腕
thumb_tip = 4 # 拇指尖
index_tip = 8 # 食指尖
middle_tip = 12 # 中指尖
ring_tip = 16 # 无名指尖
pinky_tip = 20 # 小指尖
# 转换为NumPy数组便于计算
kpts = np.array(kpts)
# 1. 计算指尖到手腕的距离(方向无关)
tips = [thumb_tip, index_tip, middle_tip, ring_tip, pinky_tip]
tip_dists = [np.linalg.norm(kpts[i] - kpts[wrist]) for i in tips]
avg_tip_dist = np.mean(tip_dists)
# 2. 判断剪刀手势(食指和中指伸直)
if (tip_dists[1] > avg_tip_dist * 0.85 and # 食指
tip_dists[2] > avg_tip_dist * 0.85 and # 中指
tip_dists[3] < avg_tip_dist * 0.7 and # 无名指
tip_dists[4] < avg_tip_dist * 0.7): # 小指
return 'scissors'
# 3. 计算手指弯曲角度特征(方向无关)
def finger_angle(finger_tip, finger_mid, finger_base):
"""计算手指弯曲角度(0-180度)"""
vec1 = kpts[finger_base] - kpts[finger_mid]
vec2 = kpts[finger_tip] - kpts[finger_mid]
cos_angle = np.dot(vec1, vec2) / (np.linalg.norm(vec1) * (np.linalg.norm(vec2) + 1e-6))
return np.degrees(np.arccos(np.clip(cos_angle, -1.0, 1.0)))
# 计算各手指弯曲角度
thumb_angle = finger_angle(thumb_tip, 3, 2)
index_angle = finger_angle(index_tip, 6, 5)
middle_angle = finger_angle(middle_tip, 10, 9)
ring_angle = finger_angle(ring_tip, 14, 13)
pinky_angle = finger_angle(pinky_tip, 18, 17)
# 4. 计算手指弯曲程度(方向无关)
def is_finger_straight(tip, dip, pip, mcp):
"""判断手指是否伸直(基于关节角度)"""
angle1 = finger_angle(tip, dip, pip) # 指尖-中关节-基关节
angle2 = finger_angle(dip, pip, mcp) # 中关节-基关节-掌关节
return angle1 > 150 and angle2 > 150
# 检查各手指是否伸直
index_straight = is_finger_straight(8, 7, 6, 5)
middle_straight = is_finger_straight(12, 11, 10, 9)
ring_straight = is_finger_straight(16, 15, 14, 13)
pinky_straight = is_finger_straight(20, 19, 18, 17)
straight_fingers = [index_straight, middle_straight, ring_straight, pinky_straight]
num_straight = sum(straight_fingers)
# 5. 计算手掌张开程度(方向无关)
# 使用手腕到中指的参考距离
wrist_to_middle = np.linalg.norm(kpts[wrist] - kpts[9]) # 关节9是中指基部
# 6. 判断石头和布(方向无关特征)
# 布手势特征:多数手指伸直,手掌张开面积大
# 由于凸包计算可能较慢,我们使用替代方法
# 计算指尖到手腕的平均距离
avg_tip_to_wrist = np.mean([np.linalg.norm(kpts[i] - kpts[wrist]) for i in [index_tip, middle_tip, ring_tip, pinky_tip]])
# 布手势特征:多数手指伸直,平均距离大
if num_straight >= 3 and avg_tip_to_wrist > wrist_to_middle * 1.5:
return 'paper'
# 石头手势特征:多数手指弯曲,平均距离小
if num_straight <= 1 and avg_tip_to_wrist < wrist_to_middle * 1.0:
return 'rock'
# 7. 备选判断:指尖与指关节的相对位置
# 布手势:指尖远离指关节
# 石头手势:指尖靠近指关节
def tip_to_pip_distance(tip, pip):
return np.linalg.norm(kpts[tip] - kpts[pip])
index_tip_dist = tip_to_pip_distance(index_tip, 6)
middle_tip_dist = tip_to_pip_distance(middle_tip, 10)
if index_tip_dist > 0 and middle_tip_dist > 0: # 确保有效距离
avg_tip_dist = (index_tip_dist + middle_tip_dist) / 2
pip_reference = np.linalg.norm(kpts[6] - kpts[10]) # 食指和中指PIP关节距离
# 布手势:指尖距离大于关节距离
if avg_tip_dist > pip_reference * 0.8:
return 'paper'
# 石头手势:指尖距离小于关节距离
if avg_tip_dist < pip_reference * 0.5:
return 'rock'
# 8. 最终基于弯曲角度判断
bent_fingers = sum(angle < 90 for angle in [index_angle, middle_angle, ring_angle, pinky_angle])
if bent_fingers >= 3:
return 'rock'
return 'paper'
# 加载预训练模型
model = YOLO('best.pt') # 使用训练好的权重文件
# 加载测试图像
image_path = 'test.jpg'
image = cv2.imread(image_path)
# 进行预测
results = model(image, conf=0.5) # 设置置信度阈值
# 关键点连接顺序
skeleton = [
(0, 1), (1, 2), (2, 3), (3, 4), # 拇指
(0, 5), (5, 6), (6, 7), (7, 8), # 食指
(0, 9), (9, 10), (10, 11), (11, 12), # 中指
(0, 13), (13, 14), (14, 15), (15, 16), # 无名指
(0, 17), (17, 18), (18, 19), (19, 20) # 小指
]
# 颜色定义
kpt_color = (0, 255, 0) # 关键点颜色 (BGR格式)
line_color = (0, 0, 255) # 骨架线颜色 (BGR格式)
bbox_color = (255, 0, 0) # 边界框颜色 (BGR格式)
# 手势颜色映射
gesture_colors = {
'rock': (0, 0, 255), # 红色 - 石头
'paper': (0, 255, 0), # 绿色 - 布
'scissors': (255, 0, 0) # 蓝色 - 剪刀
}
# 在图像上绘制结果
for result in results:
# 绘制边界框
for box in result.boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0].tolist())
conf = box.conf.item()
# 获取关键点
for keypoints in result.keypoints:
kpt_data = []
for kpt in keypoints.xy[0]:
if not kpt[0].isnan(): # 检查关键点是否有效
kpt_data.append([kpt[0].item(), kpt[1].item()])
else:
kpt_data.append([0, 0]) # 无效点用(0,0)占位
# 判断手势
if len(kpt_data) >= 21: # 确保有足够的关键点
gesture = determine_gesture(kpt_data[:21]) # 只取前21个点
else:
gesture = 'unknown'
# 根据手势设置边框颜色
color = gesture_colors.get(gesture, (255, 255, 255))
cv2.rectangle(image, (x1, y1), (x2, y2), color, 3)
# 绘制手势标签
label = f'{gesture.upper()} ({conf:.2f})'
cv2.putText(image, label, (x1, y1 + 30),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2)
# 绘制关键点和骨架
for keypoints in result.keypoints:
# 绘制关键点
for i, kpt in enumerate(keypoints.xy[0]):
if not kpt[0].isnan(): # 检查关键点是否存在
x, y = int(kpt[0].item()), int(kpt[1].item())
cv2.circle(image, (x, y), 5, kpt_color, -1)
# 可选:显示关键点序号
# cv2.putText(image, str(i), (x+5, y),
# cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1)
# 绘制骨架连接线
for start, end in skeleton:
start_kpt = keypoints.xy[0][start]
end_kpt = keypoints.xy[0][end]
if not (start_kpt[0].isnan() or end_kpt[0].isnan()): # 检查关键点是否存在
x1, y1 = int(start_kpt[0].item()), int(start_kpt[1].item())
x2, y2 = int(end_kpt[0].item()), int(end_kpt[1].item())
cv2.line(image, (x1, y1), (x2, y2), line_color, 2)
# 显示并保存结果
output_path = 'gesture_result.jpg'
cv2.imwrite(output_path, image)
print(f"结果已保存至: {output_path}")
# 显示结果
cv2.imshow('Hand Gesture Detection', image)
cv2.waitKey(0)
cv2.destroyAllWindows()
点击Mind+右上角的”运行",运行代码观察效果。
当程序运行时,弹出弹窗显示图片识别结果如下:

可以看到模型识别出了手部整体的位置框,以及每个关键点的位置,并对手势做出了判断为“布”。
4.4行空板M10传输视频到SIoT
另开一个新的Mind+窗口,用于连接行空板M10,连接行空板M10与摄像头。


运行以下图形化程序向SIoT平台发送摄像头画面信息。该程序于在行空板M10上,点击屏幕上的按钮开始游戏,通过摄像头采集图像帧,将其转为base64格式后,通过SIoT服务以MQTT方式发布。设计一个简单的行空板界面,当收到电脑端的游戏结果后,将结果显示在屏幕上。
4.5电脑推理并返回结果
返回电脑端项目,在项目中的文件目录下新建一个名为 "pc-predict.py" 文件,复制以下代码进去。
# -*- coding: utf-8 -*-
import siot
import cv2
import base64
import numpy as np
import threading
import time
import random
from queue import Queue
from ultralytics import YOLO
from collections import deque
# 全局状态变量
class GameState:
WAITING = 0 # 等待游戏开始信号
COLLECTING = 1 # 正在收集帧数据
PROCESSING = 2 # 数据处理中
SHOW_RESULT = 3 # 显示结果
current_state = GameState.WAITING
last_frame_time = 0
required_frames = 5 # 需要收集的帧数
collected_frames = 0 # 已收集的帧数
start_time = 0 # 收集开始时间
# 存储当前游戏结果
current_user_gesture = "unknown"
current_pc_gesture = "unknown"
current_game_result = "unknown"
# 实时手势识别结果
real_time_gesture = "unknown"
#手势判断函数
def determine_gesture(kpts):
"""
改进的手势判断算法,方向无关的特征解决倒置手势误识别
:param kpts: 手部21个关键点坐标 (x,y)
:return: 手势类型 ('rock', 'scissors', 'paper')
"""
# 关键点索引定义
wrist = 0 # 手腕
thumb_tip = 4 # 拇指尖
index_tip = 8 # 食指尖
middle_tip = 12 # 中指尖
ring_tip = 16 # 无名指尖
pinky_tip = 20 # 小指尖
# 转换为NumPy数组便于计算
kpts = np.array(kpts)
# 1. 计算指尖到手腕的距离(方向无关)
tips = [thumb_tip, index_tip, middle_tip, ring_tip, pinky_tip]
tip_dists = [np.linalg.norm(kpts[i] - kpts[wrist]) for i in tips]
avg_tip_dist = np.mean(tip_dists)
# 2. 判断剪刀手势(食指和中指伸直)
if (tip_dists[1] > avg_tip_dist * 0.85 and # 食指
tip_dists[2] > avg_tip_dist * 0.85 and # 中指
tip_dists[3] < avg_tip_dist * 0.7 and # 无名指
tip_dists[4] < avg_tip_dist * 0.7): # 小指
return 'scissors'
# 3. 计算手指弯曲角度特征(方向无关)
def finger_angle(finger_tip, finger_mid, finger_base):
"""计算手指弯曲角度(0-180度)"""
vec1 = kpts[finger_base] - kpts[finger_mid]
vec2 = kpts[finger_tip] - kpts[finger_mid]
cos_angle = np.dot(vec1, vec2) / (np.linalg.norm(vec1) * (np.linalg.norm(vec2) + 1e-6))
return np.degrees(np.arccos(np.clip(cos_angle, -1.0, 1.0)))
# 计算各手指弯曲角度
thumb_angle = finger_angle(thumb_tip, 3, 2)
index_angle = finger_angle(index_tip, 6, 5)
middle_angle = finger_angle(middle_tip, 10, 9)
ring_angle = finger_angle(ring_tip, 14, 13)
pinky_angle = finger_angle(pinky_tip, 18, 17)
# 4. 计算手指弯曲程度(方向无关)
def is_finger_straight(tip, dip, pip, mcp):
"""判断手指是否伸直(基于关节角度)"""
angle1 = finger_angle(tip, dip, pip) # 指尖-中关节-基关节
angle2 = finger_angle(dip, pip, mcp) # 中关节-基关节-掌关节
return angle1 > 150 and angle2 > 150
# 检查各手指是否伸直
index_straight = is_finger_straight(8, 7, 6, 5)
middle_straight = is_finger_straight(12, 11, 10, 9)
ring_straight = is_finger_straight(16, 15, 14, 13)
pinky_straight = is_finger_straight(20, 19, 18, 17)
straight_fingers = [index_straight, middle_straight, ring_straight, pinky_straight]
num_straight = sum(straight_fingers)
# 5. 计算手掌张开程度(方向无关)
# 使用手腕到中指的参考距离
wrist_to_middle = np.linalg.norm(kpts[wrist] - kpts[9]) # 关节9是中指基部
# 6. 判断石头和布(方向无关特征)
# 布手势特征:多数手指伸直,手掌张开面积大
# 由于凸包计算可能较慢,我们使用替代方法
# 计算指尖到手腕的平均距离
avg_tip_to_wrist = np.mean([np.linalg.norm(kpts[i] - kpts[wrist]) for i in [index_tip, middle_tip, ring_tip, pinky_tip]])
# 布手势特征:多数手指伸直,平均距离大
if num_straight >= 3 and avg_tip_to_wrist > wrist_to_middle * 1.5:
return 'paper'
# 石头手势特征:多数手指弯曲,平均距离小
if num_straight <= 1 and avg_tip_to_wrist < wrist_to_middle * 1.0:
return 'rock'
# 7. 备选判断:指尖与指关节的相对位置
# 布手势:指尖远离指关节
# 石头手势:指尖靠近指关节
def tip_to_pip_distance(tip, pip):
return np.linalg.norm(kpts[tip] - kpts[pip])
index_tip_dist = tip_to_pip_distance(index_tip, 6)
middle_tip_dist = tip_to_pip_distance(middle_tip, 10)
if index_tip_dist > 0 and middle_tip_dist > 0: # 确保有效距离
avg_tip_dist = (index_tip_dist + middle_tip_dist) / 2
pip_reference = np.linalg.norm(kpts[6] - kpts[10]) # 食指和中指PIP关节距离
# 布手势:指尖距离大于关节距离
if avg_tip_dist > pip_reference * 0.8:
return 'paper'
# 石头手势:指尖距离小于关节距离
if avg_tip_dist < pip_reference * 0.5:
return 'rock'
# 8. 最终基于弯曲角度判断
bent_fingers = sum(angle < 90 for angle in [index_angle, middle_angle, ring_angle, pinky_angle])
if bent_fingers >= 3:
return 'rock'
return 'paper'
# 猜拳结果判断函数
def determine_winner(user_gesture, pc_gesture):
"""
根据用户手势和电脑手势判断胜负
规则:
石头 > 剪刀
剪刀 > 布
布 > 石头
返回: 胜利方 ("user_win", "pc_win", "1:1 draw")
"""
# 如果手势无效,直接返回平局
if user_gesture not in ['rock', 'paper', 'scissors']:
return "1:1 draw"
if user_gesture == pc_gesture:
return "1:1 draw"
if (user_gesture == "rock" and pc_gesture == "scissors") or \
(user_gesture == "scissors" and pc_gesture == "paper") or \
(user_gesture == "paper" and pc_gesture == "rock"):
return "user_win"
else:
return "pc_win"
# 1. 加载YOLO手部关键点模型
model = YOLO('best_yolo.pt') # 使用训练好的权重文件
# 2. 手势定义和颜色映射
gesture_colors = {
'rock': (0, 0, 255), # 红色 - 石头
'paper': (0, 255, 0), # 绿色 - 布
'scissors': (255, 0, 0), # 蓝色 - 剪刀
'unknown': (200, 200, 200) # 灰色 - 未知
}
# 手势中文映射
gesture_chinese = {
'rock': '石头',
'paper': '布',
'scissors': '剪刀',
'unknown': '未知',
'user_win': '玩家胜利',
'pc_win': '电脑胜利',
'1:1 draw': '平局'
}
# 3. MQTT连接配置
client_id = "gesture_game_pc"
server = "10.1.2.3"
port = 1883
user = "siot"
password = "dfrobot"
camera_topic = "siot/摄像头" # 接收摄像头数据的topic
game_start_topic = "siot/游戏开始" # 接收游戏开始信号的topic
pc_gesture_topic = "siot/电脑手势" # 发送电脑手势的topic
result_topic = "siot/猜拳结果" # 发送猜拳结果的topic
user_gesture_topic = "siot/玩家手势" # 发送玩家手势的topic
# 4. 创建图像帧队列
frame_queue = Queue(maxsize=5)
# 5. 关键点历史记录
keypoint_history = deque(maxlen=10)
# 6. 关键点连接顺序(用于绘制骨架)
skeleton = [
(0, 1), (1, 2), (2, 3), (3, 4), # 拇指
(0, 5), (5, 6), (6, 7), (7, 8), # 食指
(0, 9), (9, 10), (10, 11), (11, 12), # 中指
(0, 13), (13, 14), (14, 15), (15, 16), # 无名指
(0, 17), (17, 18), (18, 19), (19, 20) # 小指
]
# 7. 回调函数:接收图像、推理、手势识别
def on_message_received(client, userdata, msg):
global current_state, collected_frames, start_time
global current_user_gesture, current_pc_gesture, current_game_result
global real_time_gesture, keypoint_history
try:
# 处理游戏开始信号
if msg.topic == game_start_topic:
command = msg.payload.decode().lower()
if command == "on":
print("[游戏] 收到游戏开始信号")
# 重置状态和收集计数器
current_state = GameState.COLLECTING
collected_frames = 0
start_time = time.time()
keypoint_history.clear()
return
# 处理图像数据
if msg.topic != camera_topic:
return
data = msg.payload.decode()
if "base64," not in data:
return
b64 = data.split("base64,")[-1]
img_bytes = base64.b64decode(b64)
img_array = np.frombuffer(img_bytes, np.uint8)
frame = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
if frame is None:
return
# 更新时间戳
last_frame_time = time.time()
# ✅ YOLO 手部关键点推理
results = model(frame, conf=0.5, verbose=False)
result = results[0]
# 创建绘制用的图像副本
overlay = frame.copy()
# 初始化手势识别结果
real_time_gesture = "unknown"
gesture_color = gesture_colors['unknown']
# 重置检测标志
hand_detected = False
# 处理检测结果
if hasattr(result, 'keypoints') and hasattr(result, 'boxes') and result.keypoints is not None and result.boxes is not None:
# 获取关键点数据
for kpts_obj in result.keypoints:
# 确保关键点数据有效
if kpts_obj.xy is not None and len(kpts_obj.xy) > 0:
keypoints = kpts_obj.xy[0]
kpt_data = []
valid_kpts = True
# 收集有效关键点
for kpt in keypoints:
if not kpt[0].isnan() and not kpt[1].isnan():
kpt_data.append([kpt[0].item(), kpt[1].item()])
else:
valid_kpts = False
break
# 如果关键点有效,添加到历史记录
if valid_kpts and len(kpt_data) == 21:
hand_detected = True
# 在收集状态下保存关键点
if current_state == GameState.COLLECTING:
keypoint_history.append(kpt_data)
collected_frames += 1
# 检查是否收集足够帧数
if collected_frames >= required_frames:
print(f"[游戏] 已收集 {required_frames} 帧数据,开始处理手势")
current_state = GameState.PROCESSING
process_game()
# 使用当前帧关键点计算实时手势
real_time_gesture = determine_gesture(kpt_data)
gesture_color = gesture_colors.get(real_time_gesture, gesture_colors['unknown'])
# 绘制关键点
for i, kpt in enumerate(keypoints):
if not kpt[0].isnan() and not kpt[1].isnan():
x, y = int(kpt[0].item()), int(kpt[1].item())
cv2.circle(overlay, (x, y), 5, (0, 255, 0), -1)
# 绘制骨架连接线
for start, end in skeleton:
if start < len(keypoints) and end < len(keypoints):
start_kpt = keypoints[start]
end_kpt = keypoints[end]
if not (start_kpt[0].isnan() or end_kpt[0].isnan()):
x1, y1 = int(start_kpt[0].item()), int(start_kpt[1].item())
x2, y2 = int(end_kpt[0].item()), int(end_kpt[1].item())
cv2.line(overlay, (x1, y1), (x2, y2), (0, 0, 255), 2)
# 获取边界框
if len(result.boxes) > 0:
box = result.boxes[0].xyxy[0].tolist()
x1, y1, x2, y2 = map(int, box)
# 绘制边界框
cv2.rectangle(overlay, (x1, y1), (x2, y2), gesture_color, 3)
# 绘制实时手势标签
cv2.putText(overlay, f'Gesture: {real_time_gesture.upper()}', (x1, y1 - 15),
cv2.FONT_HERSHEY_SIMPLEX, 1, gesture_color, 2)
# 在图像左上角显示电脑手势和比赛结果(当前次的结果)
cv2.putText(overlay, f'pc_gesture: {current_pc_gesture.upper()}', (20, 40),
cv2.FONT_HERSHEY_SIMPLEX, 0.75, (255, 255, 255), 2)
cv2.putText(overlay, f'user_gesture: {current_user_gesture.upper()}', (20, 70),
cv2.FONT_HERSHEY_SIMPLEX, 0.75, (255, 255, 255), 2)
# 设置结果文本颜色
result_color = (255, 255, 255)
if current_game_result == "user_win":
result_color = (0, 255, 0) # 绿色表示胜利
elif current_game_result == "pc_win":
result_color = (0, 0, 255) # 红色表示失败
elif current_game_result == "1:1 draw":
result_color = (255, 255, 0) # 黄色表示平局
cv2.putText(overlay, f'game_result: {current_game_result.upper()}', (20, 100),
cv2.FONT_HERSHEY_SIMPLEX, 0.75, result_color, 2)
# 在图像底部显示状态信息
state_text = f"State: {get_state_text(current_state)}"
cv2.putText(overlay, state_text, (20, overlay.shape[0] - 60),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
# 绘制收集进度
if current_state == GameState.COLLECTING:
progress = f"Collecting: {collected_frames}/{required_frames}"
cv2.putText(overlay, progress, (20, overlay.shape[0] - 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
else:
# 显示实时手势结果
cv2.putText(overlay, f'Real-time: {real_time_gesture.upper()}', (20, overlay.shape[0] - 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, gesture_colors.get(real_time_gesture, (255, 255, 255)), 2)
# 将推理后的图像放入队列
if not frame_queue.full():
frame_queue.put(overlay)
else:
frame_queue.get_nowait()
frame_queue.put(overlay)
except Exception as e:
print(f"[错误] 消息处理失败: {e}")
def get_state_text(state):
"""获取状态文本描述"""
if state == GameState.WAITING:
return "waiting"
elif state == GameState.COLLECTING:
return "collecting"
elif state == GameState.PROCESSING:
return "processing"
elif state == GameState.SHOW_RESULT:
return "show_result"
return "unknown"
def process_game():
"""处理猜拳游戏逻辑"""
global current_state, current_game_result
global current_user_gesture, current_pc_gesture
global keypoint_history
# 确保有足够的关键点数据
if len(keypoint_history) < 5:
print("[游戏] 关键点数据不足,无法判断手势")
current_game_result = "数据不足"
current_state = GameState.SHOW_RESULT
return
# 计算平均关键点
avg_kpts = np.zeros((21, 2))
for kpts in keypoint_history:
avg_kpts += np.array(kpts)
avg_kpts /= len(keypoint_history)
# 判断用户手势(基于帧平均)
current_user_gesture = determine_gesture(avg_kpts)
# 电脑随机生成手势
pc_gestures = ['rock', 'paper', 'scissors']
current_pc_gesture = random.choice(pc_gestures)
# 进行猜拳比赛
current_game_result = determine_winner(current_user_gesture, current_pc_gesture)
# 发送电脑手势和比赛结果到SIoT
siot.publish_save(pc_gesture_topic, gesture_chinese[current_pc_gesture])
siot.publish_save(result_topic, gesture_chinese[current_game_result])
siot.publish_save(user_gesture_topic, gesture_chinese[current_user_gesture])
print(f"[游戏] 用户手势: {gesture_chinese.get(current_user_gesture, '未知')}, " +
f"电脑手势: {gesture_chinese[current_pc_gesture]}, " +
f"结果: {gesture_chinese[current_game_result]}")
# 更新状态为显示结果
current_state = GameState.SHOW_RESULT
# 8. 初始化 MQTT
siot.init(client_id=client_id, server=server, port=port, user=user, password=password)
siot.set_callback(on_message_received)
siot.connect()
# 9. 启动 MQTT 后台监听线程
threading.Thread(target=siot.loop, daemon=True).start()
# 订阅摄像头和游戏开始主题
siot.getsubscribe(topic=camera_topic)
siot.getsubscribe(topic=game_start_topic)
print(f"[MQTT] 已订阅主题: {camera_topic} 和 {game_start_topic}")
# 10. 显示图像主循环
print("[系统] 手势识别猜拳游戏已启动,等待游戏开始信号... 按 q 退出")
while True:
if not frame_queue.empty():
result_frame = frame_queue.get()
cv2.imshow("手势识别猜拳游戏", result_frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
# 11. 清理资源
cv2.destroyAllWindows()
同时运行行空板端程序和电脑端程序,等同时连上siot后,可以在电脑端看到实时的摄像头窗口和手势推理结果:

点击行空板屏幕上的“开始猜拳游戏”后,会根据当前画面推理手势结果,生成随机的电脑手势,判断输赢,并显示在画面和行空板屏幕上。再次点击“开始猜拳游戏”,可进行下一轮的游戏:


4.5核心代码解析
我们一起来看一下在电脑上运行的 "pc-predict.py" 代码的核心功能代码。
1.手势识别算法

模块1:手势判断函数(determine_gesture)
- 输入:21个手部关键点坐标(kpts)
- 功能:通过分析关键点的空间关系,判断手势(石头、剪刀、布)
- 步骤:
1. 计算指尖到手腕的距离,判断剪刀(食指和中指伸直,无名指和小指弯曲)。
2. 计算各手指的弯曲角度(通过相邻关节向量的夹角)。
3. 判断手指是否伸直(角度大于150度)。
4. 根据伸直的手指数量判断布(多数伸直)和石头(多数弯曲)。
5. 最终基于弯曲角度做最后判断。
2.游戏状态机
class GameState:
WAITING = 0 # 等待游戏开始
COLLECTING = 1 # 收集帧数据
PROCESSING = 2 # 数据处理中
SHOW_RESULT = 3 # 显示结果
current_state = GameState.WAITING
游戏状态机清晰定义了四个核心状态:
• WAITING:等待行空板发送开始信号
• COLLECTING:收集5帧关键点数据(提高识别稳定性)
• PROCESSING:处理数据并判断结果
• SHOW_RESULT:显示最终游戏结果

3.物联网通信

本项目应用到的模块化设计不仅适用于猜拳游戏,还可扩展为各种实时交互应用,如手势控制、AR交互、远程协作等场景,展现了"边缘感知+云端智能"架构的强大潜力。
5.项目相关资料附录

项目文件链接: https://pan.baidu.com/s/1uViHjs4aOe86l4RW-g4tRg?pwd=w17g
rzegkly2025.08.27
学习手势识别案例,喜欢
1398142032281422025.08.21
有成品图吗?
zoey不种土豆2025.09.03
可以看一下视频哦