STM32+OV7670黑白二值化实战:从硬件到OTSU算法详解

STM32+OV7670黑白二值化实战:从硬件到OTSU算法详解 简介面向STM32与OV7670摄像头应用的嵌入式开发者这份黑白二值化实验工程完整演示了图像采集、灰度转换、阈值分割到LCD显示的全流程适合需要快速上手摄像头图像处理的初中级用户。工程内含OV7670驱动、USMART调试组件以及LED、按键、LCD、TPAD等常用外设驱动代码按硬件模块分层可直接在STM32F10x上编译运行同时通过阈值选取对比帮助理解全局阈值与自适应阈值的适用场景为实时图像处理优化打下基础。压缩包共97个文件以44个h头文件和43个c源文件为主体辅以工程配置uvprojx/uvoptx、hex固件及清理脚本总大小仅386KB便于快速下载与二次开发。已有896人学习使用适合刚接触摄像头图像处理或需要参考完整代码框架的开发者。 我是做嵌入式开发的前几天刚好帮朋友调了一个项目STM32 OV7670 摄像头做黑白二值化。这问题看着简单实际牵扯到摄像头配置、DCMI 采集、图像处理算法移植还有一堆硬件上的坑。把这个实验从头到尾走了一遍之后我想把完整的思路、代码和踩坑记录整理出来给正准备做类似图像处理入门实验的朋友一个参考。这个实验的本质是用 OV7670 采集彩色图像在 STM32 内部把 RGB 数据转换成灰度图再根据阈值把灰度图变成黑白二值图最终可以用于后续的车牌识别、条形码定位、循迹小车等视觉应用。整个过程不依赖外部 AI 芯片纯靠 MCU 算力完成虽然和 K210、树莓派这类带硬件加速的平台没法比但作为理解图像处理底层原理的入门实验价值非常大。如果你手上正好有 STM32F4 系列开发板、OV7670 摄像头模块和一块 TFT 屏那这个项目的材料就齐了。没有屏也能做后面我会讲怎么用串口把图传到电脑上看效果。1. 方案思路拆解为什么是 OV7670 STM321.1 OV7670 的优势与局限OV7670 是 OmniVision 公司一颗非常经典的 CMOS 图像传感器有效像素 30 万最大支持 VGA 分辨率640x480在入门级视觉项目里出镜率极高。这颗芯片支持 SCCB 接口配置寄存器可以输出 YUV422、RGB565、RGB444 和 Raw RGB 等多种格式灵活性很好。和现在主流的 OV2640、OV5640 相比OV7670 像素低、不支持 JPEG 硬件编码画质也一般。但在二值化这个场景里它反而有个好处数据量小。VGA 分辨率下 RGB565 每像素 2 字节一帧原始数据约 614KB对于主频 168MHz 的 STM32F407 来说DMA 搬运加实时处理是扛得住的。如果换成 OV5640 的 500 万像素MCU 这边直接就被数据量淹没了。1.2 二值化在图像处理流程中的位置很多初学者第一次接触视觉项目上来就想做“识别”。但正经的图像处理流程一定是分层的采集 → 预处理 → 特征提取 → 识别决策。黑白二值化属于预处理层核心作用是把彩色或灰度图像转换成一幅只有黑和白两个像素值的图像把目标区域和背景剥离开来。我做过的几个实际项目里二值化后面至少接三类处理车牌识别场景二值化后找字符边缘、做连通域分析、切分字符前提就是字符区域和背景有足够的灰度差。循迹小车场景黑线在白色地面上的灰度差很大二值化后直接找黑色像素的重心坐标就能算偏差量。条形码/二维码场景条码本身就是黑白条纹二值化后再做边缘检测和译码正确率能提升一个量级。所以这个实验虽然只是把图像变成黑白的但它是整个视觉 pipeline处理流程里最关键的一环。阈值选得好不好直接决定后面所有步骤的效果。1.3 硬件方案选择DCMI 直连还是 FIFO 中转市面上 OV7670 模块分两种带 FIFOAL422B的和不带 FIFO 的。带 FIFO 的模块适合没有 DCMI 接口的芯片比如 STM32F103 系列靠 GPIO 模拟时序一字节一字节读速度慢帧率低但能用。不带 FIFO 的模块信号线直接引出 VSYNC、HREF、PCLK、D0-D7 等引脚要求 MCU 具备 DCMI 外设接口最典型的就是 STM32F4 系列。这个实验我强烈建议用 DCMI 方案。原因有两点第一DCMI 接口配合 DMA 可以自动把整帧图像搬运到内存CPU 只需要在帧中断里处理数据性能开销小很多第二DCMI 支持行同步和帧同步硬件信号图像不会因为 CPU 干预导致错位花屏。我在测试时用的是 STM32F407VET6主频 168MHzDCMI DMA 双缓冲抓图QVGA320x240分辨率下 RGB565 帧率能跑到 20fps 左右二值化处理一帧约 8ms整体性能完全够用。2. 硬件接线与 DCMI 引脚规划2.1 摄像头模块引脚接线OV7670 模块引脚不少但真正接起来并不复杂。SCCB 两根信号线SIOC、SIOD用来写寄存器图像数据走 D0-D7 并行总线再加上 VSYNC、HREF、PCLK、XCLK 几个同步时钟信号。以 STM32F407VET6 为例DCMI 外设引脚是固定的不是随便接都能用。我列一个实际验证过的接线表OV7670 引脚STM32F407 引脚说明SIOCPB6SCCB 时钟模拟 IICSIODPB7SCCB 数据VSYNCPB7 或 PC4帧同步信号注意别和 SCCB 冲突HREFPA4行同步信号PCLKPA6像素时钟XCLKPA8摄像头主时钟由 TIM1 输出D0-D7PC6-PC11 PE0-PE18 位像素数据PWDNGND关闭掉电模式RESET3.3V复位引脚拉高这里有个很容易踩的坑部分 OV7670 模块的 VSYNC 引脚编号是 PB7但 PB7 也同时接到了 SIOD 上这在设计上是矛盾的。我最后用的是带独立 VSYNC 引脚的模块如果板子引脚复用了建议用杜邦线飞一下或者调整 DCMI 引脚映射。2.2 电源与时钟注意事项OV7670 的 AVDD 是 2.5V-3.0VDVDD 是 1.8V-3.0V大部分模块板载了稳压芯片直接供 3.3V 就行。但有些精简版模块没有稳压直接接 3.3V 会烧买之前一定要确认模块上有没有 LDO低压差线性稳压器。XCLK 是摄像头的“心跳”OV7670 最大支持 24MHz 输入时钟。STM32F407 的 PA8 可以通过 TIM1 输出 PWM也可以直接用 MCO 引脚输出。我用的是 TIM1 输出 24MHz配置 PSC0ARR2在 168MHz 主频下输出频率 168 / (01) / (21) 56MHz呃这个算错了实际要输出 24MHz 需要分频系数是 7即 ARR6。这里提醒一句XCLK 频率不准会导致摄像头输出异常或图像偏色配置完务必用示波器量一下。3. OV7670 寄存器配置与 DCMI 图像采集3.1 输出格式与分辨率寄存器配置OV7670 的所有配置都通过 SCCB 协议写寄存器完成。SCCB 和 IIC 非常接近但读时序不完全一样我直接用 GPIO 模拟了 SCCB 时序避免了硬件 IIC 的地址和 ACK 问题。需要重点关注的寄存器寄存器地址名称配置值作用0x12COM70x00设置 VGA 分辨率、RGB 输出模式0x40COM150xD0设置 RGB565 输出格式0x11CLKRC0x00内部时钟分频配合 XCLK 用0x6BDSPC10x00关闭色彩饱和度控制0x1EMVFP0x00设置图像方向可镜像翻转COM7 的 0x12 寄存器是核心中的核心。bit[5:4] 控制分辨率00 是 VGA01 是 CIFbit[2] 决定输出 RGB/YUV 还是 Raw。我设 0x00 表示 VGA 分辨率 RGB 输出模式。COM15 寄存器设置 RGB 格式0xD0 就是 RGB565。如果后面要直接出灰度图也可以把输出格式改成 YUV422取 Y 分量就是灰度但 RGB565 的彩色数据可以灵活复用所以我最终选了 RGB565。3.2 DCMI DMA 双缓冲配置DCMI 采集用 DMA 搬运是关键不然 CPU 逐像素读 PCLK 数据会被中断风暴搞死。我用了双缓冲机制DMA 在缓冲区 A 写满时自动切到缓冲区 B同时触发完成中断CPU 在中断里处理缓冲区 A 的数据处理完等下一次切换。这样数据采集和处理是流水线式的帧率能最大化。DMA 配置的核心代码如下HAL 库风格DCMI_HandleTypeDef hdcmi; DMA_HandleTypeDef hdma_dcmi; void DCMI_Init(void) { __HAL_RCC_DCMI_CLK_ENABLE(); __HAL_RCC_DMA2_CLK_ENABLE(); hdma_dcmi.Instance DMA2_Stream1; hdma_dcmi.Init.Channel DMA_CHANNEL_1; hdma_dcmi.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_dcmi.Init.PeriphInc DMA_PINC_DISABLE; hdma_dcmi.Init.MemInc DMA_MINC_ENABLE; hdma_dcmi.Init.PeriphDataAlignment DMA_PDATAALIGN_WORD; hdma_dcmi.Init.MemDataAlignment DMA_MDATAALIGN_WORD; hdma_dcmi.Init.Mode DMA_CIRCULAR; hdma_dcmi.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_dcmi); __HAL_LINKDMA(hdcmi, DMA_Handle, hdma_dcmi); hdcmi.Instance DCMI; hdcmi.Init.SynchroMode DCMI_SYNC_HARDWARE; hdcmi.Init.PCKPolarity DCMI_PCKPOLARITY_RISING; hdcmi.Init.VSPolarity DCMI_VSPOLARITY_LOW; hdcmi.Init.HSPolarity DCMI_HSPOLARITY_HIGH; hdcmi.Init.ExtendedDataMode DCMI_EXTEND_DATA_8B; hdcmi.Init.CaptureRate DCMI_CAPTURE_RATE_ALL_FRAME; hdcmi.Init.HSyncPulse 0; HAL_DCMI_Init(hdcmi); }注意几个细节DCMI 的 DMA 通道必须用 DMA2这是硬件层面的固定映射。PCKPolarity 设置为上升沿采样还是下降沿采样和 OV7670 的 PCLK 特性有关默认配置不对会出现图像左移或花屏。VS 和 HS 极性也要匹配 OV7670 的输出时序极性反了会导致行帧错乱。3.3 数据校验先确认图像正常再谈算法二值化算法写得再好前提是原始图像采集是对的。我第一次调的时候直接在二值化上花了半天结果发现原始图像本来就是花的白折腾。建议按这个顺序验证读 OV7670 的 PID 寄存器0x0A、0x0B确认 SCCB 通信正常能读到 0x76 和 0x73。采集一帧 RGB565 数据用串口把前几行像素的 RGB 值发到电脑用 Python 或画图工具还原成图像看是否正常。确认画面不花屏、不偏色、没有条纹后再开始做算法。串口发图速度慢但只发一帧调试足够。我用的是 1MB 波特率QVGA 一帧 RGB565 大概 150KB 数据几秒钟能传完。4. 黑白二值化算法实现从 RGB 到黑白像素4.1 RGB565 转灰度让彩色信息“降维”OV7670 输出的 RGB565 是 16 位像素格式高 5 位是 R中间 6 位是 G低 5 位是 B。RGB 三个通道的颜色信息要压缩成一个灰度值标准公式是Y 0.299 * R 0.587 * G 0.114 * B这个公式模拟了人眼对红绿蓝的敏感度差异绿色贡献最大蓝色最小。但对 STM32 来说浮点运算太奢侈系数也麻烦实际工程上用整数移位近似// RGB565 转灰度输入 rgb565输出 0-255 灰度值 uint8_t RGB565_To_Gray(uint16_t rgb565) { uint8_t r (rgb565 11) 0x1F; uint8_t g (rgb565 5) 0x3F; uint8_t b rgb565 0x1F; // 将 5 位、6 位通道扩展到 8 位 r (r 3) | (r 2); g (g 2) | (g 4); b (b 3) | (b 2); // 0.299R 0.587G 0.114B 的整数近似 return (r * 77 g * 150 b * 29) 8; }实测下来这个近似公式误差很小肉眼基本分辨不出和浮点计算的区别。如果把 OV7670 直接配置成 YUV422 输出Y 分量本身就是灰度值那就连这个转换都省了处理速度还能再快一截。4.2 阈值分割最简单也最考验经验的步骤灰度图转二值图的核心操作就一句话像素值大于阈值记为白0xFF小于等于阈值记为黑0x00。void Threshold_Binary(uint16_t *src, uint8_t *dst, uint32_t pixel_num, uint8_t threshold) { for (uint32_t i 0; i pixel_num; i) { uint8_t gray RGB565_To_Gray(src[i]); dst[i] (gray threshold) ? 0xFF : 0x00; } }这段代码看着简单真正的难点在阈值怎么选。固定的 threshold128 在很多场景下是失效的光线强的时候整体灰度偏高目标变白光线弱的时候整体灰度偏低目标变黑。我用固定阈值在室内灯光下效果还行一旦换到窗边阳光直射二值化结果立刻崩掉。解决办法有两个方向简单粗暴把手动阈值参数通过串口实时调节调试时用键盘发字符改变阈值找到当前环境的最优值。这个方案代码改动最小适合固定光照的测试环境。科学方案用 OTSU 大津法自动计算阈值原理是遍历所有可能的灰度级找到让前景和背景类间方差最大的那个值。这样环境光变化时阈值能自动跟着变鲁棒性好很多。4.3 OTSU 大津法在 MCU 上跑自适应阈值OTSU 的核心思想我记得很清楚把图像的灰度直方图看成两类像素的混合目标是找一个阈值 t让两类的类间方差最大。数学表达就是类间方差 前景比例 * 背景比例 * (前景均值 - 背景均值)^2在 MCU 上实现有两个前置条件一是需要统计灰度直方图256 个 uint32_t 的数组占 1KB 内存这个必须在采集图像时同步做不然再遍历一遍像素会浪费 8ms二是因为直方图统计只涉及加减乘除和数组操作计算耗时很短QVGA 分辨率下实测 2-3ms完全可以接受。这是我移植到大津法在 STM32 上的实现注释比较全uint8_t OTSU_GetThreshold(uint32_t *histogram, uint32_t total_pixel) { uint32_t sum_all 0; float sum_fore 0, sum_back 0; float w_fore 0, w_back 0; float max_variance 0; uint8_t best_threshold 0; // 计算总灰度值 for (uint32_t i 0; i 256; i) sum_all i * histogram[i]; uint32_t pixel_fore 0, pixel_back total_pixel; for (uint16_t t 0; t 256; t) { pixel_fore histogram[t]; if (pixel_fore 0) continue; pixel_back total_pixel - pixel_fore; if (pixel_back 0) break; w_fore (float)pixel_fore / total_pixel; w_back (float)pixel_back / total_pixel; sum_fore t * histogram[t]; sum_back sum_all - sum_fore; float mean_fore sum_fore / pixel_fore; float mean_back sum_back / pixel_back; float variance w_fore * w_back * (mean_fore - mean_back) * (mean_fore - mean_back); if (variance max_variance) { max_variance variance; best_threshold t; } } return best_threshold; }注意 OTSU 算法假设图像灰度分布是双峰的目标和背景各占一个峰。如果场景里有大量复杂纹理双峰假设不成立OTSU 也会失效。这时候需要靠图像滤波或者 ROI 区域裁剪来辅助不是万能的。关于滤波二值化之前如果图像噪声大可以先做一个 3x3 中值滤波在 MCU 上实现排序取中位数即可。但我在 QVGA 上测过纯 C 的 3x3 中值滤波一帧需要 15ms 左右代价偏大。用在二值化后的图像上做形态学开闭运算性价比更高。5. 调试、显示与性能调优的实践经验5.1 无屏幕调试法串口回传图像到 PC很多初学者卡在“看不到图像”这一步要么手头没 TFT 屏幕要么屏幕驱动还没调好。其实有串口就够了。我调试时用了最简单可靠的方案把二值化结果按字节打包通过 UART 发给上位机上位机用 Python 的 matplotlib 直接显示灰度图。STM32 端核心逻辑// QVGA 分辨率每 8 个像素打包成 1 个字节 uint8_t packed_buffer[320 * 240 / 8]; uint32_t byte_index 0, bit_index 0; for (uint32_t i 0; i 320 * 240; i) { if (binary_buffer[i] 0xFF) packed_buffer[byte_index] | (0x80 bit_index); bit_index; if (bit_index 8) { bit_index 0; byte_index; } } // 通过串口发送一个文件头 packed_buffer // 上位机解析后即可重建二值图像把 8 个像素打包成 1 个 bit 后一帧 QVGA 二值图只有 9600 字节串口发送速度很快1 秒能传好几帧。上位机端用 Python 脚本配合 OpenCV 或 matplotlib 解析即可。5.2 显示在 TFT 屏幕上的注意点如果手上有 TFT 屏幕比如 ST7735、ILI9341可以直接把二值图显示出来。但要注意屏幕的像素格式很多屏幕支持 RGB565也有只支持 RGB888 的映射关系不同会显示成颜色错乱。我用的方案是将二值图像素扩展回 RGB565黑就是 0x0000白就是 0xFFFF这样不用改屏幕初始化参数void Binary_To_RGB565(uint8_t *binary, uint16_t *rgb565_out, uint32_t pixel_num) { for (uint32_t i 0; i pixel_num; i) rgb565_out[i] (binary[i] 0x80) ? 0xFFFF : 0x0000; }这里有个性能陷阱如果一帧一帧地往屏幕刷会占用大量时间因为 SPI 屏幕的刷新率有限。我实测 ILI9341 在 SPI 40MHz 下刷一帧 320x240 需要约 50ms也就是说屏幕只能跑到 20fps 左右。所以如果要追求流畅度直接基于显存做局部刷新只更新二值变化的区域。5.3 帧率与 CPU 占用性能调优三板斧我实测的完整处理链路耗时大概是这样的处理环节耗时QVGA 320x240DCMIDMA 采集帧中断触发约 12ms帧率制约项RGB565 转灰度3.5ms固定阈值二值化0.8msOTSU 计算2.5ms二值图像素打包1.2ms如果要压榨性能三板斧用双缓冲 DMA 已经做到了采集和处理流水线重叠帧率取决于慢的一侧而非常的总和。RGB565 转灰度时不要用浮点用移位算法前面已给出直接提速度。如果只关心二值图可以让阈值比较直接在 DMA 中断里对 raw 数据操作不额外存灰度图省一次内存拷贝。这些优化做完主频 168MHz 的 F407 跑 QVGA 二值化可以做到 20fps 以上用于循迹小车或者简单条形码检测足够了。6. 常见问题与排查技巧实录6.1 摄像头数据读不到或 ID 读取失败很多人卡在第一步SCCB 读 PID 读不到。排查思路按优先级来先确认 SIOC 和 SIOD 有没有接反。SCCB 的 SID 地址是 0x42写地址/ 0x43读地址跟普通 I2C 有区别读的时候最后一位要设 1。确认模块供电。有些模块需要两路电源3.3V 是 I/O 供电2.8V 是模拟供电只供 3.3V 会出现传感器不工作的情况。确认 PWDN 引脚拉低、RESET 拉高这些引脚悬空默认有时会进掉电模式。我调试时遇到过一次很诡异的情况读 PID 时有时成功有时失败最后发现是因为 SIOD 引脚没有接上拉电阻导致信号边沿质量太差。SCCB 时钟频率降到 100kHz 以下后稳定了。6.2 图像花屏、条纹偏色或者整体偏移现象原因解决画面整体偏移右侧有黑带PCLK 采样极性不对把 PCKPolarity 改为下降沿采样图像上下颠倒或左右镜像传感器输出方向问题配置 MVFP 寄存器(0x1E)横向彩色条纹行同步信号极性不对检查 HSPolarity 配置图像大面积绿色/紫色DCMI 数据位接错检查 D0-D7 是否对应 PC6-PC11、PE0、PE1图像闪烁跳动VSYNC 信号抖动或帧率不稳定检查 XCLK 频率加滤波电容最坑的一类问题是图像大部分正常但顶部或底部有几行花线。这个通常和 HREF 信号在帧边界的行为有关我加了一个简单的处理在VSYNC中断后丢弃前 10 行的数据问题就解决了。6.3 编译下载报错no STM32 target found热词里出现“error: no stm32 target found!”也是我在实际开发中经常遇到的通常在 Keil 点击下载或 ST-Link Utility 连接时出现。这个错误的根源基本是 ST-Link 和目标板之间的 SWD 连接问题和摄像头实验本身没有直接关系但调试时组合出现会让人一头雾水。排查顺序检查 ST-Link 和开发板的 SWDIO、SWCLK、GND 三根线是否连接VCC 是否需要接取决于 ST-Link 版本。检查开发板供电是否正常。如果目标板是独立供电确认共地。检查 Keil 的 Debug 设置里选择了正确的下载器型号。如果之前烧录过禁用 SWD 引脚的代码MCU 会把 SWD 引脚释放成普通 GPIO这时必须按住复位键再点下载或者用串口 ISP 擦除 Flash。我有一次就是因为用 GPIO 模拟时序时不小心把 PA13/PA14 给重映射了导致 SWD 口废掉卡了快一个小时才反应过来。6.4 阈值选择困难症如果固定阈值效果不稳定优先切换到 OTSU 大津法代码我已经给了。但注意 OTSU 的前提是直方图双峰如果实测二值化结果很差可以先在 PC 上把直方图打出来看看是不是双峰。如果不是双峰常见原因有两个整体亮度分布太均匀目标灰度值和背景灰度值接近这时候光换算法没用得改善照明或者调整摄像头曝光寄存器AECH、COM8 等拉大目标和背景的对比度。图像里有大面积阴影或不均匀光照直接全局阈值不行需要用局部自适应阈值比如 Sauvola 算法。但局部阈值在 MCU 上计算量大实际项目里的做法是选择 ROI 区域做局部 OTSU或者干脆用硬件补光消除阴影。最后再分享一个我在实际调试中的心得。二值化的代码本身半天就能写完真正耗时间的是把图像调干净的过程。我建议你在这个实验上多花点时间观察不同光照下的二值化结果手动调几次阈值再用 OTSU 对比一次你就能直观感受到灰度分布对阈值选取的影响。这个实验做完之后后面接车牌识别、条形码检测都是水到渠成的事。如果你是初学者强烈建议先跑通数据传输链路再碰算法千万别一上来就憋大招。本文还有配套的精品资源点击获取