ESP32-S3本地人脸检测:摄像头管线、调参与门禁落地
简介:这是一份面向嵌入式视觉开发者的 ESP32-S3 人脸识别工程源码包,基于 ESP-IDF 框架,内含人脸检测、猫脸检测、颜色识别与条码扫描等静态库,以及摄像头采集、图像预处理、特征提取与特征比对的实现代码,适合具备 C/C++ 基础、希望在低功耗 IoT 芯片上落地端侧识别的进阶学习者参考。包内共 418 个文件、约 25.82MB,以 74 个 .h 与 74 个 .hpp 头文件、59 个 .c 与 16 个 .cpp 源文件为主体,另含 33 个 md 说明文档、28 个 npy 数据、8 个 py 脚本和若干 onnx/pt/params 模型文件,以及 partitions.csv、CMakeLists.txt、sdkconfig 等构建与分区配置,可据此还原编译环境。已有 2098 人学习下载。读者可对照理解从图像采集到模型推理的完整工程组织,并参考其内存管理、模型量化与硬件加速配置,快速复现并改造自己的识别项目。
1. 一块三十块的摄像头加 S3,人脸检测不必上云
上周帮人改一个门禁 demo,原方案是摄像头采集完传服务器、等结果回传,网络一抖,人就得在门口站着。换成 ESP32-S3 本地跑 esp32 人脸识别 之后,QVGA 分辨率下稳定 8~10 fps,断网照样开门,整机成本压到一杯咖啡的钱。这份资源围绕的正是这个场景:摄像头驱动、图像管线、轻量人脸检测模型、屏幕显示和串口调试打包成一套能编译的工程,改引脚、改阈值就能跑起来。它适合做门禁、考勤机、互动装置原型的嵌入式开发者,也适合想摸一摸边缘 AI 落地边界的同学。但边界要先说清楚——S3 跑得动人脸检测,跑不动高精度人脸识别,指望它替代云端大模型那条路,第一天就会撞墙。
2. 选型先算三笔账:S3 跑人脸检测的算力、内存与带宽边界
动手之前先把账算明白,后面调参才不会瞎试。ESP32-S3 这颗芯片的定位很微妙:它比普通 MCU 强得多,但离"能跑任意模型"还差一大截,人脸的轻量检测恰好卡在它能力上限附近——刚好能跑,但每一 KB 内存、每一毫秒都要抠。
2.1 算力与模型:240MHz 双核加向量指令够不够
S3 是双核 Xtensa LX7,主频 240MHz,关键点是它带 128 位向量指令(EE.128 那套 SIMD),int8 卷积能吃到实打实的加速。这意味着量化后的轻量卷积网络在它上面的推理速度,比同频的普通 MCU 快好几倍,而不是快一点点。
能跑什么样的模型,得看模型本身的"体重"。这类型号里常见的人脸检测网络,backbone 是深度可分离卷积堆出来的三尺度结构,输出一张 score map 加 box 回归,输入一般 240×240,int8 量化后权重落在 200~400KB 区间。实测下来单帧推理 60~120ms,加上采集、解码、显示,整条链路 8~12 fps。这个数字看着不高,但对门禁来说够用——人走到门前站定,一秒出结果不会有明显体感延迟。
真正拖后腿的往往不是算力,是 cache。模型权重放在 Flash 里,靠指令 cache 和数据 cache 去读,cache 命中率一低,推理时间能翻倍。血泪经验是:把前几层高频访问的权重和激活缓冲尽量往内部 SRAM 挪,或者干脆把整个模型搬进 PSRAM 的一段连续区域减少跨页,跑出来的帧率差异肉眼可见。
还有一个容易忽略的点:双核不等于性能翻倍。摄像头采集、JPEG 解码、推理三件事如果全压在 core 0 上,core 1 在空转,你会以为芯片不行。正确做法是把推理单独 pin 到 core 1,采集和显示留在 core 0,帧率通常能涨 20%~30%。
2.2 内存与带宽:为什么 8MB PSRAM 是硬门槛
算完算力算内存。S3 内部 SRAM 512KB,听着不少,但 WiFi/BLE 协议栈、DMA 描述符、任务栈、屏幕缓冲一层层剥下来,留给应用的三百来 KB 都算乐观。
而一帧 QVGA 的 RGB565 是多少?320×240×2 = 150KB。双缓冲就是 300KB。这还没算 JPEG 解码出来的 RGB888 中间缓冲——320×240×3 = 230KB。所以"帧缓冲放内部 RAM"这条路从第一帧就走不通,PSRAM 不是加分项,是入场券。
| 内存项 | 典型占用 | 放在哪 | 说明 |
|---|---|---|---|
| 内部 SRAM 总量 | 512KB | 片内 | 协议栈+驱动+DMA 先吃掉一大半 |
| 单帧 RGB565 QVGA | 150KB | PSRAM | 双缓冲 300KB,内部 RAM 放不下 |
| JPEG 解码中间缓冲 | 230KB | PSRAM | RGB888,解码完要立即缩放 |
| 模型权重 | 200~400KB | Flash/PSRAM | 走 cache,命中率决定速度 |
| 任务栈(推理) | 8~16KB | 内部 RAM 优先 | 默认 3KB 栈跑模型必崩 |
带宽账更值得算。八线 Octal PSRAM 跑 80MHz DDR,理论带宽大约 160MB/s,看着很宽。但 CPU 访问 PSRAM 是要过 cache 的,32 字节一个 cache line,miss 一次就是几百纳秒的等待。一张 230KB 的图从 PSRAM 搬一遍,理论几毫秒,实际在 cache miss 叠加上去以后常常是十几毫秒——这段时间恰恰卡在推理前面,帧率就这么被吃掉了。
所以省带宽的思路很朴素:能让 DMA 直接写进 PSRAM 的就别让 CPU 中转;能在 JPEG 域里做的缩放就别先解成全彩再缩;能只解码一次的就别每帧解两次。这几条做到位,同样的硬件能多出 2~3 fps,而这 2~3 fps 往往就是"能用"和"卡顿"的分界线。
2.3 摄像头、屏幕与供电:一份能直接下单的清单
硬件选型上翻车的概率比软件高,因为参数不匹配是沉默失败——不报错,就是不出图。
| 模块 | 关键参数 | 为什么是这个 |
|---|---|---|
| 主控模组 | ESP32-S3,带 8MB PSRAM(Octal)、8MB Flash | Quad PSRAM 带宽减半,跑检测会明显掉帧 |
| 摄像头 | DVP 接口,OV2640 一类 200 万像素 | 驱动成熟,QVGA 输出稳定,成本低 |
| 屏幕 | SPI 接口 240×240 IPS | 分辨率和检测框坐标系一致,省一次坐标变换 |
| 供电 | 3.3V LDO,持续 800mA 以上,输出端并 100μF | 摄像头+屏幕+射频峰值电流叠在一起很容易掉压 |
| 存储 | 板载 8MB Flash 起 | 双 app 分区 + 模型分区,4MB 会很紧张 |
摄像头这边有几个必须对齐的参数。DVP 是 8 位并行口,PCLK、VSYNC、HREF 三根同步线加 D0~D7 八根数据线,再加 SCCB(本质就是一路 I2C)配置寄存器。引脚不能随便改,必须和工程里那份 camera 配置结构体逐一对上,改硬件不改配置,表现就是图像全黑或者花屏。
#include "esp_camera.h" static camera_config_t cam_cfg = { .pin_pwdn = -1, // 模组把 PWDN 直接拉低时填 -1,接了 GPIO 才填号 .pin_reset = -1, .pin_xclk = 15, // 主时钟输出,OV2640 建议 20MHz,别超过 24MHz .pin_sccb_sda = 4, // SCCB 数据线,等价于 I2C SDA .pin_sccb_scl = 5, .pin_d7 = 16, .pin_d6 = 17, .pin_d5 = 18, .pin_d4 = 12, .pin_d3 = 10, .pin_d2 = 8, .pin_d1 = 9, .pin_d0 = 11, .pin_vsync = 6, .pin_href = 7, .pin_pclk = 13, .xclk_freq_hz = 20 * 1000 * 1000, // 降频能治花屏,但会掉帧 .ledc_timer = LEDC_TIMER_0, // XCLK 由 LEDC 外设产生 .ledc_channel = LEDC_CHANNEL_0, .pixel_format = PIXFORMAT_JPEG, // 先拿 JPEG,省 PSRAM 带宽 .frame_size = FRAMESIZE_QVGA, // 320x240,检测够用且帧率好看 .jpeg_quality = 12, // 0~63,数字越小画质越好、体积越大 .fb_count = 2, // 双缓冲,少于 2 会明显卡顿 .fb_location = CAMERA_FB_IN_PSRAM, // 必须放 PSRAM,内部 RAM 装不下 .grab_mode = CAMERA_GRAB_LATEST, // 只取最新帧,主动丢旧帧 };这段配置里有三个参数最容易被随手改坏。xclk_freq_hz决定摄像头主时钟,线长、排线质量差的时候 20MHz 都可能花屏,降到 16MHz 能救急,代价是曝光时间变长、暗光下拖影。jpeg_quality不是"画质档位",它是量化表系数,12 是个平衡点,调到 8 以下 JPEG 体积暴涨、解码耗时上升,检测帧率反而下降。fb_count设成 1 的时候,采集和推理会互相等,看起来像"推理变慢了",其实是管线在排队。
提示:供电别省。摄像头启动瞬间和屏幕背光同时拉电流,LDO 余量不足会触发 brownout 复位,日志里那句
Brownout detector was triggered十有八九是电源问题,不是代码问题。
3. 环境落地:从工具链到第一帧画面的四步
环境这一步没什么智力含量,但坑很集中。目标是把"能编译、能烧录、能看到摄像头出图"这三件事拆开验证,别一上来就跑完整业务,出了问题根本分不清是驱动、模型还是电源。
3.1 锁定 SDK 分支与目标芯片
第一件事是锁分支。SDK 主干在动,摄像头驱动和推理组件的接口在不同分支之间会变,边做边升级等于给自己埋雷。常见做法是选一个团队在用的稳定分支,整包 clone 下来,别用包管理器装完就走——后面要改组件源码的时候你会感谢自己。
# 1) 拉 SDK 到固定目录,分支名按团队约定替换,别用主干 cd ~/esp git clone -b <稳定分支> --recursive <SDK 仓库地址> esp-idf cd esp-idf # 2) 只装 esp32s3 的工具链,省一半磁盘和下载时间 ./install.sh esp32s3 # 3) 每次开新终端都要 source 一次,写进 shell 配置更省事 . ./export.sh idf.py --version--recursive不能漏,SDK 里大量组件是子模块,漏了会在编译阶段报一堆"找不到头文件",而且报错位置离真正原因很远。install.sh esp32s3后面那个参数也不是装饰,不指定目标会把所有芯片的工具链全装一遍。
工程建好之后第一件事是把目标芯片钉死:
# 在工程根目录执行,会重写 sdkconfig 里的 target 相关项 idf.py set-target esp32s3set-target会清空并重建sdkconfig,所以顺序很重要:先 set-target,再改配置。反过来做,前面调好的 PSRAM 和分区表会被一起抹掉,白忙半小时。
3.2 menuconfig 里不能漏的六项配置
配置项几百个,必须动的就六组。剩下的默认值大多数时候是对的,乱改反而出问题。
# PSRAM:Octal 模组必须选对模式,选错直接认不到颗粒 CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_OCT=y CONFIG_SPIRAM_SPEED_80M=y CONFIG_SPIRAM_USE_MALLOC=y CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096 CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y # Flash 与分区表:模型和 app 都要地方放 CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" # Cache:给推理留够,太小会频繁回 Flash 取权重 CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB=y CONFIG_ESP32S3_DATA_CACHE_64KB=y # 任务栈:主任务默认 3KB 多,跑模型必炸 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 # 时基与看门狗:1ms 节拍让超时判断更细 CONFIG_FREERTOS_HZ=1000 CONFIG_ESP_TASK_WDT_TIMEOUT_S=10CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096这一项的含义是:小于 4KB 的分配优先走内部 RAM。别小看它,很多驱动的小结构体如果被扔进 PSRAM,访问延迟会莫名其妙变高,帧率掉得你找不到原因。CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP则是把 WiFi 的大块缓冲往 PSRAM 引,给内部 RAM 腾地方——不开 WiFi 的时候它没影响,一开就是救命的。
分区表要自己写一份,重点是大:app 分区至少 2MB(带推理组件和显示组件的固件很容易超过 1.5MB),模型如果单独烧到独立分区,还要再留一块。
# partitions.csv:名字,类型,子类型,偏移,大小 nvs, data, nvs, 0x9000, 0x6000 phy_init, data, phy, 0xf000, 0x1000 factory, app, factory, 0x10000, 0x300000 model, data, 0x40, 0x310000, 0x100000偏移量必须严格对齐,factory的起始偏移是 0x10000,前面几项加起来不能越界,否则烧录阶段直接报 "app partition is too small",这个错经常被误以为是固件真的太大。
3.3 烧录与串口日志的三段判读
编译烧录没什么花活,重点是一次只验证一层。
# 只编译,先确认工具链和配置没打架 idf.py build # 烧录并立刻开监视器,串口设备名按系统改 idf.py -p /dev/ttyACM0 flash monitor # 退出监视器是 Ctrl+],不是 Ctrl+C(后者在某些终端会留着进程)日志要分三段看,顺序不能乱:
第一段,启动与内存。关注 PSRAM 的识别行,正常会打印出容量和模式。如果这里报PSRAM ID read error或者容量显示 0,后面所有关于帧率的讨论都没有意义,先回去查CONFIG_SPIRAM_MODE_OCT和硬件焊接。
第二段,摄像头探测。驱动初始化成功会打印出检测到的传感器型号和分辨率。如果打印了型号但图像出不来,问题在引脚或时钟;如果连型号都读不到,问题在 SCCB 那两根线,先量上拉电阻。
第三段,业务日志。自己打的帧率、检测框数量、剩余堆内存。这一段必须存在,否则你只能靠"看起来卡不卡"判断性能,那是玄学不是调试。
注意:
monitor打开的时候串口被占用,另一个终端想读同一路串口会失败。做数据采集的时候先退出监视器,别怀疑是脚本写错了。
4. 图像管线与后处理:让检测框稳稳落在人脸上
到这一步硬件通了、日志干净了,才开始碰真正决定体验的部分。人脸检测的"准"和"稳"其实分两半:一半在图像进模型之前就定死了,另一半在后处理参数里。很多人调不出来,一直在换模型,实际上是前面的图像管线把信息毁了。
4.1 JPEG 解码、缩放与双缓冲的取舍
摄像头给的是 JPEG,模型要的是固定尺寸的紧凑像素缓冲,中间这段是纯开销,但不做不行。
常见的三级管线是这样:采集任务拿到 JPEG → 解码成 RGB888 → 双线性缩放到模型输入尺寸 → 交给推理任务。每一步都有代价。解码一张 QVGA JPEG 大概 20~40ms,缩放再花几毫秒,加上推理 60~120ms,一帧的总预算就出来了。
三个取舍点值得说清楚。第一,输出格式选 JPEG 还是 RGB565:JPEG 省 PSRAM 带宽、传输快,但要额外解码;RGB565 直接可用,但一帧 150KB 全部走内存总线。帧率优先选 JPEG,内存吃紧也选 JPEG,只有在 PSRAM 带宽非常富余、想把解码时间省下来的时候才考虑 RGB565。
第二,缩放算法。双线性比最近邻慢一点点,但对小脸的检测召回明显更好——最近邻会把 40px 的人脸采样成几个像素的斑点,检测网络根本认不出来。这个地方省下的 2ms,会在漏检上十倍还回来。
第三,缓冲数量。双缓冲是底线,采集在写 A 的时候推理读 B,互不阻塞。三缓冲能进一步减少丢帧,但多占 150KB PSRAM,值不值看你屏幕刷新和业务逻辑的开销。队列长度建议设成 1,也就是"最新帧优先",宁可丢帧也别让延迟堆积——门禁场景里,三秒前的脸和现在的脸不是一回事。
4.2 推理调用与后处理代码
推理本身的调用很薄,厚的是它前后的边界处理。下面这段是典型结构,注释里标了几个必须守的规矩。
#include "dl_detect_define.hpp" static dl::detect::result_t results[10]; // 结果数组,10 个框对门禁足够 void face_task(void *arg) { while (1) { // 1) 取帧带超时,取不到就跳过这一轮,绝不阻塞采集任务 camera_fb_t *fb = esp_camera_fb_get(); if (!fb) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } // 2) 检测:内部完成解码、缩放、归一化 // 输入尺寸必须和模型训练时一致,改了会得到一堆乱框 dl::detect::detect(&results, 10, fb->buf, fb->len, /*score_thr=*/0.5f, // 分数阈值,误检多就上调 /*nms_thr=*/0.3f, // 交并比阈值,人脸挨得近就上调 /*resize_scale=*/1.0f); // 3) 小脸的关键点不可信,宽度小于 40px 的框直接丢 for (int i = 0; i < results->box_num; i++) { int w = results->boxes[i].box[2] - results->boxes[i].box[0]; if (w < 40) continue; draw_box(&results->boxes[i]); // 画框,坐标已是屏幕坐标系 } // 4) 必须归还帧缓冲,忘记归还两帧之后整个采集就卡死了 esp_camera_fb_return(fb); // 5) 让出 CPU 喂一次看门狗,尤其是低帧率场景 vTaskDelay(1); } }几个参数的实际含义值得展开。score_thr不是"越高越准",它是召回和误检的跷跷板:调到 0.7,逆光和侧脸基本全丢;调到 0.3,墙上的海报、衣服上的图案都开始被当成人脸。室内门禁场景我一般从 0.5 起步,实测一两百帧再微调。
nms_thr管的是重叠框抑制。人脸密集(比如排队打卡)时 0.3 会把相邻两张脸抑制成一张,得往上调到 0.4~0.5;单人场景可以压到 0.25 换更干净的输出。
resize_scale这个参数很多人不填,默认 1.0 表示按原始比例缩放。填大于 1 会把图放大再送进去,小脸变清楚但耗时会涨,属于典型的"用帧率换召回"。在 2 米以外的识别场景,把它设成 1.2 往往比调阈值有效。
4.3 阈值、NMS 与最小人脸尺寸的联合调参
调参最忌讳单变量乱试。这几个参数是耦合的,得按顺序来。
| 参数 | 建议起点 | 调大的后果 | 调小的后果 |
|---|---|---|---|
| score_thr | 0.5 | 误检减少,漏检增加,远距离先失效 | 误检暴涨,后级全部被拖慢 |
| nms_thr | 0.3 | 密集人脸能分开,但同一张脸可能出两个框 | 相邻人脸被吞成一个 |
| min_face_px | 40 | 只处理近处的人,帧率稳 | 小脸框抖动严重,关键点全歪 |
| frame_size | QVGA | 细节多,解码和推理都变慢 | 5 米外基本检测不到 |
| jpeg_quality | 12 | 体积小传输快,细节损失 | 体积大、解码慢,帧率反降 |
推荐的调试顺序是:先固定frame_size和jpeg_quality,用一段固定距离的录像反复跑,只动score_thr找到"该检的都检到、背景不报警"的点;然后在这个点上调nms_thr,处理多人场景;最后用min_face_px把抖动的小框过滤掉,因为它会污染后面的关键点对齐。
这套顺序的意义在于,每个参数的影响面是单向的。反过来先调min_face_px,你会发现怎么调都不对——因为阈值还没定,框的数量本身就在乱跳。
提示:每次只改一个参数,改完录 30 秒日志存一份。调参调到最后忘了哪版最好,是这条路上最常见的后悔药场景。
5. 避坑与排查:五个返工记录里的现象、原因和解法
下面这几条都是实际撞过的,按"现象 → 原因 → 解决"记,方便对照自己的日志。
5.1 启动与内存:PSRAM 认不到、栈溢出
记录一:PSRAM 容量打印为 0,或者只有几百 KB 可用堆。
现象是固件能启动、摄像头也能初始化,但一取帧就返回 NULL,堆内存打印出来只有两百多 KB。原因基本锁定在 PSRAM 模式选错——硬件贴的是 Octal 颗粒,配置里选了 Quad,颗粒根本不会被枚举。解决方式是回 menuconfig 把CONFIG_SPIRAM_MODE_OCT和CONFIG_SPIRAM_SPEED_80M一起打开,然后重新set-target或者全量 clean 后重编,只改配置不重编有时候不生效。
记录二:跑几帧之后直接重启,backtrace 指向推理函数内部。
现象很典型:前两三帧正常出框,然后突然复位,串口打印一串地址。原因是任务栈不够——默认主任务栈 3KB 多,而推理过程中的中间张量和函数调用深度轻易超过这个数。解决是把推理放在独立任务里跑,栈给到 8~16KB,xTaskCreatePinnedToCore的时候把栈深参数写足,同时优先让栈落在内部 RAM 而不是 PSRAM。
5.2 图像与模型:花屏、全黑、满屏误检
记录三:图像花屏、绿屏,或者整帧全黑但日志一切正常。
花屏和全黑是两回事。花屏多半是 XCLK 太高或者排线太长,把xclk_freq_hz从 20MHz 降到 16MHz 经常立竿见影。全黑则要分两步查:先看fb->len,如果是几十字节那种极小值,说明根本没采到数据,重点查 PCLK 和 VSYNC 有没有接反;如果len正常但画面全黑,那是摄像头没出流,去查 PWDN 引脚——很多模组要求 PWDN 拉低才工作,配置里填了-1但硬件上拉了高电平,就会一直待机。
记录四:满屏误检,墙上插座、衣服褶皱全被框成脸。
先别怀疑模型。第一步看输入图像是不是被拉伸变形了——宽高比不对的时候,检测网络对纹理的响应会完全跑偏,这是最常见的根因。第二步看score_thr是不是被谁顺手改到了 0.3 以下。第三步才轮到模型本身,比如模型输入尺寸和配置里写的不一致,导致内部缩放引入了伪影。解决顺序就是:先对齐宽高比,再回 0.5 阈值,最后核对输入尺寸。
5.3 稳定性:看门狗、掉压、WiFi 抢内存
记录五:运行十几秒到几十秒后复位,日志里要么是任务看门狗超时,要么是 brownout。
这两个要分开看。看门狗超时的堆栈会指向推理或显示循环,本质是某一段阻塞太久没让出 CPU,尤其在人脸多、后处理变重的时候。解决办法不是简单把超时从 5s 加到 10s,而是把长任务拆段,每段之间vTaskDelay(1),让空闲任务有机会喂狗。
brownout 则是电源问题,日志里明明白白写着Brownout detector was triggered。摄像头曝光瞬间、屏幕背光切换、WiFi 发射,三股电流叠加能把 3.3V 拉下去。解决是在 LDO 输出端并 100μF 电解加 0.1μF 陶瓷,线尽量短粗,别用杜邦线给摄像头供电——这个坑我踩过,换成 10cm 以内的软线立刻稳定。
还有一个隐蔽的:开了 WiFi 之后帧率断崖式下跌,甚至取帧失败。原因是 WiFi 协议栈要占内部 RAM,和摄像头的 DMA 缓冲抢地方。解决是减少fb_count、把 WiFi 缓冲往 PSRAM 引(CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP),或者干脆分时——检测时关射频,上报时暂停采集。
6. 从检测到识别:两级流水、阈值标定与一份日志验证脚本
检测框稳了之后,很多人第一反应是"直接上识别模型"。在 S3 上这么干大概率撞墙:一个 512 维特征提取网络加上检测网络,单帧耗时能翻两三倍,帧率掉到 2~3 fps,体验直接崩。
6.1 检测加特征比对的两级拆分
可行的做法是把两件事的节奏拆开。检测每帧都跑,因为它便宜且必须实时;特征提取只在"检测到稳定人脸"时才触发,而且做隔帧触发——同一张脸连续三帧都检到,才做一次对齐加特征提取。关键点在这里不是装饰,它用来做仿射变换把人脸摆正到 112×112,摆不正的话特征相似度会掉得很厉害。
比对阈值我一般从 0.6 起,注册时每人采 3~5 张取平均向量,比单张注册稳得多。这里有个边界要说清楚:S3 上跑的特征模型表达能力有限,同一个人换副眼镜、换个光照就可能掉到阈值以下。所以产品层面必须留后路——相似度落在 0.45~0.6 这段"模糊区"时,退化到刷卡或密码,别硬判。
如果识别精度实在不够,最务实的方案是 S3 只做检测加抓拍,把裁剪后的人脸 JPEG 通过串口或局域网发给本地的一台小主机做识别。这样 S3 端只承担 10ms 级的开销,帧率不受影响,整体方案还保住了离线可用。
6.2 用串口日志把帧率和延迟量出来
性能这件事不能靠"看着还行"。我习惯在业务循环里打一行固定格式的 CSV,然后用脚本采一段做统计,比肉眼靠谱得多。
import serial, statistics # 串口参数要和固件一致,115200 是常用值 ser = serial.Serial("/dev/ttyACM0", 115200, timeout=1) fps_list, lat_list, det_list = [], [], [] for raw in ser: line = raw.decode(errors="ignore").strip() # 只认自己打的标记行,避开启动日志和驱动日志的干扰 if not line.startswith("STAT,"): continue _, fps, lat, det = line.split(",") fps_list.append(float(fps)) lat_list.append(float(lat)) det_list.append(int(det)) if len(fps_list) >= 600: # 采 600 帧(约 1 分钟)就够看趋势 break lat_sorted = sorted(lat_list) print(f"帧率 中位数={statistics.median(fps_list):.1f} " f"最低={min(fps_list):.1f}") print(f"延迟 P50={lat_sorted[len(lat_sorted)//2]:.0f}ms " f"P95={lat_sorted[int(len(lat_sorted)*0.95)]:.0f}ms") print(f"平均每帧人脸数={statistics.mean(det_list):.2f}")脚本里三个指标各有用途。帧率中位数反映稳态性能,最低帧率反映最差情况——如果最低值和中位数差出一倍,说明有周期性阻塞,多半是 WiFi 上报或者屏幕刷新在抢资源。延迟 P95比平均值有价值得多,门禁的用户体验由最慢那 5% 决定,平均 90ms 而 P95 是 400ms 的方案,用起来就是"偶尔卡一下"。
固件端对应的打印就一行,放在每帧处理完之后:
// 每 30 帧打一次,避免串口本身成为瓶颈 if (++frame_cnt % 30 == 0) { printf("STAT,%.1f,%d,%d\n", fps, latency_ms, box_num); }fps用两次时间戳差算,latency_ms从取帧到画框结束,box_num是本帧有效框数。这三个数放在一起看,能快速区分"是检测变慢了"还是"是框太多拖慢了后处理"。
从那以后我每次改完参数,都会强制走一遍这个采集流程:跑 600 帧、看 P95、对比上一版的三个数,不通过就不往下做。玄学调参救不了产品,能救你的只有那几行朴素的日志。希望帮到你。
本文还有配套的精品资源,点击获取