概述
效果视频
低成本Hand Tracking机械臂手势跟踪识别
执行步骤
- esp32-cam作为视觉获取图片,并建立WebServer。
- 电脑发送request请求获取图片,获取图片使用openCV处理,并与esp8266建立socket连接发送命令。
- 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度。
789

被折叠的 条评论
为什么被折叠?



