☰
ESP32-S3本地人脸识别实战:从门禁改造到边缘AI产品化
2026/10/26 17:10:51 网站建设 项目流程

简介:这份资源面向嵌入式开发者与物联网学习者,聚焦在ESP32-S3芯片上实现人脸检测与识别的完整工程,适合已具备ESP-IDF基础、希望把机器学习算法落到低功耗MCU上的中高级开发者。包内共418个文件,以h/hpp头文件、c/cpp源码、md说明文档为主,辅以npy模型数据、py脚本、a静态库与esp32s3等芯片配置,压缩包约25.82MB,目录涵盖components组件库、main主程序、分区表与构建配置,结构清晰便于按模块研读。已有2098人学习下载。资源完整呈现了图像采集、预处理、特征提取、人脸检测与比对识别的技术链路,并附带人脸检测、颜色检测、扫码等预编译库,读者可据此理解模型量化、内存管理与硬件加速的落地思路,快速搭建可运行的人脸识别原型并对照源码排查问题。

1. 一块 30 块钱的板子,把“刷脸”这件事从云端拽回了桌面

去年帮一个做门禁改造的朋友看现场,对方张口就要“云端人脸比对、后台下发权限、断网也能开门”。预算报上去,光服务器和带宽就够买几十块开发板了。后来我们把方案换成了 ESP32-S3 本地推理:摄像头采集、人脸检测、特征比对全在板子上跑完,只把“开门/不开门”这一个结果发出去。整机成本压到百元级,断网照常工作,隐私数据压根不出设备。这就是 esp32 人脸识别这类项目真正打动人的地方——它不是玩具 Demo,而是能把一套刷脸门禁、考勤机、智能柜锁的骨架直接搬进产品里的工程起点。这篇笔记就按“这项目能干什么 → 怎么跑起来 → 核心代码长什么样 → 怎么改成你自己的东西”这条线,把一份 ESP32-S3 人脸识别工程拆开讲透,新手能照着点亮屏幕,熟手能直接看架构和改造点。

2. 项目定位与技术选型:为什么是 ESP32-S3 而不是树莓派

2.1 这个项目到底在解决什么问题

先把功能边界说清楚。一份典型的 ESP32-S3 人脸识别工程,核心链路是四段:摄像头出图 → 人脸检测框出位置 → 人脸特征提取成向量 → 与本地已注册的人脸库做比对,命中则触发业务动作(开门、打卡、点亮指示灯)。整个流程跑在 MCU 上,不依赖网络,注册和识别都在设备端完成。

它适合谁?第一类是做智能门禁、考勤、储物柜、实验室门锁这类小场景的硬件开发者,需要低成本、低功耗、离线可用的方案;第二类是想学边缘 AI 落地的嵌入式工程师,ESP32-S3 带向量指令和 AI 加速指令,是练手 TinyML 的好平台;第三类是学生做课设或竞赛,需要一套能跑通、能改、资料相对完整的参考工程。不适合谁?需要高精度大库比对(几千人以上)、需要活体防伪到金融级、需要复杂姿态识别的场景,MCU 算力和内存都撑不住,这类还是得上带 NPU 的专用芯片或边缘盒子。

这里有个反直觉的点:很多人第一反应是“树莓派不香吗”。香,但树莓派跑 Linux,启动几十秒、功耗几瓦、成本几百,做电池供电的门锁根本不现实。ESP32-S3 双核 240MHz、带 8MB PSRAM 的型号,跑一个轻量人脸模型,识别一次几百毫秒到一秒,待机功耗毫安级,这才是门锁场景要的形态。选型的本质不是“谁算力强”,而是“谁的成本和功耗配得上这个场景”。

2.2 技术栈拆解:从摄像头到模型推理的每一层

把技术栈按数据流拆开看,一层一层都是可替换的模块,理解了这个分层,改造时才知道动哪里。

层次典型选型作用可替换性
图像采集OV2640 / OV3660 / GC0308输出 RGB565 或 JPEG换摄像头需改驱动和分辨率配置
显示/交互ST7789 / ILI9341 SPI 屏预览画面、显示识别结果可换成串口日志或 OLED
人脸检测ESP-DL 自带轻量检测模型 / MTMN框出人脸区域可换成人脸检测专用轻量网络
特征提取MobileFaceNet 类轻量网络量化版输出定长特征向量换模型需同步改比对逻辑
比对余弦相似度 / 欧氏距离与本地库比对打分阈值需按模型重新标定
存储NVS / SPIFFS / SD 卡存人脸特征库和配置库大时建议上 SD 卡
框架ESP-IDF + ESP-DL底层驱动与推理引擎也可用 Arduino 框架简化上手

