☰
ESP32-S3离线人脸识别:从PSRAM配置到ESP-DL推理链路
2026/10/26 17:21:51 网站建设 项目流程

简介:面向嵌入式视觉与物联网开发者的 ESP32-S3 人脸识别工程包,适合在低功耗 SoC 上验证摄像头采集、人脸检测与识别流程的学生、创客及工程师,可用于课程设计、毕业项目和原型开发。压缩包共 418 个文件,约 25.82MB,涵盖 h/hpp 头文件、c/cpp 源码、md 说明文档、npy/onnx/pt 模型与参数文件、py 辅助脚本,以及 sdkconfig、CMake、Makefile 等构建配置和静态库,组件、主程序、分区表与依赖锁定信息齐全,便于快速梳理工程结构。已有 2098 人学习下载,参考价值较高。读者可据此理解图像采集、预处理、特征提取、人脸检测与比对识别的实现路径,并借鉴模型量化、硬件加速、内存及功耗管理思路,搭建可编译运行的 ESP-IDF 项目,降低环境配置与算法落地中的试错成本。内容还覆盖人脸检测、颜色检测与条码扫描等静态库示例,便于对比不同视觉任务的集成方式。

1. 从一块带摄像头的小板说起:ESP32-S3 人脸识别到底能跑出什么效果

前段时间有位开发者拿着一块带摄像头排线座的 ESP32-S3 小板来找我,目标朴素得不行:人往镜头前一站,串口打出名字。他从网上抄了一份 esp32 人脸识别例程,编译一路绿灯,烧进去串口只吐一行cam_hal: EV-VSYNC-OVF,然后就安静了。这类卡点几乎每个第一次做 ESP32-S3 人脸识别的人都会撞上——拦住你的往往不是模型精度,而是 PSRAM 配置、摄像头引脚和帧缓冲这三件小事。

这块板子能做的事其实比想象中实在:240×240 分辨率下做人脸检测加 1:N 特征比对,离线运行、不联网、十几帧的节奏,做成门禁原型、考勤终端、或者带屏幕的访客登记机都够用。它适合三类人:想入门边缘视觉的学生、要给硬件产品做原型的嵌入式工程师、以及拿它当 TinyML 练手场的算法同学。指望它跑 1080P 活体检测那是为难它,但把「检测 + 比对」这条链路完整跑通一遍,你对量化推理的理解会上一个台阶。

2. 硬件与算力预算:ESP32-S3 跑人脸识别绕不开的三条硬指标

动手写代码之前,先把账算清楚。ESP32-S3 这颗芯片的定位很明确:双核 Xtensa LX7,主频 240MHz,带向量指令扩展,内部 SRAM 512KB,支持外挂 SPI PSRAM 和 Flash。这个配置跑人脸检测是够的,但「够」有前提条件,三个指标里任何一个没配到位,后面全是玄学问题——编译过、能启动、就是不出结果。

我一般会建议先把手上的板子参数抄在纸上:PSRAM 多大、什么接口(Quad 还是 Octal)、Flash 多大、摄像头型号是什么、镜头视角多少度。这五个数决定了后面所有配置项,比看任何教程都管用。

2.1 PSRAM 与内存账本:8MB Octal 是门槛,不是加分项

先算一帧图的内存。240×240 的 RGB565 图像,每像素 2 字节,单帧就是 240 × 240 × 2 = 115200 字节,约 112KB。听着不多,但摄像头驱动至少要双缓冲才能稳定输出,两份就是 225KB。再往下走,人脸检测模型的输入张量、中间层输出、后处理用的临时缓冲,加起来又是几百 KB。ESP32-S3 内部那 512KB SRAM 还要分给 WiFi 协议栈、FreeRTOS 任务栈、主程序堆,根本腾不出这么多连续空间。

