1. 为什么要在单片机上折腾人脸识别?
你可能觉得人脸识别是手机或者电脑上才有的“高级”功能,需要强大的CPU和GPU才能跑得动。我以前也这么想,直到有一次,我想给自己工作室的门禁做个升级,又不想花大价钱买成品系统,就琢磨着能不能用手里现成的单片机开发板来实现。结果一研究,发现这事儿还真有搞头。
单片机,比如大家熟悉的STM32或者ESP32,虽然算力跟手机没法比,但它功耗低、成本低、体积小,非常适合做成一个独立的、嵌入式的设备。想象一下,一个火柴盒大小的板子,接上一个小摄像头,就能帮你“看门”,识别出是不是自己人,然后控制电磁锁开门。这成本可能还不到一百块钱,而且完全由你掌控,数据也不用上传到云端,隐私性更好。
当然,在单片机上做人脸识别,和我们平时在电脑上用的深度学习方法完全不同。这里玩的是“轻量级”和“极致优化”。我们不会去跑动辄几百兆的神经网络模型,而是采用一些经典的、计算量小的图像处理算法,在有限的资源(比如几十KB的内存,几百KB的存储空间)里,把核心的识别功能跑起来。这个过程就像是在小胡同里开赛车,虽然场地受限,但把车改装好、路线规划好,一样能跑得又快又稳。
这个项目特别适合智能家居爱好者、嵌入式开发的学习者,或者就是想动手做个有趣小产品的朋友。它不仅能让你深入理解人脸识别的基本原理,更能让你体会到在资源受限环境下做开发的挑战和乐趣。接下来,我就把我从硬件选型到代码调试的整个过程,以及踩过的那些坑,详细地分享给你。
2. 精打细算:硬件选型与连接方案
硬件是项目的骨架,选对了才能事半功倍。我们的目标是低成本、低功耗、够用就好,千万别追求高性能而选了不适合单片机的部件。
2.1 核心大脑:单片机怎么选?
首先得挑主控。STM32和ESP32是两大热门,它们各有侧重。
- STM32F4系列(比如F407):这是我这次项目用的。它的优势是主频高(168MHz),带有DSP指令集和FPU(浮点运算单元)。做图像处理时,经常有大量的乘加运算,DSP和FPU能大大加速这个过程,比普通的Cortex-M内核快很多。如果你追求极致的处理速度,F4系列是很好的选择。
- ESP32系列:它的最大优势是自带Wi-Fi和蓝牙。如果你的门禁系统需要联网上报记录,或者用手机APP远程管理,那ESP32几乎是唯一选择。它的CPU是双核的,主频也够用,但图像处理相关的专用指令不如STM32F4。不过,ESP32的社区资源非常丰富,很多现成的库可以用。
我最后选了STM32F407VET6,主要看中它的性能,而且我手头就有这块板子。对于第一次尝试的朋友,我其实更推荐ESP32-CAM这个模块,它把ESP32和摄像头做到了一起,还带个TF卡槽,几乎是为这个项目量身定做的,省去了很多接线和调试的麻烦。
2.2 眼睛:摄像头模块的取舍
摄像头是关键输入设备。OV7670和OV2640是最常见的两种。
- OV7670:30万像素,输出图像最大640x480。它最大的优点是接口简单(通常用SCCB,类似I2C),数据通过并口输出,单片机可以直接读取。缺点是像素低,图像质量一般,而且在光线不好的地方效果会打折扣。
- OV2640:200万像素,支持输出JPEG格式图像。这是它革命性的优势。OV7670输出的是原始的RGB或YUV数据,一帧640x480的灰度图就要300KB内存,单片机根本存不下。而OV2640可以直接输出压缩后的JPEG数据,一帧可能只有几KB到十几KB,极大地减轻了单片机的存储和传输压力。
所以,无脑推荐OV2640。虽然它比OV7670贵一点,但能帮你绕过最头疼的内存瓶颈。我一开始用的OV7670,为了存一帧图像折腾了半天内存管理,后来换到OV2640,整个项目难度直接降了一个等级。
2.3 外围辅助设备
其他部件就比较常规了:
- 存储:一个普通的SPI接口TF卡模块就行,用来存储注册好的人脸特征数据(不是存图片)。
- 显示:一块0.96寸或1.3寸的OLED屏(I2C接口),用来显示识别结果(“欢迎回家”或“识别失败”)或者调试信息。LCD屏也可以,但功耗和体积会大一些。
- 指示与执行:一个LED灯(识别成功亮绿灯),一个蜂鸣器(识别成功“滴”一声),一个继电器模块(用来控制门锁的12V电路,注意安全,操作强电务必谨慎!)。
- 电源:整个系统最好用5V/2A的USB电源供电,确保摄像头和单片机工作稳定。
2.4 硬件连接实战图
光说可能有点抽象,我画一个简单的连接示意图,你照着接就行:
[OV2640摄像头]
|-- SCL --> 单片机I2C时钟引脚 (PB8)
|-- SDA --> 单片机I2C数据引脚 (PB9)
|-- D0..D7 --> 单片机数据端口 (例如PI0-PI7)
|-- VSYNC --> 单片机定时器引脚 (例如PA8)
|-- HREF --> 通用IO引脚 (例如PC9)
|-- PCLK --> 通用IO引脚 (例如PA6)
|-- XCLK --> 单片机输出时钟 (例如PA0)
|-- 电源 --> 3.3V 和 GND
[TF卡模块]
|-- CS --> 单片机SPI片选 (例如PA4)
|-- SCK --> 单片机SPI时钟 (例如PA5)
|-- MISO --> 单片机SPI主机输入 (例如PA6)
|-- MOSI --> 单片机SPI主机输出 (例如PA7)
|-- 电源 --> 5V 和 GND (注意模块电平,有些是3.3V)
[OLED屏 (I2C)]
|-- SCL --> 单片机I2C时钟 (PB8,可与摄像头共用)
|-- SDA --> 单片机I2C数据 (PB9,可与摄像头共用)
|-- 电源 --> 3.3V 和 GND
[继电器模块]
|-- IN --> 单片机GPIO引脚 (例如PC13)
|-- 电源 --> 5V 和 GND
|-- COM/NO/NC --> 连接门锁电路
接线的时候,数据线尽量短,电源线最好在靠近模块的地方加个10uF的电解电容和0.1uF的瓷片电容滤波,能有效减少图像采集时的干扰噪点。
3. 算法瘦身:在单片机上跑通人脸识别
硬件搭好了,接下来就是最核心的软件部分。在单片机上,我们不能直接用OpenCV里那些现成的函数,必须自己实现一套精简的流程。
3.1 图像采集与预处理:给图像“减肥”
OV2640输出JPEG后,我们首先要解码。单片机解码全尺寸的JPEG依然吃力,所以要在摄像头初始化时,就把分辨率设低。设置为176x144(QCIF)或320x240(QVGA)就足够了。人脸在画面中占大部分区域,这个分辨率对于后续处理已经可以提供足够的信息。
解码得到RGB图像后,第一步永远是灰度化。彩色图像每个像素有R、G、B三个值,处理量是灰度的三倍。灰度化公式很简单:Gray = 0.299*R + 0.587*G + 0.114*B。在单片机里,我们可以用整数运算来近似:Gray = (R*299 + G*587 + B*114 + 500) / 1000,避免速度慢的浮点运算。
接着是图像二值化或均衡化。二值化就是设定一个阈值,把灰度图变成纯黑白,可以突出轮廓。但光照变化会影响阈值。所以我更推荐做一下直方图均衡化,这个算法能拉伸图像的对比度,让暗处和亮处的细节都更清晰,对光照变化有一定的鲁棒性。实现起来也不复杂,就是统计灰度直方图,然后计算累积分布函数,最后映射一下每个像素的灰度值。
3.2 特征提取:抓住“脸”的精髓
特征提取是从图像中提炼出关键信息的过程。在PC上我们可以用深度学习提取几千维的特征,在单片机上,我们得用更“小巧”的方法。
- Haar-like特征:这是OpenCV早期人脸检测用的方法。它计算图像中相邻矩形区域像素和的差值,来捕捉眼睛比脸颊暗、鼻梁比两侧亮这类特征。但它主要用于“检测”人脸在哪里,而不是“识别”这是谁。我们可以用它先框出人脸区域,减少后续处理的数据量。
- LBP(局部二值模式):这是我最终采用的方法,因为它计算简单,且对光照变化不敏感。它的原理是:以每个像素为中心,与周围的8个像素比较,比中心亮的记为1,暗的记为0,这样就得到一个8位的二进制数(0-255),这就是该点的LBP值。对整个脸区域计算LBP,然后统计这个区域的LBP值直方图(256维),这个直方图就可以作为这张脸的特征向量。关键优化:我们可以使用“圆形LBP”或者“均匀模式LBP”,将256维直方图降到59维,特征向量大小减少了近80%,但识别效果几乎不变,这对单片机是巨大的福音。
3.3 识别比对:简单的就是最好的
提取到特征(比如一个59维的向量)后,就需要和数据库里存储的特征进行比对。数据库怎么来?我们需要一个“注册”模式:当第一次使用时,让用户站在摄像头前,采集多张(比如5张)不同角度的图片,分别提取LBP特征并求平均值,得到一个“模板”特征向量,然后存入TF卡。
比对的时候,计算当前图像特征与数据库中每个模板特征的距离。常用的距离有欧氏距离和余弦相似度。在单片机里,欧氏距离计算更直接:距离 = sqrt( (v1[0]-v2[0])^2 + (v1[1]-v2[1])^2 + ... )。为了避免耗时的开方运算,我们可以直接比较距离的平方。
设定一个阈值。如果当前特征与某个模板的距离小于这个阈值,就认为是同一个人;如果比所有模板的距离都大,那就是陌生人。这个阈值需要你根据实际环境测试调整,是在“误识别”(把陌生人认成自己人)和“拒识别”(把自己人拒之门外)之间找一个平衡点。
4. 代码实战:从零搭建你的门禁系统
理论说了一大堆,是时候上代码了。我会以STM32F4配合OV2640为例,分模块讲解关键代码。完整工程代码比较长,我会把核心部分贴出来,并解释为什么这么写。
4.1 摄像头驱动与图像采集
首先,我们必须正确初始化OV2640。它有一大堆寄存器需要配置,包括输出格式、分辨率、亮度、对比度等。通常厂家会提供一个配置数组,我们通过I2C挨个写入就行。
// ov2640.c 片段
uint8_t OV2640_Init(void)
{
// 1. 检查摄像头ID
if (OV2640_ReadID() != 0x2642) {
OLED_ShowString(0, 0, "CAM ERR");
return 1;
}
// 2. 复位摄像头
OV2640_WriteReg(0xff, 0x01);
OV2640_WriteReg(0x12, 0x80);
HAL_Delay(100);
// 3. 加载预定义的JPEG输出配置(QVGA分辨率)
for (int i=0; i<sizeof(ov2640_jpeg_init_reg_tbl)/2; i++) {
OV2640_WriteReg(ov2640_jpeg_init_reg_tbl[i][0], ov2640_jpeg_init_reg_tbl[i][1]);
}
// 4. 设置图像质量、亮度等(可根据环境微调)
OV2640_WriteReg(0xff, 0x00);
OV2640_WriteReg(0x05, 0xef); // 亮度
OV2640_WriteReg(0x06, 0x94); // 对比度
OLED_ShowString(0, 0, "CAM OK");
return 0;
}
采集一帧JPEG图像数据是关键。OV2640会通过DCMI(数字摄像头接口)把数据源源不断地送过来,我们需要用DMA(直接存储器访问)来接收,这样CPU不用干预,可以同时做别的事。
// 启动一帧JPEG采集
void OV2640_Capture_Frame(void)
{
// 设置DMA目标地址和长度
HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_SNAPSHOT, (uint32_t)&jpeg_buffer, JPEG_BUF_SIZE);
// 等待DMA传输完成中断
while (jpeg_data_ready == 0);
jpeg_data_ready = 0;
// 此时 jpeg_buffer 里就是压缩好的JPEG数据,jpeg_len 是实际长度
}
4.2 LBP特征提取实现
下面是精简版的均匀模式LBP特征提取函数,输入是灰度图像数组和检测到的人脸区域坐标。
// feature.c 片段
#define LBP_UNIFORM_PATTERNS 59
void extract_LBP_histogram(uint8_t *gray_img, int width, int height, int face_x, int face_y, int face_w, int face_h, uint16_t *histogram)
{
// 1. 初始化直方图数组为0
for (int i=0; i<LBP_UNIFORM_PATTERNS; i++) histogram[i] = 0;
// 2. 只处理人脸区域内部,边界留出一圈不处理
for (int y = face_y+1; y < face_y+face_h-1; y++) {
for (int x = face_x+1; x < face_x+face_w-1; x++) {
uint8_t center = gray_img[y*width + x];
uint8_t lbp_code = 0;
// 获取3x3邻域,手动比较(比用循环快)
lbp_code |= (gray_img[(y-1)*width + (x-1)] > center) << 7;
lbp_code |= (gray_img[(y-1)*width + x ] > center) << 6;
lbp_code |= (gray_img[(y-1)*width + (x+1)] > center) << 5;
lbp_code |= (gray_img[ y *width + (x+1)] > center) << 4;
lbp_code |= (gray_img[(y+1)*width + (x+1)] > center) << 3;
lbp_code |= (gray_img[(y+1)*width + x ] > center) << 2;
lbp_code |= (gray_img[(y+1)*width + (x-1)] > center) << 1;
lbp_code |= (gray_img[ y *width + (x-1)] > center) << 0;
// 3. 转换为均匀模式索引 (0-58)
int index = uniform_lbp_map[lbp_code];
histogram[index]++;
}
}
// 4. 归一化直方图(可选,但能提升光照鲁棒性)
int total_pixels = (face_h-2)*(face_w-2);
for (int i=0; i<LBP_UNIFORM_PATTERNS; i++) {
histogram[i] = (histogram[i] * 1000) / total_pixels; // 转换为千分之比
}
}
这里的 uniform_lbp_map 是一个大小为256的查找表,预先算好了每个LBP值对应的均匀模式索引,这避免了实时计算跳变次数,是典型的以空间换时间的优化策略。
4.3 特征存储、比对与主程序逻辑
特征需要保存到TF卡。我们以二进制格式存储,每个模板包含一个ID(比如用户名字符串)和对应的59维特征向量。
// storage.c 片段
typedef struct {
char user_id[20];
uint16_t feature[LBP_UNIFORM_PATTERNS];
} face_template_t;
// 注册新面孔
void register_face(const char *id, uint16_t *feature) {
face_template_t tmpl;
strcpy(tmpl.user_id, id);
memcpy(tmpl.feature, feature, sizeof(tmpl.feature));
// 追加写入到TF卡文件 "faces.dat"
f_write(&fil, &tmpl, sizeof(face_template_t), &bytes_written);
}
// 从TF卡加载所有模板到内存数组(模板数量少时可以这样做)
uint8_t load_all_templates(face_template_t *db, int max_count) {
// 打开文件,循环读取直到文件尾
while (f_read(&fil, &db[count], sizeof(face_template_t), &bytes_read) == FR_OK && bytes_read > 0) {
count++;
if (count >= max_count) break;
}
return count; // 返回加载的模板数
}
主程序的逻辑就是一个清晰的循环:
// main.c 骨架
face_template_t face_database[10];
uint8_t registered_count = 0;
int main(void) {
// 硬件初始化
HAL_Init();
SystemClock_Config();
OLED_Init();
OV2640_Init();
SD_Init();
// 加载已注册的人脸数据库
registered_count = load_all_templates(face_database, 10);
while (1) {
OLED_ShowString(0, 0, "Ready...");
// 1. 捕获一帧JPEG
OV2640_Capture_Frame();
// 2. JPEG解码为RGB
jpeg_decode(jpeg_buffer, jpeg_len, rgb_buffer);
// 3. RGB转灰度,并做均衡化
rgb_to_gray_and_equalize(rgb_buffer, gray_buffer);
// 4. 人脸检测(简单版:在整个图中找最大连通域,或使用预置的Haar分类器数据)
if (detect_face(gray_buffer, &face_x, &face_y, &face_w, &face_h)) {
// 5. 在检测到的人脸区域提取LBP特征
extract_LBP_histogram(gray_buffer, IMG_WIDTH, IMG_HEIGHT, face_x, face_y, face_w, face_h, current_feature);
// 6. 与数据库比对
int matched_id = -1;
uint32_t min_distance = 0xFFFFFFFF;
for (int i=0; i<registered_count; i++) {
uint32_t dist = calculate_euclidean_distance_sq(current_feature, face_database[i].feature, LBP_UNIFORM_PATTERNS);
if (dist < MIN_DISTANCE_THRESHOLD && dist < min_distance) {
min_distance = dist;
matched_id = i;
}
}
// 7. 输出结果
if (matched_id != -1) {
OLED_Clear();
OLED_ShowString(0, 0, "Welcome!");
OLED_ShowString(0, 2, face_database[matched_id].user_id);
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, GPIO_PIN_SET); // 开门
HAL_Delay(3000); // 开门3秒
HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, GPIO_PIN_RESET);
} else {
OLED_ShowString(0, 0, "Stranger!");
HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_SET); // 报警声
HAL_Delay(500);
HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_RESET);
}
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
}
HAL_Delay(500); // 间隔0.5秒检测一次
}
}
5. 调优与避坑:让系统真正可用
代码跑起来只是第一步,要让系统稳定可靠,还需要大量的调试和优化。我把自己踩过的坑和解决方案列出来,希望能帮你节省时间。
坑1:图像质量不稳定,识别率随光线变化大。
- 解决:除了前面说的直方图均衡化,一定要在摄像头初始化时,关闭自动曝光(AEC)和白平衡(AWB),或者把它们锁定在某个固定值。OV2640的自动模式在光线变化时会剧烈调整参数,导致连续两帧图像的亮度差异巨大,特征提取就完全乱套了。手动设置一组室内光照下的固定参数,虽然换到室外可能不行,但在固定的门禁位置,效果非常稳定。
坑2:人脸检测框不准,或者经常检测不到。
- 解决:在单片机上跑完整的Viola-Jones检测器(Haar级联)还是比较吃力。我采用了一种“懒人方法”:因为门禁摄像头位置固定,人脸出现的位置也大致固定。我直接在图像中央划出一个“感兴趣区域”(ROI),默认这个区域里就是人脸。然后,我在这个ROI内计算图像的“人脸似然度”,比如计算垂直方向的灰度投影,看有没有两个眼睛的凹陷区和一个鼻梁的凸起区。虽然不如真正的检测器鲁棒,但在固定场景下简单有效,几乎零计算成本。
坑3:误识别,把稍微像的人认成管理员。
- 解决:这主要是阈值设置问题。不要只用一个全局阈值。我的经验是采用“双重验证”。第一次比对用较低的阈值,快速筛选出可能的候选人。如果找到了,再进行第二次验证:让用户稍微侧一下脸(可以通过语音或屏幕提示),再采集一张侧脸图像提取特征,与数据库中该用户的侧脸模板(需要预先注册)比对。只有两次都通过才算成功。这能极大降低误识率。
坑4:系统偶尔死机或无响应。
- 解决:大概率是内存溢出或者堆栈溢出。STM32F4的内存也不大。务必确保:
- 图像缓冲区(RGB缓冲、灰度缓冲)使用全局数组静态分配,不要用
malloc。 - JPEG解码库选择内存消耗小的,或者使用“流式解码”,边读边解,不保存完整位图。
- 增大任务栈空间。在
FreeRTOS(如果你用了的话)的配置里,或者启动文件的栈指针设置里,把栈空间调大。 - 使用看门狗(IWDG)。在主循环里定期“喂狗”,一旦程序跑飞,看门狗会自动复位系统,保证设备能自我恢复。
- 图像缓冲区(RGB缓冲、灰度缓冲)使用全局数组静态分配,不要用
性能优化小技巧:
- 将频繁调用的函数(如
extract_LBP_histogram)内部循环的关键代码,用汇编或编译器内联函数重写。 - 启用STM32的I-Cache和D-Cache。
- 如果使用DMA传输图像数据,将缓冲区定义在
CCM RAM(如果芯片有的话)或者DTCM RAM中,这部分内存访问速度最快。
最后,别忘了安全。继电器控制的门锁电路是强电,务必做好电气隔离,可以用光耦隔离单片机的控制信号。整个设备外壳要绝缘,防止触电。这个DIY系统适合作为学习项目和家庭内部使用,如果是重要的办公场所,建议还是采购经过安全认证的商业产品。
&spm=1001.2101.3001.5002&articleId=155018310&d=1&t=3&u=8be1bb376db349f783eef7de061e67b3)
237

被折叠的 条评论
为什么被折叠?