这张表是整篇的骨架。你会发现真正“重”的是检测和特征提取两个模型,其余都是工程胶水。ESP-DL 是乐鑫官方的深度学习推理库,针对自家芯片做了算子优化和量化支持,模型一般是 int8 量化后转成 C 数组编进固件,或者放 SPIFFS 里加载。选它而不是 TFLite Micro,主要原因是官方示例和算子适配更贴合 S3 的指令集,踩坑资料也相对集中。

2.3 硬件清单与接线要点

一套能跑起来的最小系统,我一般按这个清单备料:ESP32-S3 开发板(务必选带 PSRAM 的型号,人脸模型和图像缓冲吃内存)、OV2640 摄像头模块、SPI 接口的 TFT 屏(可选,但调试期强烈建议有)、若干杜邦线、一个 USB 数据线(注意是数据线不是纯充电线,这个坑后面会讲)。

接线这块,摄像头走的是 ESP32-S3 的 LCD_CAM 外设,引脚不是随便定的,得按工程里camera_pin_config的宏来。常见做法是把 D0-D7、PCLK、VSYNC、HREF、XCLK、SDA、SCL 这些信号分配到一组连续的 GPIO 上,屏幕则单独占一组 SPI 引脚。接线前先翻工程里的引脚定义头文件,别凭经验接,S3 的 GPIO 有复用限制,接错了轻则不亮,重则和 PSRAM 抢引脚直接起不来。

提示:带 Octal PSRAM 的 S3 模组会占用 GPIO33-37,摄像头数据线千万别往这几个脚上排,这是新手最容易翻车的地方。

3. 从零跑通:环境搭建、编译烧录与首次识别

3.1 开发环境搭建:ESP-IDF 版本别乱选

这类工程基本都基于 ESP-IDF,版本选择是有讲究的。ESP-DL 对 IDF 版本有对应关系,太新的 IDF 可能编译不过旧版 ESP-DL,太旧的又缺 S3 的某些驱动。我一般按工程README或CMakeLists.txt里声明的版本走,常见是 IDF 4.4 或 5.x 的某个具体小版本。

安装流程按官方来,Linux/macOS 下大致是:

# 克隆 IDF(以 5.1 为例,实际以工程声明为准) git clone -b v5.1 --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32s3 . ./export.sh

install.sh esp32s3只装 S3 需要的工具链,能省不少下载时间;export.sh是把idf.py等工具加进当前终端环境,注意它只对当前 shell 生效,新开终端要重新执行。Windows 下用官方安装器更省事,装完在开始菜单里打开 IDF 终端即可。

装完验证一下:

idf.py --version

能打印出版本号就说明环境通了。这一步失败的话,八成是 Python 环境冲突或者工具链没下全,先别急着往下走。

3.2 工程配置与编译烧录

拿到工程后,先看目录里有没有sdkconfig.defaults,这个文件决定了 PSRAM 是否开启、Flash 大小、分区表等关键配置。人脸识别工程几乎必然要求开 PSRAM,否则模型加载直接内存不足。

# 进入工程目录,设置目标芯片 idf.py set-target esp32s3 # 打开菜单配置,重点确认以下几项 idf.py menuconfig

在menuconfig里要确认的项:Component config → ESP PSRAM打开并选对模式(Quad/Octal 要和板子一致);Serial flasher config里 Flash size 选对(常见 8MB 或 16MB);分区表如果工程自带partitions.csv就别动,模型和字库可能占了自定义分区。

配置完编译:

idf.py build

第一次编译会拉一堆组件,慢是正常的。编译过了就烧录,先确认串口:

# Linux 下一般是 /dev/ttyUSB0 或 /dev/ttyACM0 ls /dev/ttyUSB* # 烧录并打开监视器 idf.py -p /dev/ttyUSB0 flash monitor

flash负责烧固件,monitor负责看串口日志,Ctrl+]退出监视器。Windows 下端口类似COM3。烧录失败最常见的原因是没进下载模式——按住 BOOT 键再按一下 RST,或者板子本身带自动下载电路就不用管。

3.3 首次运行:注册人脸与识别验证

固件跑起来后,串口会打印初始化日志:摄像头探测、模型加载、屏幕初始化。如果屏幕接了,一般能看到实时预览画面加一个检测框。首次使用要先“注册”——把一张人脸录进本地库。