所以 PSRAM 是硬性要求。常见的模组规格里有 2MB 和 8MB 两档,我更推荐 8MB 的 Octal PSRAM 版本,原因不是容量,是带宽——Octal 接口的 PSRAM 读写速率比 Quad 高一截,摄像头 DMA 写帧缓冲和模型读输入张量这两件事同时在跑的时候,Quad 版本很容易掉帧。这里有个代价:Octal PSRAM 会占用 GPIO33 到 GPIO37 这几根引脚,如果你的板子恰好把摄像头或屏幕接到了这几个脚上,那就只能退回 Quad 模式,或者换板子。这是买板子之前必须核对的一条。

下面这张表是我在 240×240 输入下大致的内存分配账,量级供参考,实际值看你用的模型和驱动版本:

占用项量级建议位置
摄像头单帧 RGB565约 112KBPSRAM
摄像头双缓冲约 225KBPSRAM
检测模型权重300KB~800KBFlash(mmap)或 PSRAM
推理中间张量200KB~600KBPSRAM
人脸特征向量(192 维 int8)192B内部 SRAM
特征库(100 人)约 20KBNVS 或内部 SRAM

看到这里你应该明白为什么很多人卡在第一步:他们用的是不带 PSRAM 的模组,或者买回来发现 PSRAM 没在 menuconfig 里打开。ESP-IDF 默认不一定启用 PSRAM,需要手动勾上并选对模式(Quad/Octal),选错了会在启动日志里看到spiram: Failed to init之类的报错,程序继续跑但一 alloc 大块内存就返回 NULL。

2.2 摄像头接口与像素格式:DVP 并行口是唯一现实选择

ESP32-S3 没有 MIPI-CSI 接口,这点必须先说清楚,很多人拿着手机模组想直接接上来,方向就错了。它走的是 DVP 并口,8 位数据线加 PCLK、VSYNC、HREF、XCLK 这几根同步信号,外加 I2C 配置线(SCCB)。市面上常见的 OV2640、OV3660、GC0308、GC2145 这类模组都支持 DVP,接线逻辑基本一致,区别在寄存器配置和最大分辨率。

像素格式的选择直接影响后面的推理链路。三种常见输出:

  • JPEG:带宽最省,一帧 QVGA 压缩后可能只有十几 KB,但检测模型吃不了 JPEG,你得先解码。ESP32-S3 有硬件 JPEG 解码外设,解码成 RGB888 再转 RGB565,多一步转换就多一份 CPU 开销和内存。
  • RGB565:驱动直接输出,格式和模型输入最接近,省掉解码环节,代价是带宽占用高,PCLK 频率要压到 20MHz 以内才稳。
  • YUV422:内存占用和 RGB565 一样,但转灰度做检测更省事,因为人脸检测模型很多只吃灰度图。

我一般会选 RGB565 直接输出,然后在软件里转灰度喂给检测模型。理由是链路最短,出问题时排查路径清晰:摄像头给的图对不对,看一眼屏幕或者把帧数据 dump 出来就能确认。

摄像头的配置参数里,xclk_freq_hz、frame_size、pixel_format、jpeg_quality这几个最容易被抄错。尤其是xclk_freq_hz,OV2640 一般给 20MHz,GC0308 给 24MHz 也能跑,但频率给高了在长排线上会出花屏。

2.3 引脚与供电:一份能对着原理图逐条核对的清单

接线这部分是踩坑重灾区。不同厂家的 S3 开发板引脚定义五花八门,抄别人的配置大概率不工作。下面这份表是常见的一种分配方式,你可以拿它当模板,但一定要对着自己板子的丝印和原理图改:

信号典型 GPIO注意点
PWDN-1(不接)多数模组可悬空
RESET-1同上
XCLK15输出时钟,别和 PSRAM 脚冲突
SIOD / SIOC4 / 5I2C 配置通道
D7 ~ D016,17,18,12,10,8,9,11顺序必须和驱动配置一致
VSYNC6场同步
HREF7行同步
PCLK13像素时钟

