低成本Hand Tracking机械臂手势识别与跟踪

概述

效果视频

低成本Hand Tracking机械臂手势跟踪识别

执行步骤

  1. esp32-cam作为视觉获取图片,并建立WebServer。
  2. 电脑发送request请求获取图片,获取图片使用openCV处理,并与esp8266建立socket连接发送命令。
  3. esp8266接收命令控制舵机。

出现的问题

最严重的问题就是帧数太低,在有识别的时候帧数只有4帧上下,esp32-cam通过这种方法传输图片效率正是雪上加霜。这就导致了电脑向esp8266发送的数据数量太少,为了弥补移动的速度,每次接收命令都会移动5度。这就看起来跟视屏一样卡卡的。 也算是机械臂的完结了,接下来就可能考虑做个手套来控制机械臂完成同步。

代码以及讲解

esp32-cam

代码

#include "esp_camera.h"
#include <WiFi.h>
#define CAMERA_MODEL_AI_THINKER // Has PSRAM
#include "camera_pins.h"
#include <esp32cam.h>
#include <WebServer.h>
#include <uri/UriBraces.h>
#include <Wire.h>

const char *ssid = "HUAWEI-E6F5";   //wifi名
const char *password = "tian8568361.";  //wifi密码
WebServer web_server(80);//端口
static auto loRes = esp32cam::Resolution::find(320, 240);
void serveJpg()
{
  auto frame = esp32cam::capture();
  if (frame == nullptr) {
    Serial.println("CAPTURE FAIL");
    web_server.send(503, "", "");
    return;
  }

  web_server.setContentLength(frame->size());
  web_server.send(200, "image/jpeg");
  WiFiClient client = web_server.client();
  frame->writeTo(client);
}
void setup() {
  Serial.begin(115200);    // 初始化串口,并开启调试信息
  Serial.setDebugOutput(true);

  //------- esp32摄像头初始化
  {
    using namespace esp32cam;
    Config cfg;
    cfg.setPins(pins::AiThinker);
    cfg.setResolution(loRes);
    cfg.setBufferCount(2);
    cfg.setJpeg(80);

    bool ok = Camera.begin(cfg);
    Serial.println(ok ? "CAMERA OK" : "CAMERA FAIL");
  }

  WiFi.persistent(false);
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }

  Serial.print("http://");
  Serial.println(WiFi.localIP());
  Serial.println("  /cam-lo.jpg");
  web_server.on("/cam-lo.jpg", serveJpg);

  web_server.begin();



  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.println("WiFi connected");

  Serial.print("Camera Ready! Use 'http://");
  Serial.print(WiFi.localIP());
  Serial.println("' to connect");
  Serial.println("init success");
}


void loop() {
  web_server.handleClient();
}

代码说明

从头文件开始,首先需要了解的是为什么include后面导入的库既有<>也有“”,这是因为在c语言中这两种的索引方式不同,使用双引号,会有限检索本项目下的文件;使用尖括号会优先检索编译器头文件的检索路径。为了防止检报错,这里在该项目下的其他文件,这里都用双引号。在这个项目下一共有四个文件,除了当前文件之外,其他三个文件来自于esp32的实例程序cameraWebServer中。这也是第一次接触esp32-cam会跑起来的第一个程序。将这些文件复制粘贴到项目中。具体打开示例方法的步骤为:

 讲一下代码中的serveJpg()方法。

void serveJpg()
{
  auto frame = esp32cam::capture();
  if (frame == nullptr) {
    Serial.println("CAPTURE FAIL");
    web_server.send(503, "", "");
    return;
  }

  web_server.setContentLength(frame->size());
  web_server.send(200, "image/jpeg");
  WiFiClient client = web_server.client();
  frame->writeTo(client);
}

首先就是捕获摄像头的图像,然后判空,如果为空,就会向WebServer发送503这个响应代码。