不同工程交互方式不一样,常见两种:按键触发注册,或者串口发指令。以按键为例,按下注册键后,屏幕提示“请正对摄像头”,设备连续采集若干帧,提取特征取平均后存入 NVS,并分配一个 ID。注册成功后串口会打印类似enroll success, id=0的日志。

识别阶段,人站到镜头前,设备检测到人脸、提取特征、与库里逐个比对,相似度超过阈值就判定命中,串口打印match id=0 score=0.87之类,同时触发业务动作。第一次跑建议先注册 1 到 2 个人,确认链路通了再扩库。如果一直识别不上,先看日志里检测框有没有出来——检测都没有,说明是摄像头或光照问题,不是比对逻辑问题,这个排查顺序能省很多时间。

注意:注册时的光照条件和识别时的光照条件尽量一致,模型对光照变化敏感,这是离线小模型绕不开的物理限制。

4. 核心模块拆解:一次识别从进摄像头到出结果的完整链路

4.1 摄像头初始化与图像采集

摄像头是整个链路的入口,初始化没做好后面全是白搭。ESP-IDF 下用esp_camera组件,核心是填一个camera_config_t结构体。

#include "esp_camera.h" static camera_config_t camera_config = { .pin_pwdn = -1, // 电源脚,模块没有就填 -1 .pin_reset = -1, .pin_xclk = CAM_XCLK, // 主时钟,一般 20MHz .pin_sccb_sda = CAM_SDA, // SCCB 配置总线 .pin_sccb_scl = CAM_SCL, .pin_d7 = CAM_D7, .pin_d6 = CAM_D6, .pin_d5 = CAM_D5, .pin_d4 = CAM_D4, .pin_d3 = CAM_D3, .pin_d2 = CAM_D2, .pin_d1 = CAM_D1, .pin_d0 = CAM_D0, .pin_vsync = CAM_VSYNC, .pin_href = CAM_HREF, .pin_pclk = CAM_PCLK, .xclk_freq_hz = 20000000, .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0, .pixel_format = PIXFORMAT_RGB565, // 人脸检测一般要 RGB .frame_size = FRAMESIZE_QVGA, // 320x240,够用且省内存 .jpeg_quality = 12, .fb_count = 2, // 双缓冲,采集和推理可并行 .fb_location = CAMERA_FB_IN_PSRAM, .grab_mode = CAMERA_GRAB_WHEN_EMPTY, }; esp_err_t err = esp_camera_init(&camera_config); if (err != ESP_OK) { ESP_LOGE(TAG, "camera init failed: 0x%x", err); return; }

几个参数值得单独说。pixel_format选PIXFORMAT_RGB565是因为检测模型吃 RGB 输入,如果选 JPEG 还得软解,反而更慢更吃 CPU。frame_size用 QVGA(320×240)是精度和内存的平衡点,再大人脸检测模型输入也得缩,白费带宽。fb_count = 2开双缓冲,一帧在推理时另一帧可以继续采集,避免卡顿;但每帧 RGB565 的 QVGA 大约 150KB,两帧就是 300KB,所以fb_location必须放 PSRAM。grab_mode选WHEN_EMPTY表示缓冲空时才抓新帧,防止覆盖正在处理的帧。

采集一帧就是esp_camera_fb_get(),用完必须esp_camera_fb_return(fb)归还,否则缓冲很快耗尽,表现为跑几帧后卡死。这个“借了要还”的规矩,是摄像头模块最常见的血泪经验。

4.2 人脸检测:把画面里的人框出来

检测环节的目标很单纯:输入一帧图,输出若干个人脸框坐标和置信度。工程里一般用 ESP-DL 提供的轻量检测模型,接口大致是“初始化模型 → 喂入图像 → 拿结果”。

#include "human_face_detect_msr01.hpp" // 模型对象,一般全局或静态持有,避免反复申请内存 static HumanFaceDetectMSR01 *s_detector = nullptr; void detector_init() { // 第二个参数是置信度阈值,第三个是 NMS 重叠阈值 s_detector = new HumanFaceDetectMSR01(0.3f, 0.5f, 10, 0.5f); } int detect_faces(camera_fb_t *fb, std::list<dl::detect::result_t> &results) { dl::image::img_t img = { .data = fb->buf, .width = fb->width, .height = fb->height, .pix_type = dl::image::DL_IMAGE_PIX_TYPE_RGB565, }; // 推理,结果写进 results,每个元素含框坐标和关键点 s_detector->run(img, results); return results.size(); }

