全息操作虚拟现实与工业自动化操作快速学习,

隔空手势与全息操作基础:机器视觉工业应用实战指南

面向领域:工业自动化 / 智能制造 / 人机交互 / AR虚拟现实 学习定位:零基础快速掌握机器视觉在手势识别与隔空操控中的核心原理与工程落地 更新日期:2026-08-24

写在前面:给工业自动化从业者的话

在工业4.0与智能制造的浪潮中,机器视觉已成为产线自动化、无接触操控、AR辅助装配、全息交互界面的核心技术之一。无论是机械臂的隔空示教、洁净室的无触摸操控,还是虚拟现实中的手势交互,其底层逻辑都高度一致:通过摄像头捕捉人体关键点位,提取空间姿态,进而映射为控制指令。
本文将带你从零开始,系统掌握这套技术的完整链路。需要特别说明的是:
  • 本文讲解的是一种通用技术思路,机器视觉的开源框架与商用方案非常丰富(如 OpenPose、HaGrid、各类自研深度学习模型等),不同场景下可灵活选型;
  • 本次实战以某主流开源视觉框架为例进行原理讲解与代码演示,旨在帮你建立可迁移的工程思维;
  • 所有代码均附带逐行中文注释,确保工业背景的朋友也能无障碍上手;
  • 学完本文,你将具备独立开发「隔空手势操控」「全息交互原型」「无接触工业界面」等应用的能力。

目录


第一模块:机器视觉基础——人脸网格检测与关键点定位

模块目标:理解视觉模型如何「看到」人脸,掌握关键点检测、坐标转换、自定义绘制的完整流程。这些基础能力可直接迁移到工业质检(如零件轮廓提取)、AR overlay 等场景。

1.1 环境准备与导入依赖库

步骤说明:
  1. 安装并导入视觉计算所需的四大核心库
  2. 视觉推理库负责加载预训练模型并执行推理
  3. OpenCV 负责图像采集、颜色空间转换、可视化绘制
  4. NumPy 负责高维数组运算与矩阵操作
  5. PIL 负责图像格式兼容与跨平台显示
Python
 
import mediapipe as mp      # 开源视觉推理库,封装多种预训练模型(本文以此为例做原理讲解)
import cv2                  # OpenCV,工业视觉领域最常用的图像处理框架
import numpy as np          # 数值计算库,处理数组和矩阵运算
from PIL import Image       # Python Imaging Library,图像读写和格式转换

1.2 初始化人脸网格检测模型

步骤说明:
  1. 从视觉库解决方案中导入人脸网格相关的三个模块
  2. 创建 FaceMesh 检测器实例
  3. 配置参数以平衡检测精度与实时性(工业现场对帧率要求极高)
Python
 
# ========== 步骤 1:加载视觉库模块 ==========
mp_face_mesh = mp.solutions.face_mesh      # 人脸网格核心模型(468个关键点)
mp_drawing = mp.solutions.drawing_utils    # 关键点绘制工具(画点、连线)
mp_drawing_styles = mp.solutions.drawing_styles  # 官方预设的绘制样式模板

# ========== 步骤 2:创建人脸网格检测器 ==========
face_mesh = mp_face_mesh.FaceMesh(
    static_image_mode=False,      # False=视频/流模式(带跟踪优化);True=静态图片模式(纯检测)
    max_num_faces=2,              # 最大检测人脸数量,数值越小速度越快
    refine_landmarks=True,        # 开启虹膜细化,关键点从468→478个(增加左右眼虹膜关键点)
    min_detection_confidence=0.5, # 人脸检测置信度阈值,低于0.5不识别
    min_tracking_confidence=0.5,  # 人脸跟踪置信度阈值,视频模式下生效,减少帧间抖动
)
参数详解(工业调参关键):
表格
 
 
参数推荐值含义
static_image_mode False 视频流模式利用上一帧跟踪,速度更快更稳,适合工业实时检测
max_num_faces 2 画面里最多找2张脸,设成1最快,适合单工位检测
refine_landmarks True 重点! 开启后增加10个虹膜点,做眼球追踪、注意力分析必备
min_detection_confidence 0.5 阈值越高越严格,漏检多;越低越宽松,误检多
min_tracking_confidence 0.5 视频模式下跟踪上一帧人脸的置信门槛

1.3 读取图像并转换颜色空间

步骤说明:
  1. 使用 cv2.imread() 读取本地图像或工业相机帧
  2. 关键转换:OpenCV 默认读取格式是 BGR,但绝大多数深度学习视觉模型训练用的是 RGB,必须转换!
  3. 不转换会导致红蓝通道颠倒,检测精度显著下降
Python
 
# ========== 步骤 1:读取图像 ==========
image_path = "images/girl.jpg"           # 替换为你的图片路径
image = cv2.imread(image_path)           # OpenCV读取 → 默认BGR格式

# ========== 步骤 2:BGR → RGB 转换 ==========
# 核心转换:OpenCV默认读取格式是BGR,视觉模型只支持RGB
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

# ========== 步骤 3:执行人脸关键点检测推理 ==========
results = face_mesh.process(image_rgb)
工业现场记忆口诀:OpenCV 读图是 BGR,神经网络要 RGB,中间必须转一道!

1.4 使用预设样式绘制检测结果

步骤说明:
  1. 复制原图,避免绘制时污染原始图像(工业质检中原始图像常需存档)
  2. 遍历检测到的每一张人脸
  3. 调用绘图工具自动绘制
  4. 使用官方预设样式快速出图
Python
 
# ========== 步骤 1:复制原图 ==========
annotated_image = image.copy()   # 必须复制!绘制函数会直接修改传入的图片

# ========== 步骤 2:遍历每张人脸并绘制 ==========
for face_landmarks in results.multi_face_landmarks:
    
    # 绘制1:全脸三角网格(密密麻麻的三角形,像3D线框图)
    mp_drawing.draw_landmarks(
        image=annotated_image,
        landmark_list=face_landmarks,
        connections=mp_face_mesh.FACEMESH_TESSELLATION,   # 全脸三角网格连接关系
        landmark_drawing_spec=None,                        # 不单独绘制关键点圆点
        connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_tesselation_style()
    )
    
    # 绘制2:面部轮廓线条(眉毛、眼睛、鼻子、嘴巴、脸型边界)
    mp_drawing.draw_landmarks(
        image=annotated_image,
        landmark_list=face_landmarks,
        connections=mp_face_mesh.FACEMESH_CONTOURS,         # 面部轮廓连接关系
        landmark_drawing_spec=None,
        connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_contours_style()
    )
    
    # 绘制3:虹膜关键点(只有 refine_landmarks=True 时才有)
    mp_drawing.draw_landmarks(
        image=annotated_image,
        landmark_list=face_landmarks,
        connections=mp_face_mesh.FACEMESH_IRISES,           # 左右眼虹膜连接
        landmark_drawing_spec=None,
        connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_iris_connections_style()
    )

# ========== 步骤 3:展示结果 ==========
# BGR → RGB 通道转换,PIL直接展示
Image.fromarray(annotated_image[:, :, ::-1])
三个 connections 的区别(工业应用场景):
表格
 
 
常量绘制内容工业应用
FACEMESH_TESSELLATION 全脸三角网格 3D人脸重建、数字孪生、表情迁移
FACEMESH_CONTOURS 面部轮廓线条 人脸识别门禁、美颜滤镜、五官定位
FACEMESH_IRISES 虹膜轮廓圆圈 眼球追踪、操作员注意力分析、疲劳检测

1.5 自定义绘制样式

步骤说明:
  1. 使用 DrawingSpec 自定义颜色和粗细
  2. 控制关键点圆点大小、连线颜色
  3. 不再使用官方预设,完全自定义风格(适合工业HMI界面风格统一)
Python
 
# ========== 步骤 1:定义自定义绘制样式 ==========
landmark_spec = mp_drawing.DrawingSpec(
    color=(0, 255, 0),      # 绿色关键点(BGR格式)
    thickness=2,
    circle_radius=3           # 圆点半径3像素
)

connection_spec = mp_drawing.DrawingSpec(
    color=(255, 0, 0),      # 红色连接线(BGR格式)
    thickness=2
)

# ========== 步骤 2:复制原图 ==========
annotated_image = image.copy()

# ========== 步骤 3:用自定义样式绘制 ==========
mp_drawing.draw_landmarks(
    annotated_image,
    face_landmarks,
    connections=mp_face_mesh.FACEMESH_TESSELLATION,
    landmark_drawing_spec=landmark_spec,      # 传入自定义关键点样式
    connection_drawing_spec=connection_spec,  # 传入自定义连线样式
)

# ========== 步骤 4:展示结果 ==========
Image.fromarray(annotated_image[:, :, ::-1])
注意:DrawingSpec 的颜色是 BGR 格式!(0,255,0)=绿色,(255,0,0)=红色。

1.6 分区域单独绘制(按需选择)

步骤说明:
  1. 不要把所有层叠在一起画,按需单独绘制某一区域
  2. 画面更干净,更容易观察特定部位(工业检测中常需聚焦ROI)
  3. 适合专注查看某一区域的关键点
Python
 
# ========== 步骤 1:复制原图 ==========
annotated_image = image.copy()

# ========== 步骤 2:只绘制面部轮廓(最常用)==========
mp_drawing.draw_landmarks(
    image=annotated_image,
    landmark_list=face_landmarks,
    connections=mp_face_mesh.FACEMESH_CONTOURS,   # 只画轮廓,不画三角网格
    landmark_drawing_spec=None,
    connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_contours_style()
)

# ========== 步骤 3:展示结果 ==========
Image.fromarray(annotated_image[:, :, ::-1])
分区域绘制的优势:
表格
 
 
绘制方式画面效果适合场景
只画 CONTOURS 干净简洁 人脸识别、五官定位、美颜瘦脸
只画 TESSELLATION 线框密集 3D重建、表情迁移
只画 IRISES 只显示眼睛 视线追踪、疲劳检测
三层叠加 信息最全 综合调试、教学演示

1.7 归一化坐标转像素坐标

步骤说明:
  1. 视觉模型输出的是 0~1 的归一化坐标,与图像尺寸无关
  2. 必须乘以图像宽高,转换为实际像素坐标
  3. 像素坐标 = x × 宽度,y × 高度
Python
 
# ========== 步骤 1:获取图像尺寸 ==========
height, width, _ = image.shape

# ========== 步骤 2:取第一只手的21个关键点(手部检测为例,人脸同理)==========
landmarks = results.multi_hand_landmarks[0].landmark

# ========== 步骤 3:遍历并转换坐标 ==========
for idx, landmark in enumerate(landmarks):
    x = int(landmark.x * width)     # x 比例 × 图像宽度 = 像素x
    y = int(landmark.y * height)    # y 比例 × 图像高度 = 像素y
    print(f"关键点{idx}:像素坐标({x}, {y})")
为什么要归一化? 这样无论图像是 800×600 还是 1920×1080,同一相对位置的点坐标一样,模型通用,可直接部署到不同分辨率的工业相机上。

1.8 虹膜外接圆计算

步骤说明:
  1. 提取虹膜的 5 个关键点
  2. 用 cv2.minEnclosingCircle() 拟合最小外接圆
  3. 返回圆心坐标和半径
  4. 半径乘以 0.9 优化,让圆更贴合实际虹膜
Python
 
# ========== 步骤 1:定义虹膜关键点索引(refine后新增的10个点)==========
LEFT_IRIS_POINTS = [468, 469, 470, 471, 472]    # 左眼虹膜
RIGHT_IRIS_POINTS = [473, 474, 475, 476, 477]   # 右眼虹膜