接下来就是设定发送的内容长度为捕获图像的大小,响应代码为200,标题为image/jepg。通过WebServer将图片发送出去。关于WebServer的使用,在arduino的示例中也能找到。方法与前面的找到esp32-cam一致。使用esp32-cam的camera示例,打开的那个ip地址虽然能看到视屏并且速度不错,但是那个视频流OpenCV是无法捕获的。所以得用图片传输的方式。

//------- esp32摄像头初始化
{
  using namespace esp32cam;
  Config cfg;
  cfg.setPins(pins::AiThinker);
  cfg.setResolution(loRes);
  cfg.setBufferCount(2);
  cfg.setJpeg(80);

  bool ok = Camera.begin(cfg);
  Serial.println(ok ? "CAMERA OK" : "CAMERA FAIL");
}

在setup()函数中主要就是esp32的初始化问题。这里的配置依次为使用安信可的引脚模式。设置图片的分辨率,这个在全局变量中有设置。设置缓冲区大小。设置图片压缩比例(可能是,我在网上没有找到相关字段说明)。

web_server.on("/cam-lo.jpg", serveJpg);
web_server.begin();

接下来就是运行指定需要运行在该WebServer上的服务,web_server.on的第一个参数为域名,需要接在ip地址后才能访问该服务,第二个参数这个服务的处理函数。

web_server.begin()就开启了webServer了。到这里WebServer就正式配置完成。

setup函数下面的连接wifi、获取ip地址就不多讲了。

void loop() {
  web_server.handleClient();
}

在loop函数中这一句代码就是处理来自客户机的请求。也就是根据上面设置的服务选择相对应的处理函数。

电脑端

代码

# 开发作者   :Tian.Z.L
# 开发时间   :2022/4/4  20:18 
# 文件名称   :test4.PY
# 开发工具   :PyCharm
import urllib.request
import cv2
import numpy as np
import mediapipe as mp
import time
import socket

socket = socket.socket()
host = '192.168.8.121'
port = 8266
socket.connect((host, port))
mpHands = mp.solutions.hands
hands = mpHands.Hands(static_image_mode=False,
                      max_num_hands=2,
                      min_detection_confidence=0.5,
                      min_tracking_confidence=0.5)
mpDraw = mp.solutions.drawing_utils

pTime = 0
cTime = 0
url = 'http://192.168.8.115/cam-lo.jpg'  # 改成自己的ip地址+/cam-hi.jpg
video_width = 320
video_height = 240
center_point = (160, 120)
while True:
    try:
        imgResp = urllib.request.urlopen(url)
    except Exception as e:
        continue
    imgNp = np.array(bytearray(imgResp.read()), dtype=np.uint8)
    img = cv2.imdecode(imgNp, -1)
    imgRGB = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    results = hands.process(imgRGB)
    # print(results.multi_hand_landmarks)

    fingers = []
    finger_bool = True
    cache = (0, 0)
    hand_center = (0, 0)
    if results.multi_hand_landmarks:
        for handLms in results.multi_hand_landmarks:
            for id, lm in enumerate(handLms.landmark):
                # print(id,lm)
                h, w, c = img.shape
                cx, cy = int(lm.x * w), int(lm.y * h)
                if id == 9:
                    hand_center = (cx, cy)
                if id > 0 and id % 2 == 0:
                    if finger_bool:
                        cache = (cx, cy)
                        finger_bool = False
                    else:
                        if cx ** 2 + cy ** 2 > cache[0] ** 2 + cache[1] ** 2:
                            fingers.append(False)
                        else:
                            fingers.append(True)
                        finger_bool = True
                # if id ==0:
                cv2.circle(img, (cx, cy), 3, (255, 0, 255), cv2.FILLED)
    res = 'error'
    if len(fingers) == 5:
        # if fingers[1] and ~fingers[2] and ~fingers[3] and ~fingers[4]:
        #     order = 'b'
        #     socket.send(order.encode('utf-8'))
        # if fingers[1] and fingers[2] and ~fingers[3] and ~fingers[4]:
        #     order = 'a'
        #     socket.send(order.encode('utf-8'))
        if fingers[1] and fingers[2] and fingers[3] and fingers[4]:  # 跟随模式
            if (hand_center[0] - center_point[0]) / video_width >= 1 / 6:     # 右转
                order = 'c'
                socket.send(order.encode('utf-8'))
                data = socket.recv(1024)
                # print(data == b'ok')
                # continue
            if (hand_center[0] - center_point[0]) / video_width <= -1 / 6:    # 左转
                order = 'd'
                socket.send(order.encode('utf-8'))
                data = socket.recv(1024)
                # print(data == b'ok')
                # continue
            # print((hand_center[1] - center_point[1]) / video_height)
            if (hand_center[1] - center_point[1]) / video_height >= 1 / 6:     # 抬头
                order = 'e'
                socket.send(order.encode('utf-8'))
                data = socket.recv(1024)
                # print(data == b'ok')
                # continue
            if (hand_center[1] - center_point[1]) / video_height <= -1 / 6:    # 低头
                order = 'f'
                socket.send(order.encode('utf-8'))
                data = socket.recv(1024)
                # print(data == b'ok')
                # continue
    cTime = time.time()
    fps = 1 / (cTime - pTime)
    pTime = cTime

    cv2.putText(img, str(int(fps)), (10, 70), cv2.FONT_HERSHEY_PLAIN, 3, (255, 0, 255), 3)

    cv2.imshow("Image", img)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

