简介:这份资源面向嵌入式开发初学者与图像处理入门者,围绕STM32微控制器驱动OV7670摄像头实现黑白二值化展开,帮助读者理解从图像采集到阈值分割的完整链路。压缩包共97个文件,约386KB,以44个h头文件与43个c源文件为主体,涵盖OV7670驱动、LCD显示、按键、定时器、串口等硬件模块,另含uvprojx工程文件、hex烧录文件、启动汇编与若干说明文档,工程结构完整可直接编译。内容涉及SPI接口配置、摄像头初始化、灰度化与全局/自适应阈值选取、二值化处理及LCD显示存储等关键环节,并附调试与实时性优化思路。目前已有899人学习下载,适合希望快速搭建STM32图像采集实验环境、掌握二值化算法落地方法的读者参考借鉴。
1. 从一张模糊的灰度图说起:为什么你的二值化总在阈值上翻车
很多人第一次把 OV7670 接上 STM32 跑通 RGB565 显示之后,都会冒出同一个念头——把图像转成黑白二值,做个简单的循迹或者形状识别。听起来只是设个阈值、比个大小的事,但真正动手就会发现,同一套代码在实验室白炽灯下跑得好好的,换到窗边自然光就全白或者全黑,阈值像玄学一样飘。问题不在代码逻辑,而在于 OV7670 输出的是带噪的 8 位或 16 位原始数据,光照一变,灰度分布整体平移,固定阈值必然失效。
这份资源围绕 STM32 + OV7670 这套经典组合,把摄像头采集、RGB565 转灰度、二值化处理、结果输出的完整链路拆开讲清楚。它适合已经能驱动 OV7670 出图、想进一步做图像预处理的嵌入式开发者,也适合正在做视觉相关课程设计、需要一套可复现参数的同学。核心不是教你调一个阈值,而是让你理解二值化在资源受限的 MCU 上到底该怎么落地,以及哪些参数动了会出什么事。
2. OV7670 数据流与二值化落点:从 SCCB 配置到帧缓冲的取舍
2.1 OV7670 输出格式与灰度转换的硬件约束
OV7670 常见输出格式有 RGB565、YUV422、RAW Bayer 几种。做二值化最顺手的是先拿到灰度值,而 OV7670 本身不直接输出灰度图,需要你在 MCU 侧做转换。RGB565 每个像素占 2 字节,转灰度常用加权公式:
// RGB565 转 8 位灰度,权重来自人眼亮度感知 // r5: 高 5 位,g6: 中间 6 位,b5: 低 5 位 uint8_t rgb565_to_gray(uint16_t pixel) { uint8_t r = (pixel >> 11) & 0x1F; // 取高 5 位红色 uint8_t g = (pixel >> 5) & 0x3F; // 取中间 6 位绿色 uint8_t b = pixel & 0x1F; // 取低 5 位蓝色 // 扩展到 8 位后加权:0.299R + 0.587G + 0.114B return (uint8_t)((r * 77 + g * 150 + b * 29) >> 8); }这里把 5/6/5 位分别扩展到 8 位再乘权重,最后右移 8 位,避免浮点运算。权重 77/150/29 是 0.299/0.587/0.114 乘以 256 后的近似值,误差在可接受范围内。如果你用的是 YUV422,Y 分量本身就是亮度,直接取 Y 即可,省掉转换开销,但 SCCB 寄存器配置要对应改。
提示:OV7670 的 RGB565 输出在低照度下噪声明显,转换后的灰度图会出现随机跳变点,这是后面二值化不稳定的根源之一。
2.2 帧缓冲策略:整帧缓存还是行缓存
STM32 的 SRAM 通常只有几十到一百多 KB,OV7670 一帧 QVGA(320×240)RGB565 就是 153600 字节,放不下。常见做法有三种:
| 策略 | 内存占用 | 适用场景 | 代价 |
|---|---|---|---|
| 整帧缓存 | 大 | 需要后处理算法 | 外扩 SRAM 或降分辨率 |
| 行缓存 | 小 | 逐行二值化输出 | 无法做二维滤波 |
| 直接流式处理 | 最小 | 实时二值化 | 无法回溯,时序要求高 |
我一般会选行缓存加流式处理:用 DMA 把 OV7670 的一行数据搬到缓冲区,CPU 在这行上做灰度转换和二值化,处理完直接输出到 LCD 或者串口。这样内存占用只有一行的大小,QVGA 下 RGB565 一行 640 字节,双缓冲也就 1280 字节,对 STM32F103 这类芯片很友好。
配置 DMA 时注意 OV7670 的 PCLK 极性。常见坑是 PCLK 上升沿采样还是下降沿采样没设对,导致图像整体偏移半个像素,二值化后边缘出现规律性毛刺。SCCB 写寄存器 0x11 和 0x15 可以调整 PCLK 时序,具体值要看你的模块晶振和主频匹配。
2.3 二值化阈值:固定、动态还是自适应
固定阈值最简单,一个if (gray > threshold)就完事,但前面说了,光照一变就废。动态阈值常见两种:
一种是全局动态,统计整帧或若干行的灰度均值,乘以一个系数作为阈值。比如:
// 基于行均值的动态阈值,系数 k 通常取 0.8~1.2 uint8_t calc_threshold(uint8_t *gray_line, uint16_t len, float k) { uint32_t sum = 0; for (uint16_t i = 0; i < len; i++) { sum += gray_line[i]; } uint8_t mean = sum / len; return (uint8_t)(mean * k); }系数 k 的选取要看场景:目标比背景暗就取小于 1 的值,目标比背景亮就取大于 1 的值。实际调试时我会先把 k 设成 1.0,观察二值化结果,再以 0.05 为步进调整。
另一种是局部自适应,比如对每个像素取邻域均值再比较,效果更好但计算量大,STM32 上跑 QVGA 会比较吃力。如果只是做简单循迹,全局动态阈值足够;如果要做文字或者二维码提取,局部自适应才值得上。
注意:动态阈值统计时不要包含无效行。OV7670 在帧消隐期间输出的数据是无效的,如果 DMA 没做行计数过滤,均值会被拉偏。
3. 从寄存器到二值化输出:一套可复现的配置与代码流程
3.1 SCCB 初始化关键寄存器与灰度模式选择
OV7670 上电后需要通过 SCCB 写一堆寄存器才能出图。网上流传的配置表很多,但针对二值化场景,有几个寄存器值得单独拎出来说:
- 0x12(COM7):设置输出格式。写 0x04 是 RGB565,写 0x00 是 YUV422。如果选 YUV422,后面直接取 Y 分量,省掉 RGB 转灰度。
- 0x40(COM15):设置 RGB565 的位宽和范围。常用 0xD0,输出全范围 0~255。
- 0x3A(TSLB):YUV 顺序调整,影响 Y 分量位置。
- 0x13(COM8):使能 AGC、AEC 等自动控制。做二值化时建议关掉 AGC,否则增益自动变化会让灰度分布漂移,阈值更难稳定。
我一般会先把 AGC 和 AEC 关掉,手动设曝光和增益,让图像亮度固定下来,再调二值化阈值。这样虽然适应光照变化的能力差了,但至少阈值是可复现的。如果必须开 AGC,那动态阈值的统计窗口要足够大,比如统计整帧而不是单行。
3.2 行中断驱动的采集与二值化流水线
下面是一段基于行中断的采集流程,用 HAL 库风格写,方便移植:
// 行缓冲,双缓冲交替使用 #define LINE_LEN 640 // QVGA RGB565 一行字节数 uint8_t line_buf[2][LINE_LEN]; volatile uint8_t buf_idx = 0; volatile uint8_t line_ready = 0; // 行中断回调,DMA 搬完一行后触发 void HAL_DCMI_LineEventCallback(DCMI_HandleTypeDef *hdcmi) { line_ready = 1; buf_idx ^= 1; // 切换缓冲区 // 重新配置 DMA 目标地址到另一个缓冲区 HAL_DCMI_Stop(hdcmi); // 实际项目中这里用 DMA 双缓冲模式更稳,此处简化示意 } // 主循环中处理已就绪的行 void process_line(void) { if (!line_ready) return; uint8_t *src = line_buf[buf_idx ^ 1]; // 取刚搬完的那一行 uint16_t gray_line[LINE_LEN / 2]; for (uint16_t i = 0; i < LINE_LEN / 2; i++) { uint16_t pixel = (src[2*i] << 8) | src[2*i + 1]; gray_line[i] = rgb565_to_gray(pixel); } uint8_t th = calc_threshold((uint8_t*)gray_line, LINE_LEN / 2, 1.0f); for (uint16_t i = 0; i < LINE_LEN / 2; i++) { uint8_t bin = (gray_line[i] > th) ? 255 : 0; // 输出到 LCD 或串口,此处省略 (void)bin; } line_ready = 0; }这段代码的关键点:DMA 搬一行、CPU 处理上一行,形成流水线。buf_idx切换时要注意 DMA 目标地址的更新时机,如果 DMA 还在搬当前行就改了地址,数据会错位。稳妥做法是用 DMA 的双缓冲模式(Double Buffer Mode),让硬件自动切换,CPU 只需要在中断里处理已完成的缓冲区。
参数说明:LINE_LEN是 QVGA RGB565 一行的字节数,320 像素乘以 2 字节。如果你用的是 QQVGA(160×120),这个值减半。calc_threshold的系数 k 先设 1.0,根据实际二值化效果微调。
3.3 二值化结果的输出与验证
二值化后的数据可以走几条路:直接写 LCD 显示、通过串口发给上位机、或者存到 SD 卡。调试阶段我建议走串口,用上位机画出来看,比盯着小屏幕直观。
串口输出时注意波特率。QVGA 二值化后每行 320 字节,一帧 240 行,就是 76800 字节。115200 波特率下传一帧要 6 秒多,太慢。可以降分辨率到 QQVGA,或者只传感兴趣区域(ROI)。如果只是验证阈值效果,传几行就够了。
验证方法:拿一张已知内容的图,比如白纸上的黑色方块,看二值化后方块边缘是否整齐。如果边缘出现锯齿或者断裂,说明阈值偏高或偏低;如果整行全黑或全白,说明阈值严重偏离。这时候回到calc_threshold的系数 k 上调整,每次改 0.05,观察变化趋势。
提示:OV7670 在低照度下噪声大,二值化后会出现椒盐噪声。如果后续要做轮廓提取,建议加一个 3×3 中值滤波,但会牺牲实时性。权衡点在于你的帧率要求。
4. 避坑与排查:二值化实验里最容易翻车的五个点
4.1 图像整体偏暗或偏亮,阈值怎么调都没用
现象:二值化结果要么全黑要么全白,调阈值只能改变全黑全白的比例,看不到目标轮廓。
原因:OV7670 的曝光或增益没设对,导致灰度值集中在 0 附近或 255 附近,动态范围被压缩。常见于 AGC 开启但光照突变,或者手动曝光值设得太极端。
解决:先关掉 AGC 和 AEC,手动设曝光寄存器 0x10 和 0x11 到中间值,增益寄存器 0x00 和 0x01 也设中间值。然后用串口把灰度直方图打出来,确认灰度分布覆盖了较宽的范围。如果直方图集中在一端,继续调曝光和增益,直到分布展开。
4.2 二值化后图像出现规律性竖条纹
现象:每隔几列就有一条黑线或白线,位置固定。
原因:PCLK 采样极性不对,或者 DMA 搬运时字节顺序搞反了。RGB565 是高字节在前还是低字节在前,取决于 OV7670 的寄存器配置和 MCU 的字节序。
解决:检查 SCCB 寄存器 0x11 和 0x15 的 PCLK 设置,尝试翻转极性。同时确认 RGB565 的两个字节拼接顺序,把(src[2*i] << 8) | src[2*i+1]改成(src[2*i+1] << 8) | src[2*i]试试。如果条纹消失,说明字节序反了。
4.3 动态阈值在帧间跳变,二值化结果闪烁
现象:静止场景下,二值化结果每隔几帧就整体翻转一次。
原因:动态阈值的统计窗口太小,或者统计时包含了无效行。OV7670 在帧消隐期间输出的数据是随机的,如果 DMA 没过滤,均值会被拉偏。
解决:把统计窗口扩大到整帧,或者在行中断里加一个行计数,只统计有效行。另外,阈值更新不要每帧都做,可以每 5 帧更新一次,减少跳变。
4.4 二值化后目标边缘断裂或粘连
现象:目标轮廓不连续,或者两个靠近的目标粘在一起。
原因:阈值处于目标边缘灰度的过渡区,边缘像素的灰度值在阈值附近波动,导致二值化结果不稳定。
解决:如果目标是暗背景亮目标,把阈值往亮的方向调一点,让边缘像素归入背景;反之往暗的方向调。另一种做法是加滞回比较,设两个阈值,高于高阈值判白,低于低阈值判黑,中间保持上一状态。这样边缘更稳定,但实现稍复杂。
4.5 帧率骤降,二值化处理拖慢整体采集
现象:加上二值化处理后,帧率从 30fps 掉到 5fps 以下。
原因:灰度转换和二值化在 CPU 上逐像素跑,QVGA 下每帧 76800 个像素,每个像素做乘法和移位,STM32F103 主频 72MHz 也扛不住。
解决:降分辨率到 QQVGA,像素数降到四分之一。或者用查表法替代实时计算:把 RGB565 的 65536 种组合预先算好灰度值存到数组里,运行时直接查表。查表法占用 64KB Flash,但速度提升明显。如果 Flash 不够,可以只对高 8 位建表,牺牲一点精度。
5. 进阶技巧:用积分图做局部自适应阈值,以及一个我常犯的错
全局动态阈值在光照均匀的场景够用,但遇到光照不均,比如一半亮一半暗,就歇了。这时候局部自适应阈值是正解,而积分图是让局部自适应在 MCU 上跑得动的关键。
积分图的思路:先算一张与原图同尺寸的表,每个位置存的是原图左上角到该位置的灰度累加和。有了这张表,任意矩形区域的灰度和可以在常数时间内算出来,不用逐像素累加。对于每个像素,取它周围一个窗口(比如 15×15),用积分图算出窗口内灰度和,除以窗口面积得到局部均值,再乘以系数作为该像素的阈值。
// 积分图构建,gray 是灰度图,integral 是积分图,w 和 h 是宽高 // integral 尺寸为 (w+1)*(h+1),第一行和第一列补零 void build_integral(uint8_t *gray, uint32_t *integral, uint16_t w, uint16_t h) { for (uint16_t y = 0; y < h; y++) { uint32_t row_sum = 0; for (uint16_t x = 0; x < w; x++) { row_sum += gray[y * w + x]; integral[(y + 1) * (w + 1) + (x + 1)] = integral[y * (w + 1) + (x + 1)] + row_sum; } } } // 用积分图算局部均值,窗口半径 r uint8_t local_mean(uint32_t *integral, uint16_t w, uint16_t x, uint16_t y, uint8_t r) { uint16_t x1 = (x > r) ? (x - r) : 0; uint16_t y1 = (y > r) ? (y - r) : 0; uint16_t x2 = (x + r < w) ? (x + r) : (w - 1); uint16_t y2 = (y + r < w) ? (y + r) : (w - 1); // 注意这里 y2 应该用 h 判断 uint32_t area = (x2 - x1 + 1) * (y2 - y1 + 1); uint32_t sum = integral[(y2 + 1) * (w + 1) + (x2 + 1)] - integral[y1 * (w + 1) + (x2 + 1)] - integral[(y2 + 1) * (w + 1) + x1] + integral[y1 * (w + 1) + x1]; return (uint8_t)(sum / area); }上面这段代码里我故意留了一个错误:y2的判断用了w而不是h。这个错我在早期项目里犯过,当时图像下半部分全黑,查了半天才发现是边界判断写错了变量。修正后应该是(y + r < h) ? (y + r) : (h - 1)。积分图本身不复杂,但边界条件容易翻车,尤其是宽高不等的时候。
积分图的内存占用是(w+1)*(h+1)*4字节,QQVGA 下是 161×121×4 约 78KB,STM32F103 的 64KB SRAM 放不下。所以要么外扩 SRAM,要么用 uint16 存积分值并限制分辨率。如果只是做二值化,其实可以只对当前行和上一行维护一个滑动窗口的积分,但实现起来更绕。
另一个技巧是阈值系数用整数运算替代浮点。比如局部均值乘以 0.9,可以写成(mean * 230) >> 8,230 是 0.9 乘以 256 的近似。这样避免浮点单元缺失时的软件浮点开销。
从那以后我每次做局部自适应阈值,都会先把积分图的边界条件单独测一遍:构造一张已知灰度的小图,手动算几个位置的积分值,和代码输出对比。确认无误再上真实图像。这个习惯帮我省了很多调试时间。希望帮到你。
本文还有配套的精品资源,点击获取