除了引脚,供电也常被忽略。摄像头模组峰值电流能到 100mA 以上,如果板子的 3.3V LDO 只给了 300mA 余量,同时还要供屏幕和 WiFi,画面就会出现横条纹或者间歇性丢帧。我遇到过一次「白天正常、开 WiFi 就花屏」的情况,换了颗 500mA 的 LDO 就好了,跟代码一点关系没有。摄像头排线也别太长,超过 10cm 又没有屏蔽,PCLK 上的毛刺会让图像出现规律性噪点。

3. 工程骨架:从空模板到一个能烧写的六层目录

配置对了,接下来是工程组织。很多人拿到例程之后所有代码都堆在main.c里,改到第三版就找不到东西了。人脸识别这条链路涉及摄像头驱动、模型加载、推理封装、业务逻辑、注册工具几块,天然适合分层。我习惯按下面的结构铺,idf.py create-project建完之后直接改。

3.1 目录分层:main / components / models / tools 各归各位

face_demo/ ├── CMakeLists.txt ├── sdkconfig.defaults # 关键配置固化在这里,别只改 menuconfig ├── partitions.csv # 自定义分区表 ├── main/ │ ├── CMakeLists.txt │ ├── app_main.c # 只做初始化和任务编排 │ ├── app_camera.c # 摄像头初始化与取帧封装 │ ├── app_detect.c # 检测推理 + 后处理 │ ├── app_recognize.c # 特征提取与比对 │ └── app_ui.c # 屏幕/串口输出 ├── components/ │ ├── esp32-camera/ # 摄像头驱动(以组件形式引入) │ ├── esp-dl/ # 推理框架 │ └── face_db/ # 特征库读写(NVS 封装) ├── models/ │ ├── detect.espdl # 人脸检测模型 │ └── feature.espdl # 人脸特征模型 └── tools/ └── enroll.py # PC 端注册脚本(可选)

分层的关键收益是可替换。检测模型换了,只动app_detect.c;特征库从 NVS 改成 SD 卡,只动components/face_db;app_main.c永远只是把几个 init 串起来,改需求的时候一眼能看出改哪。

模型的存放方式有两种,各有取舍。一种是编译进固件,把.espdl转成 C 数组放在components/model/,链接进 app 分区,运行时直接从 flash mmap 读,启动快、无文件系统依赖,缺点是固件体积大,OTA 的时候模型跟着一起传。另一种是放到独立的数据分区,用 SPIFFS 或者直接裸读偏移地址,模型可以单独升级,但要处理分区表和读取逻辑。做原型我倾向第一种,省事;做产品倾向第二种,因为模型迭代频率往往比代码高。

3.2 sdkconfig 三个必改项:PSRAM 模式、Flash 频率、CPU 主频

menuconfig 里几百个选项,跟这个场景强相关的其实就三类。我把它们写进sdkconfig.defaults,这样每次idf.py set-target重建工程都不会丢:

# sdkconfig.defaults —— 关键项固化,避免每次重新 menuconfig CONFIG_IDF_TARGET="esp32s3" CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y CONFIG_ESPTOOLPY_FLASHMODE_QIO=y CONFIG_ESPTOOLPY_FLASHFREQ_80M=y # PSRAM:Octal 模式,速度 80M CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_OCT=y CONFIG_SPIRAM_SPEED_80M=y CONFIG_SPIRAM_USE_MALLOC=y CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 # 主频拉满,双核都开 CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y CONFIG_FREERTOS_UNICORE=n # 摄像头任务栈给足,帧缓冲放 PSRAM CONFIG_CAMERA_TASK_STACK_SIZE=4096

参数逐个说。CONFIG_SPIRAM_MODE_OCT必须和你板子上的 PSRAM 型号匹配,Quad 的板子选了 Octal 会直接启动失败。CONFIG_SPIRAM_USE_MALLOC=y让malloc自动使用 PSRAM,但要配合CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL设一个阈值,小于这个字节数的分配仍然走内部 SRAM——这条很关键,中断里用的、DMA 描述符之类的内存如果被分配到 PSRAM,会出现访问异常甚至死机。我一般设 4096,给驱动层的小块分配留出内部空间。