# ========== 步骤 2:计算最小外接圆 ==========
(l_cx, l_cy), l_radius = cv2.minEnclosingCircle(mesh_points[LEFT_IRIS_POINTS])
(r_cx, r_cy), r_radius = cv2.minEnclosingCircle(mesh_points[RIGHT_IRIS_POINTS])

# ========== 步骤 3:转换为整数坐标 ==========
center_left = np.array([l_cx, l_cy], dtype=np.int32)
center_right = np.array([r_cx, r_cy], dtype=np.int32)

# ========== 步骤 4:优化半径(让虹膜圈更贴合眼球)==========
l_radius *= 0.9
r_radius *= 0.9

# ========== 步骤 5:绘制虹膜圆 ==========
cv2.circle(annotated_image, center_left, int(l_radius), (255, 0, 255), 2)   # 紫色左眼
cv2.circle(annotated_image, center_right, int(r_radius), (255, 0, 255), 2)  # 紫色右眼
minEnclosingCircle 计算的是刚好包住所有点的最小圆,但虹膜是椭圆,所以乘以 0.9 缩小一点更自然。

1.9 关键点顺时针排序

步骤说明:
  1. 某些区域(如眼框)需要按顺序绘制闭合多边形
  2. 计算所有点的几何中心
  3. 计算每个点相对于中心的角度
  4. 按角度降序排列 = 顺时针顺序
Python
 
def sort_points_clockwise(points):
    """对传入的二维坐标点进行顺时针排序,保证绘制多边形时轮廓闭合规整"""
    # 步骤 1:计算点集的中心点(平均x,平均y)
    centroid = np.mean(points, axis=0)
    
    # 步骤 2:计算每个点相对于中心点的角度
    angles = np.arctan2(
        points[:, 1] - centroid[1],   # y方向偏移(Δy)
        points[:, 0] - centroid[0]    # x方向偏移(Δx)
    )
    
    # 步骤 3:按角度降序排序 = 顺时针
    # np.argsort默认升序(逆时针),[::-1]倒序变成顺时针
    sorted_indices = np.argsort(angles)[::-1]
    return points[sorted_indices]
为什么要排序? 不排序直接连线,多边形会交叉乱成一团;排序后才能画出闭合规整的形状。

1.10 OpenCV 文字绘制

步骤说明:
  1. cv2.putText() 在图片上写文字
  2. 只支持英文,中文会乱码
  3. 如果要写中文,必须用 PIL(后面实战部分会讲)
Python
 
# ========== 步骤 1:在图片上写英文文字 ==========
cv2.putText(
    img=img,                          # 要绘制的图片
    text="Hello OpenCV!",             # 英文文本(只支持英文!)
    org=(50, 80),                     # 文字左下角坐标 (x, y)
    fontFace=cv2.FONT_HERSHEY_SIMPLEX,  # 字体样式
    fontScale=2,                      # 字体大小倍数
    color=(0, 255, 0),                # 颜色:绿色(BGR格式)
    thickness=3,                      # 线条粗细
    lineType=cv2.LINE_AA              # 抗锯齿,文字边缘更平滑
)

# ========== 步骤 2:创建空白图片绘制 ==========
# 创建一个 600×800 的纯白图片(高,宽,通道数)
img_blank = np.ones((600, 800, 3), dtype=np.uint8) * 255

第二模块:手势识别核心算法——从关键点提取到姿态判断

模块目标:掌握手部21个关键点的空间解析方法,理解「角度法+距离递减法」双重验证的核心算法,学会构建可落地的手势识别逻辑。这些算法可直接用于机械臂示教、无触摸HMI、全息投影交互等工业场景。

2.1 环境准备与导入依赖库

步骤说明:
  1. 基础库和人脸检测一致
  2. 新增 glob(批量文件匹配)、os(路径处理)
  3. 新增 ImageDraw(PIL绘图)、ImageFont(加载字体文件,支持中文标注)
Python
 
import cv2                          # OpenCV:图像/视频处理
import mediapipe as mp              # 开源视觉库:手部检测模型(本文以此为例做原理讲解)
import numpy as np                  # NumPy:数组运算
from PIL import Image, ImageDraw, ImageFont  # PIL:图像处理+绘制+字体
import glob                         # 批量文件路径匹配
import os                           # 操作系统接口:路径处理,文件操作

2.2 初始化手部检测模型

步骤说明:
  1. 从视觉库解决方案中导入手部模型
  2. 创建 Hands 检测器实例
  3. 手部特有参数:model_complexity 控制模型大小和精度(工业现场需在精度与帧率间权衡)
Python
 
# ========== 步骤 1:加载手部检测模块 ==========
mp_hands = mp.solutions.hands              # 手部检测核心模型(21个关键点)
mp_drawing = mp.solutions.drawing_utils    # 绘图工具
mp_drawing_styles = mp.solutions.drawing_styles  # 预设样式

# ========== 步骤 2:创建手部检测器实例 ==========
hands = mp_hands.Hands(
    static_image_mode=False,      # False=视频/实时模式(带跟踪优化);True=静态图片
    max_num_hands=2,              # 最多同时检测2只手
    model_complexity=1,           # 模型复杂度:0=轻量,1=标准,2=重型
    min_detection_confidence=0.5, # 最小检测置信度
    min_tracking_confidence=0.5,  # 最小跟踪置信度(视频模式有效)
)
model_complexity 详解(工业选型参考):
表格
 
 
值含义速度精度适用场景
0 轻量模型 最快 一般 边缘计算设备、嵌入式工控机
1 标准模型 中等 较好 平衡选择(默认推荐)
2 重型模型 最慢 最高 服务器级算力、高精度质检

2.3 读取图像,转换颜色,执行检测

步骤说明:
  1. 和人脸检测完全一致:读图 → BGR转RGB → 推理
  2. 所有深度学习视觉模型的预处理流程都一样,具备高度可迁移性
Python
 
# ========== 步骤 1:读取图像 ==========
image_path = "images/01.jpg"
image = cv2.imread(image_path)

# ========== 步骤 2:BGR → RGB 转换 ==========
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

# ========== 步骤 3:执行手部关键点检测推理 ==========
results = hands.process(image_rgb)

2.4 解析检测结果(关键点 + 左右手分类)

步骤说明:
  1. 判断检测结果是否存在
  2. 统计手的数量
  3. 遍历每只手,打印关键点数量
  4. 特有功能:自动判断左手/右手(对工业人机交互中的左右手分工至关重要)
Python
 
# ========== 步骤 1:判断是否检测到手 ==========
if results.multi_hand_landmarks:
    
    # 步骤 2:统计手的数量
    num_hands = len(results.multi_hand_landmarks)
    print(f"检测到 {num_hands} 只手")
    
    # 步骤 3:遍历每只手
    for i, hand_landmarks in enumerate(results.multi_hand_landmarks):
        print(f"  第{i+1}只手:{len(hand_landmarks.landmark)} 个关键点")
        
        # ========== 步骤 4:左右手分类(手部检测特有功能!)==========
        if results.multi_handedness:
            handedness = results.multi_handedness[i]
            label = handedness.classification[0].label    # "Left" 或 "Right"
            score = handedness.classification[0].score    # 分类置信度,如 0.95
            print(f"  手部类型:{label},置信度:{score:.2f}")
输出示例:
plain
 
检测到 2 只手
  第1只手:21 个关键点
  手部类型:Right,置信度:0.97
  第2只手:21 个关键点
  手部类型:Left,置信度:0.94
左右手分类原理:基于手掌朝向和手指布局判断,不是简单的位置左右。手心朝摄像头时准确率最高。在工业场景中,可用于区分操作员的惯用手,实现差异化的交互逻辑。

2.5 手部 21 个关键点索引详解

plain
 
        8  12  16  20   ← 指尖 (Tip)
        |   |   |   |
       7   11  15  19   ← 远节指骨 (DIP)
        |   |   |   |
       6   10  14  18   ← 近节指骨 (PIP)
        |   |   |   |
       5    9  13  17   ← 掌指关节 (MCP)
        \   |   |   /
         \  |   |  /
          \ |   | /
            0     ← 手腕 (Wrist)
           / \
          1   2   3   4  ← 大拇指 (Thumb: 1-4)
          
食指(5-8)  中指(9-12)  无名指(13-16)  小指(17-20)
表格
 
 
编号位置编号位置
0 手腕 (Wrist) 1-4 大拇指 (CMC, MCP, IP, Tip)
5-8 食指 (MCP, PIP, DIP, Tip) 9-12 中指
13-16 无名指 17-20 小指

2.6 自定义绘制手部关键点(按部位分色)

步骤说明:
  1. 遍历 21 个关键点
  2. 根据索引判断部位(手腕/指尖/指根/关节)
  3. 分配不同颜色
  4. 在图上标注索引编号,帮助记忆
Python
 
# ========== 步骤 1:准备画布 ==========
hand_image = image.copy()
height, width, _ = image.shape

# ========== 步骤 2:取第一只手的21个关键点 ==========
landmarks = results.multi_hand_landmarks[0].landmark

# ========== 步骤 3:遍历每个关键点,按部位分配颜色 ==========
for idx, landmark in enumerate(landmarks):
    x = int(landmark.x * width)
    y = int(landmark.y * height)
    
    # 根据关键点类型使用不同颜色和大小
    if idx == 0:                    # 手腕
        color = [0, 255, 0]         # 绿色
    elif idx in [4, 8, 12, 16, 20]:  # 指尖
        color = [255, 0, 0]          # 红色
    elif idx in [1, 5, 9, 13, 17]:   # 指根(MCP关节)
        color = [0, 0, 255]          # 蓝色
    else:                            # 其他关节(PIP/DIP)
        color = [255, 255, 0]        # 黄色
    
    # 在图上写索引编号(颜色跟随部位)
    cv2.putText(hand_image, str(idx), (x, y), cv2.FONT_HERSHEY_SIMPLEX, 2, color, 2)

# ========== 步骤 4:展示结果 ==========
Image.fromarray(hand_image[:, :, ::-1])
颜色分配表:
表格
 
 
部位索引颜色 (BGR)显示颜色
手腕 0 (0, 255, 0) 绿色
指根 (MCP) 1, 5, 9, 13, 17 (0, 0, 255) 蓝色
关节 (PIP/DIP) 2,3, 6,7, 10,11, 14,15, 18,19 (255, 255, 0) 黄色
指尖 (Tip) 4, 8, 12, 16, 20 (255, 0, 0) 红色
索引是固定的:手部检测的 21 个关键点索引是官方定义的,0 永远是手腕,4 永远是大拇指指尖。在工业二次开发中,可基于这些固定索引快速构建自定义测量逻辑。

2.7 手动遍历连接关系绘制骨架

步骤说明:
  1. HAND_CONNECTIONS 是预定义的连接关系列表
  2. 逐条获取起点和终点的像素坐标
  3. 用 cv2.line() 手动画线
  4. 好处:可以逐条控制颜色/粗细(适合工业可视化中的分级告警显示)
Python
 
# ========== 步骤 1:遍历手部连接关系 ==========
for connection in mp_hands.HAND_CONNECTIONS:
    start_idx, end_idx = connection
    
    # 步骤 2:获取起点和终点的像素坐标
    start_x = int(landmarks[start_idx].x * width)
    start_y = int(landmarks[start_idx].y * height)
    end_x = int(landmarks[end_idx].x * width)
    end_y = int(landmarks[end_idx].y * height)
    
    start_point = [start_x, start_y]
    end_point = [end_x, end_y]
    
    # 步骤 3:手动绘制连接线
    cv2.line(hand_image, start_point, end_point, (100, 200, 0), 2)

# ========== 步骤 4:展示结果 ==========
Image.fromarray(hand_image[:, :, ::-1])
HAND_CONNECTIONS 数据结构:
Python
 
# 本质是一个元组列表,每个元素是 (start_idx, end_idx)
HAND_CONNECTIONS = [
    (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), # 小指
]

