
简介一份基于STM32的人脸识别实验工程面向嵌入式系统爱好者和计算机视觉入门者演示如何借助OV5640摄像头模块在STM32Cortex-M3/M4平台上实现人脸检测与识别可广泛应用于物联网设备、智能家居与安防等领域。资源压缩包共含174个文件以C源码和头文件为主另有Keil工程文件.uvprojx、烧录文件.hex、生成脚本.bat及多张原理示意图整体大小仅1.03MB便于下载与快速查阅工程结构。该资源已有8607人次学习浏览适合具备一定STM32开发基础、希望深入学习轻量级视觉识别算法的开发者。工程代码完整实现了从图像采集、预处理、人脸检测到特征提取与识别的流程并针对Cortex-M4的浮点运算能力进行了优化通过学习该工程可掌握在有限存储和计算资源下部署人脸识别算法的思路以及Keil uVision5的工程组织与调试方法。1. 单片机上的人脸识别能用但不是你以为的那种“人脸识别”单片机人脸识别的第一反应通常是“跑不动”。但把摄像头分辨率从 320x240 降到 160x120、把动辄几十 MB 的深度学习模型换成 LBP 直方图加余弦相似度之后一块 168MHz 的 STM32F407 也能在 200ms 内完成“找到人脸、提取特征、比对 ID、串口输出结果”的全流程。这套基于 STM32 的人脸识别方案就是针对这种场景做的低成本门禁、宿舍考勤、毕设作品或者单纯想拿单片机练手嵌入式视觉的开发者。它能现场录入人脸、断电后在片内 Flash 里保存特征库、识别结果通过串口或 LCD 屏幕反馈整条链路在板子上闭环完成不依赖 PC、不依赖云。运行效果没办法和手机的人脸解锁比但在小成本项目里它很稳也足够你基于它做二次开发。下文从硬件架构一直讲到底层代码最后落到避坑和改造。2. 系统架构与选型DCMI 采集链路、LBP 识别算法、Flash 特征库的分工2.1 先看整体链路一个像素从镜头到屏幕要过哪些关卡这一版工程的核心数据流是OV2640 摄像头输出 RGB565 原始数据 → SCCB 总线配置寄存器把画面定为 160x120 → DCMI 并行接口在像素时钟节拍下采集 → DMA2 双缓冲把帧搬进 RAM → 主循环在帧就绪标志位后拿到稳定画面 → LCD 刷新预览、同一块内存在空闲时段进入肤色检测和 LBP 特征提取 → 比对结果写进 Flash 或从串口丢出来。很多人第一次做摄像头直接把传感器数据往 LCD 刷再用 GPIO 轮询像素时钟结果就是画面撕裂、CPU 占用拉满。DCMI 是 STM32 自带的数字摄像头接口配合 DMA2 可以把像素传输完全从 CPU 卸载掉CPU 只在 DMA 传输完成时收到一个中断。所以这套方案选 DCMI DMA2 双缓冲不是可选项而是让 MCU 有富余算力去做识别的前提。内存和带宽也要提前算清楚160x120 的 RGB565 一帧占 38.4KBF407 的内部 SRAM 是 192KB双缓冲占掉约 77KB剩下的空间给算法中间量、LCD 显存和栈勉强够用。如果贪分辨率上 320x240一帧就是 153.6KB双缓冲直接爆 RAM。降到 160x120 是内存和识别效果之间的一个甜点值这也是整套方案能跑起来的第一个关键决策。2.2 算法选型为什么不跑 CNNLBP 靠什么在 MCU 上完成识别F407 有 FPU但没有硬件卷积加速跑一帧轻量 CNN 也要秒级这在门禁场景里完全不可接受。工程里用的是 LBP 局部二值模式每个像素和周围 8 个像素比较得到一个 8bit 编码。一个 48x48 的归一化人脸图约 2304 个像素每个像素做 8 次比较十几万次运算在 168MHz 下是毫秒级这才是在 MCU 上做视觉的正确路子。LBP 原始编码是 256 种模式直接做 256bin 直方图一方面维度高另一方面对光照敏感。工程里我用均匀模式映射统计 8bit 编码中相邻位跳变次数跳变次数小于等于 2 的模式保留其余全部归入一个混合 bin。8 邻域均匀模式一共 58 种再压缩到 32bin配合人脸图分成 4x4 共 16 个 block最终特征向量是 16x32512 个 uint16约 1KB 内存。环节参数说明采集分辨率160x120 RGB565单帧 38.4KB双缓冲 77KB识别用图48x48 灰度从人脸框缩放特征描述4x4 block x 32binLBP 均匀模式512 维比对算法余弦相似度推荐阈值 0.62单次识别耗时约 120~200ms含采集、检测、特征提取、比对特征向量小比对也很便宜。在特征库里逐个算余弦相似度每个人的比对是 512 次乘加录 10 个人也就几千次运算。阈值 0.62 是这套方案在室内光照下的推荐起点后面第 5 章会讲怎么根据现场重新标定。2.3 软件模块划分一个可以直接定位到文件的工程结构工程代码按硬件、算法、应用三层拆开目录结构大致是这样FaceID/ ├─ App/ │ ├─ main.c # 主循环状态机串口命令分发 │ ├─ ui_task.c # LCD 菜单录入/识别/浏览 │ └─ cmd_parser.c # 串口指令解析add/del/list/id ├─ Driver/ │ ├─ bsp_dcmi.c # DCMI 初始化、DMA 中断、帧取出 │ ├─ bsp_ov2640.c # SCCB 读写OV2640 窗口和输出格式配置 │ ├─ bsp_lcd.c # LCD 驱动触摸菜单和识别框绘制 │ └─ bsp_flash.c # 片内 Flash 驱动特征库扇区管理 ├─ Algorithm/ │ ├─ colorspace.c # RGB565 转灰度、肤色判定 │ ├─ face_detect.c # 肤色掩膜 连通域取最大人脸框 │ └─ face_identify.c # LBP 特征提取 余弦相似度 └─ Middleware/ └─ printf_uart.c # fputc 重定向到串口 1带日志开关这样拆的好处是换摄像头只动 Driver 层换识别算法只动 Algorithm 层板和 PC 的差异被挡住。我后期在 PC 上验证算法时把 face_identify.c 整个文件夹拷出来喂同一份灰度数组跑出来的相似度和板端完全一致方便调参。2.4 电源和引脚细节DCMI 为什么不能图省事乱接OV2640 的 IO 电平和 STM32 的 3.3V 可以直接对接但摄像头模块如果供电纹波太大DCMI 采集到的数据会随机错位表现就是画面出现周期性条纹。工程里建议给摄像头单独加一颗 LDO避免和电机、继电器共用电源SCCB 的 SCL/SDA 各加 4.7k 上拉电阻否则初始化时偶发读不到传感器 ID。DCMI 的 D0-D7、PCLK、HSYNC、VSYNC 在 F407 上都有固定的引脚映射只要在 CubeMX 里按板子的原理图把对应引脚复用为 DCMI 功能即可。这里有一个实际经验DCMI 数据线不要求连续编号PCB 上可以随便走线软件里一次性配置好就行这是它比并口 RGB LCD 方便的地方。3. 核心实现拆解一帧图像从 DCMI 进入到识别结果输出的完整链路3.1 关键帧采集DCMI DMA2 双缓冲不漏帧的配置方式DMA2 双缓冲的核心思路是准备两块缓冲区DMA 先把一帧写进缓冲 0写完后自动切到缓冲 1同时触发中断通知主循环处理缓冲 0。这样摄像头一直在采集算法处理的是上一帧互不阻塞。工程里的缓冲定义和启动代码如下// 160x120 RGB565一帧 38.4KB双缓冲约 77KB static uint8_t cam_fb[2][160 * 120 * 2] __attribute__((aligned(32))); static volatile uint8_t fb_active 0; static volatile uint8_t frame_ready 0; void BSP_DCMI_Start(FrameCB cb) { // 使用 DMA2 的 Stream1通道对应 DCMI 外设 DMA2_Stream1-CR ~DMA_SxCR_EN; // 先关 DMA while (DMA2_Stream1-CR DMA_SxCR_EN); DMA2_Stream1-PAR (uint32_t)DCMI-DR; // 外设地址指向 DCMI 数据寄存器 DMA2_Stream1-M0AR (uint32_t)cam_fb[0][0]; // 缓冲 0 DMA2_Stream1-M1AR (uint32_t)cam_fb[1][0]; // 缓冲 1 DMA2_Stream1-NDTR 160 * 120 * 2 / 2; // 按 16 位传输次数计算 DMA2_Stream1-CR | DMA_SxCR_MINC | DMA_SxCR_CIRC | DMA_SxCR_DBM | DMA_SxCR_TCIE; DMA2_Stream1-CR | DMA_SxCR_EN; DCMI-CR | DCMI_CR_CAPTURE; // 启动采集 } void DMA2_Stream1_IRQHandler(void) { if (DMA2-LISR DMA_LISR_TCIF1) { DMA2-LIFCR DMA_LIFCR_CTCIF1; // 清传输完成标志 fb_active ^ 1; // 双缓冲切换 frame_ready 1; // 通知主循环 } }NDTR 除以 2 是因为 DMA 配置成 16 位传输一帧 160x120x2 字节按半字算就是 19200 次CIRC 循环模式保证 DMA 在没有 CPU 介入的情况下持续搬运DBM 是双缓冲模式的开关位。这里不建议同时开 DCMI 的帧中断和 DMA 的传输完成中断两者会在同一事件附近重复触发处理起来容易漏帧。只保留 DMA 中断判断 FIFO 溢出时再去查 DCMI 状态寄存器即可。实际调这个工程时我通常会先刷一帧纯色画面到 LCD 验证 DMA 链路确认 NDTR 和缓冲大小匹配后再接算法否则出了问题很难区分是摄像头配置错、DMA 配错还是算法错。3.2 人脸检测不依赖 OpenCV在 MCU 上用肤色连通域快速找脸检测阶段我放弃了需要大量计算的 Haar 特征改用肤色检测加连通域。先把 RGB565 抽成 R、G、B 分量用一组固定区间筛出肤色像素再通过 BFS 找最大连通块最后用宽高比、面积占比做几何校验。#define MAP_W (160 / 2) #define MAP_H (120 / 2) static uint8_t skin_map[MAP_W * MAP_H]; // 隔行采样后的人脸掩膜 static int16_t bfs_qx[1024], bfs_qy[1024]; // 预分配 BFS 队列避免 malloc static void SkinBuild(uint16_t *img, int w, int h) { memset(skin_map, 0, sizeof(skin_map)); for (int y 0; y h; y 2) { // 隔行采样速度优先 for (int x 0; x w; x 2) { uint16_t rgb __REVSH(img[y * w x]); // 高低字节交换 int r ((rgb 11) 0x1F) 3; int g ((rgb 5) 0x3F) 2; int b (rgb 0x1F) 3; if (r 95 g 40 b 20 (r - g) 15 (r - b) 15) { int idx (y / 2) * MAP_W (x / 2); skin_map[idx] 1; } } } }隔行采样把 160x120 降成 80x60 的掩膜肤色判断量直接变成四分之一。BFS 队列预分配 1024 个点遇到手部遮挡、红色背景这种大面积误检时队列可能溢出所以入队前要检查边界超过上限就丢弃当前连通块。这种做法比无脑加大数组更安全也不会因为 malloc 产生堆碎片。几何校验阶段我只保留外接矩形宽高比在 0.4 到 1.2 之间、面积占整幅画面 3% 到 60% 的连通块取面积最大的作为人脸框。人脸框的位置会同时画在 LCD 预览上也用于后续的 LBP 特征提取。3.3 识别与录入LBP 特征提取、余弦相似度比对、特征入库拿到人脸框之后先把框内图像缩放到 48x48 灰度图再做 LBP 编码。8 邻域 LBP 的编码逻辑很直接中心像素和周围 8 个像素逐一比较大于等于就置 1static uint8_t LBPCode(const uint8_t *gray, int stride, int x, int y) { uint8_t c gray[y * stride x]; uint8_t code 0; if (gray[(y - 1) * stride (x - 1)] c) code | 0x01; if (gray[(y - 1) * stride x] c) code | 0x02; if (gray[(y - 1) * stride (x 1)] c) code | 0x04; if (gray[y * stride (x 1)] c) code | 0x08; if (gray[(y 1) * stride (x 1)] c) code | 0x10; if (gray[(y 1) * stride x] c) code | 0x20; if (gray[(y 1) * stride (x - 1)] c) code | 0x40; if (gray[y * stride (x - 1)] c) code | 0x80; return code; }这里故意没有做双线性插值用的是半径 1 的最简单模式目的是省算力。编码之后并不是直接统计 256 个 bin而是先把 256 种模式映射成均匀模式编号再分 block 做直方图。映射规则是把一个 8bit 编码循环移位统计相邻位从 0 到 1 或从 1 到 0 的跳变次数跳变次数小于等于 2 的保留其他归入统一的杂类 bin。比对阶段用余弦相似度对直方图的整体缩放不敏感适合不同脸距下采集的特征float FaceCompare(FaceFeature_t *a, FaceFeature_t *b) { uint32_t dot 0, na 0, nb 0; for (int i 0; i 16; i) { for (int j 0; j 32; j) { uint16_t va a-bins[i][j]; uint16_t vb b-bins[i][j]; dot (uint32_t)va * vb; na (uint32_t)va * va; nb (uint32_t)vb * vb; } } if (na 0 || nb 0) return 0.0f; return dot / (sqrtf((float)na) * sqrtf((float)nb)); }512 个 uint16 相乘累积最大值约三千多万uint32 不会溢出。录入流程比识别多一步连拍 5 帧每帧提取一个特征然后对 5 个特征逐元素取中位数。取中位数比取均值抗异常某一帧眨眼或者偏头不会污染入库特征。最后把 ID、名字、512 个特征值打包写入片内 Flash。4. 环境搭建与运行CubeMX 配置、编译烧录、串口日志三板斧4.1 CubeMX 参数配置一次性把这些灰色选项勾对工程在 CubeMX 里主要是这几项配置不对后续排查会非常痛苦外设关键参数说明RCCHSE 8MHz 晶振PLL 到 168MHz不要用内部 HSI摄像头时序会漂DCMI外部同步模式PCLK 上升沿采样花屏时优先换下降沿再试DMA2 Stream1Channel 对应 DCMI外设到内存16bit双缓冲 循环模式传输完成中断USART1115200-8-N-1认识别日志开 TX DMA 防止阻塞SPI 或 FSMC按 LCD 型号配置刷屏和识别并发时速度够用GPIO摄像头 PCLK/HSYNC/VSYNC 上拉不是所有引脚内部都有上拉不稳就外部加GPIO 分配在 CubeMX 图形界面里按板子原理图拖引脚即可不建议手写寄存器映射手写特别容易把板子搞坏。DCMI 的 PCLK 采样沿、HSYNC/VSYNC 极性和 OV2640 的输出寄存器的极性必须匹配这个我放在第 5 章的避坑部分细讲。4.2 编译与烧录三种把程序塞进板子的方式Keil MDK 打开工程后先确认 Device 选择的是对应型号宏定义里有 STM32F40_41xxxInclude Path 不能漏掉 Middleware 和 Algorithm 目录。编译时建议勾选 MicroLIBprintf 重定向会简单很多但要注意 MicroLIB 里浮点打印占栈栈设到 2KB 以上比较稳妥。烧录首选 SWD 口接 ST-Link命令行方式最直观STM32_Programmer_CLI.exe -c portSWD modeUR -w .\Build\FaceID.hex --start 0x08000000参数含义-c portSWD指定用 ST-Link 的 SWD 接口modeUR是热复位模式-w写 hex 文件--start让目标芯片立即运行。如果手头只有 USB 转串口可以把 BOOT0 拉高进入系统 Bootloader用串口下载工具把 hex 写到应用区下完把 BOOT0 拉低复位即可。注意如果芯片之前开过读保护串口下载会直接报错需要先用 SWD 解除保护再走串口。4.3 运行验证串口日志怎么看正常跑通应该长什么样工程里所有调试输出都走重定向后的 printf并留了一个日志等级开关#define DBG_LEVEL 2 // 0 关闭1 只打印识别结果2 打印全部链路正常上电后串口应该输出类似这样的一整段[0.000] SYS OK, SYSCLK168MHz, RAM free8964B [2.113] FACE: frame_ready, skin_ratio0.13, box(30,20)-(115,105) [2.114] REC: saved id0 namedev, frames5 [3.005] ID: id0 sim0.88 th0.62 PASS [3.006] DBG: elapsed186ms fps5.3逐行看SYS 行确认主频正常RAM free 如果只剩几百字节就要检查缓冲定义FACE 行表示检测到人脸框box 坐标异常说明肤色阈值被环境干扰REC 行是录入完成后的反馈ID 行是识别命中DBG 行的耗时最能反映算力余量。跑通的标准是同一个人连续识别成功 5 次以上陌生人被拒绝这个标准比单纯亮一个绿灯更有说服力。5. 避坑笔记从拿到板子到稳定识别最容易翻车的 5 个现场5.1 画面红蓝错乱、肤色检测全灭RGB565 字节序反转现象LCD 预览时红色物体显示成蓝色肤色检测完全找不到人脸框日志里的 skin_ratio 长期接近 0。原因OV2640 输出的 RGB565 高字节在前而 STM32 是小端存储DMA 直接把字节搬进内存后以 uint16_t 读出来高低字节是反的。我在 3.2 节的肤色代码里用了__REVSH就是为了修正这个字节序。解决全项目只在一处做字节交换。读取帧缓冲时统一走宏#define RGB_SWAP(rgb) ((((uint16_t)(rgb) 8) 0xFF) | (((uint16_t)(rgb) 0xFF) 8))不要在 LCD 驱动里再交换一次否则颜色可能会被换回来整个画面又变成红蓝互换。5.2 DMA 双缓冲画面撕裂、边缘斜纹HSYNC/VSYNC 极性不匹配现象帧就绪标志经常置位但 LCD 上画面撕裂中间有斜向错位偶尔整屏变绿。原因DCMI 的 VSPOL/HSPOL 极性和 OV2640 输出寄存器配置不一致DMA 把包含消隐区的无效数据当成了有效像素写入缓冲。解决把 VSPOL、HSPOL 分别设 0/1 组合逐一套用哪一套下帧标志稳定、画面没有纹路就固定哪一套。同时注意 PCLK 的采样沿上升沿不行就换下降沿不要同时把两个极性都设反那样画面可能上下颠倒却看不出来。这个排查没有捷径我当年反复在花屏和颠倒之间来回试最后是做一个初始化函数支持运行时切换参数串口命令直接改就不用每次重烧了。5.3 跑着跑着进 HardFaultBFS 队列溢出越界现象连续识别十几分钟后突然复位或者用手挡住镜头瞬间死机调试器里 PC 指针跳到 HardFault_Handler。原因肤色大面积误检BFS 队列 1024 个点被压满代码继续写 qx、qy越界覆盖了相邻全局数组。解决入队前必须检查队列长度满了就丢弃当前连通块不要扩大数组掩盖问题。人脸框面积也要加一个上限过滤超过画面 60% 的连通块直接不认为是一张脸。正确姿势是这样if (tail BFS_MAX) { overflow 1; break; } if (overflow) continue; // 丢弃当前连通块不继续入队 if (tail BFS_MAX) { qx[tail] nx; qy[tail] ny; tail; }这种越界问题在 Debug 模式下可能不报错因为优化级别低、内存布局不同Release 优化后反而必现排查时优先怀疑所有手工维护的数组下标。5.4 录了几个人之后 Flash 写入卡顿擦除策略现象录入第 6、第 7 个人时界面卡住几百毫秒串口输出也断了一下之后又恢复正常。原因片内 Flash 按扇区擦除F407 的大扇区擦除耗时可达几十毫秒。如果每次写入特征前都先把整个扇区擦一遍主循环就会被阻塞LCD 刷新也随之停顿。解决把特征库固定放在一个专用扇区里追加写入只有空间不足时才擦一次。配合一个 db_busy 标志写入期间暂停识别循环防止读取到半写状态的数据if (used_bytes rec_len DB_SECTOR_SIZE) { Flash_EraseSector(DB_SECTOR); used_bytes 0; } Flash_Write(DB_SECTOR_ADDR used_bytes, rec, rec_len); used_bytes rec_len;实际工程里还可以把特征库放两份镜像写入失败时自动回滚上一份但这种保护性设计要权衡 Flash 寿命普通门禁场景一份就够了。5.5 谁都被认成同一个人阈值太低或者特征被环境光污染现象换个人站到镜头前识别结果还是 ID 0相似度 0.7 以上所有人都能通过。原因阈值太低确实是第一嫌疑但更隐蔽的问题是录入时和识别时的光照差异太大LBP 直方图整体漂移导致相似度虚高。解决录入时不要直接存原始特征先对框内图像做一次直方图均衡再提取 LBP识别时如果相似度低于 0.55也先对当前帧做一次均衡再比对两次都在同一个光照基准上。阈值建议从 0.68 开始调用待识别人员现场采集 20 帧负样本把“错误接受”次数降到零之后再回退 0.02这样得到的阈值才是对这个现场环境有效的。6. 二次开发给识别系统加上连续帧防抖与上位机联动跑通基础识别之后最值得做的第一个改造是连续识别防抖。现在的代码每一帧都可能触发一次识别结果如果你拿它去控制继电器人站在屏幕前两秒钟继电器会哒哒哒吸合好几次。门禁类应用必须改成“锁定窗口”模式识别成功后一段时间内不再响应。用一个状态机再加一个时间戳就能解决typedef enum { FACE_IDLE, FACE_MATCHED, FACE_LOCKED } FaceState_t; static FaceState_t st FACE_IDLE; static uint32_t lock_until 0; void FaceApp_Process(FaceResult_t *res, uint32_t now_ms) { if (now_ms lock_until) { st FACE_LOCKED; return; } if (res-sim FACE_THRESHOLD) { if (st ! FACE_MATCHED) { st FACE_MATCHED; DoUnlock(); // 只动作一次 lock_until now_ms 3000; // 三秒锁定 } } else { st FACE_IDLE; } }这里最核心的是把“识别到”和“动作”拆成两个事件人脸一直在画面里识别结果持续输出但门锁动作只在状态从 IDLE 跳到 MATCHED 的那一刻发生。如果需要更严格的防误触可以在 MATCHED 分支里要求连续三帧同一个 ID 且相似度都超过阈值再执行 DoUnlock这时的相应速度会慢半拍但可靠性明显提高。第二个值得做的改造是把识别结果输出成结构化数据。把日志格式改成固定 JSON 行上位机 Python 脚本按行解析就能做实时监控和历史记录printf({\id\:%d,\sim\:%.2f,\box\:[%d,%d,%d,%d]}\n, best_id, max_sim, box.x0, box.y0, box.x1, box.y1);上位机只负责显示和存储不在识别链路上改起来不影响板端性能。从那以后我每次改识别逻辑都会先把串口日志跑满一整天用真实样本重新标定阈值而不是在调试器里拍脑袋调一个数。希望这套方案能帮你在自己的板子上把第一帧人脸识别跑起来少走我当年踩过的那些坑。本文还有配套的精品资源点击获取