CONFIG_ESPTOOLPY_FLASHFREQ_80M配 QIO 模式能显著提升模型权重的读取速度。这里有个常见误区:有人把 Flash 频率拉到 120M,结果板子上的 Flash 芯片不支持,烧完不启动,只能按住 BOOT 重新进下载模式救回来。

3.3 模型文件与分区表:flash 放不下时的处理方式

默认的单 app 分区只有 1MB,塞一个检测模型加一个特征模型基本没戏。自定义partitions.csv:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x400000, model, data, spiffs, 0x410000, 0x300000, storage, data, spiffs, 0x710000, 0x0F0000,

factory给到 4MB,装得下代码加一个内嵌模型;model分区 3MB 留给外挂模型文件;storage用来放特征库备份或者注册时抓的人脸小图。偏移量必须按 0x1000 对齐,起始地址从 0x10000 开始是因为前面要给 bootloader 和分区表留位置。

改完分区表记得在 menuconfig 里把Partition Table选成Custom partition table CSV,并填上文件名。忘了这一步,改的 csv 完全不生效,烧进去还是老布局。

3.4 编译、烧写与串口自检流程

命令本身不难,难在出问题时知道看哪:

# 清理 + 设置目标芯片 + 编译 idf.py fullclean idf.py set-target esp32s3 idf.py build # 烧写并打开串口监视,波特率默认 115200 idf.py -p /dev/ttyUSB0 -b 460800 flash monitor

-b 460800是烧写波特率,比默认的 115200 快得多,前提是你的 USB 转串口芯片撑得住,CH340 一般没问题,CP2102 也可以,遇到烧写失败就降回 115200。

启动之后串口日志要重点看三行:spiram: Found 8MB SPI RAM(确认 PSRAM 认到了)、cam_hal: Detected OV2640 camera(确认摄像头 I2C 通了)、app: free heap: xxx(确认剩余内存够)。这三行只要有一行不对,后面的推理必然是白跑。我一般会把这几行日志在app_main里主动打一遍,比翻启动日志快。

4. 核心链路拆解:一帧图像从 DVP 到人脸框的完整路径

前面都是准备动作,这一章进入正题。整条链路可以拆成四段:采集、检测推理、后处理、特征比对。每一段都有自己的坑,我按数据流动的顺序讲,代码给的是能直接改的主干,不是伪代码。

4.1 摄像头采集:双缓冲和帧率控制是稳定性的命门

初始化这块,camera_config_t里的字段看着多,真正影响能不能出图的就那么几个:

#include "esp_camera.h" #include "esp_log.h" static const char *TAG = "cam"; esp_err_t camera_init(void) { camera_config_t cfg = {0}; /* 引脚:必须和板子原理图一一对应,抄别人的配置是最常见的翻车点 */ cfg.pin_pwdn = -1; cfg.pin_reset = -1; cfg.pin_xclk = 15; cfg.pin_sccb_sda = 4; cfg.pin_sccb_scl = 5; cfg.pin_d7 = 16; cfg.pin_d6 = 17; cfg.pin_d5 = 18; cfg.pin_d4 = 12; cfg.pin_d3 = 10; cfg.pin_d2 = 8; cfg.pin_d1 = 9; cfg.pin_d0 = 11; cfg.pin_vsync = 6; cfg.pin_href = 7; cfg.pin_pclk = 13; cfg.xclk_freq_hz = 20000000; /* OV2640 常用 20MHz,长排线可降到 16MHz */ cfg.ledc_timer = LEDC_TIMER_0; cfg.ledc_channel = LEDC_CHANNEL_0; cfg.pixel_format = PIXFORMAT_RGB565; /* 直接出 RGB565,省掉 JPEG 解码 */ cfg.frame_size = FRAMESIZE_240X240; /* 检测模型输入尺寸,别贪大 */ cfg.fb_count = 2; /* 双缓冲,单缓冲必丢帧 */ cfg.fb_location = CAMERA_FB_IN_PSRAM; cfg.grab_mode = CAMERA_GRAB_LATEST;/* 取最新帧,避免排队积压 */ esp_err_t err = esp_camera_init(&cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "camera init failed: 0x%x", err); return err; } return ESP_OK; }

