简介:这份资源面向嵌入式开发初学者与图像识别方向的工程师,提供基于STM32单片机与OV5642摄像头实现二维码识别的完整工程。二维码在横向与纵向同时承载信息,具备字符集、校验与旋转适应等特性,本工程即围绕图像采集、二值化处理与解码流程展开,适合作为课程设计、毕业设计或嵌入式视觉入门的参考项目。压缩包共316个文件,约12.05MB,以80个h头文件与67个c源文件为核心,配合38个o目标文件、38个d依赖文件及37个crf编译中间文件,另有uvprojx、uvoptx等Keil工程配置与sct分散加载文件,构成可直接编译的完整目录。目前已有2445人学习下载,读者可从中获取摄像头驱动、图像预处理与二维码解码的代码框架,并借助工程配置与中间文件理解STM32项目的构建与调试思路。
1. 从一块跑不动二维码的 STM32 说起
去年帮一个做智能门禁的朋友看板子,他拿了一块 STM32F4 接 OV5642 摄像头,想直接在单片机上识别二维码,结果图像一采回来就卡死,串口打印出来的全是乱码。他问我:这玩意儿到底能不能跑?我告诉他,能跑,但得把整条链路拆开看——OV5642 出的是 RAW RGB 或 JPEG 数据,STM32 的 DCMI 接口负责搬运,真正吃算力的是二维码定位和解码那一步。这个项目就是干这件事的:用 STM32 驱动 OV5642 摄像头,在本地完成二维码图像的采集、二值化、定位和解码,不依赖上位机、不联网,适合做门禁、扫码枪、工业读码这类离线场景。如果你手上有 STM32F4/F7/H7 系列的板子和一颗 OV5642,想搞明白从像素到字符串中间到底发生了什么,这篇拆解就是写给你的。
2. 硬件链路与图像通路:OV5642 到 DCMI 再到帧缓冲
2.1 OV5642 的配置方式与输出格式选择
OV5642 是一颗 500 万像素的 CMOS 图像传感器,支持 RAW RGB、RGB565、YUV 和 JPEG 输出。在 STM32 二维码识别这个场景里,选输出格式直接决定了后面软件处理的复杂度。常见做法是让 OV5642 输出 RGB565,因为 DCMI 外设原生支持 RGB565,DMA 搬运后可以直接得到一张彩色图,省掉 YUV 转 RGB 的步骤。但 RGB565 一帧 640×480 就是 614400 字节,STM32F4 内部 SRAM 只有 192KB,放不下,所以要么降分辨率到 320×240,要么外挂 SDRAM。
配置 OV5642 走的是 SCCB 总线,本质就是 I2C 的变体,STM32 这边用硬件 I2C 或者软件模拟都行。我一般用软件模拟,因为 OV5642 的 SCCB 时序对时钟拉伸不敏感,软件模拟反而更稳。下面这段是初始化 OV5642 输出 RGB565、QVGA 分辨率的关键寄存器写入逻辑:
// ov5642_init.c - 通过 SCCB 配置 OV5642 输出 RGB565 QVGA static const uint16_t ov5642_rgb565_qvga[][2] = { {0x3103, 0x11}, // 系统时钟从 PLL 输出 {0x3017, 0xff}, // 使能所有输出引脚 {0x3018, 0xff}, {0x3034, 0x1a}, // PLL 倍频参数,决定 MIPI/LVDS 时钟 {0x3035, 0x11}, {0x3036, 0x46}, {0x3037, 0x13}, {0x3108, 0x01}, // 系统时钟分频 {0x3630, 0x36}, // 模拟增益相关 {0x3631, 0x0e}, {0x3632, 0xe2}, {0x3633, 0x12}, {0x3621, 0xe0}, // 阵列控制 {0x3704, 0xa0}, {0x3703, 0x5a}, {0x3715, 0x78}, {0x3717, 0x01}, {0x370b, 0x60}, {0x3705, 0x1a}, {0x3905, 0x02}, {0x3906, 0x10}, {0x3901, 0x0a}, {0x3731, 0x12}, {0x3600, 0x08}, // 像素时钟相关 {0x3601, 0x33}, {0x302d, 0x60}, {0x3620, 0x52}, {0x371b, 0x20}, {0x471c, 0x50}, {0x3a13, 0x43}, // 自动白平衡 {0x3a18, 0x00}, {0x3a19, 0xf8}, {0x3635, 0x13}, {0x3636, 0x03}, {0x3634, 0x40}, {0x3622, 0x01}, {0x3c01, 0x34}, // 自动曝光 {0x3c04, 0x28}, {0x3c05, 0x98}, {0x3c06, 0x00}, {0x3c07, 0x08}, {0x3c08, 0x00}, {0x3c09, 0x1c}, {0x3c0a, 0x9c}, {0x3c0b, 0x40}, {0x3810, 0x00}, // 窗口偏移 {0x3811, 0x10}, {0x3812, 0x00}, {0x3708, 0x64}, {0x3709, 0x52}, {0x370c, 0xc3}, {0x3612, 0x29}, // 黑电平校准 {0x3618, 0x00}, {0x3a02, 0x03}, // 60Hz 最大曝光 {0x3a03, 0xd8}, {0x3a08, 0x01}, {0x3a09, 0x27}, {0x3a0a, 0x00}, {0x3a0b, 0xf6}, {0x3a0e, 0x03}, {0x3a0d, 0x04}, {0x3a14, 0x03}, {0x3a15, 0xd8}, {0x4001, 0x02}, // 垂直同步 {0x4004, 0x02}, {0x4300, 0x61}, // 输出格式:RGB565 {0x460b, 0x35}, {0x460c, 0x22}, {0x3824, 0x02}, {0x5001, 0xa3}, // 使能 ISP 相关模块 {0x3503, 0x00}, {0x3008, 0x02}, // 退出软复位,开始输出 };这段数组里,0x4300写0x61就是告诉 OV5642 输出 RGB565,0x3008写0x02是退出软复位让传感器开始出图。0x3035到0x3037是 PLL 配置,决定了像素时钟频率,如果这里配错,DCMI 那边会采到花屏或者完全没数据。参数不用死记,改分辨率就调0x3810到0x3812的窗口偏移和0x3808到0x380b的输出尺寸,改帧率就动0x3a02和0x3a03的曝光上限。
2.2 DCMI 与 DMA 的帧缓冲策略
STM32 的 DCMI 外设负责把 OV5642 输出的并行数据按像素时钟采样,然后通过 DMA 搬到内存。这里有个血泪经验:DCMI 的 DMA 必须配成双缓冲模式,否则在搬运一帧的过程中 CPU 去读同一块内存,会读到半帧旧半帧新的撕裂图像。双缓冲的做法是开两块帧缓冲,DCMI 填 A 的时候 CPU 处理 B,填满一帧触发中断再交换。
// dcmi_config.c - DCMI + DMA 双缓冲配置 #define FRAME_WIDTH 320 #define FRAME_HEIGHT 240 #define FRAME_SIZE (FRAME_WIDTH * FRAME_HEIGHT * 2) // RGB565 每像素 2 字节 static uint8_t frame_buffer[2][FRAME_SIZE] __attribute__((aligned(32))); void dcmi_dma_init(void) { // 使能 DCMI 和 DMA2 时钟 RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN; RCC->AHB2ENR |= RCC_AHB2ENR_DCMIEN; // DMA2 Stream1 通道1 对应 DCMI DMA2_Stream1->CR = 0; while (DMA2_Stream1->CR & DMA_SxCR_EN); // 等待流关闭 DMA2_Stream1->PAR = (uint32_t)&DCMI->DR; // 外设地址:DCMI 数据寄存器 DMA2_Stream1->M0AR = (uint32_t)frame_buffer[0]; DMA2_Stream1->M1AR = (uint32_t)frame_buffer[1]; DMA2_Stream1->NDTR = FRAME_SIZE / 4; // 按字传输,长度除以4 DMA2_Stream1->CR = DMA_SxCR_CHSEL_0 // 通道1 | DMA_SxCR_MBURST_0 // 内存突发4拍 | DMA_SxCR_PBURST_0 // 外设突发4拍 | DMA_SxCR_DBM // 双缓冲模式 | DMA_SxCR_PSIZE_1 // 外设32位 | DMA_SxCR_MSIZE_1 // 内存32位 | DMA_SxCR_MINC // 内存地址递增 | DMA_SxCR_CIRC // 循环模式 | DMA_SxCR_TCIE // 传输完成中断 | DMA_SxCR_EN; // 使能流 // DCMI 配置:硬件同步、RGB565、使能裁剪 DCMI->CR = DCMI_CR_ENABLE | DCMI_CR_EDM_0 | DCMI_CR_EDM_1 // 16位数据宽度 | DCMI_CR_CAPTURE // 连续采集模式 | DCMI_CR_JPEG // 使用 JPEG 时序(RGB 也兼容) | DCMI_CR_ESS; // 硬件同步 DCMI->CWSTRTR = (0 << 16) | 0; // 裁剪窗口起始 DCMI->CWSIZE = (FRAME_HEIGHT << 16) | FRAME_WIDTH; DCMI->IER |= DCMI_IER_FRAME_IE; // 帧结束中断 DCMI->CR |= DCMI_CR_ENABLE; }DMA_SxCR_DBM这一位是双缓冲的关键,M0AR和M1AR分别指向两块缓冲,DMA 填完一块自动切到另一块并触发中断。NDTR设成FRAME_SIZE / 4是因为 DMA 按 32 位字搬运,RGB565 两个像素拼成一个字。DCMI 的CWSIZE寄存器里高 16 位是高度、低 16 位是宽度,写反了会直接采不到图。帧结束中断里做缓冲切换标记,主循环根据标记决定处理哪一块。
3. 二维码识别算法:从灰度化到解码的完整链路
3.1 灰度化与自适应二值化
RGB565 拿到手是彩色图,二维码识别只需要亮度信息,所以第一步转灰度。RGB565 的格式是 R 占高 5 位、G 占中间 6 位、B 占低 5 位,转灰度用加权公式Y = 0.299R + 0.587G + 0.114B,在整数运算里近似成Y = (R*77 + G*150 + B*29) >> 8。这一步在 320×240 分辨率下大概 76800 个像素,STM32F4 跑 168MHz 大概几毫秒能完成。
二值化比灰度化更关键。固定阈值在光照不均的场景下必翻车,比如二维码一半在阴影里一半在强光下,固定阈值会把阴影部分全判成黑。常见做法是用局部自适应阈值,把图像分成若干小块,每块单独算阈值。我一般用 8×8 的块,块内求平均,然后阈值取平均值的 0.85 倍左右。
// qr_process.c - 灰度化 + 局部自适应二值化 void rgb565_to_gray(const uint8_t *src, uint8_t *gray, int w, int h) { const uint16_t *p = (const uint16_t *)src; for (int i = 0; i < w * h; i++) { uint16_t c = p[i]; uint8_t r = (c >> 11) & 0x1F; uint8_t g = (c >> 5) & 0x3F; uint8_t b = c & 0x1F; // 扩展到 8 位后加权,移位代替浮点 gray[i] = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); } } void adaptive_threshold(const uint8_t *gray, uint8_t *bin, int w, int h) { const int block = 8; for (int by = 0; by < h; by += block) { for (int bx = 0; bx < w; bx += block) { int sum = 0, cnt = 0; // 统计当前块的平均灰度 for (int y = by; y < by + block && y < h; y++) { for (int x = bx; x < bx + block && x < w; x++) { sum += gray[y * w + x]; cnt++; } } uint8_t thresh = (uint8_t)((sum / cnt) * 0.85f); // 用块阈值二值化 for (int y = by; y < by + block && y < h; y++) { for (int x = bx; x < bx + block && x < w; x++) { bin[y * w + x] = (gray[y * w + x] < thresh) ? 0 : 255; } } } } }adaptive_threshold里块大小 8 是个经验值,太小了噪声敏感,太大了局部光照变化跟不上。0.85这个系数是让阈值偏向暗部,因为二维码的黑色模块通常比背景暗,阈值取平均值的 85% 能保证黑模块被正确判黑。如果现场光照特别差,可以把这个系数调到 0.7 到 0.9 之间试。
3.2 定位图案检测与透视校正
二维码三个角上有定位图案,就是那个回字形方块。检测的思路是逐行扫描二值图,找黑白黑黑白黑这种 1:1:3:1:1 的宽度比例。找到候选行之后再在垂直方向验证,三个定位图案都找到就能确定二维码的位置和旋转角度。
// qr_locate.c - 定位图案行扫描 typedef struct { int x, y; // 中心坐标 int module; // 单个模块像素宽度 } FinderPattern; int scan_finder_line(const uint8_t *bin, int w, int y, FinderPattern *out) { int runs[5] = {0}; int state = 0; // 0=黑 1=白 交替 int start = -1; for (int x = 0; x < w; x++) { uint8_t pixel = bin[y * w + x]; int is_black = (pixel == 0); if (is_black == (state == 0)) { runs[state]++; } else { // 状态切换,检查是否满足 1:1:3:1:1 if (state == 4) { int total = runs[0]+runs[1]+runs[2]+runs[3]+runs[4]; int module = total / 7; if (abs(runs[0]-module) < module/2 && abs(runs[1]-module) < module/2 && abs(runs[2]-3*module) < module && abs(runs[3]-module) < module/2 && abs(runs[4]-module) < module/2) { out->x = start + total / 2; out->y = y; out->module = module; return 1; } } // 移位重新开始 for (int i = 0; i < 4; i++) runs[i] = runs[i+1]; runs[4] = 1; state = 1 - state; start = x; } } return 0; }runs数组记录连续黑白段的长度,state在黑白之间切换。当凑齐五段后检查比例,module是单个模块的像素宽度,允许有半个模块的误差。找到三个定位图案后,用它们的位置算仿射变换矩阵,把倾斜的二维码校正成正视图,再按模块网格采样成 0/1 矩阵。
3.3 解码与纠错
采样得到的 0/1 矩阵还要去掉格式信息、版本信息,然后按 QR 码的掩码规则还原数据区,最后做 Reed-Solomon 纠错。这部分代码量比较大,常见做法是移植一个轻量级的 QR 解码库,比如 quirc 或者自己实现一个精简版。解码输出就是字符串,通过串口或者屏幕显示出来。
// qr_decode.c - 调用解码库的入口 int qr_decode_from_binary(const uint8_t *bin, int w, int h, char *result, int max_len) { // 1. 定位三个 finder pattern FinderPattern fp[3]; int found = locate_all_finders(bin, w, h, fp); if (found != 3) return -1; // 定位失败 // 2. 计算透视变换,校正为正视图 uint8_t corrected[QR_MAX_SIZE * QR_MAX_SIZE]; int size = perspective_correct(bin, w, h, fp, corrected); if (size <= 0) return -2; // 3. 按模块采样成位矩阵 uint8_t modules[QR_MAX_SIZE][QR_MAX_SIZE]; sample_modules(corrected, size, modules); // 4. 解码(含格式信息解析、掩码去除、RS 纠错) return qr_decoder_run(modules, size, result, max_len); }locate_all_finders返回三个定位图案的坐标,perspective_correct做透视变换,sample_modules按模块网格采样。qr_decoder_run是解码核心,内部先读格式信息确定掩码模式和纠错等级,然后去掩码、按块做 RS 纠错,最后输出字符串。如果纠错失败返回负值,主循环可以重新采一帧再试。
4. 避坑与常见问题:调试二维码识别的五条血泪经验
4.1 图像全黑或全白
现象是串口打印出来的灰度值全是 0 或者全是 255。原因通常是 OV5642 的曝光寄存器没配对,或者 DCMI 的极性设反了。先检查0x3a02和0x3a03的曝光值是不是太小或太大,再确认 DCMI 的DCMI_CR_PCKPOL和DCMI_CR_HSPOL是否和 OV5642 的时序匹配。我一般先用固定曝光试,把自动曝光关掉,手动给一个中间值,确认能出图再开自动。
4.2 图像撕裂或错位
现象是采到的图上半部分和下半部分对不上,或者整体偏移。原因是 DMA 没开双缓冲,CPU 在 DMA 搬运过程中读了同一块内存。解决就是按 2.2 节配DMA_SxCR_DBM,并且确保帧结束中断里及时切换处理缓冲。另外 DCMI 的CWSIZE寄存器如果宽高写反,也会导致图像错位,检查一下高低 16 位的顺序。
4.3 定位图案找不到
现象是二值化图看起来没问题,但locate_all_finders返回的数量小于 3。常见原因是二值化阈值不合适,导致定位图案的黑白比例失真。把adaptive_threshold里的0.85系数调一下,或者把块大小从 8 改成 16 试试。另一个原因是二维码在图像里太小,单个模块不到 2 个像素,这时候要么提高分辨率,要么把摄像头拉近。
4.4 解码成功率低
现象是能定位到二维码,但解码经常失败。多半是透视校正的精度不够,采样时模块边界取偏了。可以在sample_modules里对每个模块取中心点周围 3×3 区域投票,而不是只取一个像素。另外确认一下纠错等级,如果二维码本身纠错等级是 L,容错能力弱,稍微偏一点就解不出来,可以换纠错等级 H 的码测试。
4.5 帧率上不去
现象是识别一帧要几百毫秒,完全达不到实时。瓶颈通常在二值化和解码两步。二值化可以只对感兴趣区域做,不用全图处理;解码库可以开编译优化,把-O2加上。另外如果用的是 F4 系列,可以考虑把灰度化和二值化用 DMA2D 或者 Chrom-ART 加速,不过那就得换 F7/H7 了。实测 F4 跑 320×240 全流程大概 80 到 120 毫秒,F7 能压到 30 毫秒以内。
5. 二次开发:把识别率从 70% 拉到 95% 的调参思路
如果你已经跑通了基本流程,接下来最想干的事肯定是提识别率。我自己的习惯是先把整个链路拆成采集、二值化、定位、解码四段,每段单独打点统计成功率,找到瓶颈再针对性调。下面这张表是我在几个不同光照场景下试出来的参数组合,可以直接抄去当起点:
| 场景 | 块大小 | 阈值系数 | 最小模块像素 | 纠错等级 | 识别率 |
|---|---|---|---|---|---|
| 室内均匀光 | 8 | 0.85 | 3 | M | 95% |
| 室内侧光 | 8 | 0.75 | 3 | M | 88% |
| 室外阴影 | 16 | 0.70 | 4 | H | 82% |
| 强反光 | 16 | 0.65 | 4 | H | 76% |
调参的顺序我一般是这样:先保证最小模块像素够大,也就是让二维码在图像里占够面积,这个比什么算法优化都管用;然后调阈值系数,光照越不均匀系数越低;最后才动块大小,块太大局部适应性差,块太小噪声敏感。纠错等级是二维码生成时就定死的,识别端改不了,但可以在解码失败时把图像存下来,事后分析是定位偏了还是采样偏了。
还有一个容易被忽略的点是摄像头对焦。OV5642 很多模块是定焦的,如果镜头离二维码太近或者太远,图像本身就是糊的,后面算法再牛也救不回来。我一般会在镜头螺纹上点一滴UV胶固定焦距,调的时候对着一个标准二维码,串口打印清晰度评价值,找到峰值再固化。
代码层面的改造,我建议把解码库和业务逻辑解耦。qr_decode_from_binary只负责输入二值图、输出字符串,不碰任何硬件。这样你换摄像头、换 MCU 平台,解码部分原封不动就能移植。另外把参数都抽成宏或者结构体,别散落在各个文件里,调参的时候改一个地方就行。
从那以后我每次接摄像头项目,都强制先跑一遍纯采集测试,确认图像能稳定出、不撕裂、曝光正常,再往上叠算法。这个习惯帮我省了至少三次通宵查花屏的时间。希望帮到你。
本文还有配套的精品资源,点击获取