代码讲解

socket = socket.socket()
host = '192.168.8.121'
port = 8266
socket.connect((host, port))
mpHands = mp.solutions.hands
hands = mpHands.Hands(static_image_mode=False,
                      max_num_hands=2,
                      min_detection_confidence=0.5,
                      min_tracking_confidence=0.5)
mpDraw = mp.solutions.drawing_utils

pTime = 0
cTime = 0
url = 'http://192.168.8.115/cam-lo.jpg'  # 改成自己的ip地址+/cam-hi.jpg
video_width = 320
video_height = 240
center_point = (160, 120)

这是在while循环上面的代码。前四行的socket连接是用来连接esp8266也就是控制舵机的主控,host、port在之后的esp8266讲,就是esp8266服务器端的ip以及端口号。

手势识别的主要方法在这里使用的是OpenCV的mediapipe库。详情信息这里不做过多介绍,这个库可以识别手势,身体的形态等。详情的API文档查看MediaPipe。这里选择的是手势。

在配置手势识别中。

  • static_image_mode。有true和false两种值。当为false时,就将输入的图片视为是视屏流。当为True时,就会将在第一张图片中检测手并记录位置,稍后只需要在此基础上进行坐标的改变即可一直到失去识别。这能大大的减少了延迟。默认为False
  • max_num_hands。顾名思义就是最大检测手的数量。默认值为2
  • min_detection_confidence。最小置信值。当画面中的物体的置信度大于等于这个值得时候就判定为手。默认值为0.5
  • min_tracking_confidence。追踪手的的最小置信度。将这个值设置为更高可以提高检测的稳健性,但是开销高。如果static_image_mode的值设置为True那么这个参数无效。默认值为0.5

下面的url就是要访问WebServer的地址。

try:
    imgResp = urllib.request.urlopen(url)
except Exception as e:
    continue
imgNp = np.array(bytearray(imgResp.read()), dtype=np.uint8)
img = cv2.imdecode(imgNp, -1)
imgRGB = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
results = hands.process(imgRGB)

在while循环中。首先就是需要获取图片。因为esp32-cam的性能限制,将图片上传的效率是非常低的,大概只有5帧左右,但是电脑的处理速度远大于这个,就会导致请求发送,却得不到回应,返回404 not found的情况,所以这里加了异常处理模块。

hands.process函数返回的results包含了关键部位的索引和x,y,z坐标归一化之后的值。下图为官方提供的索引值与关键点对应。

 

