简介:这是一套基于ESP32-S3的人脸识别工程源码,面向嵌入式开发者、物联网方向学生和边缘AI爱好者,适合不依赖云端、希望本地完成人脸识别的项目场景。资源覆盖从摄像头采集、图像预处理、人脸检测到特征识别与比对的完整流程,包含主程序、组件库、构建配置与SDK配置,可在低功耗芯片上根据项目需要直接复现,并进一步做检测与识别功能的切换优化。压缩包共418个文件,大小25.82MB,以C/C++源文件、Python脚本及模型文件为主,同时附有数据表与构建脚本,便于对照算法实现、模型结构和编译依赖,也可按需调整训练数据与模型参数。已有2087人学习下载。通过源码可理解硬件加速调用、分区表设计、模型量化与内存管理思路,也能修改摄像头参数和模型权重,搭建自己的离线人脸识别原型。
1. esp32S3 人脸识别:一块几十块的开发板能做多完整的离线识别
有人第一次跑通 esp32S3 人脸识别时,常被那个画面震住:一块比银行卡大不了多少的开发板,外接一个小摄像头和屏幕,没有任何云服务器参与,就能实时框出人脸、报出名字。这不是演示 Demo 的效果,而是在 MCU 级别、嵌入式 Linux 都嫌贵的功耗预算下,跑完“检测-对齐-特征提取-比对”整个人脸识别链路。它的可落地价值很明确:做离线、低功耗、本地的智能门锁、考勤终端、储物柜、儿童陪伴设备原型验证,成本比树莓派方案低一个数量级,隐私上又比云端识别干净得多。适合两类人:一类是想快速验证人脸识别产品逻辑的硬件工程师,另一类是刚接触 ESP32-S3、想把手头板子玩出真东西的嵌入式开发者。这篇文章按我自己的实践路径展开:先讲为什么选 S3,再讲怎么跑通最小工程,接着处理注册和识别,最后是我踩过的坑和固化特征库的习惯。
2. 选型与识别链路:为什么非要是 S3
很多人第一个问题是:ESP32 不行吗?同样是乐鑫家的芯片,价格更低,开发资料更全。答案很直接:做检测可以勉强,做识别基本没戏。真正让人脸识别在 MCU 上成立的,是 ESP32-S3 那几条别人没有的底牌。
2.1 S3 的底牌:向量指令、PSRAM 与摄像头接口
S3 和普通 ESP32 拉开差距的地方有三个。第一是向量指令扩展,官方给的数字是数学运算比 ESP32 快几十倍,实际做人脸模型的 int8 推理时,这个差距直接决定能不能实时出结果。第二是 PSRAM,常见模组外挂 8MB,而模型、图像帧缓冲、特征向量全都要吃内存,512KB 内部 SRAM 根本装不下完整链路;没有 PSRAM 的 S3 模块跑人脸识别基本是空中楼阁。第三是摄像头和屏幕接口都齐全,DVP 摄像头接口可以直连 OV2640、OV5640 这类传感器,并口 LCD 能直接驱动常见屏幕,数据通路不需要转接板。
选型时还需要注意一个容易被忽略的点:S3 有不同封装和模组,同样是 S3,RAM 大小、Flash 大小、是否带 PSRAM 差异很大。我一般建议做原型时选带 8MB PSRAM 和 8MB Flash 以上的模组,理由后面会提到,特征库、模型、固件、日志分区都要占地方。别在这个环节省钱,省了后面全是痛苦。
| 硬件项 | 建议配置 | 说明 |
|---|---|---|
| 主控 | ESP32-S3 双核 240MHz | 向量指令是人脸推理的基础 |
| PSRAM | 8MB 外挂 | 模型和图像缓冲的主要载体 |
| Flash | 8MB 起步 | 固件、模型、特征库分区 |
| 摄像头 | OV2640 或 OV5640 | QVGA 分辨率足够识别用 |
| 屏幕 | 1.3 英寸 LCD 或更大 | 调试时看画面,量产可去掉 |
2.2 识别链路拆解:检测、对齐、特征提取、比对
把“人脸识别”拆开看,实际上是四个步骤串联。第一步是检测,模型在一帧图像里找到人脸的位置框;第二步是对齐,根据眼睛、鼻尖、嘴角的关键点把人脸区域转正、缩放成标准尺寸,这一步直接影响特征提取质量,脸歪着拍和正脸拍出的特征差异很大;第三步是特征提取,把对齐后的人脸图像压缩成一个固定长度的特征向量,这个向量就是“这张脸的数字身份证”;第四步是比对,拿当前特征向量和特征库里存的人脸向量做距离计算,距离小于阈值就判定为同一个人。
在 S3 上,这四个步骤对应的模型会统一量化成 int8 格式,因为浮点运算在 MCU 上太慢。量化带来的精度损失是真实存在的,但人脸特征提取这种任务对量化的容忍度比较高,实际用下来效果可接受。推理耗时整体在几百毫秒量级,这决定了产品交互设计:你不能指望它做 30 帧每秒的连续识别,常见做法是每隔几帧检测一次人脸,检测到了再去做识别,这样既省电又够用。
这里要区分一下“人脸检测”和“人脸识别”这两个概念。检测解决的是“画面里有没有人、人在哪”,识别解决的是“这个人是谁”。很多刚入门的人把这两个词混着用,导致配置工程时找错开关、选错模型。官方示例工程里也是分成两个模块的,检测模块负责出框,识别模块负责匹配身份,后面配置时会分别打开。
3. 跑通最小工程:环境、配置与第一个能出框的固件
第一章到这章之间是个坎:原理懂和跑起来之间隔着一堆工具链问题。我在帮人排查时发现,十个人里有七个是卡在环境配置和 menuconfig 配置上,不是卡在代码上。这章按我自己的操作顺序写,照着做一遍能少走弯路。
3.1 搭建 IDF 环境:最小命令与常见安装翻车点
官方的人脸识别示例不是独立工程,它运行在官方物联网开发框架之上,所以第一步是装框架。以最常见的 Linux 开发机为例,我通常用一串命令搞定基础环境,然后单独建一个工程目录放示例代码:
# 安装依赖工具链(Ubuntu/Debian 系) sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util # 拉取官方框架到 ~/esp 目录,并切换到稳定分支 mkdir -p ~/esp cd ~/esp git clone --recursive https://github.com/espressif/esp-idf.git cd esp-idf git checkout <稳定分支名> git submodule update --init --recursive # 安装编译工具链和 Python 环境,安装完成后需要 source 一下 ./install.sh esp32s3 source ./export.sh这段命令里,install.sh esp32s3是只安装 S3 对应的工具链,能省不少下载时间。source ./export.sh设置环境变量,每次打开新终端都要执行一次,建议把它写进 shell 配置文件里。如果遇到 Python 包下载超时,通常是网络问题,把 pip 源换成国内镜像再重新执行 install 脚本即可。
接下来是新建工程目录、把官方示例代码放进去,然后把目标芯片指定为 S3:
# 创建工程并复制示例代码(示例代码来自官方视觉应用框架) mkdir -p ~/esp/face_project cd ~/esp/face_project # 把官方人脸识别示例代码复制到当前目录 # 指定目标芯片并编译 idf.py set-target esp32s3 idf.py buildidf.py set-target esp32s3会生成适用于 S3 的 sdkconfig 文件。第一次编译比较慢,要下载依赖、编译工具链组件,十几分钟很正常,别以为卡死了。编译成功后会生成build/face_project.bin,接下来接上开发板烧录:
# 烧录并打开串口监视器 idf.py -p /dev/ttyUSB0 flash monitor-p指定串口设备,Linux 下常见的是ttyUSB0或ttyACM0;如果用的是 Windows,设备管理器里看到的是 COM 口,路径写成COM3之类的即可。烧录失败时先查两个地方:开发板是否处于下载模式,串口驱动是否装好。Windows 下 CH340 和 CP210x 两种常见 USB 转串口芯片的驱动不太一样,经常被误判为硬件故障。
3.2 配置摄像头与模型:menuconfig 里必须动的几个开关
代码能编译只是第一步,真正让摄像头出画面、检测模型跑起来的,是 menuconfig 里的配置。执行idf.py menuconfig会打开一个文本界面,这个界面看着复古,但它是全局配置的总闸。基于示例代码做最小改动时,我一般会动这几个开关:
| 配置项 | 推荐值 | 作用 |
|---|---|---|
| 外部 SPI RAM | 开启 | 必须开,否则模型加载失败 |
| 摄像头型号 | OV2640 / OV5640 | 和实际硬件对应 |
| 图像分辨率 | QVGA (320x240) | 平衡帧率和识别精度 |
| 像素格式 | RGB565 | 识别模块通常要求 RGB |
| 检测使能 | 开启 | 人脸检测功能总开关 |
| 识别使能 | 开启 | 人脸识别功能总开关 |
第一项“外部 SPI RAM”是最大的一个坑。很多模块明明焊接了 PSRAM,但默认配置没开,结果编译能过、烧录能跑,一加载模型就报内存不足。判断方法很直接:启动日志里如果看到PSRAM相关行显示不支持或未初始化,就去 menuconfig 里找支持外部 SPI RAM 的选项打开,把运行模式选为全速,然后重新编译烧录。
摄像头型号配置错了的典型表现是初始化报错或者画面颜色发绿、花屏。OV2640 和 OV5640 的 I2C 地址不同,驱动不匹配时传感器根本不响应,日志里会看到 camera init failed。还有管脚定义,S3 连接摄像头用的是 DVP 接口,数据线、时钟线、I2C 控制线的管脚必须在配置里和实际接线一致。某个厂商的开发板管脚定义和官方默认不同,这种不匹配会让人排查好几个小时。
3.3 从启动日志判断系统状态:健康日志长什么样
配置完烧录之后,串口监视器里会输出大量日志。新手容易盯着满屏滚动的信息发呆,其实只需要关注几个关键节点:
I (xxx) spi_flash: detected chip: 8MB I (xxx) psram: PSRAM initialized, size: 8MB I (xxx) camera: Detected camera at address 0x21 I (xxx) model: Face detection model loaded I (xxx) model: Face recognition model loaded I (xxx) app: Face detection started看到这几行,说明 Flash 识别正常、PSRAM 正常、摄像头初始化成功、检测模型加载成功、识别模型加载成功,整个链路已经通了。如果卡在camera之前,优先查摄像头接线和型号配置;如果卡在model加载,优先查 PSRAM 是否开启、模型是否烧录进 Flash。
示例代码默认会把模型放在单独的分区,和固件一起打包烧录。这里要提醒一件事:如果改了分区表配置,必须执行完整擦除后再烧录,idf.py erase-flash先跑一遍,否则旧分区数据会和新固件冲突,表现是模型加载报错或者直接死循环重启。这个玄学问题我遇到过不止一次,后来养成习惯了,改分区必擦 Flash。
4. 把人脸变成可识别对象:注册流程与阈值调节
工程能出框、能检测到人脸之后,离“认出这个人”还差最后一步:注册和识别比对。这一步业务逻辑味道很重,因为涉及到“这个人叫什么名字”“特征存哪里”“怎么保证不被误认”这些产品问题。但底层机制是一致的:把人脸图像转换成一个特征向量,存进库,识别时再做距离比对。
4.1 注册人脸:把一张脸变成一个特征向量
注册的实质是让人脸识别模块提取当前检测到的人脸特征,并给定一个编号或姓名,然后把特征值保存下来。典型流程是:先检测到一张人脸,确认这是有效人脸后,调用注册接口,传入人脸框数据和 ID,模块返回是否成功,最后把特征数据持久化保存。
// 注册人脸示例:检测到人脸后,将当前人脸特征保存到指定 ID int enroll_face(face_detection_result_t *face, int person_id) { // 确保检测结果有效 if (face == NULL || face->score < 80) { printf("no valid face detected\\n"); return -1; } // 调用识别模块的注册接口,返回特征提取状态 int ret = face_recognition_enroll(face, person_id); if (ret == 0) { printf("enroll success, id = %d\\n", person_id); // 登记成功后再保存到非易失存储 save_features_to_flash(); } else { printf("enroll failed, ret = %d\\n", ret); } return ret; }这段代码里,face->score是检测置信度,低于某个值说明当前画面里的脸可能模糊、侧脸或被遮挡,注册进去的特征质量会很差,后面识别时误识率会上升。person_id是这个人在特征库里的唯一编号,实际产品里可以映射成员工工号或用户 ID。save_features_to_flash()这一步至关重要,不做的话,注册的人脸只在内存里,一断电就归零,后面第六章会展开讲。
注册时的操作有个小讲究:距离摄像头半米左右、正脸对着镜头,保持一两秒,让系统多取几帧。示例代码通常默认取一帧,但一帧的检测框位置可能偏了,导致对齐后的图像不够标准,特征提取结果也飘。我自己的习惯是连续取 3 帧,特征值做平均或取置信度最高的一帧,稳定性明显好于单帧注册。
4.2 识别回调:拿到“这是谁”只是第一步
识别流程和注册是两条互相关联但不同的路径。识别时,模块会周期性检测画面中的人脸,发现人脸后做特征提取,然后和特征库里的所有特征比对。关键信息通过回调函数上报:当前画面出现了几张脸、每张脸的坐标、匹配到的 ID、匹配距离。
// 识别结果回调:系统检测到人脸并完成比对后自动调用 void recognition_callback(int face_count, face_result_t *faces) { for (int i = 0; i < face_count; i++) { printf("face %d: box (%d,%d,%d,%d), matched id = %d, dist = %.3f\\n", i, faces[i].box.x, faces[i].box.y, faces[i].box.width, faces[i].box.height, faces[i].matched_id, faces[i].distance); if (faces[i].distance < 0.6) { printf("access allowed, user id = %d\\n", faces[i].matched_id); } else { printf("unknown face, dist = %.3f\\n", faces[i].distance); } } }回调参数里最有价值的是distance。这个值代表当前人脸特征和库中最佳匹配特征之间的距离,距离越小越像同一个人。不同框架对距离的定义不同,有的是欧氏距离,有的是余弦距离转成的距离值,取值范围不一样。所以“距离小于 0.6 就放行”这个 0.6 不是通用的,换了个模型或换了特征库就要重新测。判断放行时,只认matched_id还不够,必须同时看距离是否在阈值内,否则库里的每个人都可能被误匹配,系统会乱认人。
在一些实现里,识别逻辑不是直接写在回调里,而是提供了一个查询接口:检测到人脸后,应用层主动去查当前人脸的匹配结果。两种方式本质一样,回调方式更适合做事件驱动型产品,比如门锁、门禁这类“有人来了就判断”的场景。
4.3 阈值与场景参数:同一个固件如何适配不同场景
阈值调节是所有做人脸识别的人都会反复折腾的环节。阈值太宽松,误识别率高,A 的脸可能匹配到 B 的特征;阈值太严格,拒识率高,本人稍微换个发型、戴副眼镜就被当成陌生人。初始值别完全相信示例代码给的默认值,那是实验室环境测的,现场光照、摄像头角度、注册照片质量都会影响实际效果。
| 参数 | 经验初始值 | 调优方向 |
|---|---|---|
| 检测置信度 | 80 | 太低会误检,太高会漏检远处的人脸 |
| 识别阈值 | 0.6 | 高安全性场景调低,高便利性场景调高 |
| 检测间隔 | 3~5 帧 | 间隔越长越省电,但反应越慢 |
| 注册帧数 | 3 帧 | 注册质量差时增加到 5 帧 |
| 图像分辨率 | QVGA | 识别精度不够时尝试提为 VGA |
调试阈值时,我一般先准备 10 个人的注册数据,然后做两类测试:一类是自己人去试,记录距离值,找正常范围;另一类是拿没注册过的人脸去试,看距离值会落在什么区间。两类距离分布之间的分界点就是合适的阈值位置。不要上来就拍脑袋定一个数,人脸识别的玄学之一就是不同人脸之间的距离分布差异很大,有的人两张脸距离天然就小,阈值选不好就会踩坑。
检测间隔这个参数在产品上很容易被忽略。如果每帧都跑识别,S3 的算力会被拉满,功耗升高、发热明显,而且交互上没有意义——人刚站到门前,不需要毫秒级反应。我一般把检测间隔设在 3 到 5 帧,识别到有人脸后再连续识别几次做确认,这个策略在功耗和体验上的平衡点比较合理。
5. 常见问题与避坑:从黑屏到误识别的排查记录
这章记录真实项目里遇到过的典型问题,按“现象 → 原因 → 解决”的方式写。这些问题覆盖了硬件接线、系统配置、算法参数三个层面,也是跑人脸识别的过程中最消耗时间的部分。
5.1 每次开机都黑屏或花屏
现象:烧录成功、日志正常,但屏幕全黑或显示花屏,偶尔有模糊的人影轮廓。原因:大概率是摄像头有问题而不是屏幕问题。黑屏时先看日志里有没有camera init failed;花屏时先查摄像头的数据线 D0-D7 是否有接错或虚焊,再接错的情况下图像会呈现噪点状扭曲。解决:先从配置里确认摄像头型号和像素格式,型号不匹配会直接初始化失败;再对照原理图逐个核对 DVP 数据线和 I2C 控制线的管脚定义。还有一个容易翻车的点:摄像头供电不足。OV2640 在采图瞬间电流会拉高,如果用的是开发板本身的 3.3V 排针供电,长线或接触不良就会导致供电跌落,画面闪屏甚至黑屏。处理办法是给摄像头单独供电,或者换粗短的排线。
5.2 画面正常但永远检测不到人脸
现象:屏幕画面清晰流畅,但不管怎么晃脸,框一直不出现。原因:先确认检测模型是否真的加载成功,有些工程配置里模型分区大小写错或模型文件没打包进固件,日志里虽然没报错,但检测线程拿不到有效模型,一直空转。其次确认图像格式,人脸检测模型一般吃的是 RGB565 或灰度图,如果摄像头输出的是 JPEG,需要先解码转换,配置里开错格式就会导致检测器读到无效数据。解决:烧录后先在日志里搜索 model 加载成功的标志行;然后把摄像头输出格式在配置里改成 RGB565,分辨率降到 QVGA 再试;最后把检测置信度阈值临时调低到 60,排除“有人脸但置信度没过线”的情况。
5.3 重启一次,注册的人脸就全没了
现象:注册成功,当时能识别;断电或按复位键重启后,识别功能正常,但谁都不认识了,日志里特征库条数为 0。原因:注册的特征只写进了内存,没有持久化到 Flash。示例代码里注册流程和保存流程经常是分离的,如果不显式调用保存接口,重启后内存清空,特征库就跟着消失。解决:在注册成功后,把内存中的特征数据遍历导出,序列化成二进制格式写入 Flash 专区分区。这个流程看起来多写几行代码,但如果不做,每次调试验证都要重新录入人脸,浪费大量时间。具体保存方式在第六章展开讲。
5.4 识别距离稍微远一点就开始乱认
现象:半米距离识别准确,一米半以上就开始把未注册的人识别成库里的某个人,或者把本人拒之门外。原因:检测到的人脸尺寸太小,对齐后送入特征提取网络的图像有效信息不足,特征向量不稳定。另外注册时如果只拍了近景正脸,远距离时人脸占比和角度都有变化,特征匹配距离自然变大。解决:把图像分辨率从 QVGA 提升到 VGA,让远处的人脸仍然有足够的像素数;同时注册时在多个距离各录几帧,不要只录一种姿态。调整后再重新统计距离分布,适配当前场景重新定阈值。
5.5 烧录失败和日志乱码:先查这两个地方
现象:idf.py flash反复报连接失败,或者串口监视器里输出一堆乱码。原因:烧录失败通常是开发板没有进入下载模式或串口芯片驱动异常;日志乱码是波特率不匹配和芯片复位时的电压毛刺导致的。解决:烧录失败时先手动让板子进入下载模式,多数开发板有 BOOT 按键,按住再点复位;也可以检查串口设备权限,Linux 下当前用户不在 dialout 组里时,open port failed会反复出现。日志乱码时,把串口监视器波特率固定为 115200,而不是用默认自动检测;如果 GPIO0 引脚接了外部下拉器件,干扰复位时序也会导致启动时日志乱码,检查硬件连接后重新上电。
6. 把特征库固化到 Flash:断电不丢的离线识别习惯
到了实际做产品原型这一步,最重要的一件事是把注册过的特征向量保存到非易失存储里。我自己的习惯是:所有做人脸识别的项目,第一版代码就带保存和加载功能,一次到位,因为后续调试中反复重启是家常便饭,不保存意味着每次改动都要重新录人脸,纯粹是血泪教训。
6.1 特征库的序列化与加载
特征库本质上是一个数组,每个元素包含一个人脸 ID 和一个特征向量。保存思路不复杂:遍历数组,按固定格式写入 Flash。新的 chip 出厂时默认有一个叫 NVS 的非易失存储分区,但 NVS 的读写模式适合键值对,不适合频繁整体写入大块数据。在工程里新建一个自定义分区专门放特征库,容量给 64KB 通常够用,一个特征向量几百字节左右,64KB 可以存几十人。
// 保存特征库到 Flash 自定义分区 void save_features_to_flash(void) { // 打开自定义存储分区 esp_partition_t *partition = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, 0x40, "feature_store"); if (partition == NULL) { printf("no feature partition found\\n"); return; } // 把特征库整体擦除后重新写入 esp_partition_erase_range(partition, 0, partition->size); esp_partition_write(partition, 0, feature_db, feature_db_size); printf("saved %d features to flash\\n", feature_db_count); }注意esp_partition_find_first的三个参数:分区类型是数据分区,子类型 0x40 是自定义类型,名字要和分区表里的名称一致。写入前必须先擦除,Flash 的特性是只能从 1 写成 0,直接覆写会得到脏数据。特征库写入频率不需要高,只在注册和删除时执行,所以完全不担心 Flash 寿命。
6.2 验证离线保存是否生效
写完保存逻辑后,验证方法很简单:注册一个人脸,重启板子,看启动日志里有没有加载特征库的记录,然后站到摄像头前看能否直接识别出来。判断标准有三个:日志显示加载条数等于注册人数;识别时不重新注册也能报出正确 ID;断电至少 10 秒再上电,结果仍然一致。如果加载条数是 0,去查分区表有没有生效——改完分区表必须重新执行全擦除烧录。
// 启动时加载特征库 void load_features_from_flash(void) { esp_partition_t *partition = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, 0x40, "feature_store"); if (partition == NULL) return; size_t read_size = 0; esp_partition_read(partition, 0, feature_db, feature_db_size, &read_size); printf("loaded %d features from flash\\n", feature_db_count); }这个加载函数放在系统启动后期、摄像头初始化之前调用。识别模块启动时会把特征库数据读进内存,然后开始正常服务。我把这个固化流程视为人脸识别工程的及格线,凡是没做这一步的原型,后面每次改代码都要重新录一遍所有测试人脸,一周下来必然会怀疑人生。希望这个习惯能帮你省下同样多的无用功。
本文还有配套的精品资源,点击获取