2.8 构建 landmarks_dict(标准化数据结构)

步骤说明:
  1. 将视觉库输出的 landmark 列表转换为字典格式
  2. 同时存储归一化坐标(0~1)和像素坐标
  3. 方便后续按索引快速取值(工业算法开发中强烈推荐此标准化封装)
Python
 
# ========== 步骤 1:创建空字典 ==========
landmarks_dict = {}

# ========== 步骤 2:遍历所有关键点 ==========
for idx, landmark in enumerate(hand_landmarks.landmark):
    x_pixel = int(landmark.x * width)
    y_pixel = int(landmark.y * height)
    
    landmarks_dict[idx] = {
        "pixel": (x_pixel, y_pixel),                    # 像素坐标,用于绘制
        "normalized": [landmark.x, landmark.y, landmark.z],  # 归一化坐标,用于计算
    }

# 现在可以用 landmarks_dict[8] 直接取食指指尖
tip = landmarks_dict[8]["pixel"]        # (x, y) 像素坐标
tip_norm = landmarks_dict[8]["normalized"]  # [x, y, z] 归一化坐标

2.9 核心数学函数:3D 角度计算

步骤说明:
  1. 输入三个点的 3D 坐标(a, b, c)
  2. 计算以 b 为顶点的夹角 ∠abc
  3. 使用向量点积公式:cos(θ) = (A·B) / (|A||B|)
Python
 
def calculate_angle_3d(a, b, c):
    """使用三维坐标计算三点之间的角度(b是顶点)"""
    # 步骤 1:构建 numpy 数组 [x, y, z]
    a = np.array([a[0], a[1], a[2]])
    b = np.array([b[0], b[1], b[2]])
    c = np.array([c[0], c[1], c[2]])
    
    # 步骤 2:构建向量(从顶点b指向a和c)
    ba = a - b    # 向量 ba = 从b指向a
    bc = c - b    # 向量 bc = 从b指向c
    
    # 步骤 3:计算点积和模长
    dot_product = np.dot(ba, bc)           # 向量点积:ba · bc
    norm_ba = np.linalg.norm(ba)           # |ba| 的模长
    norm_bc = np.linalg.norm(bc)           # |bc| 的模长
    
    # 步骤 4:零向量保护(避免除零错误)
    if norm_ba == 0 or norm_bc == 0:
        return 0.0
    
    # 步骤 5:计算角度
    cos_angle = dot_product / (norm_ba * norm_bc)   # cos(θ)
    cos_angle = np.clip(cos_angle, -1.0, 1.0)       # 防止浮点误差导致越界
    angle = np.degrees(np.arccos(cos_angle))        # 弧度 → 角度(0°~180°)
    return angle
为什么要 np.clip:浮点计算可能有微小误差,导致 cos_angle 略大于1或略小于-1,arccos 会报错。clip 限制在 [-1, 1] 安全范围内。

2.10 核心数学函数:3D 距离计算

步骤说明:
  1. 输入两个点的 3D 坐标
  2. 计算欧几里得距离:√(Δx² + Δy² + Δz²)
Python
 
def calculate_3d_distance(p1, p2):
    """计算两点之间的3D欧几里得距离"""
    p1 = np.array([p1[0], p1[1], p1[2]])   # [x, y, z]
    p2 = np.array([p2[0], p2[1], p2[2]])
    return np.linalg.norm(p1 - p2)         # √(Δx² + Δy² + Δz²)

2.11 核心判断函数:手指是否伸直

步骤说明:
  1. 双重验证:角度法 + 距离递减法
  2. 角度 > 阈值(如160°)→ 三点近似共线 → 手指伸直
  3. 距离递减:指尖到手腕的距离 > 中节 > 近节 > 掌骨
  4. 两者同时满足 = 手指伸直
Python
 
def is_finger_straight(landmarks_dict, finger_points, angle_threshold, distance_tolerance=0.01):
    """
    验证单根手指是否伸直(同时满足角度和距离递减条件)
    
    参数:
        landmarks_dict: 手部关键点字典
        finger_points: 手指关键点序列(指尖,远节,近节,掌骨),如 (8,7,6,5)
        angle_threshold: 关节角度阈值(度),如160
        distance_tolerance: 距离递减的容差(防止浮点误差),默认0.01
    
    返回:
        bool: 是否伸直
    """
    # ========== 步骤 1:解包手指4个关键点 ==========
    tip, dip, pip, mcp = finger_points    # 指尖,远节关节,近节关节,掌指关节
    palm_root = 0                           # 手腕(手掌根)编号
    
    # ========== 步骤 2:计算关节角度(验证弯曲度)==========
    tip_norm = landmarks_dict[tip]["normalized"]
    dip_norm = landmarks_dict[dip]["normalized"]
    pip_norm = landmarks_dict[pip]["normalized"]
    
    # 计算 ∠PIP-DIP-TIP(以DIP为顶点的角度)
    angle = calculate_angle_3d(pip_norm, dip_norm, tip_norm)
    
    # ========== 步骤 3:计算各关键点到手掌根的距离(验证递减性)==========
    dist_tip = calculate_3d_distance(landmarks_dict[tip]["normalized"], landmarks_dict[palm_root]["normalized"])
    dist_dip = calculate_3d_distance(landmarks_dict[dip]["normalized"], landmarks_dict[palm_root]["normalized"])
    dist_pip = calculate_3d_distance(landmarks_dict[pip]["normalized"], landmarks_dict[palm_root]["normalized"])
    dist_mcp = calculate_3d_distance(landmarks_dict[mcp]["normalized"], landmarks_dict[palm_root]["normalized"])
    
    # ========== 步骤 4:验证距离递减(允许小容差)==========
    # 伸直时:指尖距离 > 中节距离 > 近节距离 > 掌骨距离
    distance_ok = (
        dist_tip - dist_dip > -distance_tolerance      # 指尖 ≥ 中节(容差内)
        and dist_dip - dist_pip > -distance_tolerance   # 中节 ≥ 近节
        and dist_pip - dist_mcp > -distance_tolerance    # 近节 ≥ 掌骨
    )
    
    # ========== 步骤 5:综合判断 ==========
    angle_ok = angle > angle_threshold   # 角度是否达标
    return angle_ok and distance_ok      # 两者同时满足 = 伸直
为什么需要两种方法结合?
表格
 
 
方法优点缺点单独使用的问题
角度法 直接反映关节弯曲程度 对手指侧向倾斜敏感 手指侧放时角度可能误判
距离递减法 不受手指方向影响 对缩放敏感 手指很短时容差不准
两者结合 互补,更鲁棒 计算稍多 工业场景推荐!

2.12 判断食指伸直(其他手指弯曲)

步骤说明:
  1. 食指伸直(角度阈值160°)
  2. 其他四根手指弯曲(not is_finger_straight)
  3. 用于「绘画模式」触发(工业中可映射为「示教模式」触发)
Python
 
def is_index_finger_extended(landmarks_dict):
    """检查食指是否伸直(其他手指弯曲)—— 用于触发绘画/示教模式"""
    # 步骤 1:食指伸直(角度阈值160度)
    index_straight = is_finger_straight(landmarks_dict, (8, 7, 6, 5), 160)
    
    # 步骤 2:其他手指弯曲(not = 伸直的反面)
    middle_bent = not is_finger_straight(landmarks_dict, (12, 11, 10, 9), 160)   # 中指弯曲
    ring_bent = not is_finger_straight(landmarks_dict, (16, 15, 14, 13), 160)    # 无名指弯曲
    pinky_bent = not is_finger_straight(landmarks_dict, (20, 19, 18, 17), 160)   # 小指弯曲
    thumb_bent = not is_finger_straight(landmarks_dict, (4, 3, 2, 1), 150)      # 大拇指弯曲(阈值150)
    
    # 步骤 3:所有条件同时满足
    return index_straight and middle_bent and ring_bent and pinky_bent and thumb_bent
大拇指阈值为什么是150? 大拇指关节结构和其他四指不同(鞍状关节),即使完全伸直,三点角度也很难达到160°,所以用150更合理。这是人体解剖学在工业算法中的体现。

2.13 四种手势检测函数

2.13.1 张开手掌

条件:5根手指都伸直
Python
 
def detect_open_palm(landmarks_dict):
    """检测张开手掌手势(所有手指都伸直)"""
    thumb_straight = is_finger_straight(landmarks_dict, (4, 3, 2, 1), 150)
    index_straight = is_finger_straight(landmarks_dict, (8, 7, 6, 5), 160)
    middle_straight = is_finger_straight(landmarks_dict, (12, 11, 10, 9), 160)
    ring_straight = is_finger_straight(landmarks_dict, (16, 15, 14, 13), 160)
    pinky_straight = is_finger_straight(landmarks_dict, (20, 19, 18, 17), 160)
    
    return (
        thumb_straight and index_straight and middle_straight 
        and ring_straight and pinky_straight
    )

2.13.2 剪刀手

条件:食指+中指伸直,拇指+无名指+小指弯曲
Python
 
def detect_scissors_gesture(landmarks_dict):
    """检测剪刀手势(食指+中指伸直,拇指+无名指+小指弯曲)"""
    # 食指和中指伸直
    index_straight = is_finger_straight(landmarks_dict, (8, 7, 6, 5), 160)
    middle_straight = is_finger_straight(landmarks_dict, (12, 11, 10, 9), 160)
    
    # 其他手指弯曲
    thumb_bent = not is_finger_straight(landmarks_dict, (4, 3, 2, 1), 150)
    ring_bent = not is_finger_straight(landmarks_dict, (16, 15, 14, 13), 160)
    pinky_bent = not is_finger_straight(landmarks_dict, (20, 19, 18, 17), 160)
    
    return index_straight and middle_straight and thumb_bent and ring_bent and pinky_bent

2.13.3 OK 手势

条件:拇指+食指指尖接触,其余手指伸直
Python
 
def detect_ok_gesture(landmarks_dict, distance_threshold=0.15):
    """
    检测OK手势(拇指+食指指尖接触,其余手指伸直)
    distance_threshold:指尖距离阈值(归一化坐标下),默认0.15
    """
    # ========== 步骤 1:检查拇指和食指指尖距离(是否接触)==========
    thumb_tip = landmarks_dict[4]["normalized"]    # 大拇指指尖
    index_tip = landmarks_dict[8]["normalized"]    # 食指指尖
    
    # 两指尖的实际距离
    tip_distance = calculate_3d_distance(thumb_tip, index_tip)
    
    # 用「手掌尺寸」做归一化(消除个体差异:大人手大,小孩手小)
    comp_distance = calculate_3d_distance(
        landmarks_dict[0]["normalized"],   # 手腕
        landmarks_dict[9]["normalized"]    # 中指根部(MCP)
    )
    
    # 相对距离 = 指尖距离 / 手掌长度
    tips_touch = (tip_distance / comp_distance) < distance_threshold
    
    # ========== 步骤 2:检查其余手指伸直 ==========
    middle_straight = is_finger_straight(landmarks_dict, (12, 11, 10, 9), 160)
    ring_straight = is_finger_straight(landmarks_dict, (16, 15, 14, 13), 160)
    pinky_straight = is_finger_straight(landmarks_dict, (20, 19, 18, 17), 160)
    
    # ========== 步骤 3:核心条件 ==========
    # 指尖接触 + 其余三指伸直 = OK手势
    return tips_touch and middle_straight and ring_straight and pinky_straight
归一化距离技巧:用 tip_distance / comp_distance 消除手掌大小差异。大人小孩比值一样,算法更鲁棒!在工业场景中,此技巧可推广到「不同规格工件的归一化测量」。

2.13.4 握拳

条件:5根手指都弯曲
Python
 