几个参数值得单独说。fb_count = 2是底线,驱动在一个缓冲被占用时还能往另一个写,单缓冲的话你处理一帧的这段时间里摄像头就停了,帧率直接掉一半。grab_mode = CAMERA_GRAB_LATEST让取帧永远拿最新的,代价是可能跳帧,但对人脸识别来说跳帧比拿到旧帧好,旧帧意味着你识别的是几百毫秒前的人。

frame_size选 240×240 而不是 320×240,是因为多数轻量人脸检测模型的输入就是 240×240,选大了还得缩放,多一次内存搬运。当然如果你的模型输入是 320×240,就按模型来,别反过来改模型。

取帧的封装要注意配对释放:

/* 处理完一帧必须 return,否则缓冲池很快耗尽,表现为 esp_camera_fb_get 返回 NULL */ camera_fb_t *fb = esp_camera_fb_get(); if (!fb) { ESP_LOGW(TAG, "fb get timeout, heap=%d", esp_get_free_heap_size()); vTaskDelay(pdMS_TO_TICKS(10)); return; } /* ... 送入检测 ... */ esp_camera_fb_return(fb);

esp_camera_fb_get返回 NULL 是最常见的报错,八成原因是前面的分支里忘了 return 缓冲,或者是 PSRAM 不足导致驱动分配失败。看到这个日志先数一遍代码里的fb_get和fb_return是不是一样多,比怀疑硬件快得多。

4.2 检测推理:量化输入、归一化和张量生命周期

检测用的是轻量人脸检测模型,输入 240×240,输出一堆候选框加热度值。ESP-DL 这套框架的典型用法是先构造一个检测器对象,再把图像喂进去:

#include "human_face_detect.hpp" static HumanFaceDetect *s_detector = nullptr; void detect_init(void) { /* 模型权重默认从 flash 里 mmap 读取,构造时只做一次,别放进循环 */ s_detector = new HumanFaceDetect(); } int detect_run(camera_fb_t *fb, std::list<dl::detect::result_t> &out) { /* 摄像头给的是 RGB565,框架内部按需转成 RGB888 并归一化到 int8 */ dl::image::img_t img = { .data = fb->buf, .width = fb->width, .height = fb->height, .pix_type = dl::image::DL_IMAGE_PIX_TYPE_RGB565, }; s_detector->run(img, out); return out.size(); }

这段代码短,但三个地方容易出问题。

第一,检测器对象绝对不能在循环里new。每次构造都要加载模型、分配张量,几百毫秒就没了,还会把 PSRAM 啃光。我曾经见过有人把构造写在任务循环里,跑十几秒之后直接alloc failed重启,日志看起来像内存泄漏,其实就是构造开销。

第二,pix_type必须和摄像头输出严格对应。摄像头配了PIXFORMAT_RGB565,这里写成DL_IMAGE_PIX_TYPE_RGB888,程序不会崩,但检测结果会完全错乱——模型把两个字节当成一个像素的一个通道,画面相当于被斜切。这种 bug 最难查,因为没有任何报错。

第三,返回的候选框坐标是相对输入尺寸的,如果你的显示或裁剪用的是原始帧尺寸,得做一次缩放。多数框架已经帮你映射回去了,但换版本时务必确认,我在一次组件升级后就被这个坑了一天。

关于量化的人脸检测模型,输入是 int8,模型文件里带着 scale 和 exponent 参数,框架会自动处理。你需要关心的只有一点:摄像头的曝光和白平衡如果偏得厉害,量化后的动态范围会被压缩,检测置信度整体下滑。室内逆光场景下我一般会把摄像头亮度调高一档,比调阈值有效。

4.3 后处理:锚框解码、NMS 与坐标还原

模型输出的候选框是密集的,几十上百个,必须做非极大值抑制。很多例程里这段被封装在框架内部,一旦你要加自己的逻辑(比如只保留画面中心区域的人脸),就得自己写一遍:

typedef struct { int x, y, w, h; float score; } face_box_t; /* 计算两个框的交并比 */ static float iou(const face_box_t *a, const face_box_t *b) { int x1 = (a->x > b->x) ? a->x : b->x; int y1 = (a->y > b->y) ? a->y : b->y; int x2 = ((a->x + a->w) < (b->x + b->w)) ? (a->x + a->w) : (b->x + b->w); int y2 = ((a->y + a->h) < (b->y + b->h)) ? (a->y + a->h) : (b->y + b->h); if (x2 <= x1 || y2 <= y1) return 0.0f; float inter = (float)(x2 - x1) * (y2 - y1); float uni = (float)a->w * a->h + (float)b->w * b->h - inter; return inter / uni; } /* 按 score 降序做贪心抑制,nms_thr 一般取 0.3 ~ 0.5 */ int nms(face_box_t *boxes, int n, float nms_thr, int max_out) { /* 简单插入排序,n 通常 < 100,冒泡都够用 */ for (int i = 1; i < n; i++) { face_box_t key = boxes[i]; int j = i - 1; while (j >= 0 && boxes[j].score < key.score) { boxes[j + 1] = boxes[j]; j--; } boxes[j + 1] = key; } int keep = 0; for (int i = 0; i < n && keep < max_out; i++) { if (boxes[i].score < 0.0f) continue; /* 已被抑制 */ for (int j = i + 1; j < n; j++) { if (boxes[j].score < 0.0f) continue; if (iou(&boxes[i], &boxes[j]) > nms_thr) { boxes[j].score = -1.0f; /* 打标记,不搬数据 */ } } boxes[keep++] = boxes[i]; } return keep; }

nms_thr这个阈值调起来有讲究。设得太低(比如 0.2),同一个人的两个候选框会被合并掉一个还好,但如果框本身偏移大,可能把相邻的两个人也压掉;设得太高(比如 0.6),同一个人脸上会出现两个框,后面的特征比对会做两次,浪费时间。0.3 到 0.4 之间是个稳妥区间,人脸间距小的时候往下调,检测框抖动厉害的时候往上调。

坐标还原这一步容易出低级错误。模型输出的框一般是相对坐标(0~1 的浮点,或者相对输入尺寸的整数),你要乘回原始帧尺寸。如果检测输入是 240×240、摄像头输出也是 240×240,乘 1 就行;但如果摄像头给了 320×240 而你缩放进了 240×240,x 轴要乘 320/240,y 轴不用动,漏了这个比例,画出来的框会整体偏左。

4.4 特征比对:192 维向量和那个不能拍脑袋定的阈值

检测框出来之后,把框里的人脸裁出来,缩放到特征模型的输入尺寸(常见 112×112),送进特征提取网络,得到一个 192 维或 128 维的向量。比对用余弦相似度:

/* a、b 是归一化后的特征向量,长度 len */ float cosine_sim(const float *a, const float *b, int len) { float dot = 0.0f, na = 0.0f, nb = 0.0f; for (int i = 0; i < len; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } if (na <= 0.0f || nb <= 0.0f) return 0.0f; return dot / (sqrtf(na) * sqrtf(nb)); } /* 在特征库里找最相似的一条,thr 建议先用 0.55 试,再按实测调 */ int face_match(const float *feat, const float db[][192], int n, float thr) { int best = -1; float best_sim = thr; for (int i = 0; i < n; i++) { float s = cosine_sim(feat, db[i], 192); if (s > best_sim) { best_sim = s; best = i; } } return best; /* -1 表示未识别 */ }

阈值是这套系统里最需要实测的参数。同一批人、同一个镜头、同样的光照下,同一个人不同角度的相似度可能在 0.6 到 0.9 之间浮动,不同人之间可能在 0.2 到 0.5 之间。把阈值定在 0.55 是个常见起点,然后在实际场景里录 20 组正样本、20 组负样本,画个分布看重叠区在哪,再往下压或者往上提。直接抄别人的 0.8 当阈值,结果就是十次有八次认不出来,然后开始怀疑模型有问题。