fingers = []    # 手指张开数组
finger_bool = True    # 标志位
cache = (0, 0)    
hand_center = (0, 0)    # 手中心的点
if results.multi_hand_landmarks:
    for handLms in results.multi_hand_landmarks:
        for id, lm in enumerate(handLms.landmark):
            # print(id,lm)
            h, w, c = img.shape
            cx, cy = int(lm.x * w), int(lm.y * h)
            if id == 9:
                hand_center = (cx, cy)
            if id > 0 and id % 2 == 0:
                if finger_bool:
                    cache = (cx, cy)
                    finger_bool = False
                else:
                    if cx ** 2 + cy ** 2 > cache[0] ** 2 + cache[1] ** 2:
                        fingers.append(False)
                    else:
                        fingers.append(True)
                    finger_bool = True
            # if id ==0:
            cv2.circle(img, (cx, cy), 3, (255, 0, 255), cv2.FILLED)

上面讲到了results里包含的是手势的索引值以及x,y,z坐标归一化后的值,这里在迭代器中用id代表索引值,lm代表x,y,z坐标归一化后的值取出这些数据。通过归一化值与长宽相乘就能得到对应点的真实坐标。这里将9号点设置为手的中心点。

如何判断手指屈伸?这里使用了偶数号点的坐标距离图像左上方(0,0)点的距离进行比较。例如8号点的距离小于6号点的距离就能表示手指是竖着的。所以只要取到偶数点进行比较即可。将手指情况存入数组中。

下面的代码就是判断是手指情况以及向esp8266发送命令就不多讲了。

eps8266

代码

#include <ESP8266WiFi.h>
#include <Servo.h>

const char *ssid = "HUAWEI-E6F5";   //wifi名
const char *password = "tian8568361.";  //wifi密码
WiFiServer server(8266);            //端口

int ActonFlag;
Servo servo[4];  // Servo object
int servo_pos[4] = {96, 39, 56, 90};

void setup() {
  Serial.begin(115200);    // 初始化串口,并开启调试信息
  Serial.println();
  IPAddress softLocal(192, 168, 8, 121);  //IP地址
  IPAddress softGateway(192, 168, 1, 121);
  IPAddress softSubnet(255, 255, 255, 0);
  WiFi.config(softLocal, softGateway, softSubnet);
  WiFi.begin(ssid, password);
  server.begin();            //Tells the server to begin listening for incoming connections.
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }

  Serial.print("http://");
  Serial.println(WiFi.localIP());
  servo[0].attach(5);
  servo[0].write(96);
  servo[1].attach(4);
  servo[1].write(servo_pos[1]);
  servo[2].attach(00);
  servo[2].write(servo_pos[2]);
  servo[3].attach(2);
  servo[3].write(servo_pos[3]);
  Serial.println("init success!");
}


void loop() {
  WiFiClient client = server.available();  //Gets a client that is connected to the server and has data available for reading.
  if (client)
  {
    while (client.connected())      //Whether or not the client is connected.
    {
      if (client.available()) //Returns the number of bytes available for reading
      {
        ActonFlag = client.read();
        Serial.print(ActonFlag);
        switch (ActonFlag) {
          case 'a':
            servo[0].write(96);
            break;
          case 'b':
            servo[0].write(180);
            break;
          case 'c':
            servo_pos[3] -= 5;
            servo_pos[3] < 0 ? servo_pos[3] = 0 : servo_pos[3] = servo_pos[3];
            servo[3].write(servo_pos[3]);
            client.print("ok");
            break;
          case 'd':
            servo_pos[3] += 5;
            servo_pos[3] > 180 ? servo_pos[3] = 180 : servo_pos[3] = servo_pos[3];
            servo[3].write(servo_pos[3]);
            client.print("ok");
            break;
          case 'e':
            servo_pos[2] -= 5;
            servo_pos[2] < 0 ? servo_pos[2] = 0 : servo_pos[2] = servo_pos[2];
            servo[2].write(servo_pos[2]);
            client.print("ok");
            break;
          case 'f':
            servo_pos[2] += 5;
            servo_pos[2] > 180 ? servo_pos[2] = 180 : servo_pos[2] = servo_pos[2];
            servo[2].write(servo_pos[2]);
            client.print("ok");
            break;
        }
      }
    }
    delay(1);
    client.stop();
  }
}