def detect_fist_gesture(landmarks_dict):
    """检测握拳手势(所有手指均弯曲)"""
    thumb_bent = not is_finger_straight(landmarks_dict, (4, 3, 2, 1), 150)
    index_bent = not is_finger_straight(landmarks_dict, (8, 7, 6, 5), 160)
    middle_bent = not is_finger_straight(landmarks_dict, (12, 11, 10, 9), 160)
    ring_bent = not is_finger_straight(landmarks_dict, (16, 15, 14, 13), 160)
    pinky_bent = not is_finger_straight(landmarks_dict, (20, 19, 18, 17), 160)
    
    return thumb_bent and index_bent and middle_bent and ring_bent and pinky_bent

第三模块:工业级实战——隔空绘图与全息交互原型开发

模块目标:综合运用前两模块所学,开发一套「隔空手势操控」原型系统。该系统可直接迁移到工业场景:如洁净室无触摸HMI、AR辅助装配中的隔空标注、机械臂的空中示教轨迹记录等。

3.1 项目概述

功能:通过摄像头捕捉手部动作,用食指在空中画画,支持手势控制(张开手掌清空,食指绘画,按键保存)。
交互逻辑:
表格
 
 
手势/操作功能工业映射
张开手掌 清空画布 复位/急停/清除轨迹
食指伸直,其他弯曲 绘画模式(食指当画笔) 示教模式/轨迹记录
按键 'q' 退出程序 关闭系统
按键 'c' 清空画布(备用) 软复位
按键 's' 保存绘画结果 保存示教轨迹/质检报告

3.2 完整代码实现

Python
 
import cv2
import mediapipe as mp
import numpy as np

# ========== 【步骤1】初始化手部检测模型 ==========
mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils

hands = mp_hands.Hands(
    static_image_mode=False,    # 视频/实时模式(带跟踪优化)
    max_num_hands=1,            # 只检测1只手(简化问题)
    model_complexity=1,
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5,
)

# ========== 【步骤2】初始化摄像头和画布 ==========
cap = cv2.VideoCapture(0)       # 打开默认摄像头(0=第一个摄像头)

# 读取一帧获取画面尺寸
ret, frame = cap.read()
height, width = frame.shape[:2]

# 创建空白画布(全黑,和摄像头画面一样大)
canvas = np.zeros((height, width, 3), dtype=np.uint8)

# 绘画参数
drawing_color = (0, 255, 255)     # 青色画笔
drawing_thickness = 5             # 画笔粗细
last_point = None                 # 记录上一帧画笔位置(用于连线)

# ========== 【步骤3】核心函数:判断食指伸直 ==========
def is_index_finger_extended(landmarks_dict):
    """检查食指是否伸直(其他手指弯曲)"""
    index_straight = is_finger_straight(landmarks_dict, (8, 7, 6, 5), 160)
    middle_bent = not is_finger_straight(landmarks_dict, (12, 11, 10, 9), 160)
    ring_bent = not is_finger_straight(landmarks_dict, (16, 15, 14, 13), 160)
    pinky_bent = not is_finger_straight(landmarks_dict, (20, 19, 18, 17), 160)
    thumb_bent = not is_finger_straight(landmarks_dict, (4, 3, 2, 1), 150)
    return index_straight and middle_bent and ring_bent and pinky_bent and thumb_bent

def is_open_palm(landmarks_dict):
    """检测张开手掌"""
    thumb = is_finger_straight(landmarks_dict, (4, 3, 2, 1), 150)
    index = is_finger_straight(landmarks_dict, (8, 7, 6, 5), 160)
    middle = is_finger_straight(landmarks_dict, (12, 11, 10, 9), 160)
    ring = is_finger_straight(landmarks_dict, (16, 15, 14, 13), 160)
    pinky = is_finger_straight(landmarks_dict, (20, 19, 18, 17), 160)
    return thumb and index and middle and ring and pinky

# (is_finger_straight, calculate_angle_3d, calculate_3d_distance 函数见第二模块)

# ========== 【步骤4】主循环:实时处理视频流 ==========
while True:
    # ---- 4.1 读取摄像头帧 ----
    ret, frame = cap.read()
    if not ret:
        break
    
    # ---- 4.2 水平翻转(镜像效果,看着更直观)----
    frame = cv2.flip(frame, 1)
    
    # ---- 4.3 BGR -> RGB 转换 ----
    rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    
    # ---- 4.4 手部检测 ----
    results = hands.process(rgb_frame)
    
    # ---- 4.5 创建显示帧(原图+画布叠加)----
    # 先把画布也翻转以匹配镜像后的画面
    flip_canvas = cv2.flip(canvas, 1)
    # 叠加:原图70%透明度 + 画布30%透明度
    display_frame = cv2.addWeighted(frame, 0.7, flip_canvas, 0.3, 0)
    
    # ---- 4.6 如果检测到手 ----
    if results.multi_hand_landmarks:
        for hand_landmarks in results.multi_hand_landmarks:
            
            # 4.6.1 绘制手部骨架(在显示帧上)
            mp_drawing.draw_landmarks(
                display_frame,
                hand_landmarks,
                mp_hands.HAND_CONNECTIONS,
                mp_drawing.DrawingSpec(color=(0, 0, 255), thickness=2, circle_radius=2),
                mp_drawing.DrawingSpec(color=(255, 0, 0), thickness=2),
            )
            
            # 4.6.2 构建 landmarks_dict
            landmarks_dict = {}
            for idx, landmark in enumerate(hand_landmarks.landmark):
                x_pixel = int(landmark.x * width)
                y_pixel = int(landmark.y * height)
                landmarks_dict[idx] = {
                    "pixel": (x_pixel, y_pixel),
                    "normalized": [landmark.x, landmark.y, landmark.z],
                }
            
            # 4.6.3 获取食指指尖坐标
            index_tip = landmarks_dict[8]["pixel"]
            
            # ========== 【步骤5】手势判断与交互 ==========
            
            # ---- 5.1 张开手掌 -> 清空画布 ----
            if is_open_palm(landmarks_dict):
                canvas = np.zeros((height, width, 3), dtype=np.uint8)   # 全黑画布
                last_point = None                                        # 重置画笔
                cv2.putText(
                    display_frame, "CLEAR CANVAS", (50, 50),
                    cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2
                )
            
            # ---- 5.2 食指伸直 -> 绘画模式 ----
            elif is_index_finger_extended(landmarks_dict):
                # 在食指指尖画圆点(视觉反馈,让用户知道「画笔」在哪)
                cv2.circle(display_frame, index_tip, 10, (0, 255, 255), -1)
                
                # 在画布上绘制(注意:画布需要翻转回来再画)
                flip_canvas = cv2.flip(canvas, 1)
                if last_point is not None:
                    # 从上一帧位置画线到当前位置
                    cv2.line(
                        flip_canvas,
                        last_point,           # 上一帧的食指位置
                        index_tip,            # 当前帧的食指位置
                        drawing_color,        # 画笔颜色
                        drawing_thickness,    # 画笔粗细
                    )
                canvas = cv2.flip(flip_canvas, 1)   # 画完再翻转回来
                
                # 更新上一个点
                last_point = index_tip
                
                # 显示「正在绘画」
                cv2.putText(
                    display_frame, "DRAWING", (50, 50),
                    cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2
                )
            
            # ---- 5.3 其他手势 -> 停止绘画 ----
            else:
                last_point = None   # 断开连线,下次画线会重新起点
    
    # ========== 【步骤6】显示结果 ==========
    cv2.imshow("Finger Drawing", display_frame)
    
    # ========== 【步骤7】按键交互 ==========
    key = cv2.waitKey(1) & 0xFF   # 读取按键(& 0xFF 兼容不同系统)
    
    if key == ord("q"):           # 按 q 退出
        break
    elif key == ord("c"):         # 按 c 清空画布
        canvas = np.zeros((height, width, 3), dtype=np.uint8)
        last_point = None
    elif key == ord("s"):         # 按 s 保存绘画结果
        cv2.imwrite("drawing_result.png", canvas)
        print("绘画已保存为 drawing_result.png")

# ========== 【步骤8】释放资源 ==========
cap.release()                     # 释放摄像头
cv2.destroyAllWindows()           # 关闭所有窗口

3.3 关键技巧详解

3.3.1 为什么需要 cv2.flip(canvas, 1) 翻转画布?

plain
 
摄像头画面:                    镜像后的画面(你看到的):
    
    你的左手                    你的「右手」
      |                          |
      V                          V
+-------------+              +-------------+
|   *         |              |         *   |
|             |   --镜像-->   |             |
|             |              |             |
+-------------+              +-------------+
    
你在屏幕前举起左手,镜像后看起来是右手。
如果你直接按摄像头坐标画线,画出来的方向是反的!
解决方案:
  1. 先把画布水平翻转
  2. 在翻转后的画布上画线(方向和你手的移动一致)
  3. 画完再翻转回来
  4. 最终显示时,画布和摄像头画面都是正常方向

3.3.2 cv2.addWeighted() 叠加原理

Python
 
display_frame = cv2.addWeighted(frame, 0.7, canvas, 0.3, 0)
# 公式:dst = frame * 0.7 + canvas * 0.3 + 0
  • 摄像头画面占 70% 透明度
  • 绘画痕迹占 30% 透明度(半透明)
  • 这样你能同时看到手、视频画面和画过的线条

3.3.3 last_point 的作用

plain
 
第1帧:index_tip 在 A 点 -> last_point = None -> 不画线
第2帧:index_tip 在 B 点 -> last_point = A -> 画线 A->B
第3帧:index_tip 在 C 点 -> last_point = B -> 画线 B->C
第4帧:手势改变 -> last_point = None -> 断开,下次重新起点
通过记录上一帧位置,实现连续画线效果。在工业示教中,这等价于「轨迹点插值记录」。

3.4 隔空绘图的数据流总结

plain
 
+---------------------------------------------+
|  1. 摄像头读取帧                             |
|     frame = cv2.flip(frame, 1)  <- 镜像     |
+---------------+-----------------------------+
                |
                V
+---------------------------------------------+
|  2. 视觉模型检测手部                         |
|     results = hands.process(rgb_frame)       |
+---------------+-----------------------------+
                |
        +-------+--------+
        |                |
        V                V
   没检测到手          检测到手
        |                |
        V                V
   显示原图           构建landmarks_dict
                      绘制手部骨架
                           |
              +------------+------------+
              |            |            |
              V            V            V
         张开手掌      食指伸直      其他手势
              |            |            |
              V            V            V
         清空画布      绘画模式      停止绘画
         canvas归零    画线+更新      last_point=None
         last_point=None last_point=tip
              |            |            |
         显示CLEAR     显示DRAWING    无操作
              +------------+------------+
                           |
+---------------------------------------------+
|  3. 叠加显示                                 |
|     addWeighted(frame, 0.7, canvas, 0.3)   |
|     cv2.imshow("Finger Drawing", ...)      |
+---------------------------------------------+

附录:常见问题速查

Q1:视觉模型输出的坐标为什么是 0~1?

A:这是归一化坐标,与图像尺寸无关。像素坐标 = x × 宽度,y × 高度。在工业多相机系统中,归一化坐标可实现跨设备兼容。

Q2:OpenCV 和 PIL 的颜色格式有什么区别?

A:
  • OpenCV 用 BGR:(0, 0, 255) = 红色
  • PIL 用 RGB:(255, 0, 0) = 红色

Q3:为什么 cv2.putText 不支持中文?

A:OpenCV 只支持系统内置英文字体。中文需要用 PIL 的 ImageFont.truetype() 加载 .ttf 字体文件。工业HMI中建议统一使用PIL绘制UI文字。

Q4:static_image_mode=True 和 False 有什么区别?

