关于一个增量式人脸跟踪云台

介绍

识别人脸的方法有很多,这里使用了Haar特征加Cascade分类器进行检测。Haar特征其实就是拿一堆黑白小方块在图像上滑动,比较不同区域的明暗差值,比如眼睛一带比脸颊暗、鼻梁比两边亮,这些固定的明暗模式就是特征。单看一个特征很容易误判,所以还要用AdaBoost把一堆弱特征组合成强分类器,再把强分类器一级一级串起来:前面几级结构简单,很快就能把明显不是脸的窗口筛掉,只有活到最后的窗口才判定为人脸。OpenCV里直接带现成的分类器文件,省了自己训练的功夫,用起来也确实快,跑起来不怎么吃力。

不过这种检测只画出人脸,判断不了人脸是谁,如果需要识别具体是谁,那就得用CNN了。对于人脸识别我们可以使用目标检测方法,包括但不限于FaceNet、Fast RCNN等,这些模型的计算量就上来了而且我也没打算做,我这里只是说一下。

整套云台的思路很直接:摄像头对着前方,上位机检测到人脸之后,算出人脸框中心相对画面中心的偏移,再把偏移换算成舵机的角度增量,通过串口丢给STM32,STM32输出PWM让舵机往人脸的方向转一点,转完再看下一帧,人脸就慢慢被拉回画面中间了。因为是走一步看一步地递进调整,所以叫增量式,这么做的好处是不用提前知道人脸离多远、有多大,反馈一次修正一次就行。

设备清单:

  • Stm32F103C8T6:主控,负责收串口数据、换算占空比、驱动舵机
  • USB转TTL:给单片机下载程序,顺便看打印
  • 一枚摄像头,我用的是一个红外摄像头,视角为105°:红外的好处是晚上没光也能拍到人脸;视角大一点,人脸偏到画面边缘也能框进来
  • 舵机(180°足够):这里只用一个自由度,上下方向的跟踪没做
  • 供电部分:单片机是3.3V,舵机得单独给5V,别从板子上取电,舵机堵转的电流能把单片机一起带崩

代码

首先看看STM32CubeMX的PWM配置:

1

这里把定时器配成PWM输出模式,让TIM1的通道1输出50Hz的方波,也就是周期20ms。舵机的控制信号看的是脉宽:一般0.5ms对应0°,2.5ms对应180°,中间线性对应。所以只要在代码里根据角度算出对应的比较值,再丢给__HAL_TIM_SetCompare就能控制角度。PWM的频率由预分频和自动重装值决定,CubeMX里填的时候按自己板子的时钟算一下,保证最后出来是50Hz就行。

USART设置速度为115200即可,接收长度定为3个字节。我们看看单片机的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include "Method.h"
#include "tim.h"
#include "usart.h"

// ----
// 大小
uint16_t angle_memory=0;
// 数据结束标志
uint8_t OK=1;
// 角度缓冲区
uint8_t data[3];
// ----

// 传递角度返回占空比
uint16_t Servos(uint8_t angle){
uint16_t pwm;
pwm=500+angle*((2500-500)/180)+(angle*pro);
return pwm;
}

// TIM1进行旋转
void Turn(uint8_t angle){
while(Abs(angle,angle_memory)>=Acc){
if(angle>angle_memory){
__HAL_TIM_SetCompare(&htim1,TIM_CHANNEL_1, Servos(angle_memory));
angle_memory+=turn_speed;
}else if(angle<angle_memory){
__HAL_TIM_SetCompare(&htim1,TIM_CHANNEL_1, Servos(angle_memory));
angle_memory-=turn_speed;
} else{
angle_memory=angle;
}
HAL_Delay(8);
}
}

// 初次上电进行复位
void Start(){
HAL_TIM_PWM_Start(&htim1,TIM_CHANNEL_1);
__HAL_TIM_SetCompare(&htim1,TIM_CHANNEL_1, Servos(90));
HAL_UART_Receive_IT(&huart1,data,LENGTH);
angle_memory=90;
HAL_Delay(1000);
}

