简介:这份资源面向嵌入式开发初学者与计算机视觉入门者,提供在STM32平台上从图像采集到人脸识别的完整工程实践,帮助读者理解微控制器如何驱动OV5640摄像头并运行轻量级人脸检测算法。压缩包共174个文件,以72个C源文件与72个头文件为主体,构成摄像头驱动、图像处理与识别算法的核心代码,另含14张png截图、6份txt说明及Keil工程文件(uvprojx、uvoptx)、hex固件与lib库,整体约1.03MB,目录结构便于按模块查阅。资源已有8608人学习下载,具备较好的参考基础。工程围绕Cortex-M4内核展开,涉及图像预处理、Haar特征级联分类器与PCA-LBP特征提取等环节,并针对单片机内存与算力受限做了算法裁剪与优化,读者可据此掌握Keil5下的编译调试流程、摄像头数据解析方法及资源受限场景下的算法移植思路,适合作为课程设计或嵌入式视觉项目的起步参考。
1. 一块 F103 跑人脸识别,到底卡在哪
去年帮一个做门禁的团队看板子,对方抱来一台样机,说“人脸识别模块老死机”。拆开一看,主控是 STM32F103C8T6,64KB Flash、20KB RAM,摄像头是 OV7670 并口输出,识别算法跑在片内。这个组合第一反应就是资源不够:一帧 QVGA 灰度图就是 76800 字节,光图像缓冲就超过整个 RAM 三倍。后来把分辨率降到 QQVGA、改成逐行采集加滑窗比对,才勉强跑通。基于 STM32 的人脸识别项目,核心矛盾从来不是“算法准不准”,而是“在几十 KB 内存里怎么把一帧图像塞进去、算完、再吐出来”。这套东西适合谁?适合已经玩过 STM32 基础外设、想啃一个完整图像处理链路的开发者,也适合做低成本门禁、考勤、智能锁原型的团队——它不追求深度学习那种精度,胜在离线、低功耗、BOM 便宜。
2. 系统架构与硬件选型:从摄像头到执行机构的完整链路
2.1 整体分层:采集层、处理层、决策层
这类项目在工程上一般拆成三层,别一上来就写识别算法,先把数据通路理顺。采集层负责摄像头时序驱动和 DMA 搬运,把一帧图像落到内存缓冲;处理层做灰度化、二值化、人脸区域粗定位、特征提取;决策层拿特征去比对模板库,输出“通过/拒绝”,再驱动继电器或舵机开锁。三层之间用环形缓冲和状态机解耦,采集用中断/DMA 填,处理在主循环里消费,避免采集和处理抢同一块内存。
我一般会把处理层再切一刀:预处理(与硬件无关的纯算法)和业务逻辑(比对、阈值、日志)分开,前者放image_proc.c,后者放face_app.c。这样换摄像头型号时只动采集层,换比对策略时只动业务层,血泪经验是早期把三者揉在一个main.c里,改一个阈值要重新理解三百行时序代码。
2.2 主控与摄像头:为什么 F103 要配 OV7670
主控选 STM32F103 系列是成本驱动的常见做法,C8T6 便宜、资料多,但 RAM 只有 20KB,这是硬约束。摄像头配 OV7670 也顺理成章:并口输出、支持 RGB565 和 YUV422、可配置分辨率,驱动成熟。两者之间的连接是 8 位数据线加 PCLK、VSYNC、HREF 三根同步信号,外加 SCCB(本质是 I2C)配置寄存器。
关键参数得先算清楚,不然写到一半发现内存炸了:
| 分辨率 | 像素数 | 灰度单帧 | RGB565 单帧 | F103 可行性 |
|---|---|---|---|---|
| VGA 640x480 | 307200 | 300KB | 600KB | 不可行 |
| QVGA 320x240 | 76800 | 75KB | 150KB | 不可行 |
| QQVGA 160x120 | 19200 | 18.75KB | 37.5KB | 灰度勉强,需分块 |
| 80x60 | 4800 | 4.7KB | 9.4KB | 可行,识别精度下降 |
结论很直接:F103 上跑人脸识别,分辨率基本锁死在 QQVGA 以下,而且要用灰度单通道。RGB565 双字节直接翻倍,20KB RAM 根本放不下。常见做法是 OV7670 配置成 YUV422 输出,只取 Y 分量当灰度图,等于硬件帮你做了灰度化,省一次软件转换。
2.3 存储与执行机构:模板库放哪、锁怎么开
模板库不能放 RAM,掉电就没了。F103C8T6 的 64KB Flash 里,前 60KB 放程序,尾部留 2~4KB 存人脸模板,按扇区擦写。模板数量别贪多,每个模板按 80x60 降采样后的特征算,一条 200~500 字节,4KB 也就存十几个人。要存更多人,外挂 SPI Flash(如 W25Q64)是常规操作,通过 SPI 读写,模板带 CRC 校验。
执行机构一般是继电器控制电磁锁,或者舵机模拟旋钮。GPIO 推继电器模块时注意加三极管或光耦隔离,别直接用 IO 灌电流,烧过口的都懂。开锁逻辑里加一个“识别成功后延时 3 秒再断开”的状态,避免继电器抖动。
3. 核心代码拆解:一帧图像从采集到比对的完整链路
3.1 摄像头初始化与 SCCB 配置
OV7670 上电后要先通过 SCCB 写一堆寄存器,配时钟分频、输出格式、分辨率。这段代码是模板化的,但顺序不能乱,先软复位再逐条写。
/* ov7670_init.c —— SCCB 写寄存器,配置 YUV422 + QQVGA */ static void sccb_write(uint8_t reg, uint8_t val) { i2c_start(); i2c_send_byte(OV7670_ADDR_W); /* 0x42,写地址 */ i2c_wait_ack(); i2c_send_byte(reg); i2c_wait_ack(); i2c_send_byte(val); i2c_wait_ack(); i2c_stop(); } void ov7670_init(void) { sccb_write(0x12, 0x80); /* COM7 软复位 */ delay_ms(10); sccb_write(0x12, 0x0C); /* COM7: YUV422, 输出使能 */ sccb_write(0x40, 0xD0); /* COM15: 输出范围 0x10~0xF0 */ sccb_write(0x11, 0x01); /* CLKRC: 内部时钟不分频 */ sccb_write(0x0C, 0x00); /* COM3: 关闭缩放 */ sccb_write(0x3E, 0x00); /* COM14: 正常模式 */ sccb_write(0x70, 0x3A); /* 关键:QQVGA 160x120 */ sccb_write(0x71, 0x35); sccb_write(0x72, 0x22); sccb_write(0x73, 0xF2); sccb_write(0x17, 0x13); /* HSTART */ sccb_write(0x18, 0x01); /* HSTOP */ sccb_write(0x32, 0x92); /* HREF 起止 */ sccb_write(0x19, 0x02); /* VSTART */ sccb_write(0x1A, 0x7A); /* VSTOP */ }0x70~0x73这四个寄存器决定输出窗口,不同批次的 OV7670 模块默认值有差异,翻车点在于照抄别人的值后图像偏色或只出一半。0x12写0x0C表示选 YUV422,如果写成0x14就是 RGB565,后面取灰度就得自己算。SCCB 的 ACK 一定要判,摄像头没插好时i2c_wait_ack会一直超时,加个重试上限,别死等。
3.2 DMA 双缓冲采集与灰度提取
采集用 DCMI(F103 没有 DCMI,用 GPIO 加外部中断或定时器采样 PCLK)的常见替代方案是:PCLK 接定时器外部时钟,每个像素触发一次 DMA 请求,把并口数据搬到缓冲。F103 没有 DCMI 外设,实际工程里多用“PCLK 中断 + 手动读 GPIO”或“FSMC 模拟”,前者简单但 CPU 占用高,后者快但接线复杂。这里给一个基于行中断的简化采集框架:
/* camera.c —— 行中断里按行搬运,双缓冲交替 */ #define WIDTH 160 #define HEIGHT 120 static uint8_t frame_buf[2][WIDTH * HEIGHT / 4]; /* 4bit 降采样存储 */ static volatile uint8_t wr_idx = 0, rd_idx = 0; static volatile uint8_t frame_ready = 0; /* HREF 上升沿触发,表示一行开始 */ void EXTI_HREF_IRQHandler(void) { static uint16_t line = 0; if (line < HEIGHT) { /* 每 4 个像素取 1 个,直接降采样到 40x120 */ for (uint8_t x = 0; x < WIDTH; x += 4) { while (!PCLK_HIGH); /* 等像素时钟 */ uint8_t y = GPIOA->IDR & 0xFF; /* 读 8 位数据口 */ frame_buf[wr_idx][line * (WIDTH/4) + x/4] = y >> 4; } line++; } else { line = 0; frame_ready = 1; rd_idx = wr_idx; /* 提交读缓冲 */ wr_idx ^= 1; /* 切换写缓冲 */ } EXTI->PR = EXTI_PR_PR0; }这段代码做了两件事:按行采集、水平 4 倍降采样。y >> 4把 8 位灰度压到 4 位,内存直接减半,代价是精度损失,但对粗定位够用。双缓冲的意义是采集下一帧时处理上一帧,frame_ready是唯一的同步标志,主循环里判到就处理,处理完清标志。注意while (!PCLK_HIGH)这种忙等在 160 宽度下还能接受,再高分辨率必须换 DMA,否则 CPU 全耗在等时钟上。
3.3 人脸粗定位与特征比对
F103 上跑不了 Haar 级联那种计算量,常见做法是“肤色阈值 + 连通域粗定位 + 降采样模板比对”。肤色在 YUV 空间用 Cr、Cb 范围筛,比 RGB 稳。定位到矩形框后缩放到固定 20x20,和模板算归一化互相关(NCC)。
/* face_match.c —— 肤色筛选 + 20x20 模板 NCC 比对 */ #define TEMPLATE_DIM 20 #define NCC_THRESHOLD 0.72f /* 返回 1 表示命中,score 输出相似度 */ int face_match(const uint8_t *gray, const uint8_t *tmpl, float *score) { float sum_a = 0, sum_b = 0, sum_ab = 0, sum_aa = 0, sum_bb = 0; for (int i = 0; i < TEMPLATE_DIM * TEMPLATE_DIM; i++) { float a = gray[i]; float b = tmpl[i]; sum_a += a; sum_b += b; sum_ab += a * b; sum_aa += a * a; sum_bb += b * b; } float n = TEMPLATE_DIM * TEMPLATE_DIM; float num = sum_ab - (sum_a * sum_b) / n; float den = sqrtf((sum_aa - sum_a*sum_a/n) * (sum_bb - sum_b*sum_b/n)); *score = (den < 1e-6f) ? 0.0f : num / den; return (*score >= NCC_THRESHOLD) ? 1 : 0; }NCC_THRESHOLD是调参重点,0.72 是经验起点,光照差的环境要降到 0.65,误识率会上升,得配合“连续 3 帧命中才开锁”来压。sqrtf在 F103 上是软件浮点,一次比对几十微秒,模板多的时候要算总账。肤色筛选那步没贴出来,逻辑是遍历像素判Cr∈[133,173]、Cb∈[77,127],统计密度最高的区域作为候选框,框太小直接丢弃,避免把墙面当人脸。
4. 编译烧录与联调:从空板到能识别的完整步骤
4.1 工程组织与编译配置
工程按模块分目录,别全堆在Core里:
face_stm32/ ├── Core/ # main.c、中断向量、时钟配置 ├── Drivers/ │ ├── camera/ # ov7670_init.c、camera.c │ └── lock/ # relay.c ├── App/ │ ├── image_proc.c # 降采样、肤色筛选 │ └── face_app.c # 比对、模板管理、状态机 ├── Templates/ # 模板数组,编译进 Flash 尾部 └── Makefile编译用arm-none-eabi-gcc,关键链接参数是-Wl,--section-start=.templates=0x0800F000,把模板段钉在 Flash 尾部,避免和代码重叠。-Os优化必开,浮点运算多的话考虑-ffast-math,但 NCC 里别开,精度会飘。烧录用 ST-Link,openocd或st-flash都行:
make clean && make -j4 st-flash write build/face_stm32.bin 0x08000000烧完先别急着测识别,串口打一行版本号和模板数量,确认程序真的跑起来了,而不是卡在 HardFault 里装死。
4.2 分阶段联调:先出图,再定位,最后比对
联调顺序错了会浪费大量时间。第一步只验证摄像头:把采集到的灰度值通过串口按 16x16 网格打印成字符画,能看出明暗轮廓说明时序对了。第二步验证肤色定位,串口输出候选框坐标,拿手在镜头前晃,坐标跟着动就对了。第三步才接比对,先用自己录的模板测,再换人测误识。
串口字符画那段代码很土但极有用:
/* debug.c —— 16x16 降采样后按灰度打字符 */ void dump_ascii(const uint8_t *buf, int w, int h) { const char *ramp = " .:-=+*#%@"; for (int y = 0; y < h; y += h/16) { for (int x = 0; x < w; x += w/16) { uint8_t v = buf[y*w + x]; putchar(ramp[v * 9 / 255]); } putchar('\n'); } }ramp从暗到亮共 10 级,v * 9 / 255把灰度映射到字符索引。这招在没屏幕的板子上就是黑匣子,图像歪了、反了、全黑,一眼看出来。等字符画正常了再上 LCD,能省一半调试时间。
5. 避坑与排查:五个真实踩过的坑
5.1 图像全黑或全白
现象:串口字符画全是空格或全是@。原因通常是 OV7670 的COM15(0x40)输出范围没配对,或者 PCLK 极性反了导致采样点落在消隐区。解决:先把0x40写0xD0恢复默认范围,再用示波器看 PCLK 和 HREF 的相位关系,必要时在定时器捕获里加反相。另一个隐蔽原因是模块供电不足,3.3V 跌落时摄像头输出异常,万用表量一下带载电压。
5.2 识别时好时坏,重启后模板丢失
现象:录完模板能用,断电再上电就比对失败。原因是模板写进了 RAM 里的数组,没落 Flash。解决:模板写入走 Flash 扇区擦写流程,先解锁FLASH->KEYR,擦整扇区再按半字写,写完回读校验 CRC。注意 F103 擦除最小单位是 1KB 扇区,模板区要单独划出来,别和代码段抢。
5.3 HardFault 随机触发
现象:跑几分钟进 HardFault,复位后又正常。八成是 DMA 或中断里访问了正在被主循环改写的缓冲,双缓冲切换时wr_idx和rd_idx竞争。解决:切换索引的临界区关中断,或者用__LDREX/__STREX做原子交换。另外检查栈大小,浮点运算和sqrtf吃栈,startup文件里栈默认 1KB,调到 2KB 试试。
5.4 误识率高,照片也能开锁
现象:拿手机里一张人脸照片对着镜头也能通过。这是纯 2D 灰度比对的通病,不是 bug 是方案局限。缓解手段:加活体检测的轻量版,比如要求连续 3 帧相似度都过阈值且帧间有微小位移,静态照片帧间几乎不变,会被卡掉。更稳的做法是加一个红外对管测反射率,成本几毛钱。
5.5 继电器动作干扰采集
现象:开锁瞬间图像花屏或程序复位。原因是继电器线圈反向电动势打穿电源,或者电磁干扰耦合到摄像头排线。解决:线圈并续流二极管,光耦隔离控制端,摄像头排线远离继电器走线,电源入口加 100uF 电解加 0.1uF 瓷片。这个坑不解决,识别再准也白搭。
6. 二次开发:换算法、扩模板、上低功耗的改造思路
拿到这套代码想往上加东西,先想清楚瓶颈在哪。如果嫌识别不准,别急着换深度学习,F103 跑不动,务实路线是换主控到带 FPU 的 F4 系列,或者加一颗专用人脸识别模组做协处理,STM32 只负责调度和开锁。如果只是模板不够存,外挂 SPI Flash 最省事,把face_app.c里的模板读写接口抽象成tmpl_read/tmpl_write,底层换 W25Q 驱动即可,上层比对逻辑一行不用改。
扩模板时有个细节:模板要带版本号和采集时的光照均值,比对前先按当前帧均值做一次亮度归一化,否则白天录的模板晚上用,NCC 直接掉到 0.5 以下。我一般会在模板结构体里加uint8_t mean_luma字段,比对前把两边的均值对齐。
低功耗场景(电池门禁)改造分三步:空闲时摄像头进 power-down(写COM20x09),主控进 STOP 模式,靠 PIR 或按键外部中断唤醒;唤醒后先出低分辨率预览确认有人脸再全速采集;开锁后延时回睡。实测这套下来平均电流能压到毫安级,具体数值看唤醒频率。
验证改造是否成功,别只看“能开锁”,要跑一个量化指标:准备 10 个人各 5 张不同光照的样本,统计误识率( impostor 被放行)和拒识率(本人被拒),两个都压到 5% 以下才算能交付。我吃过亏,早期只看“我自己能开”就交付,结果客户戴眼镜就识别不了。从那以后每次改完比对逻辑,都强制走一遍这套小样本测试再上板。希望帮到你。
本文还有配套的精品资源,点击获取