A:
  • True:每帧都重新全量检测(适合单张图片/静态质检)
  • False:利用上一帧跟踪信息加速(适合视频流/实时操控,更快更稳)

Q5:手势识别为什么用角度+距离双重验证?

A:单一方法都有局限。角度法对侧向倾斜敏感,距离法对缩放敏感。两者结合更鲁棒,适合工业现场复杂光照与多角度工况。
 
# -*- coding: utf-8 -*-
"""
空中画图(隔空绘图)示例 v4
=============================
交互逻辑:
  - 伸出食指(其余手指弯曲)-> 绘画:食指需先【静止 0.3 秒】确认落笔(防误触),
      之后轨迹平滑 + 断线宽限,画线更连贯;结束绘画:伸出食指+中指(转入移动判定)
    · 稳定性优化:绘画状态带迟滞(进入严格/退出宽松)、退出需连续多帧确认、
      指尖用 One Euro 滤波(静止更稳、快动更跟手),食指角度小幅变化不断画
  - 食指+中指伸直【静止 0.3 秒】-> 激活移动画布:食指指尖拖动画面,
        蜷回中指或食指即精确结束移动
  - 拇指+食指伸直成 L 形    -> 缩放画布:手腕先静止 0.4 秒确认,之后
        慢慢捏拢 = 缩小,慢慢张开 = 放大(目标倍率锚定基准 + 死区 + 限速,不漂移)
  - 食/中/无名指伸直(拇指收起,三指四指均可)-> 打开/关闭左侧颜色菜单(边沿触发);
        指尖停留的菜单项变清晰并显示进度条,其余变淡;
        指尖在菜单项上静止停留 0.25 秒 = 选中该颜色(进度条走满即确认,移动会重新计时),
        菜单最后一项为“关闭菜单”按钮,同样静止停留 0.25 秒选中即关闭;
        OK 手势保留,留给将来的“确认”操作
  - 张开手掌【静止保持 0.5 秒】-> 清空画布(张掌晃动视为误识别,重新计时,便于“张掌→握拳”保存)
  - 由张开手掌变为握拳       -> 保存图片:
        · 画布为空          -> 上方提示“无效指令”,忽略
        · 内容与上次相同     -> 提示是否覆盖(握拳=覆盖,张掌=取消)
        · 正常              -> 保存并在下方提示保存路径
  - OK 手势                 -> 保留给将来的“确认”操作(当前不触发任何功能)
  - 画面右下角显示操作日志(手势状态变化 / 菜单开关 / 选色 / 清屏 / 保存),便于排查状态
  - 按键 q / c / s          -> 退出 / 清空(备用)/ 保存(备用)

依赖:mediapipe==0.10.x / opencv-python / numpy / pillow
运行:python air_drawing.py
"""

import os
import time

import cv2
import mediapipe as mp
import numpy as np

# ==================== 核心数学函数(角度法 + 距离递减法) ====================

def calculate_angle_3d(a, b, c):
    """使用三维坐标计算三点之间的角度(b 是顶点),返回 0~180 度"""
    a = np.array([a[0], a[1], a[2]])
    b = np.array([b[0], b[1], b[2]])
    c = np.array([c[0], c[1], c[2]])
    ba, bc = a - b, c - b
    norm_ba, norm_bc = np.linalg.norm(ba), np.linalg.norm(bc)
    if norm_ba == 0 or norm_bc == 0:
        return 0.0
    cos_angle = np.clip(np.dot(ba, bc) / (norm_ba * norm_bc), -1.0, 1.0)
    return np.degrees(np.arccos(cos_angle))


def calculate_3d_distance(p1, p2):
    """计算两点之间的 3D 欧几里得距离"""
    return np.linalg.norm(np.array(p1[:3]) - np.array(p2[:3]))


def shift_canvas(img, dx, dy):
    """将画布内容整体平移 (dx, dy) 像素,移出边界的内容丢弃,空出区域填黑"""
    h, w = img.shape[:2]
    out = np.zeros_like(img)
    x0, x1 = max(0, dx), min(w, w + dx)         # 目标区域
    y0, y1 = max(0, dy), min(h, h + dy)
    sx0, sy0 = max(0, -dx), max(0, -dy)         # 对应的源区域
    if x1 > x0 and y1 > y0:
        out[y0:y1, x0:x1] = img[sy0:sy0 + (y1 - y0), sx0:sx0 + (x1 - x0)]
    return out


def scale_canvas(img, factor, center=None):
    """以 center(默认画布中心)为锚点同比例缩放画布内容,空出区域填黑、溢出裁掉"""
    h, w = img.shape[:2]
    if center is None:
        center = (w / 2.0, h / 2.0)
    mat = cv2.getRotationMatrix2D(center, 0.0, factor)   # 纯缩放的仿射矩阵
    return cv2.warpAffine(img, mat, (w, h), flags=cv2.INTER_LINEAR,
                          borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))


def is_finger_straight(landmarks_dict, finger_points, angle_threshold,
                       distance_tolerance=0.01):
    """验证单根手指是否伸直(角度 + 距离递减 双重验证)"""
    tip, dip, pip, mcp = finger_points
    palm_root = 0
    angle = calculate_angle_3d(
        landmarks_dict[pip]["normalized"],
        landmarks_dict[dip]["normalized"],
        landmarks_dict[tip]["normalized"])
    dists = [calculate_3d_distance(landmarks_dict[i]["normalized"],
                                   landmarks_dict[palm_root]["normalized"])
             for i in (tip, dip, pip, mcp)]
    distance_ok = all(dists[k] - dists[k + 1] > -distance_tolerance
                      for k in range(3))
    return angle > angle_threshold and distance_ok


# ==================== 手势判断函数 ====================

FINGERS = [(4, 3, 2, 1), (8, 7, 6, 5), (12, 11, 10, 9),
           (16, 15, 14, 13), (20, 19, 18, 17)]   # 拇/食/中/无名/小
THRESHOLDS = [150, 160, 160, 160, 160]

# 绘画状态迟滞阈值:进入绘画用严格角度,退出绘画允许食指弯到 128° 才判定离开,
# 避免绘画过程中食指角度轻微变化导致频繁断画。
DRAW_EXIT_ANGLE = 128


def _straight_flags(d):
    return [is_finger_straight(d, fp, th) for fp, th in zip(FINGERS, THRESHOLDS)]


def is_thumb_open(d, loose=False):
    """拇指是否张开(外展),专为 L 形缩放姿势设计。

    通用伸直判定要求各关节离手腕距离递减,但 L 形姿势下拇指尖朝向掌心,
    离手腕反而更近,会被误判为弯曲。这里改用:
      - 拇指 IP 关节角度足够直(2-3-4 关节)
      - 拇指尖(4)与食指 MCP(5)距离足够 —— 外展特征(比“相对小指根”
        更稳:捏合缩小时 tip 靠近食指,旧 tip_far 判定会立刻失败)
    loose=True 用于缩放保持:捏合过程中角度会变小,放宽阈值避免掉出 zoom。
    """
    angle = calculate_angle_3d(d[2]["normalized"], d[3]["normalized"],
                               d[4]["normalized"])
    # 拇指尖到食指根的距离:张开时明显大于捏合;用掌长归一化
    tip_to_index_mcp = calculate_3d_distance(d[4]["normalized"], d[5]["normalized"])
    palm_len = calculate_3d_distance(d[0]["normalized"], d[9]["normalized"])
    if palm_len <= 1e-6:
        return False
    spread = tip_to_index_mcp / palm_len
    if loose:
        # 保持:允许捏得很近(缩小动作),只要食指仍伸、拇指还在参与即可
        return angle > 100 and spread > 0.12
    # 进入:要求拇指明显外展,避免绘画时拇指自然微张被误判为 L 形
    return angle > 135 and spread > 0.45


def is_wide_l_shape(d):
    """是否标准的宽 L 形(缩放进入的补充判定):
    拇指尖与食指尖张得很开(间距/掌长 > 0.9)。绘画姿势下拇指放松收拢,
    两指间距明显更小,以此把绘画和缩放彻底分开。"""
    span = calculate_3d_distance(d[4]["normalized"], d[8]["normalized"])
    palm_len = calculate_3d_distance(d[0]["normalized"], d[9]["normalized"])
    return palm_len > 1e-6 and span / palm_len > 0.9


def _pinch_span(landmarks_dict):
    """拇指-食指间距:优先 3D 归一化距离(对前后晃动更稳),失败时退回像素距。"""
    try:
        return calculate_3d_distance(landmarks_dict[4]["normalized"],
                                     landmarks_dict[8]["normalized"])
    except Exception:
        t, i = landmarks_dict[4]["pixel"], landmarks_dict[8]["pixel"]
        return float(np.hypot(t[0] - i[0], t[1] - i[1]))


def classify_gesture(landmarks_dict, flags, prev_gesture="other"):
    """返回当前手势:'palm' / 'draw' / 'move' / 'zoom' / 'ok' / 'menu' / 'fist' / 'other'

    对 'draw' 状态做了迟滞处理:进入严格(食指伸直、其余手指弯曲),
    退出宽松(食指明显弯曲或其他手指张开才退出),拇指状态不影响绘画保持。
    'move' 状态:食指+中指伸直即判定(静止 0.3 秒激活在外部完成),
    保持期间容忍无名指/小指抖动,蜷回中指或食指即结束。
    'zoom' 状态:宽 L 形——食指伸直、拇指明显外展且拇食两指张得很开
    (双重判定,绘画的拇指微张不会误判);已进入 zoom 后用宽松保持,
    捏合缩小不会掉回绘画。
    'menu' 状态:食/中/无名指伸直且拇指收起(小指不参与,五指全伸是张掌)。
    'ok' 状态:仅保留分类,交给将来的“确认”操作使用。
    """
    thumb, index, middle, ring, pinky = flags

    if all([thumb, index, middle, ring, pinky]):
        return "palm"                                   # 张开手掌

    # OK 手势:拇指与食指指尖相触,其余三指伸直(保留给将来的“确认”操作)
    if middle and ring and pinky:
        tip_dist = calculate_3d_distance(landmarks_dict[4]["normalized"],
                                         landmarks_dict[8]["normalized"])
        palm_len = calculate_3d_distance(landmarks_dict[0]["normalized"],
                                         landmarks_dict[9]["normalized"])
        if palm_len > 0 and tip_dist / palm_len < 0.35:
            return "ok"

    # 移动画布:食指+中指伸直(无名指、小指弯曲)——静止 0.3 秒激活在外部状态机完成,
    # 这里只负责形态判定;结束绘画伸出双指会自然衔接进入移动
    if index and middle and not any([ring, pinky]):
        return "move"

    if not any([thumb, index, middle, ring, pinky]):
        return "fist"                                   # 握拳

    # 缩放迟滞保持:已在缩放中,食指仍大致伸直、中/无名/小未张开,且拇指仍参与
    # (宽松判定)——捏合缩小过程中拇指尖靠近食指,严格外展会失败,必须放宽。
    if prev_gesture == "zoom":
        index_angle = calculate_angle_3d(
            landmarks_dict[6]["normalized"],
            landmarks_dict[7]["normalized"],
            landmarks_dict[8]["normalized"])
        if (index_angle > DRAW_EXIT_ANGLE and not any([middle, ring, pinky])
                and is_thumb_open(landmarks_dict, loose=True)):
            return "zoom"

    # 绘画迟滞保持:已在绘画中,食指未明显弯曲且其余三指未张开 -> 继续绘画,
    # 这样食指角度的小幅抖动不会导致断画(拇指状态不影响,避免误触缩放)。
    if prev_gesture == "draw":
        index_angle = calculate_angle_3d(
            landmarks_dict[6]["normalized"],
            landmarks_dict[7]["normalized"],
            landmarks_dict[8]["normalized"])
        if index_angle > DRAW_EXIT_ANGLE and not any([middle, ring, pinky]):
            return "draw"

    # 缩放(同比例放大/缩小):宽 L 形——食指伸直、其余三指弯曲,
    # 拇指明显外展且拇指-食指张得很开(双重判定,绘画的拇指微张不会误判);
    # 放在绘画保持之后,绘画中拇指误张不会触发缩放
    if index and not any([middle, ring, pinky]) \
            and is_thumb_open(landmarks_dict) and is_wide_l_shape(landmarks_dict):
        return "zoom"

    # 进入绘画的严格条件:食指伸直且其余三指弯曲(拇指伸直会被上面的缩放拦截)
    if index and not any([middle, ring, pinky]):
        return "draw"

    # 菜单保持(迟滞):已在菜单状态且食/中/无名指仍伸直(拇指收起)-> 保持,
    # 容忍小指识别抖动
    if prev_gesture == "menu" and index and middle and ring and not thumb:
        return "menu"

    # 移动保持(迟滞):食指+中指仍伸直即保持移动(容忍无名指/小指抖动),
    # 蜷回中指(或食指)即精确结束移动
    if prev_gesture == "move" and index and middle:
        return "move"

    # 打开菜单(进入严格):三指/四指并排伸直——食/中/无名指伸直且拇指收起,
    # 小指不参与判定(五指全伸是张掌,已在最上面拦截),避免小指抖动在菜单与张掌间乱跳
    if index and middle and ring and not thumb:
        return "menu"

    return "other"


