使用 OpenCV 处理 esp32-cam 推流

使用 OpenCV 处理 esp32-cam 推流

使用 Arduino 刷入 esp32-cam 的 CameraWebServer 示例,然后就可以通过访问 http://ip:80/ 进入控制页,或者访问 http://ip:81/stream 获取“实时视频推流”。

但是这个视频推流并不是常见的视频格式,而是 jpg 图片流,这个可以抓包分析出来。

我在浏览器使用 F12 没法查看具体的响应,所以我用 Fiddler 进行抓包分析的。

我的 esp32-cam 响应的内容如下:

1
2
3
4
5
6
7
\r\n
--123456789000000000000987654321\r\n
Content-Type: image/jpeg\r\n
Content-Length: 5123
\r\n
\r\n
/* 这里是jpg图片的二进制内容 */

为了方便查看,我把响应中的\r\n都保留了,没有删去。

其中 Content-Length 的内容就是 jpg 图片的长度,所以要先把它的值取出来,然后才能往后取出 jpg 图片的内容。

我的需求是将这一张张 jpg 图片转化为 OpenCV 的 Mat 格式,这样就能使用 OpenCV 作进一步处理了。

因为这个推流是源源不断地进行的,OpenCV 的处理也是不断在进行的,这就需要用两个线程同时进行处理。一个线程不断获取 esp32 推流,另一个线程不断解析响应,并且用 OpenCV 处理响应中的 jpg 图片。另外,两个线程都要访问响应数据,如果同时访问响应数据,可能会出现 race condition,所以还要考虑多线程同步

我用 libcurl 库发起 HTTP 请求,这是使用 libcurl 发起请求部分的代码:

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
static size_t reWriter(char* buffer, size_t size, size_t nmemb, string* content)
{
unsigned long sizes = size * nmemb;
content->append(buffer, sizes);
return sizes;
}

int main()
{
curl_global_init(CURL_GLOBAL_ALL);
CURL* curl;
curl = curl_easy_init();

// esp32-cam 推流的网址,改成自己的
string url = "http://192.168.1.123:81/stream";
// 这里使用 string 作为 buffer,比较方便
string buffer;

thread th = std::thread([&]()
{
while (true)
{
/* 开启跟随跳转, 这里没必要设置,抄代码的时候没删掉 */
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, true);
/* 设置超时时间(单位:秒),不要太长,因为 esp32 的网络不太稳定,如果断连需要尽快重连 */
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)5LL);
/* 关闭 SSL 验证, 这里没必要设置,抄代码的时候没删掉 */
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, false);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, false);
/* enable TCP keep-alive for this transfer */
curl_easy_setopt(curl, CURLOPT_TCP_KEEPALIVE, true);
/* keep-alive idle time to 120 seconds */
curl_easy_setopt(curl, CURLOPT_TCP_KEEPIDLE, 120L);
/* interval time between keep-alive probes: 60 seconds */
curl_easy_setopt(curl, CURLOPT_TCP_KEEPINTVL, 60L);
// curl 数据处理函数
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, reWriter);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void*)&(buffer));
// 设置处理HTTP头部的功能函数
curl_easy_setopt(curl, CURLOPT_URL, url.data());
// 发起请求(会阻塞当前线程)
cout << "正在发起请求..." << endl;
curl_easy_perform(curl);
cout << "连接已经断开!" << endl;
}
});
th.detach(); // 让线程独立运行

// TODO: 处理响应

return 0;
}

其中 reWriter 函数是响应数据的处理函数,将接收到的响应数据存起来并返回其长度就好了。

响应的解析部分我直接在主线程进行,先使用正则表达式取出 Content-Length,然后再取出 jpg 图片的内容。

使用 string 作为 buffer 的好处就是可以用 substr 比较方便地取出其中的部分内容。

具体代码实现如下:

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
while (true)
{
int64_t buffer_length = buffer.size();
cout << "buffer_length: " << buffer.size() << endl;
regex pattern("\r\n--123456789000000000000987654321\r\nContent-Type: image\/jpeg\r\nContent-Length: ([\\d]+)\r\n\r\n");
smatch result;
if (!regex_search(buffer, result, pattern)) continue;
if (result.size() <= 1) continue;
int64_t image_length = stoll(result[1].str());
int64_t header_length = result.length(0);
// 如果图片完整地储存在 buffer 中,才读取图片的数据
if (header_length + image_length <= buffer_length)
{
// 取出 jpg 图片内容
string image_buffer = buffer.substr(header_length, image_length);
// 清除 buffer,这一步很关键
buffer.clear();
// 使用 OpenCV 读取 jpg 图片
Mat tmp = Mat(1, image_length, CV_8UC1, (void*)image_buffer.data());
cv::Mat decodedImage = imdecode(tmp, 1);
if (decodedImage.data != NULL)
{
// 翻转 esp32-cam 传来的图片,根据需要删去或保留
Mat filped;
flip(decodedImage, filped, -1);
imshow("display", filped);
waitKey(10);
}
}
}

这里面有一个步骤非常重要,就是每解析一张图片就清除 buffer

我们接收到的 buffer 里,可能不止含有一份响应。一份响应里包含一个 HTTP 头部和一张 jpg 图片。