代码讲解

#include <ESP8266WiFi.h>
#include <Servo.h>

const char *ssid = "HUAWEI-E6F5";   //wifi名
const char *password = "tian8568361.";  //wifi密码
WiFiServer server(8266);            //端口

int ActonFlag; // 存储传来的指令
Servo servo[4];  // Servo object
int servo_pos[4] = {96, 39, 56, 90};

在setup函数之前这些全局变量。包括局域网的名称密码、创建服务器的端口号(8266)、用来存储传来指令的变量ActonFlag、实例化舵机数组大小为4、舵机的位置信息数组servo_pos。

void setup() {
  Serial.begin(115200);    // 初始化串口,并开启调试信息
  Serial.println();
  IPAddress softLocal(192, 168, 8, 121);  //IP地址
  IPAddress softGateway(192, 168, 1, 121);
  IPAddress softSubnet(255, 255, 255, 0);
  WiFi.config(softLocal, softGateway, softSubnet);
  WiFi.begin(ssid, password);
  server.begin();            //Tells the server to begin listening for incoming connections.
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }

  Serial.print("http://");
  Serial.println(WiFi.localIP());
  servo[0].attach(5);
  servo[0].write(96);
  servo[1].attach(4);
  servo[1].write(servo_pos[1]);
  servo[2].attach(0);
  servo[2].write(servo_pos[2]);
  servo[3].attach(2);
  servo[3].write(servo_pos[3]);
  Serial.println("init success!");
}

使用IPAddress来设置静态的ip地址,掩码等信息。也可以不需要这几行代码,让局域网自动分配ip地址。因为esp8266所有的I/O引脚都复用pwm,舵机必须要接在pwm引脚上,不能接在数字输出引脚。具体的esp8266对应的arduino引脚关系如下图

 

在这里,我把第一个舵机插在1号位置,也就是代码提到的servo[0].attach(5)。第一个舵机插在1号位置,也就是代码提到的servo[1].attach(4),以此类推。应该就清楚的展示了arduino以及esp8266的引脚关系了。

ActonFlag = client.read();
        Serial.print(ActonFlag);
        switch (ActonFlag) {
          case 'a':    # 爪子送开
            servo[0].write(96);
            break;
          case 'b':    # 爪子闭合
            servo[0].write(180);
            break;
          case 'c':    # 向左平移五个单位
            servo_pos[3] -= 5;
            servo_pos[3] < 0 ? servo_pos[3] = 0 : servo_pos[3] = servo_pos[3];
            servo[3].write(servo_pos[3]);
            client.print("ok");
            break;    
          case 'd':    # 向右平移五个单位
            servo_pos[3] += 5;
            servo_pos[3] > 180 ? servo_pos[3] = 180 : servo_pos[3] = servo_pos[3];
            servo[3].write(servo_pos[3]);
            client.print("ok");
            break;
          case 'e':    # 向下平移五个单位
            servo_pos[2] -= 5;
            servo_pos[2] < 0 ? servo_pos[2] = 0 : servo_pos[2] = servo_pos[2];
            servo[2].write(servo_pos[2]);
            client.print("ok");
            break;
          case 'f':    # 向上平移五个单位
            servo_pos[2] += 5;
            servo_pos[2] > 180 ? servo_pos[2] = 180 : servo_pos[2] = servo_pos[2];
            servo[2].write(servo_pos[2]);
            client.print("ok");
            break;

在舵机的控制中主要就是担心舵机度数越界的情况出现。因此这里使用了三目运算符防止越界,并且用数组记录这些舵机的位置信息。在实机演示中,会发现一卡一卡的。由于电脑获取到esp32-cam的帧数也只在5帧左右,如果传给esp8266的信息过小,虽然很平滑那么一秒钟转的度数太小了。因此在权衡之下每次命令都转5度。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值