# ==================== 中文文字渲染(cv2.putText 不支持中文,用 PIL) ====================

HUD_FONT_PATH = None
for _candidate in (r"C:\Windows\Fonts\msyh.ttc",       # 微软雅黑
                   r"C:\Windows\Fonts\simhei.ttf"):    # 黑体
    if os.path.exists(_candidate):
        HUD_FONT_PATH = _candidate
        break

_font_cache = {}


def _get_font(size):
    from PIL import ImageFont
    if size not in _font_cache:
        _font_cache[size] = ImageFont.truetype(HUD_FONT_PATH, size)
    return _font_cache[size]


def render_hud(img_bgr, texts):
    """
    在 BGR 图像上绘制中文文本(不透明)。
    texts: [(text, (x, y), color_bgr, font_size), ...]
    """
    if HUD_FONT_PATH is None:
        for text, org, color, size in texts:
            cv2.putText(img_bgr, text.encode("ascii", "replace").decode(),
                        org, cv2.FONT_HERSHEY_SIMPLEX, size / 30.0, color, 2)
        return img_bgr
    from PIL import Image, ImageDraw
    pil_img = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
    draw = ImageDraw.Draw(pil_img)
    for text, org, color_bgr, size in texts:
        draw.text(org, text, font=_get_font(size),
                  fill=(color_bgr[2], color_bgr[1], color_bgr[0]))
    return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)


def render_menu(img_bgr, hover_idx, hover_dwell, colors, geometry, current_color):
    """
    用 PIL RGBA 叠加层绘制半透明颜色菜单:
      - 面板半透明,能看到后面的手和画面
      - 指尖停留的菜单项清晰(高透明度 + 绿色进度条显示停留进度),其余变淡
      - 颜色列表下方带“关闭菜单”按钮(hover_idx == len(colors) 时高亮)
    geometry: (x0, x1, top, item_h, gap)
    """
    from PIL import Image, ImageDraw
    x0, x1, top, item_h, gap = geometry

    base = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)).convert("RGBA")
    layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)

    panel_bottom = top + (len(colors) + 1) * (item_h + gap)
    draw.rectangle([x0 - 5, top - 30, x1 + 5, panel_bottom], fill=(30, 30, 30, 130))
    draw.text((x0 + 8, top - 27), "选择颜色", font=_get_font(16),
              fill=(255, 255, 255, 170))

    for i, (name, color_bgr) in enumerate(colors):
        y0 = top + i * (item_h + gap)
        y1 = y0 + item_h
        hovered = (i == hover_idx)
        alpha = 235 if hovered else 80           # 悬停项清晰,其余变淡
        rgb = (color_bgr[2], color_bgr[1], color_bgr[0])
        border = (255, 255, 255) if color_bgr == current_color else (150, 150, 150)

        draw.rectangle([x0, y0, x1, y1], outline=border + (alpha,), width=3 if hovered else 2)
        draw.rectangle([x0 + 4, y0 + 7, x0 + 24, y1 - 7], fill=rgb + (alpha,))
        draw.text((x0 + 32, y0 + 7), name, font=_get_font(15), fill=rgb + (alpha,))

        if hovered:                              # 停留选中进度条
            bar_w = int((x1 - x0) * min(hover_dwell, 1.0))
            draw.rectangle([x0, y1 - 4, x0 + bar_w, y1], fill=(0, 255, 0, 230))

    # ---- 列表底部的“关闭菜单”按钮(静止停留 0.25 秒选中即关闭) ----
    y0 = top + len(colors) * (item_h + gap)
    y1 = y0 + item_h
    close_hovered = (hover_idx == len(colors))
    alpha = 235 if close_hovered else 80
    draw.rectangle([x0, y0, x1, y1], outline=(255, 100, 100, alpha),
                   width=3 if close_hovered else 2)
    draw.text((x0 + 22, y0 + 7), "关闭菜单", font=_get_font(15),
              fill=(255, 100, 100, alpha))
    if close_hovered:
        bar_w = int((x1 - x0) * min(hover_dwell, 1.0))
        draw.rectangle([x0, y1 - 4, x0 + bar_w, y1], fill=(0, 255, 0, 230))

    out = Image.alpha_composite(base, layer)
    return cv2.cvtColor(np.array(out.convert("RGB")), cv2.COLOR_RGB2BGR)


# ==================== One Euro 滤波(指尖轨迹稳定) ====================

class OneEuroFilter2D:
    """One Euro 滤波器:静止时强滤波(去抖),快速移动时弱滤波(低延迟)。
    相比固定系数的 EMA 平滑,画慢线更稳、画快线更跟手。"""

    def __init__(self, min_cutoff=1.2, beta=0.015, d_cutoff=1.0):
        self.min_cutoff, self.beta, self.d_cutoff = min_cutoff, beta, d_cutoff
        self.t_prev = None
        self.x_prev = None
        self.dx_prev = np.zeros(2)

    @staticmethod
    def _alpha(cutoff, dt):
        tau = 1.0 / (2.0 * np.pi * cutoff)
        return 1.0 / (1.0 + tau / dt)

    def __call__(self, t, x):
        x = np.asarray(x, dtype=float)
        if self.t_prev is None:
            self.t_prev, self.x_prev = t, x
            return x
        dt = max(t - self.t_prev, 1e-4)
        dx = (x - self.x_prev) / dt
        a_d = self._alpha(self.d_cutoff, dt)
        dx_hat = a_d * dx + (1 - a_d) * self.dx_prev
        cutoff = self.min_cutoff + self.beta * np.abs(dx_hat)
        a = self._alpha(cutoff, dt)
        x_hat = a * x + (1 - a) * self.x_prev
        self.t_prev, self.x_prev, self.dx_prev = t, x_hat, dx_hat
        return x_hat


# ==================== 初始化手部检测模型 ====================

mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils

hands = mp_hands.Hands(
    static_image_mode=False,
    max_num_hands=1,
    model_complexity=1,
    min_detection_confidence=0.5,
    min_tracking_confidence=0.5,
)

# ==================== 初始化摄像头和画布 ====================

cap = cv2.VideoCapture(0)
ret, frame = cap.read()
if not ret:
    raise RuntimeError("无法打开摄像头,请检查设备连接")

height, width = frame.shape[:2]
canvas = np.zeros((height, width, 3), dtype=np.uint8)

# ---- 绘画参数 ----
DRAW_COLOR = (0, 255, 255)      # 当前画笔颜色(BGR 青色)
DRAW_THICKNESS = 5
GRACE_FRAMES = 15               # 手势短暂丢失时的宽限帧数(防断线)
MIN_MOVE = 2                    # 小于此位移视为抖动,不画(One Euro 已先滤波)
MAX_JUMP = 120                  # 大于此位移视为跳变,断笔重连
GESTURE_CONFIRM = 2             # 手势确认所需连续帧数(进入状态)
DRAW_EXIT_CONFIRM = 6           # 退出绘画状态所需连续帧数(退出更严格,防误断画)

# ---- 落笔确认(食指静止 0.3 秒才开始画,防误触) ----
DRAW_ARM_SECONDS = 0.3          # 进入绘画状态后食指需静止保持的秒数
DRAW_ARM_STILL_PX = 60          # 确认期间指尖允许的最大位移(超过则重新计时)

# ---- 缩放参数(拇指+食指 L 形:手腕静止确认 -> 按张合比率映射目标倍率) ----
# 算法:以确认瞬间的拇指-食指间距和画布倍率为基准,目标倍率 = 基准倍率 × 张合比率,
# 死区内不动作、每帧限速平滑趋近目标倍率——不会逐帧连乘漂移失控。
ZOOM_ARM_SECONDS = 0.35         # 手腕需静止保持的秒数(确认进入缩放)
ZOOM_STILL_PX = 55              # 确认阶段:手腕允许的最大位移(超过则重新确认)
ZOOM_ACTIVE_STILL_PX = 120      # 已就绪后:捏合/张开时手腕会动,用更松阈值,避免误退出
ZOOM_RATIO_MIN, ZOOM_RATIO_MAX = 0.25, 3.0    # 有效比率范围(超出视为手势变化)
ZOOM_DEAD = 0.04                # 比率相对基准变化小于该值 = 死区,不缩放(防抖)
ZOOM_FLOOR, ZOOM_CAP = 0.3, 3.0  # 画布总缩放倍率上下限(30% ~ 300%)
ZOOM_RATE = 0.15                # 每帧倍率最大变化量(限速,视觉上平滑)
ZOOM_EXIT_CONFIRM = 5           # 退出缩放所需连续帧数(防捏合瞬间掉回绘画)

# ---- 张掌清空参数(张掌晃动视为误识别会重新计时,静止才是指令) ----
PALM_HOLD_SECONDS = 0.5         # 张掌需静止保持的秒数
PALM_STILL_PX = 60              # 保持期间指尖允许的最大位移(超过则重新计时)

# ---- 颜色菜单配置(左侧弹出面板) ----
MENU_COLORS = [
    ("青色", (0, 255, 255)),
    ("绿色", (0, 255, 0)),
    ("红色", (0, 0, 255)),
    ("黄色", (0, 200, 255)),
    ("紫色", (255, 0, 255)),
    ("白色", (255, 255, 255)),
]
MENU_X0, MENU_X1 = 10, 130      # 面板横向范围
MENU_TOP = 90                   # 第一个菜单项的 y
MENU_ITEM_H, MENU_GAP = 38, 6   # 菜单项高度与间隔
MENU_GEOMETRY = (MENU_X0, MENU_X1, MENU_TOP, MENU_ITEM_H, MENU_GAP)
HOVER_SECONDS = 0.25            # 指尖在菜单项上静止停留达到该秒数即选中
WIGGLE_MIN_PX = 3               # 每帧位移小于该值视为静止(超过则重新计时)
MENU_COOLDOWN = 1.0             # 三指手势开关菜单的冷却秒数(防连开)

# ---- 移动画布参数(食指+中指伸直,静止 0.3 秒激活,食指拖动,蜷回一指结束) ----
MOVE_ARM_SECONDS = 0.3          # 双指伸直后需静止保持的秒数(激活移动)
MOVE_ARM_STILL_PX = 50          # 激活期间食指指尖允许的最大位移(超过重新计时)

