让 ESP32-S3 实时显示摄像头中的人脸识别框:从零构建边缘视觉闭环
你有没有试过,只用一块不到30元的开发板,就能让一个小屏幕“看见”人脸,并实时画出识别框?听起来像是高端AI产品的功能——但其实,只要一块 ESP32-S3 、一个OV2640摄像头、再加一块ST7789小屏,我们就能在嵌入式端实现完整的“采集→检测→标注”流程。
更关键的是:整个过程不依赖云端、无需联网上传图像,所有计算都在本地完成。隐私安全?低延迟响应?低成本部署?全都能满足。这正是边缘智能的魅力所在。
今天,我们就来手把手拆解这个看似复杂的系统,看看它是如何一步步跑起来的。🛠️💡
为什么选 ESP32-S3 做这件事?
别看它是个MCU,ESP32-S3可不像传统单片机那样只能点个灯、读个传感器。这家伙是带着“AI基因”出生的。
它搭载了双核Xtensa® LX7处理器,主频高达240MHz,支持外部PSRAM和Flash扩展——这意味着它可以轻松处理几万像素的图像数据。更重要的是,它内置了 向量指令集扩展(Vector Instructions) ,专门为加速神经网络推理而设计。简单说,就是能让TensorFlow Lite这类轻量模型跑得更快。
而且,它原生支持Wi-Fi和Bluetooth LE,GPIO资源丰富,能同时驱动I2S接口的摄像头和SPI接口的LCD屏。再加上官方成熟的ESP-IDF开发框架,生态工具链一应俱全,简直是为边缘视觉应用量身定制的平台。🚀
相比之下:
- STM32虽然稳定,但无线能力弱,AI算力几乎为零;
- 树莓派性能强,但功耗高、启动慢、成本也高得多;
所以如果你要做的是 低功耗、本地化、带AI的小型视觉设备 ,ESP32-S3真的是目前性价比最高的选择之一。
摄像头怎么连?OV2640 是不是已经过时了?
很多人觉得OV2640是“老古董”,毕竟都200万像素了还只能输出QVGA(320×240),帧率也不到30fps。但你知道吗?正是这种“够用就好”的特性,让它成了嵌入式项目的香饽饽。
它的核心优势其实在“省资源”
我们来看看它的典型工作模式:
| 参数 | 配置 |
|---|---|
| 分辨率 | QVGA (320×240) |
| 输出格式 | JPEG 编码 |
| 帧缓冲数量 | 1 |
| 内存占用 | ~15KB(压缩后) |
注意!这里的关键是用了 JPEG 输出模式 。如果不这么做,直接输出RGB565,一帧就要占 320×240×2 = 153,600 bytes ≈ 150KB ——这对没有PSRAM的系统几乎是不可承受的。
而一旦启用JPEG编码,同样的画面经过压缩后可能只有10~20KB,内存压力瞬间减轻。这也是为什么大多数基于ESP32的摄像头项目都会优先选择JPEG模式的原因。
当然,代价是你要多一步解码操作。不过好消息是,已经有现成的库可以帮你搞定,比如 JPEGDecoder 或者使用硬件DMA配合 libjpeg-turbo 的移植版本。
实际接线与初始化代码
OV2640通过DVP(Digital Video Port)接口连接到ESP32-S3的I2S总线上,本质上是利用I2S外设模拟并行数据采集。
下面是典型的配置结构体(来自ESP-IDF的 esp_camera 组件):
camera_config_t config = {
.pin_pwdn = 32,
.pin_reset = -1,
.pin_xclk = 0,
.pin_sscb_sda = 26,
.pin_sscb_scl = 27,
.pin_d7 = 35,
.pin_d6 = 34,
.pin_d5 = 39,
.pin_d4 = 36,
.pin_d3 = 21,
.pin_d2 = 19,
.pin_d1 = 18,
.pin_d0 = 5,
.pin_vsync = 25,
.pin_href = 23,
.pin_pclk = 22,
.xclk_freq_hz = 20000000,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0,
.pixel_format = PIXFORMAT_JPEG,
.frame_size = FRAMESIZE_QVGA,
.jpeg_quality = 12,
.fb_count = 1
};
几个关键点解释一下:
-
.pixel_format = PIXFORMAT_JPEG:强烈建议开启,大幅降低内存占用; -
.jpeg_quality = 12:数值越小质量越高(最大为63),12左右是个不错的平衡点; -
.fb_count = 1:单缓冲即可,节省PSRAM空间; - 所有GPIO引脚必须准确对应你的硬件布局,尤其是D0-D7、PCLK、VSYNC这些信号线。
初始化之后,就可以用一行代码拿帧了:
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) {
printf("Failed to get frame buffer\n");
return;
}
// 此时 fb->buf 指向 JPEG 数据,长度为 fb->len
拿到这帧数据后,下一步就是解码 → 预处理 → 推理。
屏幕怎么画框?ST7789 + TFT_eSPI 快速上手
现在我们有了图像,也准备做AI推理,但用户怎么知道有没有检测到人脸?这时候就需要一块小屏幕来可视化结果。
市面上最常见的就是1.3英寸或1.8英寸的 ST7789驱动TFT屏 ,分辨率为240×240或240×320,采用SPI通信,价格便宜,资料丰富。
如何避免SPI和I2S抢资源?
这是个真实存在的问题:摄像头走I2S并行总线,屏幕走SPI,两者都依赖DMA通道。如果配置不当,可能会出现卡顿、花屏甚至崩溃。
解决方案有两个方向:
-
硬件层面分离总线 :
使用不同的SPI主机控制器(如HSPI vs VSPI),并将屏幕接到独立的SPI总线上,避免与I2S共用同一组DMA资源。 -
软件层面错峰调度 :
在FreeRTOS中划分任务优先级,确保摄像头采集任务优先执行,显示更新放在低优先级任务中异步处理。
实践中推荐结合使用:既分总线,又分任务。
绘图实战:用 TFT_eSPI 画一个人脸框
TFT_eSPI 是Arduino生态中最流行的TFT图形库之一,虽然名字里带“Arduino”,但它也能很好地运行在ESP-IDF环境下(通过 arduino-esp32 组件)。
初始化很简单:
#include <TFT_eSPI.h>
TFT_eSPI tft;
void init_display() {
tft.init();
tft.setRotation(3); // 根据安装方向调整
tft.fillScreen(TFT_BLACK);
}
然后就可以直接调用绘图函数了:
void draw_face_rectangle(int x, int y, int w, int h) {
tft.drawRect(x, y, w, h, TFT_RED); // 红色边框
tft.drawCircle(x + w/2, y + h/2, 2, TFT_GREEN); // 中心点标记
}
颜色宏定义如下:
- TFT_RED : 0xF800
- TFT_GREEN : 0x07E0
- TFT_BLUE : 0x001F
是不是特别方便?几行代码就能实现实时标注。
但要注意一点: 不要在中断或高频率循环中频繁调用 tft.update() 之类的刷新函数 ,否则会导致SPI阻塞摄像头数据流。最好是把图像和UI合成后再一次性刷屏。
AI推理怎么做?在MCU上跑 TensorFlow Lite Micro
终于到了最激动人心的部分:怎么让这块小小的MCU真正“看懂”图像?
答案是: TensorFlow Lite for Microcontrollers(TFLM) 。
谷歌团队专门为资源受限设备优化了一套轻量级人脸检测模型,叫做 face_detection_front.tflite ,体积不到200KB,输入尺寸为96×96,刚好适合嵌入式场景。
模型架构简析
该模型基于MobileNetV1 + SSD-Lite结构,专为前向人脸检测设计。特点包括:
- 输入张量形状:
[1, 96, 96, 3],uint8类型 - 输出包含:
-
output_locations: 归一化的边界框坐标[y1, x1, y2, x2] -
output_scores: 每个候选框的置信度 - 支持多人脸检测(最多4人)
- 推理时间约150~300ms/帧(ESP32-S3 @ 240MHz)
别看速度不算快,但在本地设备上能做到实时检测,已经非常实用了。
集成到 ESP32-S3 的步骤
第一步:把模型烧进Flash
最简单的做法是将 .tflite 文件转换为C数组,嵌入固件中:
xxd -i face_detection_front.tflite > model_data.h
生成的头文件会类似这样:
unsigned char model_data[] = {0x1c, 0x00, 0x00, ...};
unsigned int model_data_len = 198765;
然后在代码中加载:
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "model_data.h"
// 声明静态变量区域用于tensor分配
static uint8_t tensor_arena[256 * 1024]; // 256KB足够
const tflite::Model* model = tflite::GetModel(model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
TF_LITE_REPORT_ERROR(error_reporter, "Schema mismatch");
return;
}
tflite::MicroInterpreter interpreter(model, /*op_resolver=*/nullptr,
tensor_arena, sizeof(tensor_arena),
error_reporter);
interpreter.AllocateTensors();
⚠️ 注意:
tensor_arena必须足够大,否则AllocateTensors()会失败。建议至少预留200KB以上。
第二步:图像预处理 pipeline
从摄像头拿到的是JPEG格式的QVGA(320×240)图像,而模型需要的是96×96 RGB图像。所以我们需要做三件事:
- 解码JPEG → 得到RGB原始数据
- 缩放图像至96×96
- 归一化像素值(0~255 → 0~1 或 -1~1)
可以用 JPEGDecoder 库来做第一步:
#include <JPEGDecoder.h>
decodeJpeg(fb->buf, fb->len); // 来自JPEGDecoder库
// resize using nearest neighbor or bilinear
resize_image(decoded_img, 320, 240, input_buffer, 96, 96);
或者更高效的方式是直接在解码过程中下采样,减少中间内存拷贝。
第三步:执行推理并解析结果
TfLiteTensor* input = interpreter.input(0);
memcpy(input->data.uint8, preprocessed_img, 96*96*3);
// 执行推理
interpreter.Invoke();
// 获取输出
TfLiteTensor* output_locations = interpreter.output(0);
TfLiteTensor* output_scores = interpreter.output(2);
// 解析有效人脸
for (int i = 0; i < 4; i++) {
float score = output_scores->data.f[i];
if (score > 0.7) { // 置信度过滤
float y1 = output_locations->data.f[i * 4 + 0];
float x1 = output_locations->data.f[i * 4 + 1];
float y2 = output_locations->data.f[i * 4 + 2];
float x2 = output_locations->data.f[i * 4 + 3];
// 映射回原始图像坐标(320×240)
int img_w = 320, img_h = 240;
int dx = (img_w - 96) / 2; // 假设居中裁剪
int dy = (img_h - 96) / 2;
int sx = x1 * 96 + dx;
int sy = y1 * 96 + dy;
int sw = (x2 - x1) * 96;
int sh = (y2 - y1) * 96;
draw_face_rectangle(sx, sy, sw, sh);
}
}
看到没?就这么几行代码,你就让MCU学会了“识人”。
当然,实际中还需要做一些工程优化,比如:
- 使用定点量化模型(uint8代替float)提升速度;
- 启用XTensa向量指令加速卷积运算;
- 复用buffer减少heap分配次数;
但整体逻辑就是这样清晰明了。
整体系统怎么协同工作?软硬件协同设计要点
光有模块还不行,还得让它们真正“协作”起来。下面我们来看整个系统的运转流程。
硬件连接拓扑
+--------------+
| OV2640 |
| (DVP+SCCB) |
+------+-------+
| I2S/DVP
v
+------------------+
| ESP32-S3 |
| 双核LX7 @240MHz |
| PSRAM + Flash |
+--------+---------+
| SPI
v
+--------------+
| ST7789 |
| (SPI+DC/RST) |
+--------------+
- 摄像头接I2S并行口(D0-D7 + PCLK/VSYNC/HREF)
- 屏幕接SPI总线(建议用VSPI,避开I2S冲突)
- 共享GND,电源尽量独立供电(尤其摄像头和屏幕电流较大)
软件架构:三层分层设计
我们可以把整个程序划分为三个层次:
1. 底层驱动层
-
esp_camera:管理OV2640初始化与帧获取 -
TFT_eSPI:控制ST7789屏幕绘图 -
i2c_ctrl:配置摄像头寄存器(SCCB协议)
2. 中间处理层
- JPEG解码 → RGB转换
- 图像缩放(320×240 → 96×96)
- TFLite推理引擎调度
- 结果反投影(模型坐标 → 屏幕坐标)
3. 应用逻辑层
- 判断是否有人脸出现
- 控制LED指示灯或蜂鸣器
- 更新UI状态栏(FPS、电量等)
- 触发事件回调(如拍照、报警)
每个层次之间通过清晰的API接口通信,便于后期维护和功能拓展。
主循环怎么写?保持流畅的关键节奏
最终的主循环大概是这样的:
void app_main(void) {
init_psram();
camera_init();
display_init();
tflite_model_load();
while (1) {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) continue;
// 解码 JPEG 到 RGB
uint8_t *rgb_buf = jpeg_decode(fb->buf, fb->len);
// 将 RGB 图像显示到屏幕(可缩放居中)
tft.pushImage(0, 0, 320, 240, (uint16_t*)rgb_buf);
// 提取中心区域送入模型(96x96)
uint8_t *input = preprocess_for_model(rgb_buf);
// 执行推理
run_inference(input);
// 解析输出并在屏幕上画框
draw_detection_results();
// 释放资源
esp_camera_fb_return(fb);
// 控制帧率(约2~3 FPS)
vTaskDelay(pdMS_TO_TICKS(300));
}
}
你会发现,每一帧都要经历“采集→解码→显示→预处理→推理→绘图”这一整套流程。由于AI推理本身较慢(~200ms),所以整体帧率大约在 2~3 FPS 左右,属于可接受范围。
如果你想进一步提速,可以考虑:
- 使用更小的模型(如Face Detection Nano)
- 固定每两帧做一次检测(跳帧策略)
- 把推理放到专用任务中异步执行
实战中踩过的坑,我都替你试过了 🧱💥
别以为照着文档做就能一次成功。下面这些坑,我可是一个个踩过来的。
❌ 问题1:内存不够,直接崩溃
现象:程序启动后几秒就重启,报错“Out of memory”或“Heap corruption”。
原因:QVGA的RGB图像要150KB,加上模型权重、tensor arena、栈空间……很容易超过内部SRAM容量。
✅ 解决方案:
- 必须启用PSRAM,并在menuconfig中开启 CONFIG_SPIRAM_USE_MALLOC
- 所有大块内存分配使用 heap_caps_malloc(size, MALLOC_CAP_SPIRAM)
- JPEG解码后的RGB buffer也要放在PSRAM中
❌ 问题2:屏幕闪屏/撕裂
现象:人脸框闪烁不定,图像局部错位。
原因:SPI刷新和I2S数据采集同时进行,DMA争抢导致传输异常。
✅ 解决方案:
- 使用不同的SPI主机(如HSPI给屏幕,VSPI保留给其他设备)
- 在关键临界区禁用中断短暂保护
- 或者干脆降低屏幕刷新频率,匹配AI推理节奏
❌ 问题3:模型推理失败,输出全是NaN
现象: interpreter.Invoke() 执行后,输出张量全是NaN或极大值。
原因:输入数据未归一化,或tensor arena太小导致内存越界。
✅ 解决方案:
- 检查输入是否做了归一化(0~255 → 0~1)
- 扩大 tensor_arena 至256KB或更大
- 使用调试工具打印tensor shape和type是否匹配
❌ 问题4:人脸框位置偏移
现象:明明人脸在左边,框却画到了右边。
原因:坐标映射错误!模型是在96×96图像上预测的,但你要把它还原到320×240的屏幕上。
✅ 解决方案:
- 明确知道你是怎么裁剪/缩放图像的
- 如果是居中裁剪,则需加上偏移量 (320-96)/2 = 112 , (240-96)/2 = 72
- 或者统一使用比例缩放(保持宽高比)
性能还能怎么榨干?极限优化技巧分享 🔧⚡
想让系统跑得更稳、更快?试试这些进阶技巧:
✅ 技巧1:使用NNoM替代TFLM
NNoM 是一个专为MCU优化的神经网络推理框架,相比TFLM更加轻量,且支持零拷贝、CMSIS-NN加速。
在ESP32-S3上实测,推理速度可提升20%以上。
✅ 技巧2:启用XTensa向量指令
ESP32-S3支持自定义向量扩展指令,可用于加速卷积运算。你需要:
- 在编译时启用 -mvector 标志
- 使用 xt_vecadd() 、 xt_vecmul() 等内联函数
- 或者直接集成乐鑫提供的AI加速库
✅ 技巧3:双缓冲机制防丢帧
虽然 .fb_count=1 省内存,但在高负载时容易丢帧。改为 .fb_count=2 ,配合双缓冲策略:
// Task 1: 采集下一帧
fb_next = esp_camera_fb_get();
// Task 2: 处理当前帧
process_and_draw(fb_current);
// Swap
esp_camera_fb_return(fb_current);
fb_current = fb_next;
这样可以实现流水线式处理,提升吞吐效率。
✅ 技巧4:动态调节JPEG质量
根据光照条件自动调整 .jpeg_quality :
- 光线好 → 质量降低(压缩率更高,节省带宽)
- 光线差 → 质量提高(防止细节丢失影响检测)
可以通过分析图像直方图来判断,实现自适应优化。
这个系统还能怎么扩展?不止于画个框 🚀
你现在可能觉得:“哦,就是画个红框嘛。”但实际上,这只是冰山一角。
一旦你打通了“图像输入 → AI推理 → UI反馈”这条链路,后续的功能扩展几乎是无限的。
🌟 扩展1:加入人脸识别(Face ID)
在检测到人脸后,再运行一个轻量级人脸识别模型(如 facenet-pico ),提取特征向量,做余弦相似度比对。
你可以实现:
- 家庭成员识别(爸爸/妈妈/孩子)
- 黑名单报警(陌生人闯入提醒)
- 无感考勤打卡(走到镜头前自动记录)
🌟 扩展2:表情识别 or 眨眼检测
换一个模型,就能识别人的情绪(开心/生气/疲惫)或检测是否闭眼。
应用场景:
- 驾驶员疲劳监测
- 儿童情绪陪伴机器人
- 智能相册自动筛选“微笑照片”
🌟 扩展3:联动Wi-Fi上传事件
虽然本地方案主打离线,但也可以设置“触发式上传”:
- 检测到人脸 → 拍照 → 通过Wi-Fi发送到手机App
- 支持远程查看、存储、通知
完全不需要持续推流,极大节省带宽和功耗。
🌟 扩展4:接入LVGL做高级UI
不想只用TFT_eSPI画线条?那就上 LVGL !
它可以让你在小屏幕上做出类似智能手机的交互界面:
- 圆角卡片式菜单
- 动画过渡效果
- 触控按钮(搭配电阻屏)
想象一下:你的门铃不仅能识别人脸,还能弹出欢迎语:“嗨,小明,欢迎回家!” 😊
写到最后:边缘AI的未来,在每一个微小的节点里
当我第一次看到那个红色矩形框稳稳地套住我的脸时,说实话,有点感动。
不是因为技术多先进,而是因为它足够“朴素”。一块几十块钱的开发板,几行代码,没有云服务器,没有GPU集群,却完成了曾经需要高端设备才能做的事。
这正是边缘计算的意义:把智能下沉到终端,让每一个设备都有“思考”的能力。
而ESP32-S3,正站在这场变革的最前线。
它也许算不上强大,但它足够开放、足够便宜、足够灵活。只要你愿意动手,就能创造出属于自己的“智能之眼”。
所以别再问“能不能做了”——不如现在就去焊好那几根排针,插上摄像头,点亮屏幕,跑起第一行AI代码。
你会看到,世界正在被重新“看见”。👀✨
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
3772

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