特征向量的存储也有讲究。192 维 float 是 768 字节,100 个人就是 76KB,放 NVS 里完全可以。但如果存 int8 量化版本,体积能压到四分之一,比对时反量化即可,代价是一点点精度损失。做 1:N 且 N 上到几百人的场景,int8 存储更合适,NVS 读写也快。

5. 排查与避坑:五条血泪经验,每条都能省你半天

这一章单独拿出来,因为前面讲的是「怎么跑通」,这里讲的是「跑不通的时候看哪里」。下面五条是我和身边人真实撞过的,按出现频率排序。

5.1 现象:编译通过,串口刷cam_hal: EV-VSYNC-OVF,图像一帧都出不来

原因基本锁定在引脚或者时钟。EV-VSYNC-OVF的意思是场同步信号溢出,摄像头在拼命发帧,但驱动这边没跟上或者根本没对齐。三个可能:数据线 D0~D7 的顺序配反了(有些模组的丝印顺序和驱动里的数组顺序不一致);xclk_freq_hz给太高,模组跟不上;PCLK 引脚接到了被 PSRAM 占用的 GPIO 上。

解决办法是先把xclk_freq_hz降到 10MHz,把frame_size降到 QQVGA,如果这样能出图,说明是带宽问题,逐步往上加回去找到稳定点。如果降频也不行,就逐根量引脚,用万用表测 XCLK 有没有波形——没有波形说明 LEDC 没配好或者引脚被别的外设占了。我一般还会写个最小测试:只初始化摄像头,把每帧的前 16 个字节打印出来,能打印出变化的数据就说明采集通了。

5.2 现象:跑几分钟后fb_get开始返回 NULL,重启后又能跑几分钟

原因是帧缓冲泄漏。检查所有分支路径——包括那些if (ret != ESP_OK) return;的提前返回分支,只要在fb_get之后返回了,就必须先fb_return。另一种可能是 PSRAM 被模型中间张量吃光,剩余空间不足以为新的缓冲分配内存。

解决办法:在取帧失败的日志里带上esp_get_free_heap_size()和heap_caps_get_free_size(MALLOC_CAP_SPIRAM),看是整体内存不足还是只有 PSRAM 不足。前者要收缩模型或者减少缓冲数,后者在 menuconfig 里把CONFIG_SPIRAM_USE_MALLOC相关的阈值调一下。另外,如果代码里有多个人脸检测和特征提取同时跑的路径,注意别让两个模型实例并存。

5.3 现象:检测框位置整体偏移,或者框大小只有实际的一半

原因是坐标缩放没做对。摄像头输出尺寸、模型输入尺寸、显示尺寸三者不一致,中间少乘了比例因子。还有一种更隐蔽的情况:模型输出的是 (x, y, w, h),而你按 (x1, y1, x2, y2) 去解读,画出来的框宽度就变成原来的一半。

解决办法是在后处理里把中间变量打印一次:原始框坐标、缩放系数、最终坐标,三个数对一遍。我习惯在调试阶段把检测结果画在屏幕上,肉眼一看就知道是缩放问题还是模型问题。缩放错的框会稳定地偏一个方向,模型错的框是随机乱跳,这两种现象很好区分。

5.4 现象:识别率忽高忽低,同一个人有时候 0.9 有时候 0.4

先别怀疑模型。八成是送进特征网络的人脸区域不对——检测框偏了,裁剪出来带进了半张背景,特征自然不稳定。其次是光照,逆光时人脸整体偏暗,量化后细节丢失严重。

解决办法:裁人脸时按框的中心向外扩 10%~15%,把人脸完整包住,比紧贴框的效果稳定得多。光照问题可以加一步直方图均衡,ESP-DL 里有现成的算子,代价是几毫秒。另外,注册时多录几个角度(正面、左偏 15 度、右偏 15 度),比对时取最大相似度,比只录一张正面照靠谱得多。