# ---- 运行状态 ----
last_point = None               # 上一帧画笔位置
smooth_tip = None               # 滤波后的指尖位置
tip_filter = None               # One Euro 滤波器实例(每次落笔重建)
grace_count = 0                 # 断线宽限计数
move_last = None                # 移动画布:上一帧食指指尖位置(拖动基准)
move_activated = False          # 双指静止 0.3 秒后已激活拖动(区分“摆姿势”与“拖动中”)
move_arm_start = 0.0            # 激活确认:静止计时起点
move_arm_pos = None             # 激活确认:计时起点处的食指指尖位置
draw_ready = False              # 落笔确认:食指已静止保持 0.3 秒,可以绘画
draw_arm_start = 0.0            # 落笔确认:静止计时起点
draw_arm_pos = None             # 落笔确认:计时起点处的指尖位置
zoom_ready = False              # 缩放:手腕已静止确认,可以缩放
zoom_arm_start = 0.0            # 缩放确认:手腕静止计时起点
zoom_arm_pos = None             # 缩放确认:计时起点处的腕部位置
pinch_base = None               # 缩放基准:确认瞬间拇指-食指间距(像素)
zoom_base_level = 1.0           # 缩放基准:确认瞬间的画布倍率(目标倍率锚点)
zoom_level = 1.0                # 当前画布累计缩放倍率(平滑趋近目标档)
prev_gesture = "other"
candidate_gesture, candidate_count = "other", 0

palm_start = 0.0                # 张掌开始时间
palm_start_pos = None           # 张掌开始时的指尖位置(静止判定)
palm_cleared = False            # 本次张掌是否已触发过清空

menu_open = False               # 颜色菜单是否打开
menu_cooldown_until = 0.0       # 三指手势开关菜单的冷却
hover_idx = None                # 当前停留的菜单项
hover_still = 0.0               # 当前菜单项内累计静止停留时间(秒)
hover_last_xy = None            # 上一帧指尖位置(计算每帧位移)
hover_last_t = 0.0              # 上一帧时间戳(计算帧间隔)

top_msg, top_msg_until = "", 0.0          # 上方提示(无效指令/覆盖确认)
bottom_msg, bottom_msg_until = "", 0.0    # 下方提示(已保存路径)
pending_overwrite = False                 # 等待覆盖确认
overwrite_armed = False                   # 拳头松开后才允许确认覆盖
last_saved_path = None
last_saved_snapshot = None

save_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "drawings")
os.makedirs(save_dir, exist_ok=True)

# ---- 底部操作日志 ----
GESTURE_NAMES = {"palm": "张掌", "draw": "绘画", "move": "移动画布", "ok": "OK手势",
                 "zoom": "缩放", "menu": "三指菜单", "fist": "握拳",
                 "other": "过渡", "none": "无手"}
logs = []                       # [(过期时间, 文本), ...],显示在画面底部


def add_log(msg):
    """记录操作日志:打印到控制台并显示在画面底部(保留最近 6 条)"""
    print(msg)
    logs.append((time.time() + 4.0, msg))
    del logs[:-6]


def do_save(path=None):
    """保存画布,返回保存路径"""
    global last_saved_path, last_saved_snapshot
    if path is None:
        path = os.path.join(
            save_dir, "drawing_%s.png" % time.strftime("%Y%m%d_%H%M%S"))
    cv2.imwrite(path, canvas)
    last_saved_path = path
    last_saved_snapshot = canvas.copy()
    return path


print("空中画图 v4 已启动:")
print("  食指伸直静止 0.3 秒   -> 开始绘画;伸出食指+中指 -> 结束绘画(转入移动判定)")
print("  食指+中指伸直静止 0.3 秒 -> 激活移动:食指拖动画面,蜷回中指即结束")
print("  拇指+食指 L 形        -> 缩放画布(手腕静止 0.4 秒后,慢捏缩小、慢展放大)")
print("  张掌静止保持 0.5 秒   -> 清空画布(张掌晃动视为误识别,重新计时)")
print("  张掌后握拳            -> 保存图片(空画布忽略;重复内容询问覆盖)")
print("  食/中/无名指伸直(拇指收起)-> 打开/关闭颜色菜单;指尖静止 0.25 秒选中,末项可关闭菜单")
print("  OK 手势               -> 保留给将来的确认操作;按键 q/c/s -> 退出/清空/保存")

# ==================== 主循环 ====================