变量 buffer 里储存的内容,可能包含一份完整的响应半份响应(半份响应:比如完整的 HTTP 头部和 jpg 图片的一部分内容,甚至只有一部分 HTTP 头部)。

正常来说,那半份响应应该保留在 buffer 中。等到接收到更多内容时再尝试解析,因为buffer中旧的内容加上新接收的内容可能就凑出了一份完整的响应。如果我们清除掉未解析的数据,会导致丢失掉一些数据!

但是我们的需求有点特殊,因为视频推流对实时性要求很高。我们不在意过去的每一帧内容,只在意当前 esp32-cam 看到的那一帧内容。

如果 esp32-cam 推流的速度大于我们解析的速度,那么 esp32-cam 推送的内容就会堆积在 buffer 变量中(因为 TCP 是可靠的协议,TCP 保证将 esp32-cam 发送到内容送达)。占用大量的内存不说,还会遇到累积延迟的问题。也就是说程序当前解析的一帧,其实发生在过去,表现就是画面的延迟。而且这种延迟是会累积的,随着时间的推移,延迟会越来越大。

所以如果我们解析的时候遇到了很多份响应(说明此时 esp32-cam 推流速度大于解析速度),那么就应该只取其中的一份,其他的响应都丢弃,这样就不会有内容堆积在 buffer 中。

最后,主线程和 libcurl 的线程同时访问了变量 buffer,需要加锁控制。

考虑到只需要在 buffer 被修改后才需要解析 buffer,所以我还用上了条件变量(condition_variable)。

条件变量通常和 lock_guard/unique_lock 一起使用。它会释放锁并阻塞,直到另一个线程将 flag 变为 true,并发出通知后才继续执行。

修改后的完整程序:

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
#include <iostream>
#include <string>
#include <regex>
#include <future>
#include <curl/curl.h>
#include <opencv2/opencv.hpp>
using namespace std;
using namespace cv;

std::mutex mutex_buffer;
std::condition_variable cv_buffer;
// flag: 表示是否有新数据到达
bool arrived = false;

static size_t reWriter(char* buffer, size_t size, size_t nmemb, string* content)
{
unsigned long sizes = size * nmemb;
{
std::lock_guard<std::mutex> lock(mutex_buffer);
content->append(buffer, sizes);
}
arrived = true;
cv_buffer.notify_one();
return sizes;
}

int main()
{
curl_global_init(CURL_GLOBAL_ALL);
CURL* curl;
curl = curl_easy_init();

// esp32-cam 推流的网址,改成自己的
string url = "http://192.168.1.123:81/stream";
// 这里使用 string 作为 buffer,比较方便
string buffer;

thread th = std::thread([&]()
{
while (true)
{
/* 开启跟随跳转, 这里没必要设置,抄代码的时候没删掉 */
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, true);
/* 设置超时时间(单位:秒),不要太长,因为 esp32 的网络不太稳定,如果断连需要尽快重连 */
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)5LL);
/* 关闭 SSL 验证, 这里没必要设置,抄代码的时候没删掉 */
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, false);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, false);
/* enable TCP keep-alive for this transfer */
curl_easy_setopt(curl, CURLOPT_TCP_KEEPALIVE, true);
/* keep-alive idle time to 120 seconds */
curl_easy_setopt(curl, CURLOPT_TCP_KEEPIDLE, 120L);
/* interval time between keep-alive probes: 60 seconds */
curl_easy_setopt(curl, CURLOPT_TCP_KEEPINTVL, 60L);
// curl 数据处理函数
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, reWriter);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void*)&(buffer));
// 设置处理HTTP头部的功能函数
curl_easy_setopt(curl, CURLOPT_URL, url.data());
// 发起请求(会阻塞当前线程)
cout << "正在发起请求..." << endl;
curl_easy_perform(curl);
cout << "连接已经断开!" << endl;
}
});
th.detach(); // 让线程独立运行

while (true)
{
std::unique_lock<std::mutex> lock(mutex_buffer);
cv_buffer.wait(lock, [] { return arrived; });
arrived = false;

int64_t buffer_length = buffer.size();
cout << "buffer_length: " << buffer.size() << endl;
regex pattern("\r\n--123456789000000000000987654321\r\nContent-Type: image\/jpeg\r\nContent-Length: ([\\d]+)\r\n\r\n");
smatch result;
if (!regex_search(buffer, result, pattern)) continue;
if (result.size() <= 1) continue;
int64_t image_length = stoll(result[1].str());
int64_t header_length = result.length(0);
// 如果图片完整地储存在 buffer 中,才读取图片的数据
if (header_length + image_length <= buffer_length)
{
// 取出 jpg 图片内容
string image_buffer = buffer.substr(header_length, image_length);
// 清除 buffer,这一步很关键
buffer.clear();
// 使用 OpenCV 读取 jpg 图片
Mat tmp = Mat(1, image_length, CV_8UC1, (void*)image_buffer.data());
cv::Mat decodedImage = imdecode(tmp, 1);
if (decodedImage.data != NULL)
{
// 翻转 esp32-cam 传来的图片,根据需要删去或保留
Mat filped;
flip(decodedImage, filped, -1);
imshow("display", filped);
waitKey(10);
}
}
}

return 0;
}
作者

uint128.com

发布于

2021-11-10

更新于

2022-05-18

许可协议

评论