硬件介绍:Funpack第二季3期活动带来的FireBeetle ESP32-E是一款基于ESP-WROOM-32E双核芯片的主控板,它专为IoT设计。它支持WIFI和蓝牙双模通信并具有体积小巧、超低功耗、板载充电电路、接口易用等特性。可灵活的用于家庭物联网改装、工业物联网改装、可穿戴设备等等。FireBeetle ESP32-E深度支持ArduinoIDE编程,并且即将支持Scratch图形化编程及MicroPython编程。
任务选择:之前在B站看到有一个“NN机器人”的开源项目,很有意思。使用振动电机振动探针做为机器人移动的动力,想法很别致。让我更加感兴趣的是开源项目中使用OPENCV识别图像,判断出机器人的位置、角度,然后驱动机器人的运动。正好这次活动任务中 任务三:遥控小车用FireBeetle ESP32-E开发板作为控制单元,制作一台可以遥控的小车,遥控方式可以是蓝牙、红外、espnow,wifi等方式实现车辆前进后退转向等功能。就选择这个任务啦。
任务实现:选择了制作小车的任务,那么第一步就应该有辆小车。手头在PCB打样还免费的时代,打样过一个小车底盘,正好拿来使用。
小车底盘使用三个轮子的支撑方式。两个N20减速电机做为动力轮,一个万向牛眼轮做为跟随轮。N20电机用一片TB6621作为驱动。电源部分设计上使用单节锂电池做为电源,用TP5400来将锂电池的电压升压至5V,同时也作为充电电路。5V做为电机的驱动电源。驱动芯片的AN1、AN2、BN1、BN2脚可以用来控制输出方向,这里直接使用一个逻辑芯片,保证两对管脚电平相反。在电机的轮子上还设计安装了红外对管ITR8307,可以用来测量轮子的转速。但是这个实验没有使用上。
这次项目主要目的是学习一下OpenCV部分,所以对小车做了简化,仅仅引出了四条线和FireBeetle ESP32-E开发板做连接。+5V、gnd、pwm1、pwm2。也就是说esp32开发板的供电是由小车底盘提供的5V电压供电,ESP32开发板对小车底盘仅仅只是控制左右两个电机的转速。
上位机参考了NN机器人的上位机。这里是重点学习了它上位机中的实现方法。上位机使用python。首先调用OpenCV打开摄像头,用摄像头来收集小车的状态信息。将摄像头采集到的图片信息,先做一次高斯模糊,这样可以降低图片的细节层次。然后转换为灰度图,在做二值化,将图片转换为黑白两色图。
调用OpenCV的寻找轮廓的函数,寻找图片中的轮廓信息。这里小车上边贴着一个直角三角形的贴纸,用三角形来标识小车。直角标识着小车的方向。这里使用了cv2.findContours函数来寻找轮廓。直角三角型贴纸使用的是黑色的空心三角,这样寻找到的轮廓信息就应该有三层,最外层的白色三角框为第一层,黑色三角为第二层,白色三角形为第三层。寻找到这个三角形后,就需要解算出三角形三个顶点的坐标,找出直角三角形的直角长边做为机器人的Y轴,斜边的中点做为小车的原点。上位机通过抓取鼠标的点击动作获得小车的目标位置。这样就可以计算出小车与目标位置间的距离和角度信息了。
import cv2
import numpy as np
import socket
import math
import numpy as np
from base.tools import longline, get_angle_by_cos, length
BUFSIZE = 1024
client = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
ip_port = ('192.168.10.11', 1234) #输入机器人IP地址
n = 199
Mx = 0
My = 0
Point = []
sta = True
def nothing(x):
pass
def createbars():
cv2.createTrackbar("n", "image", n, 250, nothing)
def setpoint(event, x, y, flags, param): # 鼠标点击位置
global Point
if event == cv2.EVENT_LBUTTONDOWN:
print("Point is", x, y)
Point.append((x, y))
print(Point)
if event == cv2.EVENT_RBUTTONDOWN:
Point = []
if __name__ == "__main__":
camera = cv2.VideoCapture(0) # 参数0表示第一个摄像头
camera.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
# 判断视频是否打开
if (camera.isOpened()):
print('Open')
else:
print('摄像头未打开')
cv2.namedWindow("image", cv2.WINDOW_NORMAL)
createbars()
while True:
grabbed, frame_lwpCV = camera.read()
# cv2.imshow('contours', frame_lwpCV)
frame_lwpCV = cv2.GaussianBlur(frame_lwpCV, (3, 3), 0) # 高斯模糊
frame_gray = cv2.cvtColor(frame_lwpCV, cv2.COLOR_BGR2GRAY)
n = cv2.getTrackbarPos("n", "image") # 获取"n"滑块的实时值
ret, binary = cv2.threshold(frame_gray, n, 255, cv2.THRESH_BINARY) # 二值化
# cv2.imshow("binary",binary)
contours, hierarchy = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) # 获取轮廓
# print(contours, hierarchy)
level = 0
contours_sign = []
if hierarchy is not None:
for i in range(len(hierarchy[0])): # 查找三层轮廓
hie = hierarchy[0][i]
if hie[2] != -1:
level += 1
pass
elif hie[2] == -1 and level != 0:
level += 1
if level == 3:
# print("三层",cv2.contourArea(contours[i]) / cv2.contourArea(contours[i - 1]))
if 0.05 < (cv2.contourArea(contours[i]) / cv2.contourArea(contours[i - 1])) < 0.5:
contours_sign.append(contours[i - 1])
pass
level = 0
pass
else:
level = 0
if len(contours_sign) != 0: #意味着找到了三角形
retval, triangle = cv2.minEnclosingTriangle(contours_sign[0]) #绘制最小外包三角形
triangle = np.int0(triangle)
# img=cv2.polylines(frame_lwpCV,[triangle], -1, (255,0,0),2) #绘制轮廓最小三角形
line = longline(triangle) # 找到直角边的长边作为机器人的y轴
D = ((line[1][0] + line[2][0] - line[0][0]), (line[1][1] + line[2][1] - line[0][1]))
line.append(D)
cv2.line(frame_lwpCV,line[2],line[1],(0,0,255),2)
cv2.line(frame_lwpCV,line[0],line[1],(0,0,255),2)
Nx1 = int((abs(line[0][0] - line[2][0])) / 2 + min(line[0][0], line[2][0]))
Ny1 = int((abs(line[0][1] - line[2][1])) / 2 + min(line[0][1], line[2][1]))
Nx = int((abs(line[1][0] - line[3][0])) / 2 + min(line[1][0], line[3][0]))
Ny = int((abs(line[1][1] - line[3][1])) / 2 + min(line[1][1], line[3][1]))
Nx = int((abs(Nx - Nx1)) / 2 + min(Nx, Nx1))
Ny = int((abs(Ny - Ny1)) / 2 + min(Ny, Ny1))
print(Nx,Ny,Nx1,Ny,Point)
# cv2.line(frame_lwpCV, (Nx1, Ny1), (Nx, Ny), (0, 0, 255), 2)
# else:
# img = cv2.drawContours(frame_lwpCV, contours, -1, (0, 255, 0), 2) # 绘制轮廓 img为三通道才能显示轮廓
if len(Point) != 0 :
cv2.line(frame_lwpCV, (Nx, Ny), Point[0], (0, 0, 255), 2)
if (len(Point)) > 1:
for j in range(len(Point) - 1):
cv2.line(frame_lwpCV, Point[j], Point[j + 1], (0, 0, 255), 2)
Mx = Point[0][0]
My = Point[0][1]
else:
Mx = 0
My = 0
# cv2.imshow('drawimg', img)
if Mx != 0 and My != 0: # 处理三个点的数据,机器人y轴线两个点line,鼠标单击的点Mx,My
cv2.line(frame_lwpCV, (Nx, Ny), (Mx, My), (0, 0, 255), 2)
a = get_angle_by_cos((Nx1, Ny1), (Nx, Ny), (Mx, My)) # 计算机器人与目标点的弧度
b = ((Mx - Nx1) * (Ny - Ny1)) - ((My - Ny1) * (Nx - Nx1)) # 计算机器人方向
angle = round(math.degrees(a), 2) # 计算机器人与目标点角度
N_M = length((Nx, Ny), (Mx, My)) # 机器人与目标点距离
# print(f"弧度:{a}, 角度:{angle} , 方向: {b} ,距离:{N_M}")
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(frame_lwpCV, str(int(N_M)), (Nx, Ny), font, 1, (180, 0, 120), 2)
if N_M > 25:
if(b>0):
msg = "C" + str(int(N_M)) + ",R" + str(180-int(angle))
else:
msg = "C" + str(int(N_M)) + ",R" + str(-1*(180 - int(angle)))
print(msg)
client.sendto(msg.encode('utf-8'), ip_port) # UDP发送数据
if int(N_M)<=100 and abs((180 - int(angle)))<=5:
Point.pop(0)
cv2.imshow('drawimg', frame_lwpCV)
cv2.setMouseCallback("drawimg", setpoint)
key = cv2.waitKey(1) & 0xFF
# 按'q'健退出循环
if key == ord('q'):
break
if key == ord('p'):
sta = not sta
print(sta)
camera.release()
cv2.destroyAllWindows()
获取了距离和角度信息后,就通过网络以UPD的方式将信息广播出去。下位机接收到消息后,优先处理角度信息,先调整小车的旋转角度,然后再前进。下位机设置时间间隔,一个命令执行后超过500ms,就停机,以保护小车,防治上位机故障时,无法发出新的命令。目前遇到个新情况,上位机的摄像头找不到三角形时,会按上次命令继续发送消息,暂时还没解决这个bug。
// 2022/12/14 小车
#include <Arduino.h>
#include <FastLED.h>
#include <WiFi.h>
#define DATA_PIN 5
#define CLOCK_PIN 13
#define NUM_LEDS 1
#define SPEED 40 //限制电机速度
// Define the array of leds
CRGB leds[NUM_LEDS];
String templine ="";
int dis,ste,ang,dir;
uint8_t leftwheel=16; //左轮
uint8_t rightwheel=17; //右轮
//配置WIFI名和密码
const char *WIFI_SSID = "A1005";
const char *WIFI_PASSWORD = "xxxxxxxxxxx";
#define UDP_TX_PACKET_MAX_SIZE 24
unsigned int localPort = 1234; // 可以自定义端口号
WiFiUDP Udp;
char packetBuffer[UDP_TX_PACKET_MAX_SIZE + 1]; // buffer to hold incoming packet,
//连接wifi
void connectWiFi()
{
Serial.print("Connecting to ");
Serial.println(WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("WiFi connected");
Serial.print("IP Adderss: ");
Serial.println(WiFi.localIP());
}
void turnL(){
ledcWrite(1, 0);
ledcWrite(2, SPEED);
}
void turnR(){
ledcWrite(1, SPEED);
ledcWrite(2, 0);
}
void forward(){
ledcWrite(1, SPEED);
ledcWrite(2, SPEED);
}
void stop(){
ledcWrite(1, 0);
ledcWrite(2, 0);
}
void setup()
{
Serial.begin(115200);
FastLED.addLeds<NEOPIXEL, DATA_PIN>(leds, NUM_LEDS); // GRB ordering is assumed
delay(500);
//连接WIFI
leds[0] = CRGB::Red; //灯珠显示红色
FastLED.show();
connectWiFi();
Udp.begin(localPort);
ledcSetup(1, 1000, 8);
ledcSetup(2, 1000, 8);
ledcAttachPin(leftwheel, 1); //引脚绑定到ledc通道1
ledcAttachPin(rightwheel, 2);
leds[0] = CRGB::Black; // 灯珠灭
FastLED.show();
delay(1500);
}
unsigned long cmd_chk=0;
void loop()
{
int packetSize = Udp.parsePacket();
if (packetSize)
{ //收到网络消息
int n = Udp.read(packetBuffer, UDP_TX_PACKET_MAX_SIZE);
packetBuffer[n] = 0;
Serial.print("收到数据:");
Serial.println(packetBuffer);
Serial.print("发送端IP:");
Serial.print(Udp.remoteIP().toString().c_str());
Serial.println(Udp.remotePort());
String line = packetBuffer;
String a = packetBuffer;
if(line.indexOf("C") != -1){
templine=line.substring(line.indexOf("C"));
dis=templine.substring(1,templine.indexOf(",")).toInt();
Serial.print("距离: ");
Serial.println(dis);
}
if(line.indexOf("R") != -1){
templine=line.substring(line.indexOf("R"));
ang=templine.substring(1,templine.indexOf(",")).toInt();
Serial.print("角度: ");
Serial.println(ang);
}
//优先处理角度
if(ang>5 || ang<-5 ){ //5度以内的角度,忽略
cmd_chk=millis();
if(ang>5) turnR();
else turnL();
}else if(dis>100){ //100内的距离误差做忽略处理
cmd_chk=millis();
forward();
}
}
if(millis()-cmd_chk>500) stop();
}
结尾:制作小车过程欢乐很多,可是最不幸的是到了快结束时,自己阳了。下位机还有很多需要改进的,电路中做了检测轮子转速的电路,但是软件上没去实现,有了轮子的转速就可以使用PID的闭环控制了。而不是像现在这样固定了电机的转速。在此感谢硬禾团队的技术支持。