简介这份资源面向嵌入式开发初学者与图像处理入门者围绕STM32微控制器驱动OV7670 CMOS摄像头实现黑白二值化展开帮助读者理解从图像采集到阈值分割的完整链路。压缩包共97个文件约386KB以44个h头文件与43个c源文件为主体另含uvprojx、uvoptx工程文件、hex烧录文件、s启动文件及若干txt说明覆盖STM32F10x固件库、SYSTEM、HARDWARE、USER等目录涉及USMART、LCD、TIMER、EXTI、KEY等模块便于直接编译与二次开发。资源重点呈现灰度化、全局与自适应阈值选取、二值化处理及LCD显示存储等关键环节并附调试与优化思路如SPI接口排查、中断服务程序提速与实时性权衡。目前已有899人学习下载适合希望掌握嵌入式图像二值化基础、快速搭建实验环境的开发者参考。1. 从「能出图」到「能出黑白轮廓」STM32 驱动 OV7670 做二值化的真实门槛很多人第一次把 OV7670 接到 STM32 上看到 LCD 里出现模糊的彩色画面就以为大功告成结果一上二值化画面要么全黑、要么全白、要么像雪花一样跳。问题不在摄像头本身而在于 OV7670 输出的是 YUV/YCbCr 或 RGB565 原始数据二值化需要的是稳定的灰度值和一个可调的阈值而这两样东西在 STM32 的有限 RAM 和没有硬件 JPEG 的 F4/F1 上都得自己算。这个实验真正要解决的是用最少的 CPU 开销把 OV7670 的 8 位数据流实时转成黑白二值图并让阈值能随光照变化调整。适合已经能驱动 OV7670 出图、但卡在图像处理第一步的嵌入式开发者也适合想用低成本方案做循迹、形状识别、简单视觉判断的人。2. 先搞清 OV7670 到底吐出了什么数据格式与二值化的接口关系2.1 为什么二值化前必须先确定输出格式OV7670 支持多种输出格式常见的有 YUV422、RGB565、RAW Bayer。二值化只需要一个亮度值所以最省事的是直接拿 Y 分量。YUV422 输出时每个像素两个字节第一个字节就是 Y第二个字节是 U 或 V 交替。如果你用 RGB565那就得先算灰度公式是 Gray (R×77 G×150 B×29) 8多一次乘加对 STM32F103 来说每帧多出几毫秒开销。我一般会优先把 OV7670 配成 YUV422然后只取 Y 字节这样二值化就是一次比较速度最快。配置寄存器时关键在 COM7 和 COM15。COM7 的 bit2 决定输出格式置 0 是 YUV置 1 是 RGB。COM15 控制 RGB565 的位宽用 YUV 时不用管。很多人抄的初始化代码里 COM7 写的是 0x04那是 RGB 模式结果拿到的数据当 Y 用二值化自然乱套。改回 0x00 或 0x02 才能拿到 YUV。2.2 用 SCCB 写寄存器的代码模板与参数说明下面这段是写 OV7670 寄存器的核心函数基于软件 I2C 模拟 SCCB。注意 SCCB 的时序和标准 I2C 几乎一样但不需要 ACK 检测写完后直接发停止位。// 向 OV7670 指定寄存器写入一个字节 // reg: 寄存器地址, val: 写入值 // 返回 0 表示成功, 1 表示失败 uint8_t OV7670_WriteReg(uint8_t reg, uint8_t val) { SCCB_Start(); if (SCCB_WriteByte(0x42) ! 0) { // 0x42 是 OV7670 写地址 SCCB_Stop(); return 1; } if (SCCB_WriteByte(reg) ! 0) { SCCB_Stop(); return 1; } if (SCCB_WriteByte(val) ! 0) { SCCB_Stop(); return 1; } SCCB_Stop(); return 0; }逻辑说明SCCB_WriteByte 内部按位发送每发一位拉高 SCL 再拉低发完 8 位后释放 SDA 读 ACK。OV7670 的写地址固定是 0x42读地址是 0x43。参数上reg 就是寄存器编号比如 COM7 是 0x12COM15 是 0x40。写完后最好延时 1ms 再操作下一个寄存器因为 OV7670 内部 PLL 需要稳定时间。2.3 把 YUV422 的 Y 分量取出来的两种方式方式一DMA 直接搬运到缓冲区然后只取偶数索引字节。方式二在 DCMI 中断里逐字节判断只存 Y。对于 STM32F4 带 DCMI 的型号我一般用 DMA 双缓冲一帧搬完再处理避免中断里做太多事。对于 F1 没有 DCMI 的只能用 GPIO 抓 PCLK 和 HSYNC这时候更要在中断里只存 Y否则 RAM 不够。// 假设 DMA 已经把一帧 YUV422 数据搬到 yuv_buf长度是 2*W*H // 提取 Y 分量到 gray_buf长度 W*H void ExtractY(uint8_t *yuv_buf, uint8_t *gray_buf, uint32_t pixel_cnt) { for (uint32_t i 0; i pixel_cnt; i) { gray_buf[i] yuv_buf[i * 2]; // 每个像素两个字节第一个是 Y } }参数说明pixel_cnt 是一帧的像素总数比如 QVGA 是 320×24076800。yuv_buf 的大小必须是 2 倍 pixel_cnt。这个循环在 72MHz 的 F103 上大约耗时 3~5ms可以接受。如果嫌慢可以用 DMA 的 2 字节传输模式只搬第一个字节但 OV7670 的 FIFO 输出是连续的不好跳过所以还是软件提取更稳。3. 二值化算法在 STM32 上的落地阈值怎么定、怎么算得快3.1 固定阈值与自适应阈值的选型理由固定阈值最简单一个 if 比较就行但光照一变就废。自适应阈值常用大津法Otsu能根据当前帧的灰度直方图自动算阈值但计算量不小要遍历 256 个灰度级算类间方差。在 STM32F103 上跑一次 Otsu 大约 10~20ms如果帧率要求不高比如 5fps可以接受。如果要求 15fps 以上就得用简化版比如只统计 32 个灰度级或者用迭代法逼近。我一般会先跑固定阈值验证通路再换成 Otsu。固定阈值取 128 是默认值但实际要根据镜头和光照调。一个经验在室内白光下Y 分量集中在 80~160 之间阈值取 110~130 比较合适。室外阳光下Y 会整体偏高阈值要提到 150 以上。3.2 固定阈值二值化的最小实现与内存布局// 将灰度图二值化结果存到 bin_buf每个字节存 8 个像素节省 RAM // gray_buf: 灰度图, W*H // bin_buf: 二值图, W*H/8 // threshold: 阈值 0~255 void BinarizeFixed(uint8_t *gray_buf, uint8_t *bin_buf, uint32_t pixel_cnt, uint8_t threshold) { uint32_t byte_idx 0; uint8_t bit_idx 0; uint8_t tmp 0; for (uint32_t i 0; i pixel_cnt; i) { if (gray_buf[i] threshold) { tmp | (1 (7 - bit_idx)); // 白点置 1 } bit_idx; if (bit_idx 8) { bin_buf[byte_idx] tmp; tmp 0; bit_idx 0; } } if (bit_idx ! 0) { bin_buf[byte_idx] tmp; // 处理剩余不足 8 位的像素 } }逻辑说明每个字节存 8 个像素从高位到低位依次对应从左到右的像素。这样 QVGA 的二值图只占 9600 字节F103 的 20KB RAM 也能放下。参数上threshold 就是比较门限gray_buf[i] threshold 判为白否则为黑。注意 bit_idx 从 0 到 7写位时用 7-bit_idx 是为了让第一个像素在字节的最高位方便后续按字节寻址显示。3.3 Otsu 自适应阈值的简化实现与耗时评估完整 Otsu 需要先算直方图再遍历 256 个阈值算方差。下面是一个简化版只统计 32 个灰度级把 0~255 映射到 0~31计算量降到 1/8。// 简化 Otsu统计 32 级直方图返回最佳阈值0~255 uint8_t OtsuThreshold(uint8_t *gray_buf, uint32_t pixel_cnt) { uint32_t hist[32] {0}; for (uint32_t i 0; i pixel_cnt; i) { hist[gray_buf[i] 3]; // 每 8 个灰度级合并 } uint32_t total pixel_cnt; uint32_t sum 0; for (int i 0; i 32; i) { sum i * hist[i]; } uint32_t sumB 0, wB 0; uint32_t maxVar 0; uint8_t bestTh 0; for (int t 0; t 32; t) { wB hist[t]; if (wB 0) continue; uint32_t wF total - wB; if (wF 0) break; sumB t * hist[t]; uint32_t mB sumB / wB; uint32_t mF (sum - sumB) / wF; uint32_t varBetween wB * wF * (mB - mF) * (mB - mF); if (varBetween maxVar) { maxVar varBetween; bestTh t; } } return bestTh 3; // 还原到 0~255 范围 }逻辑说明先统计 32 级直方图然后遍历每个阈值 t计算前景和背景的类间方差取最大时的 t。最后把 t 乘以 8 还原成灰度阈值。参数上pixel_cnt 是像素总数hist 数组大小 32。这个函数在 F103 上跑一次大约 2~3ms比完整 Otsu 快很多。注意 sumB 和 wB 是累加的不要每次重新算。3.4 二值化结果怎么显示LCD 打点与串口输出如果 LCD 是 ILI9341 这类可以直接把二值图的每个位展开成 16 位色打点。但逐点打太慢我一般会先把二值图转成灰度图0 或 255再用 DMA 刷屏。或者更省事只把二值图通过串口发到上位机用 Python 显示。串口波特率至少 460800QVGA 二值图 9600 字节一帧不到 0.3 秒。// 把二值图通过串口发送每字节代表 8 个像素 void SendBinToUart(uint8_t *bin_buf, uint32_t byte_cnt) { for (uint32_t i 0; i byte_cnt; i) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); USART_SendData(USART1, bin_buf[i]); } }参数说明byte_cnt 是二值图的字节数QVGA 是 9600。发送前最好加一个帧头比如 0xAA 0x55方便上位机同步。这个函数是阻塞发送如果不想阻塞可以用 DMA 串口。4. 避坑与排查OV7670 二值化最常见的 5 个翻车现场4.1 画面全黑或全白阈值怎么调都没用现象LCD 上要么全黑要么全白改阈值只改变全黑全白的比例但看不到任何轮廓。原因OV7670 的输出格式没配对比如 COM7 设成了 RGB565但代码按 YUV 取 Y拿到的其实是 R 或 G 分量灰度分布完全不对。解决用 OV7670_WriteReg(0x12, 0x00) 确保 COM7 的 bit2 为 0输出 YUV。然后读一个像素的 Y 值在串口打印出来正常应该在 0~255 之间变化如果一直是 0 或 255检查时钟极性。4.2 二值图有大量噪点像雪花现象黑白图上有随机白点或黑点轮廓边缘毛刺严重。原因PCLK 太快STM32 抓取时丢字节或者电源纹波大导致 Y 值抖动。解决降低 OV7670 的 PCLK通过 CLKRC 寄存器分频比如写 0x03 把 PCLK 降到 6MHz 左右。同时在 Y 值上做简单滤波比如连续取 3 个像素取中间值或者对二值图做 3×3 中值滤波。电源上OV7670 的 VDD 和 VDDIO 都要加 100nF 和 10uF 电容。4.3 帧率突然掉到 1fps 以下现象原本能跑 10fps加了二值化后变成 1fps。原因Otsu 计算太慢或者二值化循环里用了浮点。解决把 Otsu 换成 32 级简化版或者直接用固定阈值。二值化循环里不要用除法比较用 而不是 能省一点。另外如果用了 LCD 逐点打点改成 DMA 刷屏或者只刷二值图的边界区域。4.4 串口发送二值图时丢数据现象上位机收到的图缺行或错位。原因串口发送没有流控STM32 发得太快上位机处理不过来。解决加帧头和帧尾比如 0xAA 0x55 开头0x55 0xAA 结尾上位机按帧解析。或者降低波特率到 115200但这样一帧要 0.8 秒。更好的办法是加一个简单的应答机制上位机收到一帧后回一个字节STM32 再发下一帧。4.5 换不同光照后二值化完全失效现象室内调好的阈值拿到窗边就全白或全黑。原因固定阈值无法适应光照变化。解决改用 Otsu 自适应阈值或者加一个自动增益控制根据当前帧的平均灰度调整阈值。一个简单做法算当前帧的平均 Y如果平均 Y 大于 180阈值取 160如果小于 80阈值取 60否则取 128。这个经验公式在多数场景下够用。5. 进阶技巧用二值图做简单形状判断与阈值自校准5.1 从二值图里数黑点白点判断有没有目标二值图最直接的用途就是统计。比如循迹小车只需要看中间几行的黑线位置。你可以把二值图按行分成左中右三块分别数白点或黑点的数量哪块最多就往哪边转。下面这个函数统计指定矩形区域内的白点数量。// 统计二值图中指定矩形区域的白点数量 // bin_buf: 二值图, W*H/8 // x, y, w, h: 矩形区域 // 返回白点数量 uint32_t CountWhite(uint8_t *bin_buf, uint16_t img_w, uint16_t x, uint16_t y, uint16_t w, uint16_t h) { uint32_t cnt 0; for (uint16_t row y; row y h; row) { for (uint16_t col x; col x w; col) { uint32_t idx row * img_w col; uint8_t byte bin_buf[idx 3]; if (byte (1 (7 - (idx 7)))) { cnt; } } } return cnt; }逻辑说明idx 是像素在二值图中的线性索引idx3 得到字节下标idx7 得到位下标。参数上img_w 是图像宽度x,y 是矩形左上角w,h 是宽高。这个函数在 QVGA 上统计 100×100 区域大约 0.5ms足够实时。5.2 用滑动窗口做阈值自校准固定阈值加一个滑动窗口平均能适应缓慢的光照变化。每帧算一次平均灰度然后和上一帧的平均值做加权平均再根据这个平均值调整阈值。下面是一个简单的自校准逻辑。// 根据当前帧平均灰度调整阈值alpha 是平滑系数 uint8_t AdaptiveThreshold(uint8_t *gray_buf, uint32_t pixel_cnt, uint8_t *last_avg, float alpha) { uint32_t sum 0; for (uint32_t i 0; i pixel_cnt; i 4) { // 每 4 个像素采样一次加快速度 sum gray_buf[i]; } uint8_t avg sum / (pixel_cnt / 4); *last_avg (uint8_t)(alpha * avg (1 - alpha) * (*last_avg)); // 根据平均灰度映射阈值平均越亮阈值越高 if (*last_avg 180) return 160; if (*last_avg 80) return 60; return 128; }参数说明alpha 取 0.1~0.3越小越平滑但响应越慢。pixel_cnt/4 是采样次数。这个函数每帧调用一次开销很小。注意 last_avg 要初始化为 128否则第一帧可能跳变。5.3 把二值图存到外部 SRAM 或 SD 卡做离线分析如果 STM32 的 RAM 不够或者想记录一段视频做分析可以把二值图通过 SPI 写到 SD 卡。每帧 9600 字节1 秒 10 帧就是 96KB一张 2GB 卡能存 5 个多小时。写卡时用 FatFS 的 f_write一次写一帧注意加帧头和时间戳。回放时用 Python 读二进制文件按帧解析就能看到二值化后的动态过程。这个技巧在调阈值时特别有用不用反复烧录直接看记录。我自己的习惯是每次改完阈值或算法先用串口发 10 帧到上位机用 Python 画出来看效果确认没问题再烧到板子上跑。这样能省很多来回插拔的时间。另外OV7670 的镜头焦距和光圈对二值化影响很大如果发现边缘总是糊先调镜头别急着改代码。希望帮到你。本文还有配套的精品资源点击获取