HumanFaceDetectMSR01的构造参数里,第一个是置信度阈值,调高会漏检、调低会误检,0.3 到 0.5 是常见区间;第二个是 NMS(非极大值抑制)阈值,用来合并重叠框,人脸密集时调小一点。run的输入是img_t,把摄像头缓冲的裸数据、宽高、像素类型包一下就行,注意pix_type必须和摄像头输出格式对上,对不上会得到一堆乱框或者直接崩。

检测结果results里除了框,通常还带 5 个关键点(双眼、鼻尖、两嘴角),这些点后面做对齐会用到。检测这一步的耗时和分辨率强相关,QVGA 下一般几十毫秒量级,是整个链路里相对轻的一环。

4.3 特征提取与比对:真正决定“是不是同一个人”

检测框出来后,把框内人脸裁出来、缩放到模型输入尺寸(常见 112×112),送进特征提取网络,得到一个定长向量(常见 128 或 512 维)。这一步是识别精度的核心。

#include "human_face_recognition.hpp" static HumanFaceRecognition *s_recognizer = nullptr; void recognizer_init() { s_recognizer = new HumanFaceRecognition(); } bool extract_feature(camera_fb_t *fb, const dl::detect::result_t &face, float *feature_out, int feat_len) { dl::image::img_t img = { .data = fb->buf, .width = fb->width, .height = fb->height, .pix_type = dl::image::DL_IMAGE_PIX_TYPE_RGB565, }; // 内部会按框裁剪、对齐、归一化,再推理出特征向量 return s_recognizer->run(img, face, feature_out, feat_len); }

run内部做了裁剪、关键点对齐、归一化这些脏活,调用方只要给原图、框和输出缓冲。特征向量拿到后,和本地库比对。比对用余弦相似度最直观:

float cosine_similarity(const float *a, const float *b, int len) { float dot = 0.f, na = 0.f, nb = 0.f; for (int i = 0; i < len; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } return dot / (sqrtf(na) * sqrtf(nb) + 1e-6f); } // 遍历本地库,取最高分 int match_face(const float *feat, int len, float threshold, float *best_score) { int best_id = -1; *best_score = 0.f; for (int i = 0; i < g_face_count; i++) { float s = cosine_similarity(feat, g_face_db[i], len); if (s > *best_score) { *best_score = s; best_id = i; } } return (*best_score >= threshold) ? best_id : -1; }

阈值threshold是这套系统最需要现场标定的参数。同一个模型,不同光照、不同摄像头,最佳阈值能差 0.1 以上。我一般先用 0.6 起步,拿真人反复测,看“本人最低分”和“他人最高分”之间的间隔,把阈值卡在中间偏本人一侧。间隔太窄说明模型或图像质量不行,别硬调阈值,先解决光照和分辨率。

4.4 特征库存储与业务触发

特征库放哪,取决于库的大小。几个人到几十个人,存 NVS 完全够,键值对形式,每个 ID 存一段二进制特征。上百人以上,NVS 读写和内存都吃紧,建议上 SPIFFS 或 SD 卡,按文件存。

// 以 NVS 为例,保存一条特征 nvs_handle_t handle; nvs_open("face_db", NVS_READWRITE, &handle); char key[16]; snprintf(key, sizeof(key), "face_%d", id); nvs_set_blob(handle, key, feature, feat_len * sizeof(float)); nvs_commit(handle); nvs_close(handle);

业务触发就是识别命中后的动作,工程里一般抽象成一个回调或事件:命中 ID 有效就拉高某个 GPIO 驱动继电器,或者通过串口/MQTT 上报一条记录。改造时这块是最容易替换的,把“开门”换成“打卡上报”“点亮绿灯”都只是改回调内容,不影响识别主链路。

5. 避坑与排查:那些让我熬夜的现场问题

5.1 现象:烧录成功但串口一直重启

原因通常是 PSRAM 配置和板子不匹配,或者摄像头引脚和 PSRAM 抢了 GPIO。S3 带 Octal PSRAM 的模组会占用 33-37 脚,摄像头数据线如果排到这几个脚,初始化阶段就崩。解决:先看menuconfig里 PSRAM 模式选对没有,再对照板子原理图核对摄像头引脚,把冲突的线挪开。判断是不是 PSRAM 问题,可以临时关掉 PSRAM 编译一版,能起来就说明是内存配置的锅。

5.2 现象:画面能出,但检测框乱飞或一个人检出好几个框

前者多半是像素格式对不上,img_t.pix_type写成了 RGB888 而摄像头实际输出 RGB565,数据被错误解释。后者是 NMS 阈值设太大,重叠框没合并掉。解决:先确认摄像头pixel_format和喂给模型的pix_type严格一致;再把 NMS 阈值从 0.5 往 0.3 调,观察框是否收敛。还有一种情况是图像太暗导致模型输出噪声,补光比调参管用。

