简介:基于STM32F407与OV7670的颜色识别与目标跟踪系统完整工程,适合嵌入式学习者、电子竞赛团队及机器人爱好者,帮助掌握图像处理、云台控制与无线交互的完整流程。系统融合二自由度舵机云台PD算法、HSL颜色空间转换、阈值判定以及蓝牙通信安卓APP交互,覆盖从硬件选型、算法设计到人机界面的设计闭环。资源包共164个文件,约2.07MB,以C/H源码为主,包含摄像头驱动、颜色识别、舵机控制、蓝牙通信等核心代码;另有XML工程配置、JAVA安卓端代码、Gradle构建脚本及PNG原理图,可按模块对照学习。已有183人学习下载。工程内含完整可编译的嵌入式项目与配置,便于复现颜色追踪流程,理解PD参数整定与HSL阈值选取方法,可作为毕业设计、电子设计竞赛或智能车开发的参考模板。 前段时间我把一套“STM32F407 + OV7670摄像头 + 二自由度云台”的颜色识别与目标跟踪系统从头到尾跑通了,从摄像头出图、HSL颜色判断、云台跟随到蓝牙上报安卓APP,整条链路都调稳定了。最初我以为最难的是把摄像头调出图像,真正动手之后才发现,颜色阈值在不同光照下的稳定性、云台跟随时那套控制参数,才是真正让人掉头发的地方。这篇文章把这套系统的选型思路、核心原理、关键代码和踩坑过程完整写出来,适合正在做课程设计、准备电子设计竞赛,或者想第一次把“图像采集 + 算法 + 运动控制 + 无线交互”整条链路跑通的同学参考,也适合想从纯单片机开发往嵌入式视觉方向过渡的工程师。
1. 硬件选型逻辑:F407、OV7670与云台结构怎么搭
1.1 为什么是STM32F407而不是F103或ESP32
选型这件事,很多初学者容易拍脑袋。我在这套项目里选定STM32F407,不是因为它便宜,而是因为它的算力、外设和调试成本刚好卡在这类项目的甜点上。
STM32F407主频168MHz,带单精度FPU。做颜色识别时,如果每个像素都做浮点HSL转换,没有FPU的F103会慢到让人怀疑人生,而F407的FPU虽然不能直接让像素处理飞起来,但至少能在代码不做大规模整数化改造的情况下跑得动。除了算力,更关键的是外设:F407自带DCMI数字摄像头接口,可以接OV7670这类并行摄像头,配合DMA直接搬运数据,CPU只在关键帧中断里介入;同时它还有多个高级定时器,可以输出舵机用的PWM,多个UART也方便挂蓝牙、调试串口、显示模块,一颗芯片就把“采集、处理、控制、通信”全包了。
对比一下:F103算力弱、没有DCMI,用IO口模拟抓取像素基本只能跑低分辨率;ESP32虽然有摄像头接口,但如果你已经熟悉STM32的生态,又要输出多路PWM控制舵机,调试起来其实没有F407顺手。所以结论很直接:这类“图像处理+运动控制”项目,F407是当前综合成本最低的起步平台。
1.2 OV7670的数据通路:DCMI直连和FIFO缓存怎么选
OV7670是一颗很经典的CMOS图像传感器,支持SCCB接口配置寄存器,输出格式可以设成RGB565或YUV422,分辨率常用QVG A(320x240)或VGA(640x480)。它最麻烦的地方是时序,VSYNC、HREF、PCLK、D0-D7这些信号都要正确接入主控。
在STM32F407上接OV7670,有两种主流方案。第一种是DCMI直连,把OV7670的8位数据线和同步信号直接接DCMI引脚,配置DCMI为RGB565模式,DMA把帧数据搬到内存,这种方案效率高,但要注意F407的SRAM有限,一帧QVGA RGB565约150KB,加上系统其他变量,整帧缓存很紧张,所以实际通常会开两路DMA缓冲,边采集边用半传输中断处理上半帧数据。第二种是加FIFO芯片(典型是AL422B,256KB的异步FIFO),OV7670先把一帧图像写进FIFO,STM32再用普通IO或FSMC把数据读回来,这种方案对时序要求低很多,调起来更容易,代价是多一颗芯片和走线。
如果只做单色目标的颜色识别,我建议优先选DCMI直连方案,虽然配置麻烦,但帧率和稳定性都比FIFO好。如果你只是想快速把Demo跑通,FIFO方案能减少很多调试时间,但要提醒一句:AL422B在QVGA RGB565下可以缓存一帧,如果调高分辨率或帧率,FIFO很快就会撑爆。
1.3 舵机云台与供电设计
云台结构上,两个自由度分别由一个9g舵机控制,常见型号是SG90或MG90S,PWM频率50Hz,脉宽0.5ms到2.5ms对应0度到180度。水平舵机控制左右转动(Pan),垂直舵机控制上下俯仰(Tilt),安装时尽量让两个舵机的转动轴垂直,这样X方向偏差和Y方向偏差的调节就是解耦的。
电机选型上,SG90扭矩小、响应快,适合轻载;如果摄像头模组加支架比较重,用MG90S金属齿轮版本会更稳,但金属齿轮舵机的死区通常比塑料齿轮大一点,后面PD控制死区参数也要相应调大。
这里有一个很容易踩的坑:舵机启动瞬间电流能到几百毫安到1A级别,如果把舵机电源和STM32主控接到同一个LDO上,舵机一转,主控电压就掉到3V以下,直接复位。我实际把5V电源单独给舵机,主控用另外的3.3V LDO供电,两个电源的地线单点相连,系统才稳定下来。供电设计看起来不起眼,但排查复位问题的时候,好多次都是它背锅。
2. HSL颜色识别:从RGB565到阈值判定的完整实现
2.1 为什么RGB直接判阈值扛不住光照变化
做颜色识别,第一反应就是直接对RGB分量做范围判断,比如红色就判断R>100且G<80且B<80。这个方法在固定光源下能用,但稍微把物体挪到窗前,或者灯光颜色偏暖偏冷,同样的红色物体,RGB三个分量的值就会剧烈变化,阈值马上失效。
原因很简单:RGB三个分量把“亮度”和“颜色”混在一起,光照变强时R、G、B同时变大,光照变弱时又同时变小,单纯看某个通道的绝对范围,很容易把阴影里的目标漏掉,或者把高光白墙误判成目标。HSL空间把颜色的本质拆开了——H(色相)描述“这是什么颜色”,S(饱和度)描述“颜色有多浓”,L(亮度)描述“有多亮”。对同一类颜色,光照变化主要影响L和S,而H相对稳定,所以在HSL空间做阈值判定,鲁棒性比RGB高一个台阶。
这也是为什么标题里强调“HSL颜色空间转换”而不是直接RGB——实际项目里,人眼觉得“这是一个红色球”的时候,它的H值确实落在红色区间,而RGB三个分量可能完全不在同一量级。用阈值判定目标身份时,HSL让“语义颜色”成为可能。
2.2 嵌入式HSL转换的整数实现
OV7670在RGB565模式下,每个像素用16位表示,高5位是R,中间6位是G,低5位是B。先要拆出RGB分量并扩展到8位,再转HSL。转换公式本身不复杂,但如果在STM32上对每个像素做浮点运算,速度会非常难看。实际工程里我用整数运算和查表近似。
// RGB565转8位RGB分量 uint8_t r = (rgb565 >> 11) & 0x1F; uint8_t g = (rgb565 >> 5) & 0x3F; uint8_t b = rgb565 & 0x1F; r = (r << 3) | (r >> 2); g = (g << 2) | (g >> 4); b = (b << 3) | (b >> 2);拿到0-255范围的RGB后,做HSL转换。标准的HSL公式里,H是0-360度的浮点数,但为了在嵌入式里快速比较和保存,我会把H压缩到0-255的uint8,阈值上下限用uint8来判断。
void rgb888_to_hsl(uint8_t r, uint8_t g, uint8_t b, uint8_t *h, uint8_t *s, uint8_t *l) { uint8_t max = MAX(r, MAX(g, b)); uint8_t min = MIN(r, MIN(g, b)); uint8_t delta = max - min; *l = (max + min) >> 1; if (delta == 0) { *h = 0; *s = 0; return; } if (*l < 128) { *s = 255 * delta / (max + min); } else { *s = 255 * delta / (511 - max - min); } uint16_t hue; if (max == r) { hue = 60 * (g - b) / delta; if (g < b) hue += 360; } else if (max == g) { hue = 60 * (b - r) / delta + 120; } else { hue = 60 * (r - g) / delta + 240; } *h = (uint8_t)(hue * 255 / 360); }这段代码里,S和H的计算都用了一次除法、一次乘法。对单张QVGA图来说,7万多像素全过一遍,裸算会占不少CPU时间。实际项目里我加了一道预处理:先用RGB粗筛把明显不可能是目标颜色的像素直接跳过,只有通过粗筛的像素才做完整HSL转换。比如目标如果是红色,可以先快速判断R明显大于G且R明显大于B,这样大部分背景像素就被挡掉了,HSL转换的数量能降到原来的十分之一。
2.3 阈值标定:别手撸参数,用颜色学习模式
手动设阈值参数是最不靠谱的做法,因为不同环境下同一种颜色的H、S、L范围都不一样。我在系统里加了一个“颜色学习模式”:云台先对准目标物体,按下按键或通过蓝牙下发一条标定指令,MCU读取画面中心一块区域的像素,统计这块区域的H均值与标准差,然后自动生成阈值。
具体的判定逻辑并不复杂:
uint8_t target_h_low = h_mean - 15; uint8_t target_h_high = h_mean + 15; uint8_t target_s_min = s_mean - 30; uint8_t target_l_min = l_mean - 40; uint8_t target_l_max = l_mean + 40;色相用均值加减15左右,是因为同一物体上轻微色偏导致的H漂移通常不会超过这个范围;饱和度下限定在30以上,是为了排除灰白色区域——当S太低时,颜色接近灰色,H值没有意义。这个“颜色学习+自动阈值”的思路,比对着图片调参数高效得多,而且换一个目标物体时不用改代码,重新标定一次就行。
2.4 帧间目标质心计算
阈值判定得到的是一个个像素的“是/不是目标”二值结果,要把这些像素变成云台能用的坐标,需要做连通域分析或至少是质心计算。我的做法是扫描整帧,把所有判定为目标像素的坐标累加,最后除以目标像素总数,得到目标的质心坐标。
uint32_t sum_x = 0, sum_y = 0; uint16_t count = 0; for (uint16_t y = 0; y < IMG_HEIGHT; y++) { for (uint16_t x = 0; x < IMG_WIDTH; x++) { if (is_target_pixel(x, y)) { sum_x += x; sum_y += y; count++; } } } if (count > MIN_TARGET_PIXELS) { target_cx = sum_x / count; target_cy = sum_y / count; } else { target_lost = true; }这里有个细节:如果直接用整帧扫描,一些零散的噪声点会影响质心位置,我用了一个简单的去噪条件——目标像素总数必须大于一个最小值(比如画面面积的0.5%),否则认为目标丢失,这能有效滤掉传感器噪声和随机的环境干扰。
3. 二自由度云台PD控制:让摄像头稳稳咬住目标
3.1 图像偏差到舵机角度的映射
目标质心坐标拿到后,下一步是把坐标偏差转换成舵机角度增量。设图像中心为(cx_mid, cy_mid),目标质心为(target_cx, target_cy),水平偏差error_x = target_cx - cx_mid,垂直偏差error_y = target_cy - cy_mid。
这里有一个很多新手理解错的地方:偏差不等于舵机绝对角度,舵机要按偏差比例去“校正”当前角度。比如目标偏右了,云台就要往右转;偏差越大,转得越快,但不是一步转到最大角度,否则目标稍微移动一点,云台就会疯狂抽搐。这个“按偏差比例调整”的过程,就是PD控制的基础思想。
3.2 PD控制器的离散化实现与限幅、死区
PD控制里,P项(比例项)让舵机响应偏差,偏差大就转得多;D项(微分项)感知偏差的变化趋势,偏差快速变化时产生阻尼,抑制超调和振荡。之所以不用PID,是因为云台系统有舵机死区和机械摩擦,I项会让误差积分越积越大,导致系统周期性过冲,甚至进入极限环振荡。实际测试下来,PD已经足够稳住跟踪,加I反而画蛇添足。
离散化的PD实现非常直接,用当前误差和上一帧误差算出误差变化率:
float pd_control(float error, float kp, float kd, float dt) { static float prev_error = 0; float derivative = (error - prev_error) / dt; prev_error = error; float output = kp * error + kd * derivative; // 限幅:每帧最多调整3度,防止目标突变时猛打舵 if (output > MAX_DELTA_ANGLE) output = MAX_DELTA_ANGLE; if (output < -MAX_DELTA_ANGLE) output = -MAX_DELTA_ANGLE; // 死区:偏差小于5个像素时不动,避免舵机微抖 if (fabs(error) < 5) output = 0; return output; }限幅是必须的。目标从画面左侧跳到右侧时,误差可能瞬间达到160像素,如果不限幅,舵机会以最大速度猛甩过去,产生严重过冲和机械冲击。限幅之后,舵机每次最多转3度,目标就算跳变,云台也只是快速但不粗暴地追过去,机械结构寿命也更好。
3.3 参数整定的实际操作顺序
PD参数整定是我在这个项目里耗时最多的一步。我推荐一套很直观的流程:
第一步,只保留P项,Kd设为0。从很小的Kp开始(比如0.1),观察云台是否跟随目标。逐步增大Kp,直到云台出现明显的小幅度振荡,记录下这个临界Kp,然后把它降到临界值的一半左右。
第二步,固定Kp后,慢慢增加Kd。观察云台在目标突然移动时是否过冲,Kd越大阻尼越强,但太大也会让舵机动作变得迟滞、发肉。调到目标快速移动后云台能平滑停下,不来回摆动,Kd就差不多了。
在我这套320x240画面、跟踪帧率约20-30fps的系统里,Kp落在0.3-0.5附近,Kd落在0.05-0.15附近。这个值不是通用答案,不同舵机、不同帧率、不同图像尺寸下都会变,但整定顺序是通用的。
实际调试中还有一个容易被忽略的问题:目标丢失时的策略。我设定的逻辑是,连续几帧找不到目标,云台停止更新角度,保持原地等待;如果目标在画面边缘连续出现又消失,就往最后一次出现的偏差方向小幅搜索,但不对超时状态做持续猛转。没有这个逻辑,目标一丢,舵机会在最后误差方向反复横跳,很快烧舵机。
4. 蓝牙通信与安卓APP交互
4.1 串口协议设计
蓝牙模块选HC-05,它支持主从一体和串口透传,配置成从机模式后,数据从USART3直接透传到手机。串口波特率设成115200,这个速率下发送一帧几十字节的状态数据毫无压力。
通信协议设计上,我建议直接用“帧头 + 命令 + 长度 + 数据 + 校验”的标准结构,不要发裸数据。没有协议的裸串口流,事后解析就是灾难。我用的是这样一帧:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头1 | 1 | 0xAA |
| 帧头2 | 1 | 0x55 |
| 命令字 | 1 | 0x01 跟踪状态;0x02 手动控制等 |
| 数据长度 | 1 | 后面数据的字节数 |
| 数据字段 | N | 实际业务数据,小端序 |
| 校验和 | 1 | 前面所有字节累加和的低8位 |
发送跟踪状态时,数据字段包含目标质心X、目标质心Y、水平舵机角度、垂直舵机角度、目标是否锁定五个值。接收端先等0xAA 0x55再解析,避免串口断帧导致错位。
uint8_t tx_buf[12]; tx_buf[0] = 0xAA; tx_buf[1] = 0x55; tx_buf[2] = CMD_TRACK_STATUS; tx_buf[3] = 4; tx_buf[4] = target_cx >> 8; tx_buf[5] = target_cx & 0xFF; tx_buf[6] = target_cy >> 8; tx_buf[7] = target_cy & 0xFF; tx_buf[8] = checksum(tx_buf, 8); HAL_UART_Transmit(&huart3, tx_buf, 9, 10);上位机下发的指令也很简单,比如手动模式、标定当前颜色、启动/停止跟踪。命令集保持精简,两侧的解析代码都容易维护。
4.2 安卓端实现与权限避坑
安卓端有两种快速实现方式。如果你只是验证通信链路,用MIT App Inventor最省事,拖一个蓝牙客户端组件,选择HC-05配对,就能收发串口数据,适合快速原型。如果你要做得完整一些,用Android Studio原生BluetoothAdapter写SPP连接,关键代码核心是这几个调用:
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); BluetoothDevice device = adapter.getRemoteDevice("XX:XX:XX:XX:XX:XX"); BluetoothSocket socket = device.createRfcommSocketToServiceRecord( UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")); socket.connect();这个UUID是SPP服务的标准UUID,不要随便改。连接成功后,从socket.getInputStream()读取数据,并把读取放到子线程或协程里,Android不允许在主线程做阻塞IO,否则界面会ANR。
权限是安卓端最容易被忽视的大坑。Android 6.0以上扫描蓝牙需要动态申请定位权限,Android 12以上又引入了BLUETOOTH_SCAN和BLUETOOTH_CONNECT运行时权限,只写Manifest不动态申请,程序会直接崩。我在项目里把权限申请流程放在进入主界面之前,统一处理,避免后面排查半天。
APP显示内容不用做得太花哨,我设置了一个简易仪表盘:显示目标X、目标Y、云台水平角和俯仰角、目标是否锁定,再加上两个按钮控制颜色标定模式和手动云台模式,足够完成联调。数据解析时用状态机逐字节处理,每次读到0xAA 0x55就认为进入新帧,再按长度读取后续字节,这样就算蓝牙偶尔丢一个字节,也不会让整个数据流错乱。
5. 真实环境中的三个大坑与对应解法
5.1 光照突变导致丢目标
第一个大坑是光照突变。把系统从室内搬到窗边,目标瞬间丢失,这是我在调试初期遇到最频繁的情况。HSL虽然比RGB鲁棒,但光照突变时,S值和L值变化剧烈,如果S降到阈值下限以下,或者L超出范围,目标照样丢失;极端过曝时,红色物体的H值也会因为RGB分量饱和而发生几十度的漂移。
对策有两层。第一层,在OV7670配置中开启自动曝光和自动白平衡,让传感器尽量把亮度拉回中间范围,这能显著减少L值的大幅漂移。第二层,系统里加一个“阈值自适应”逻辑——当目标连续丢失超过一定时间,重新用当前环境下的亮度平均值微调L阈值范围。有次我用手机屏幕光照射目标,因为屏幕色温偏蓝,本来红色的物体拍出来H值直接漂了将近40度,固定阈值完全没法用,后来加了标定按钮,每次换环境重新标定一次,问题才根治。
5.2 帧率上不去:ROI局部扫描
第二个坑是帧率。全帧扫描320x240,每个像素先粗筛再HSL精判,加上计算质心,一帧下来CPU耗时相当可观,系统实时性明显下降。优化手段是ROI局部扫描:目标锁定后,不再扫描整帧,而是以目标当前位置为中心,只扫描一块约120x90的区域。这样既保证目标在移动时大概率还在搜索窗内,又让单帧处理像素数降到原来的七分之一。
ROI的边界处理要小心:当目标靠近画面边缘时,搜索窗会被切掉一部分,这时我做了自动扩展——如果搜索窗接触画面边界,就把搜索窗稍微向另一侧扩展,避免目标在边缘时反而搜不到。加上ROI优化后,跟踪帧率稳定到了30fps左右,云台的跟随平滑度明显提升。
5.3 舵机抖动、不回中和供电地线问题
第三个坑是舵机抖动和不回中。最典型的表现是:目标静止不动,云台却在目标附近小幅来回摆动。一开始我以为是PD参数没调好,反复调Kd都没有本质改善,最后用万用表一测,舵机供电在动作瞬间跌到4.2V,是电源带不动负载。
解决办法是给舵机一个独立的5V2A电源,主控供电和舵机供电的地线单点连接,模拟地和功率地彻底分开走线。电源问题解决后,舵机瞬间大电流不再拖垮主控,角度也稳定了。还有一档抖动是PWM占空比精度问题,TIM配置不当导致舵机角度在小数级别来回跳动,这个可以通过降低PWM分辨率的冗余——调整定时器预分频和自动重载值,让1度对应足够大的计数值,来避免最低有效位抖动脉宽。
关于不回中,还有一个经验:每次上电后,我把两个舵机先驱动到同一个已知角度(比如90度),做一次“归零校准”,再进入跟踪模式。这样机械装配误差和舵机初始位置差异就不会影响后续PD控制。
最后说点个人体会。这类项目的复杂度不在某个单点,而在把图像采集、颜色识别、运动控制、无线交互串成一条稳定链路的全过程。颜色阈值标定、PD参数整定、供电设计,每一环都会消耗大量调试时间,我踩过的这些坑对应的基本都不是复杂算法,而是工程细节。建议读者做的时候,先把工作环境固定下来——固定光源、固定背景、固定目标颜色,把链路跑通,再逐步放开环境变量。这套系统的扩展空间也很大,比如把颜色学习模式升级成“多颜色目标切换”,或者把HSL阈值判定换成更轻量的色彩直方图反向投影,都能在现有硬件上获得更好效果。希望大家照着这份笔记搭起来时,少走几趟我走过的弯路。
本文还有配套的精品资源,点击获取