// 取绝对值
uint16_t Abs(uint16_t a,uint16_t b){
uint16_t abs;
if(a>=b)abs=a-b;
else abs=b-a;
return abs;
}

// 循环体的函数
void Control(){
while (OK){}
Turn(data_hand(data));
OK=1;
}

// 中断处理,负责清理结束标志
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *UartHandle){
OK=0;
HAL_UART_Receive_IT(UartHandle,data,LENGTH);
}

// 获取USART传递的数据并返回上位机传递的角度
uint8_t data_hand(uint8_t *Data){
uint8_t x=0;
x=((Data[0]-48)*100+(Data[1]-48)*10+(Data[2]-48));
return x;
}

这样我们就可以通过串口控制舵机了。控制流程是这样的:上电后Start()先把舵机复位到90°的中间位置,同时打开串口接收中断;电脑那边检测到人脸偏移够大就把角度转成3个字节发过来,每收满3个字节,中断回调就把结束标志OK清0,主循环里的while(OK){}这句就放行了,接着把ASCII码转成角度,调用Turn()一步一步转过去,转过以后再置回OK等下一帧数据。一次数据只处理一次,不会重复触发。

Turn()里没有直接把角度写进去,而是每次只挪turn_speed度,挪一步延时8ms,一直挪到目标和当前位置的差值小于Acc为止。这么做是为了让舵机转得平滑一点,要是直接跳过去,机械结构会抖,摄像头画面也会跟着晃,跟踪效果反而差。Abs()就是求两个角度差的绝对值,角度是有正反方向的,这一步不能省。

接下来是人脸跟踪同时发送角度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import cv2
import numpy as np
import serial
import time

# 参数传递需要的数据
# 初始角度
degree = 90
# 误差
e = 10


# 两点距离,目的是让摄像头跟随最近的人脸
def distance(x1, y1, x2, y2):
return np.sqrt(np.square(x1 - x2) + np.square(y1 - y2))


# 发送数据
def postData(x, name, bpm):
ser = serial.Serial(name, bpm)
data = str(int(x))
print(f'data = {data}')
# STM32内的数据暂存为3位,所以需要补0
if len(data) == 2:
data = '0' + data
elif len(data) == 1:
data = '00' + data
ser.write(data.encode('utf-8'))
time.sleep(0.1)
ser.close()
pass


# 发现人脸
def getFace(videoStream):
gary = cv2.cvtColor(videoStream, cv2.COLOR_RGB2GRAY)

cent_x = videoStream.shape[1] / 2
cent_y = videoStream.shape[0] / 2
dis = distance(cent_x, cent_y, 0, 0)
back_x = cent_x
back_y = cent_y

kernel = np.array([
[-1, 1, -1],
[1, 1, 1],
[-1, 1, -1]
])
# 锐化人脸
gary = cv2.filter2D(gary, -1, kernel)
preclass = cv2.CascadeClassifier("haarcascade_frontalface_alt.xml")

faceRects = preclass.detectMultiScale(gary, scaleFactor=1.3, minNeighbors=3, minSize=(12, 12))
if len(faceRects) > 0:
for faceRect in faceRects:
x, y, w, h = faceRect
if distance(x + w / 2, y + h / 2, cent_x, cent_y) < dis:
dis = distance(x + w / 2, y + h / 2, cent_x, cent_y)
back_x = x + w / 2
back_y = y + h / 2
pass
cv2.rectangle(videoStream, (x, y), (x + w, y + h), (0, 255, 255), 4)
pass
pass
# 最近的人脸的相对坐标
return back_x - cent_x, back_y - cent_y