while True:
    ret, frame = cap.read()
    if not ret:
        break

    frame = cv2.flip(frame, 1)    # 镜像,手的移动方向与画面一致
    rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = hands.process(rgb_frame)

    display_frame = cv2.addWeighted(frame, 0.7, canvas, 0.3, 0)
    hud = []                      # 本帧要绘制的中文提示
    now = time.time()

    raw_gesture, index_tip = "none", None
    thumb_tip, wrist_pos = None, None
    landmarks_dict = None
    straight_flags = [False] * 5            # 拇/食/中/无名/小 是否伸直(供取消移动判定复用)
    if results.multi_hand_landmarks:
        hand_landmarks = results.multi_hand_landmarks[0]
        mp_drawing.draw_landmarks(
            display_frame, hand_landmarks, mp_hands.HAND_CONNECTIONS,
            mp_drawing.DrawingSpec(color=(0, 0, 255), thickness=2, circle_radius=2),
            mp_drawing.DrawingSpec(color=(255, 0, 0), thickness=2))

        landmarks_dict = {}
        for idx, landmark in enumerate(hand_landmarks.landmark):
            landmarks_dict[idx] = {
                "pixel": (int(landmark.x * width), int(landmark.y * height)),
                "normalized": [landmark.x, landmark.y, landmark.z],
            }
        straight_flags = _straight_flags(landmarks_dict)
        raw_gesture = classify_gesture(landmarks_dict, straight_flags, prev_gesture)
        index_tip = landmarks_dict[8]["pixel"]
        thumb_tip = landmarks_dict[4]["pixel"]
        wrist_pos = landmarks_dict[0]["pixel"]

    # ---- 手势消抖(不对称:退出绘画/缩放需要更多连续帧,避免捏合瞬间掉状态) ----
    if raw_gesture == candidate_gesture:
        candidate_count += 1
    else:
        candidate_gesture, candidate_count = raw_gesture, 1
    gesture = prev_gesture
    confirm_need = GESTURE_CONFIRM
    if prev_gesture == "draw" and candidate_gesture != "draw":
        confirm_need = DRAW_EXIT_CONFIRM
    elif prev_gesture == "zoom" and candidate_gesture != "zoom":
        confirm_need = ZOOM_EXIT_CONFIRM
    if candidate_count >= confirm_need:
        gesture = candidate_gesture

    # ==================== 三指/四指手势:开关颜色菜单(边沿触发,保持手势不会反复开关) ====================
    if gesture == "menu" and prev_gesture != "menu" and now > menu_cooldown_until:
        menu_open = not menu_open
        menu_cooldown_until = now + MENU_COOLDOWN
        hover_idx, hover_still, hover_last_xy = None, 0.0, None
        last_point, smooth_tip, tip_filter = None, None, None      # 菜单开关时抬笔
        add_log("菜单已打开" if menu_open else "菜单已关闭")
        hud.append(("菜单已打开" if menu_open else "菜单已关闭",
                    (width // 2 - 80, 20), (0, 255, 0), 28))

    # ---- 手势状态变化日志(排查“想画却画不上”时看状态卡在哪) ----
    if gesture != prev_gesture:
        add_log("状态:%s -> %s" % (GESTURE_NAMES.get(prev_gesture, prev_gesture),
                                    GESTURE_NAMES.get(gesture, gesture)))

    # ==================== 手势交互 ====================

    if gesture != "move":                        # 离开移动状态:丢弃拖动基准与激活状态
        move_last, move_activated, move_arm_pos = None, False, None
    if gesture != "draw":                        # 离开绘画状态:落笔确认重置
        draw_ready, draw_arm_pos = False, None
    if gesture != "zoom":                        # 离开缩放状态:确认与基准重置(累计倍率保留)
        zoom_ready, zoom_arm_pos, pinch_base = False, None, None

    if gesture == "palm":
        if pending_overwrite:                    # 覆盖确认中:张掌 = 取消
            pending_overwrite = False
            top_msg, top_msg_until = "已取消覆盖", now + 1.5
            palm_start, palm_start_pos, palm_cleared = 0.0, None, False
        else:
            # 张掌需【静止保持 0.5 秒】才清空,晃动的张掌视为误识别会重新计时
            if prev_gesture != "palm":
                palm_start, palm_start_pos, palm_cleared = now, index_tip, False
            elif palm_start_pos is not None and index_tip is not None:
                moved = np.hypot(index_tip[0] - palm_start_pos[0],
                                 index_tip[1] - palm_start_pos[1])
                if moved > PALM_STILL_PX:        # 手移动明显:重新计时
                    palm_start, palm_start_pos = now, index_tip

            if palm_cleared:
                hud.append(("画布已清空", (10, 20), (0, 0, 255), 26))
            else:
                held = now - palm_start
                if held >= PALM_HOLD_SECONDS:
                    canvas = np.zeros((height, width, 3), dtype=np.uint8)
                    palm_cleared = True
                    top_msg, top_msg_until = "画布已清空", now + 1.0
                    add_log("画布已清空")
                else:                            # 保持进度提示
                    hud.append(("保持张掌清空: %d%%" % int(held / PALM_HOLD_SECONDS * 100),
                                (10, 20), (0, 165, 255), 26))
        last_point, smooth_tip, tip_filter = None, None, None

    elif gesture == "fist":
        if pending_overwrite:
            if overwrite_armed:                  # 由掌变握后再次握拳 = 确认覆盖
                path = do_save(last_saved_path)
                bottom_msg = "已覆盖保存: %s" % path
                bottom_msg_until = now + 3.0
                top_msg_until = 0.0
                pending_overwrite = False
                add_log("已覆盖保存:%s" % path)
        elif prev_gesture == "palm":             # 由掌变握 -> 请求保存
            if not np.any(canvas):               # 画布为空:无效指令,忽略
                top_msg = "识别到保存指令,但画布为空,已忽略"
                top_msg_until = now + 2.0
                add_log("画布为空,保存指令已忽略")
            elif (last_saved_snapshot is not None
                  and np.array_equal(canvas, last_saved_snapshot)):
                pending_overwrite, overwrite_armed = True, False
                top_msg = "内容与上次保存相同:握拳=覆盖,张掌=取消"
                top_msg_until = now + 5.0
                add_log("内容与上次相同,等待覆盖确认")
            else:
                path = do_save()
                bottom_msg = "已保存: %s" % path
                bottom_msg_until = now + 3.0
                add_log("已保存:%s" % path)
        last_point, smooth_tip, tip_filter = None, None, None

    elif gesture == "move" and not menu_open and index_tip is not None:
        # ---- 食指+中指伸直:静止 0.3 秒激活后,食指指尖拖动整个画布;蜷回一指结束 ----
        if not move_activated:
            # 激活确认:双指伸直且食指静止保持 0.3 秒(手在移动则重新计时)
            if move_arm_pos is None:
                move_arm_start, move_arm_pos = now, index_tip
            elif np.hypot(index_tip[0] - move_arm_pos[0],
                          index_tip[1] - move_arm_pos[1]) > MOVE_ARM_STILL_PX:
                move_arm_start, move_arm_pos = now, index_tip
            held = now - move_arm_start
            if held >= MOVE_ARM_SECONDS:
                move_activated = True
                move_last = index_tip
                add_log("移动已激活:食指拖动画面,蜷回中指结束")
            else:
                hud.append(("保持静止激活移动: %d%%" % int(held / MOVE_ARM_SECONDS * 100),
                            (10, 20), (255, 255, 255), 26))
        else:
            # 拖动中:按食指指尖位移整体平移画布
            if move_last is not None:
                dx = index_tip[0] - move_last[0]
                dy = index_tip[1] - move_last[1]
                if dx or dy:
                    canvas = shift_canvas(canvas, dx, dy)
            move_last = index_tip
            hud.append(("移动画布中(蜷回中指结束)", (10, 20), (255, 255, 255), 26))

        cv2.circle(display_frame, index_tip, 10, (255, 255, 255), 2)
        last_point, smooth_tip, tip_filter = None, None, None   # 移动时抬笔

    elif gesture == "zoom" and not menu_open \
            and thumb_tip is not None and index_tip is not None \
            and wrist_pos is not None and landmarks_dict is not None:
        # ---- 拇指+食指 L 形:手腕先静止确认,再按张合比率映射目标倍率(死区 + 限速趋近) ----
        if not zoom_ready:
            if zoom_arm_pos is None:
                zoom_arm_start, zoom_arm_pos = now, wrist_pos
            elif np.hypot(wrist_pos[0] - zoom_arm_pos[0],
                          wrist_pos[1] - zoom_arm_pos[1]) > ZOOM_STILL_PX:
                zoom_arm_start, zoom_arm_pos = now, wrist_pos    # 手腕移动:重新计时
            held = now - zoom_arm_start
            if held >= ZOOM_ARM_SECONDS:
                zoom_ready = True
                # 用 3D 归一化间距作基准,减少前后晃动造成的伪缩放
                pinch_base = max(_pinch_span(landmarks_dict), 1e-4)
                zoom_base_level = zoom_level     # 锚定确认瞬间的倍率,防漂移
                add_log("缩放已就绪:慢慢捏合缩小,慢慢展开放大")
            else:
                hud.append(("手腕静止准备缩放: %d%%" % int(held / ZOOM_ARM_SECONDS * 100),
                            (10, 20), (0, 200, 255), 26))
        else:
            wrist_moved = np.hypot(wrist_pos[0] - zoom_arm_pos[0],
                                   wrist_pos[1] - zoom_arm_pos[1])
            if wrist_moved > ZOOM_ACTIVE_STILL_PX:
                # 手腕大幅平移才退出;捏合/张开时的小幅腕动不再打断缩放
                zoom_ready, zoom_arm_pos, pinch_base = False, None, None
            else:
                pinch = max(_pinch_span(landmarks_dict), 1e-4)
                ratio = pinch / pinch_base if pinch_base else 1.0
                if ZOOM_RATIO_MIN <= ratio <= ZOOM_RATIO_MAX:
                    if abs(ratio - 1.0) <= ZOOM_DEAD:
                        # 死区:回到/保持基准倍率附近,避免抖动;手指回到初始间距≈原倍率
                        target = zoom_base_level
                    else:
                        # 目标倍率 = 确认瞬间基准倍率 × 当前张合比率(绝对映射,不连乘)
                        target = zoom_base_level * ratio
                        target = max(ZOOM_FLOOR, min(ZOOM_CAP, target))
                    delta = target - zoom_level
                    if abs(delta) > 0.001:
                        step = max(-ZOOM_RATE, min(ZOOM_RATE, delta))   # 限速平滑趋近
                        canvas = scale_canvas(canvas, (zoom_level + step) / zoom_level)
                        zoom_level += step
                        hud.append(("放大中" if step > 0 else "缩小中",
                                    (width // 2 - 60, 20), (0, 200, 255), 26))
                    hud.append(("缩放: %d%%" % int(zoom_level * 100),
                                (10, 20), (0, 200, 255), 26))
                else:
                    # 比率极端时只刷新基准,不立刻退出,避免一次捏过头就整段作废
                    pinch_base = pinch
                    zoom_base_level = zoom_level
        cv2.line(display_frame, thumb_tip, index_tip, (0, 200, 255), 2)
        last_point, smooth_tip, tip_filter = None, None, None   # 缩放时抬笔

    elif gesture == "draw" and not menu_open and index_tip is not None:
        if not draw_ready:
            # ---- 落笔确认:食指静止保持 0.3 秒才开始画(防误触),移动则重新计时 ----
            if draw_arm_pos is None:
                draw_arm_start, draw_arm_pos = now, index_tip
            elif np.hypot(index_tip[0] - draw_arm_pos[0],
                          index_tip[1] - draw_arm_pos[1]) > DRAW_ARM_STILL_PX:
                draw_arm_start, draw_arm_pos = now, index_tip
            held = now - draw_arm_start
            if held >= DRAW_ARM_SECONDS:
                draw_ready = True
                add_log("开始绘画")
            else:
                hud.append(("保持静止开始绘画: %d%%" % int(held / DRAW_ARM_SECONDS * 100),
                            (10, 20), DRAW_COLOR, 26))
                cv2.circle(display_frame, index_tip, 10, DRAW_COLOR, 2)
        if draw_ready:
            # ---- 食指绘画:One Euro 滤波 + 防断线 ----
            if tip_filter is None:                   # 新的一笔:重建滤波器
                tip_filter = OneEuroFilter2D()
            fx, fy = tip_filter(now, index_tip)
            if np.isnan(fx) or np.isnan(fy):         # 滤波器异常输出:重置本笔
                last_point, smooth_tip, tip_filter, grace_count = None, None, None, 0
            else:
                smooth_tip = (int(fx), int(fy))
                cv2.circle(display_frame, smooth_tip, 10, DRAW_COLOR, -1)

                if last_point is not None:
                    dist = np.hypot(smooth_tip[0] - last_point[0],
                                    smooth_tip[1] - last_point[1])
                    if MIN_MOVE <= dist <= MAX_JUMP:     # 正常移动:连线
                        cv2.line(canvas, last_point, smooth_tip, DRAW_COLOR, DRAW_THICKNESS)
                    # dist < MIN_MOVE:抖动不画;dist > MAX_JUMP:跳变断笔重连
                last_point = smooth_tip
                grace_count = 0

    else:
        # 手势短暂丢失/切换:给 GRACE_FRAMES 帧宽限,避免画线频繁断开
        if raw_gesture in ("other", "none") and last_point is not None \
                and grace_count < GRACE_FRAMES:
            grace_count += 1
        else:
            last_point, smooth_tip, tip_filter, grace_count = None, None, None, 0

    # ---- 覆盖确认的超时与确认动作武装 ----
    if pending_overwrite:
        if gesture != "fist":
            overwrite_armed = True               # 拳头已松开,允许下次握拳确认
        if now > top_msg_until:
            pending_overwrite = False

    # ==================== 颜色菜单(左侧半透明面板) ====================
    if menu_open:
        # 指尖悬停判定:在菜单项上静止停留累计 HOVER_SECONDS 秒即选中,移动则重新计时
        hover_dwell = 0.0
        if index_tip is not None and MENU_X0 <= index_tip[0] <= MENU_X1:
            rel_y = index_tip[1] - MENU_TOP
            slot, in_y = divmod(rel_y, MENU_ITEM_H + MENU_GAP)
            # 0..len-1 是颜色项,len(MENU_COLORS) 是底部的“关闭菜单”按钮
            idx = slot if 0 <= slot <= len(MENU_COLORS) and in_y < MENU_ITEM_H else None
            if idx is not None:
                if hover_idx != idx:             # 进入新菜单项:重新计时
                    hover_idx, hover_still = idx, 0.0
                    hover_last_xy, hover_last_t = index_tip, now
                else:
                    dt = now - hover_last_t
                    hover_last_t = now
                    if np.hypot(index_tip[0] - hover_last_xy[0],
                                index_tip[1] - hover_last_xy[1]) <= WIGGLE_MIN_PX:
                        hover_still += dt        # 本帧静止:累计停留时间
                    else:
                        hover_still = 0.0        # 移动明显:重新计时
                    hover_last_xy = index_tip
                    hover_dwell = hover_still / HOVER_SECONDS
                    if hover_dwell >= 1.0:
                        if idx == len(MENU_COLORS):      # 选中“关闭菜单”按钮
                            add_log("菜单已关闭")
                        else:
                            DRAW_COLOR = MENU_COLORS[idx][1]
                            hud.append(("已选择: %s" % MENU_COLORS[idx][0],
                                        (width // 2 - 80, 20), DRAW_COLOR, 28))
                            add_log("已选择颜色:%s" % MENU_COLORS[idx][0])
                        menu_open = False
                        hover_idx, hover_still = None, 0.0
                        last_point, smooth_tip, tip_filter = None, None, None  # 选完抬笔重连
            else:
                hover_idx, hover_still = None, 0.0
        elif index_tip is not None:
            hover_idx, hover_still = None, 0.0

        if menu_open:
            # 半透明面板 + 悬停项清晰、其余变淡
            display_frame = render_menu(display_frame, hover_idx, hover_dwell,
                                        MENU_COLORS, MENU_GEOMETRY, DRAW_COLOR)
            # 指尖光标:让用户看清自己的“指针”在菜单上的位置
            if index_tip is not None:
                cv2.circle(display_frame, index_tip, 14, (255, 255, 255), 2)
                cv2.circle(display_frame, index_tip, 4, DRAW_COLOR, -1)

    # ==================== HUD 提示 ====================
    if gesture == "draw" and draw_ready and not menu_open:
        hud.append(("绘画中", (10, 20), DRAW_COLOR, 26))
    if now < top_msg_until and top_msg:
        hud.append((top_msg, (width // 2 - 220, 20), (0, 0, 255), 26))
    if now < bottom_msg_until and bottom_msg:
        hud.append((bottom_msg, (10, height - 40), (0, 255, 0), 22))

    # ---- 底部操作日志(右下角小字体右对齐,4 秒过期自动消失,不遮挡左侧菜单) ----
    logs[:] = [(t, m) for t, m in logs if now < t]
    log_font = _get_font(14)
    for i, (_, msg) in enumerate(logs):
        text_w = int(log_font.getlength(msg))
        hud.append((msg, (width - 10 - text_w,
                          height - 60 - (len(logs) - 1 - i) * 20),
                    (180, 180, 180), 14))

    if hud:
        display_frame = render_hud(display_frame, hud)

    prev_gesture = gesture
    cv2.imshow("Air Drawing", display_frame)

    key = cv2.waitKey(1) & 0xFF
    if key == ord("q"):
        break
    elif key == ord("c"):
        canvas = np.zeros((height, width, 3), dtype=np.uint8)
        last_point, smooth_tip, tip_filter = None, None, None
    elif key == ord("s"):
        if np.any(canvas):
            print("已保存: %s" % do_save())
        else:
            print("画布为空,未保存")

# ==================== 释放资源 ====================

cap.release()
cv2.destroyAllWindows()

 


写在最后:从原型到工业落地

恭喜你完成了整套「隔空手势与全息交互」技术的学习!
需要再次强调的是:
  • 本文讲解的是一种通用机器视觉思路,开源框架与商用方案非常丰富(如 OpenPose、HaGrid、各类基于YOLO/Pose的自研模型等),不同工业场景下可灵活选型;
  • 本次实战以某主流开源视觉框架为例进行原理讲解,旨在帮你建立可迁移的工程思维,而非绑定特定技术栈;
  • 在真实工业落地中,你还需要考虑:相机标定、光照补偿、抗抖动滤波、通信协议(Modbus/ROS/MQTT)、安全互锁等;
  • 建议下一步尝试:将「食指轨迹」映射为机械臂末端轨迹,或将「手势识别」接入PLC控制逻辑,实现真正的无接触工业操控。
工业4.0的交互方式正在从「按钮+触摸屏」走向「手势+语音+眼动」的多模态融合。掌握机器视觉,就是掌握未来智能制造的入口。
祝你在工业自动化的道路上越走越远!
posted @ 2026-08-24 14:11  鬼门元歌  阅读(24)  评论(0)    收藏  举报