5.3 现象:跑十几帧后卡死,日志停在fb_get附近

典型的帧缓冲没归还。esp_camera_fb_get()拿到的fb在处理完必须esp_camera_fb_return(fb),中间任何提前 return 的分支漏了归还,缓冲池很快耗尽。解决:把所有出错分支都补上归还,或者用 goto cleanup 统一收口。这个坑我在三个不同项目里都见过,属于肌肉记忆级别的检查项。

5.4 现象:本人识别不上,但换个人反而能过

这不是玄学,是阈值和特征质量的问题。常见原因是注册时和识别时光照差异大,或者注册只采了一帧、姿态不好。解决:注册时多采几帧取平均,注册和识别尽量在同一光照环境;再拿本人和他人各测 20 次,统计分数分布重新定阈值。如果本人最低分和他人最高分几乎重叠,说明模型或图像分辨率不够,考虑换更高分辨率的检测输入或更好的摄像头,别在阈值上死磕。

5.5 现象:编译报找不到 ESP-DL 头文件

多半是组件依赖没声明。ESP-IDF 的组件模型要求在自己的CMakeLists.txt里REQUIRES或PRIV_REQUIRES里写上esp-dl相关组件名,光把源码拷进目录不够。解决:检查工程main/CMakeLists.txt的依赖列表,确认 ESP-DL 组件路径被正确引用;如果是把 ESP-DL 作为子模块放的,确认EXTRA_COMPONENT_DIRS指对了位置。

6. 二次开发改造:把 Demo 变成你自己的产品

改造这件事,我的建议是从“最不影响主链路”的地方下手,逐步替换,每改一处都留一个可回退的验证点。下面按改造收益从高到低排。

第一刀砍在业务触发层。识别命中后的回调是最独立的模块,把它抽成一个函数指针或事件队列,你就能在不碰识别代码的前提下接继电器、接蜂鸣器、接串口协议。我一般会定义一个on_face_matched(id, score)回调,主流程只负责调用,具体动作在业务层实现。这样换场景只改一个文件。

第二刀是特征库的存储介质。人数少继续用 NVS,人数多就换成 SPIFFS 按文件存,接口抽象成db_add / db_match / db_delete三个函数,底层换实现不影响上层。这里有个细节:删除人脸时 NVS 的键不会自动回收空间,长期反复注册删除要做一次nvs_erase_key加整理,否则分区会慢慢被占满。

第三刀才是模型替换。想提精度就换更强的特征提取模型,但要注意三件事:模型输入尺寸变了,裁剪缩放代码要跟着改;特征维度变了,本地库要清空重录,老特征和新模型不兼容;量化方式变了,比对阈值要重新标定。换模型不是换个文件那么简单,是一次小版本升级,务必留出标定时间。

验证方法上,我习惯做一个“离线回放”工具:把现场采集的几十张图存到 SD 卡,写个循环批量跑检测加比对,输出每张的分数。这样调阈值不用真人反复站镜头前,效率高很多。下面是个简化示意:

// 批量回放:遍历 SD 卡图片,输出比对分数 for (int i = 0; i < img_count; i++) { camera_fb_t *fb = load_image_from_sd(i); // 自定义加载 std::list<dl::detect::result_t> faces; detect_faces(fb, faces); if (faces.empty()) { printf("img %d: no face\n", i); continue; } float feat[FEAT_LEN]; extract_feature(fb, faces.front(), feat, FEAT_LEN); float score; int id = match_face(feat, FEAT_LEN, 0.0f, &score); printf("img %d: best_id=%d score=%.3f\n", i, id, score); free_image(fb); }

把阈值传 0 是为了强制输出最高分,方便你统计分布,而不是直接判定。跑完一批,把本人和他人分数画个简单的直方图,阈值该定在哪一目了然。

最后说个我踩过的坑:早期我图省事,把注册和识别放在同一个任务里串行跑,结果注册时屏幕卡住、识别响应也慢。后来拆成采集任务、推理任务、业务任务三个,用队列传帧指针,流畅度立刻不一样。从那以后我每次做这类边缘视觉项目,都强制先把任务划分和缓冲归还这两件事在纸上画一遍再动手,省下的调试时间远超画图那十分钟。希望这套拆解能帮到你,把板子跑起来只是开始,真正有意思的是把它改成你场景里那个“刚好够用”的版本。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询