# 发现行人
def getFooter(img):
cent_x = img.shape[1] / 2
cent_y = img.shape[0] / 2
dis = distance(cent_x, cent_y, 0, 0)
back_x = cent_x
back_y = cent_y
kernels = np.array([
[1, 0, 1],
[0, -3, 0],
[1, 0, 1]
])
gary_img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
blur_img = cv2.filter2D(gary_img, -1, kernels)
hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
(rects, weights) = hog.detectMultiScale(blur_img, winStride=(4, 4), padding=(8, 8), scale=0.9)
print(rects)
for (x, y, w, h) in rects:
if distance(x + w / 2, y + h / 2, cent_x, cent_y) < dis:
dis = distance(x + w / 2, y + h / 2, cent_x, cent_y)
back_x = x + w / 2
back_y = y + h / 2
cv2.rectangle(img, (x, y), (x + w, y + h), (255, 255, 0), 4)
pass
return cent_x - back_x, cent_y - back_y


if __name__ == "__main__":
video = cv2.VideoCapture(1)
if video.isOpened():
lastime = 0
while True:
ret, frame = video.read()
if not ret:
print("Video End!")
break
x, y = getFace(frame)
print(f'x:{x}')
cv2.imshow("Video", frame)
# 保证变化足够多
if not -1 * e < x < e and abs(lastime - x) > e+2:
if x> 0:
degree = degree - e/2
else:
degree = degree + e/2
if degree != 90:
# 发送角度数据
postData(degree, "COM11", 115200)
pass
else:
lastime = x
pass
if cv2.waitKey(1) & 0xFF == ord('q'):
break
pass
pass

上位机这边的逻辑分几块来说。getFace()先把画面转成灰度,再用一个3×3的锐化核对图像做滤波,把边缘对比拉大一点,人脸轮廓更清楚,接着把灰度图丢给Haar分类器检测。detectMultiScale里scaleFactor=1.3 表示每轮把图像缩到约 1/1.3 再扫,倍数越大越快但小脸容易漏;minNeighbors=3是候选框的合并阈值,设小了会重叠出一堆框;minSize限制最小人脸尺寸,不然噪点也会被当成脸。如果一帧里检出好几张脸,就用distance()算每张脸中心到画面中心的距离,留最近的那张,这样摄像头优先跟住离得最近的人。

拿到人脸中心的相对偏移后,程序用e=10做了个死区:偏移小的时候不动,免得人脸细微晃动让舵机来回抖;lastime记录上一次的偏移,只有变化量超过e+2才发新角度,省得串口一直刷。偏移为正说明人脸在画面右边,就让角度减一点,为负就加一点,每次只调e/2,靠多帧慢慢逼近。postData()里把角度转成字符串,不够3位就在前面补0,因为STM32那边接收长度固定是3个字节,协议得对齐。发送的时候把串口打开、发完就关,每次开关串口都有开销,更好的做法是初始化时打开一次、全程复用。

代码里还顺手写了一个getFooter(),用的是HOG特征加SVM的行人检测,hog.detectMultiScale那一行就是,姿势和光照对它的影响比较大,算起来也比人脸检测慢,如果要装在车上跟随行人,这个思路可以直接拿来改。摄像头设备号填的是1,换台电脑得自己确认一下;分类器的xml文件要放在脚本同级目录,不然CascadeClassifier会加载失败。

最后的效果让人太满意啦!

总结

这次简单地做了一次项目,积累了一些视觉控制的技术经验,项目分工明确,电控不是我做的,我只负责视觉这块。整套东西不复杂,核心就是检测算偏移、串口发角度、舵机跟着转这么一条线,把死区和转速调好基本就稳了。本期内容不算硬核,可以稍微看一看,感兴趣的话把摄像头和舵机装到小车上,改一下行人检测那部分就能玩起来。


关于一个增量式人脸跟踪云台
https://blog.minloha.cn/posts/130833fd81258f2023030810.html
作者
Minloha
发布于
2023年3月8日
更新于
2023年3月8日
许可协议