5.5 现象:OTA 升级后设备起不来,卡在 bootloader 循环重启

原因是分区表改了但 OTA 数据分区没跟着调整,或者新固件体积超过了factory分区大小。日志里会看到invalid magic number或者app image is too big。

解决办法:改分区表之后一定要idf.py fullclean重新编译,并确认sdkconfig里CONFIG_PARTITION_TABLE_CUSTOM_FILENAME指向的是新文件。OTA 场景下还要给ota_0、ota_1两个分区各留出和 app 一样大的空间,总 flash 8MB 的话,app 分区控制在 2MB 以内比较安全。升级前用idf.py size看一眼固件实际大小,留 15% 余量。

6. 二次开发:把 Demo 改成能站住脚的终端设备

跑通例程只是起点,真正要做成设备,改动集中在三个地方:状态机、特征库管理、以及功耗。

先说状态机。例程一般是死循环里检测加识别,实际设备不能这样——没人站在门口的时候没必要以 15 帧的速度烧电。我一般会把主循环改成三态:空闲态每 500ms 取一帧做低分辨率检测,检测到人脸就切到活跃态做高频率的检测加识别,连续 3 秒没人脸退回空闲。这样平均功耗能降一半以上,PSRAM 的发热也小很多。

typedef enum { ST_IDLE, ST_ACTIVE, ST_ENROLL } app_state_t; void app_task(void *arg) { app_state_t st = ST_IDLE; int miss_cnt = 0; while (1) { switch (st) { case ST_IDLE: /* 低频巡检:500ms 一帧,只看有没有人 */ if (probe_face_once()) { st = ST_ACTIVE; miss_cnt = 0; } else { vTaskDelay(pdMS_TO_TICKS(500)); } break; case ST_ACTIVE: /* 高频识别:连续 3 秒无人脸退回空闲 */ if (run_recognize_once() == PD_FOUND) { miss_cnt = 0; } else if (++miss_cnt > 45) { /* 约 3 秒 */ st = ST_IDLE; miss_cnt = 0; } break; } } }

miss_cnt的阈值按帧率换算,15 帧就填 45。这套状态机的好处是逻辑边界清晰,加业务(比如识别成功后开门、记录打卡)只需要在run_recognize_once的返回值分支里挂回调,不会把主循环搅成一团。

再说特征库。Demo 里一般是几个硬编码的数组,产品里得支持增删改。我的做法是用 NVS 存一个索引表加若干条特征记录,每条记录带 id、姓名哈希、特征向量、注册时间。注册流程做成一个独立状态:长按按键进入注册态,屏幕提示「请正对镜头」,连续采集 3 帧,取三帧特征的平均值存入——单帧注册的稳定性太差,这个改动几乎没有成本但收益明显。删除就走 id 直接擦除,别做全表重排,NVS 的擦写寿命经不起频繁整块操作。

最后是模型替换的验证方法。换检测模型时,不要只看「能不能检测到」,要准备一组固定场景的照片(顺光、逆光、戴眼镜、侧脸各若干张),每次换模型跑一遍,记录检出率和误检数,两次结果对比才有意义。换特征模型更麻烦,因为阈值要重新标定,老库里存的向量和新模型不兼容,必须强制所有人重新注册。所以特征模型一旦上线,能不动就不动,这是我在一个项目里改过一次模型、导致两百多条注册记录全部作废之后定下的规矩,从那以后每次动模型文件之前,我都会先把models/目录打个 tag,并且强制走一遍「旧库兼容性检查 + 全量重注册」的流程。

这套东西跑下来,一个能离线工作、支持注册和识别、功耗还算克制的人脸识别终端原型就成型了。工程里的摄像头封装、检测后处理、特征库读写这几块都是解耦的,你想换成屏幕显示、换成继电器开门、换成 WiFi 上传打卡记录,改的都是外围那一层。代码和模型都在这份工程里,建议先从app_camera.c和app_detect.c两个文件看起,把一帧图的路径走通,剩下的按需加。希望帮到你。

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

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

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

立即咨询