简介:这份面向2024电赛H题的巡线小车完整工程,以STM32为主控、OpenMV摄像头巡线,重点展示黑线与中心线偏差的闭环控制方案,适合备赛学生和嵌入式控制初学者对照学习。压缩包共1053个文件、约27.45MB,主体为570个C源文件、256个头文件及51个汇编文件,并含工程配置、编译中间件、库文件与OpenMV Python脚本,构成从底层驱动到控制逻辑的完整项目结构。已有4265人学习。工程围绕ABCD四点间的平滑衔接展开,给出统一速度下的稳定行驶思路,并提示可改用变速策略提升稳定性与缩短耗时;同时记录MPU6050零漂、初始摆放角度对转向参考的影响,以及四轮小车左右轮同步与参数微调的适配方法。读者可据此理解灰度传感器与摄像头巡线在偏差获取上的共性,复盘控制难点、排错方向与优化空间。
1. 2024电赛小车上STM32+OpenMV到底谁指挥谁:先定控制边界
2024电赛小车题里,STM32+OpenMV这套组合最常见的翻车点不是识别不到目标,而是图像坐标传到STM32后底盘追不上:弯道已经过了,轮子还在按上一帧的偏差打角。这个完整工程要解决的就是控制边界——OpenMV只做视野内的偏差提取,STM32负责串口协议、速度闭环和状态切换,两边用固定帧长和明确超时断开。适合正在搭竞赛小车、想把“能识别”推到“能稳定跑”的开发者,也适合第一次做双控制器联调的团队。下面按协议、识别、闭环、排查、状态机五块拆开,参数能给起始值的都给,坑也按现象写清。
2. STM32与OpenMV串口协议:帧头、长度、校验和重发窗口怎么定
联调第一道坎通常不是算法,而是串口。OpenMV端用print("x=%d,y=%d\n")发字符串,STM32端用sscanf解析,实验室里能跑,一到车上丢一个字节就错位,小车会突然满舵。血泪经验是:图像偏差这类周期性数据,用定长二进制帧,帧头、长度、序号、CRC8一个都别省;参数配置这类低频数据,再叠一层ACK和重发。先把协议定死,后面PID和阈值才有意义。
2.1 为什么控制帧不能直接发浮点:帧头、长度和CRC8的取舍
OpenMV和STM32的端序、对齐方式、浮点格式都可能出问题,直接struct.pack('<f', x)发4字节浮点,在STM32端用指针强转,代码看着短,移植一次就翻车。小车控制需要的精度是像素级偏差,QVGA下偏差范围大约-160到+160,int16_t完全够用,一帧10字节,115200波特率下传输时间不到1ms,20ms控制周期绰绰有余。
我一般用这样的帧结构:帧头0xAA 0x55,1字节长度表示payload字节数,1字节序号,payload放int16 x_err + int16 y_err + uint8 flags共5字节,最后1字节CRC8。CRC8只算长度、序号和payload,多项式用0x07,初值0。别小看这个CRC,串口线上电机一启动就可能有误码,没有校验时STM32会把乱码当偏差,车会像被踢了一脚。
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 | 固定0xAA 0x55,用于快速对齐 |
| LEN | 1 | payload长度,当前固定5 |
| SEQ | 1 | 0到255循环,用来丢旧帧 |
| X_ERR | 2 | int16小端,目标中心相对ROI中心的横向偏差 |
| Y_ERR | 2 | int16小端,纵向偏差,云台或测距用 |
| FLAGS | 1 | bit0目标有效,bit1丢线,bit2保留 |
| CRC8 | 1 | 覆盖LEN+SEQ+payload |
长度字段不要省。有人用固定帧长直接数字节,一旦以后加一个标志位,收发两端版本不一致就全乱。带上LEN,解析状态机可以按长度收payload,升级时只改长度和CRC范围,帧头仍然能对齐。CRC8比CRC16省时间,10字节帧在STM32的F4系列上算一次不到几微秒,够用;如果现场干扰特别大,再换CRC16,不要一上来就把协议做重。
2.2 收包状态机:从环形缓冲到CRC校验的C代码
STM32端不要在串口中断里直接解析业务。常见做法是中断只把字节塞进环形缓冲,主循环或20ms任务里取出来跑状态机。下面这段是可直接改的骨架,vision_parse_byte()每收到一个字节调用一次,解析成功返回1。
#include <stdint.h> #include <string.h> #define RX_BUF_SIZE 128 typedef struct { uint8_t buf[RX_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuf; static RingBuf g_rx; static uint8_t g_payload[8]; typedef enum { S_H1 = 0, S_H2, S_LEN, S_SEQ, S_PAYLOAD, S_CRC } ParseState; typedef struct { uint8_t seq; int16_t x_err; int16_t y_err; uint8_t flags; uint8_t fresh; } VisionFrame; static VisionFrame g_vision; static uint8_t crc8_update(uint8_t crc, uint8_t data) { crc ^= data; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x80) crc = (uint8_t)((crc << 1) ^ 0x07); else crc = (uint8_t)(crc << 1); } return crc; } uint8_t vision_parse_byte(uint8_t b) { static ParseState st = S_H1; static uint8_t len = 0, seq = 0, idx = 0, crc_calc = 0, crc_recv = 0; switch (st) { case S_H1: if (b == 0xAA) st = S_H2; break; case S_H2: st = (b == 0x55) ? S_LEN : S_H1; break; case S_LEN: len = b; if (len == 0 || len > sizeof(g_payload)) { st = S_H1; break; } crc_calc = crc8_update(0, len); st = S_SEQ; break; case S_SEQ: seq = b; crc_calc = crc8_update(crc_calc, seq); idx = 0; st = S_PAYLOAD; break; case S_PAYLOAD: g_payload[idx++] = b; crc_calc = crc8_update(crc_calc, b); if (idx >= len) st = S_CRC; break; case S_CRC: crc_recv = b; st = S_H1; if (crc_recv != crc_calc) break; if (len >= 5) { g_vision.seq = seq; g_vision.x_err = (int16_t)(g_payload[0] | (g_payload[1] << 8)); g_vision.y_err = (int16_t)(g_payload[2] | (g_payload[3] << 8)); g_vision.flags = g_payload[4]; g_vision.fresh = 1; return 1; } break; default: st = S_H1; break; } return 0; }这段代码的逻辑是:帧头不对就回到S_H1,长度超过缓冲区就丢弃,CRC不对就整帧丢掉,只有校验通过才更新g_vision。参数上注意三个点:RX_BUF_SIZE至少留两倍最大帧长的余量,128字节对10字节帧很宽裕;len > sizeof(g_payload)是保护,别让乱码把数组写穿;CRC计算范围必须和OpenMV端完全一致,少算一个SEQ就会永远校验失败。
串口中断里只做环形缓冲写入,类似g_rx.buf[g_rx.head++] = b;,注意head回绕和主循环读tail的竞争。主循环每次取一个字节喂给状态机,比在中断里跑find_blobs后果轻得多。如果字节间隔超过2ms还没有新字节,可以在定时器里把状态机复位到S_H1,防止半帧卡死。这个超时复位是后悔药,平时用不上,现场干扰时能救一次。
2.3 序号与超时:哪些帧要ACK,哪些帧必须丢弃旧帧
图像帧不要做ACK重发。OpenMV以50Hz发偏差,STM32只关心最新一帧,旧帧重发只会让车更迟钝。做法是STM32记录last_seq,收到新帧后比较序号,如果序号比当前旧超过一定范围就丢弃;更简单的做法是只保留一个g_vision,每次覆盖,控制任务读之前看fresh标志,读完清零。这样串口队列不会堆积,弯道不会追着三帧前的偏差打角。
参数配置帧要ACK。比如上位机或OpenMV要给STM32发“设置速度环Kp=8.0”,这种帧丢了小车不会立刻失控,但调参效率会崩溃。我一般定义下行帧0xBB 0x66 + LEN + SEQ + CMD + DATA + CRC8,STM32收到后回一个ACK帧,ACK里带回原SEQ。发送端等ACK最多20ms,超时重发3次,3次失败就置错误标志,不无限重试。控制帧和参数帧走同一个串口时,用CMD区分,解析状态机里多一个分支,别开两个串口浪费引脚。
提示:ACK帧也要带CRC,别因为“只是应答”就省校验。现场见过ACK被干扰成别的CMD,参数被反复写入,调参调半天以为PID有问题。
3. OpenMV端识别输出:ROI、阈值、曝光和坐标映射的四个可调项
OpenMV端最容易犯的错是“全图找色块”。QVGA全图find_blobs一次可能十几毫秒,加上自动曝光抖动,帧率看着有20fps,实际偏差更新像心电图。竞赛小车不需要看懂整幅图,它只需要知道目标在ROI里的横向偏差。把ROI锁死、曝光锁死、只发偏差,OpenMV端就从一个“图像处理Demo”变成稳定的传感器。
3.1 ROI先锁视野:find_blobs的阈值和面积参数怎么给
先确定目标在画面里的大致区域。小车摄像头通常朝前下方,目标线或色块出现在画面下半部,ROI可以取(60, 40, 200, 140),也就是从x=60、y=40开始,宽200高140。ROI越小,处理越快,但太小会一出弯就丢目标。我一般让ROI覆盖目标可能出现的左右边界,留10%余量,别把ROI卡到刚好。
阈值用LAB空间,比RGB稳。比如追一条偏黄色的线,阈值可能是(20, 100, -30, 50, -20, 60),六个数字分别是L Min、L Max、A Min、A Max、B Min、B Max。这不是万能值,现场要用OpenMV IDE的阈值编辑器重新取。面积参数pixels_threshold=200、area_threshold=200,意思是色块至少200个像素才认,防止反光点被当成目标。merge=True把相邻色块合并,适合线有断点的情况。
import sensor, image, time, ustruct from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us=6000) sensor.skip_frames(time=500) uart = UART(3, 115200) ROI = (60, 40, 200, 140) THRESHOLD = (20, 100, -30, 50, -20, 60) SEQ = 0 def crc8(data): crc = 0 for b in data: crc ^= b for _ in range(8): if crc & 0x80: crc = ((crc << 1) ^ 0x07) & 0xFF else: crc = (crc << 1) & 0xFF return crc while True: img = sensor.snapshot() blobs = img.find_blobs([THRESHOLD], roi=ROI, pixels_threshold=200, area_threshold=200, merge=True) flags = 0 x_err = 0 y_err = 0 if blobs: b = max(blobs, key=lambda x: x.pixels()) cx = b.cx() cy = b.cy() roi_cx = ROI[0] + ROI[2] // 2 roi_cy = ROI[1] + ROI[3] // 2 x_err = int(cx - roi_cx) y_err = int(cy - roi_cy) flags = 1 else: flags = 2 # 丢线标志 payload = ustruct.pack('<hhB', x_err, y_err, flags) body = ustruct.pack('<BB', len(payload), SEQ) + payload frame = b'\xAA\x55' + body + bytes([crc8(body)]) uart.write(frame) SEQ = (SEQ + 1) & 0xFF time.sleep_ms(20)这段OpenMV代码的关键不是find_blobs本身,而是它只发x_err、y_err和flags,不发票据也不发坐标数组。x_err是色块中心减ROI中心,正数表示目标在右,负数在左。flags=2告诉STM32这一帧没找到目标,STM32可以进搜索状态而不是继续用旧偏差。time.sleep_ms(20)对应50Hz,和STM32控制周期对齐;如果OpenMV处理一帧超过20ms,实际帧率会掉,STM32端必须靠序号判断新旧,不能假设50Hz一定到。
参数上,pixels_threshold和area_threshold不要设太小,太小会把反光、噪点认成目标;也不要设太大,线在远处只占几十个像素时会丢。常见做法是先设200,现场看IDE里色块像素数,取正常值的1/3作为下限。merge=True在线有断点时有帮助,但两个相邻色块也会被合并,如果目标旁边有同色干扰,改回False并加roi限制。
3.2 固定曝光与白平衡:让阈值不在场地漂移的三个开关
自动增益、自动白平衡、自动曝光是OpenMV端的三根“玄学旋钮”。实验室日光灯下调好的阈值,到赛场窗户边就偏蓝,自动白平衡一动作,A和B通道全变,阈值直接失效。我一般把三个都关掉:set_auto_gain(False)、set_auto_whitebal(False)、set_auto_exposure(False, exposure_us=6000)。曝光时间6000微秒是起始值,光线暗就加到8000到10000,光线亮就降到3000到4000,但一旦定了,比赛过程中不要再让它自动变。
关掉自动曝光后,图像亮度靠灯光和曝光时间保证。好处是阈值稳定,坏处是换场地要重新标一次。标定方法不复杂:把目标放在ROI中心,看IDE里色块的LAB值,取目标正常亮度和阴影亮度的交集作为阈值范围。比如L通道在30到90之间都算目标,就不要卡到40到60,否则一有阴影就丢。A、B通道同理,范围太窄会挑光线,太宽会认到背景。
如果只追一条黑线,可以直接用灰度图sensor.set_pixformat(sensor.GRAYSCALE),阈值只调一个灰度范围,处理更快,受色温影响更小。但灰度图识别彩色目标会丢信息,所以追线用灰度、识别色块用RGB565,这是常见分工。不要在同一套代码里频繁切像素格式,切换本身有开销,还会让曝光重新收敛。
3.3 像素偏差到车体偏差:标定表和打包代码
x_err是像素偏差,不是转向角。STM32拿到的x_err=40,到底该打多少度,要靠标定。简单做法是把车放在目标左侧10cm、20cm、30cm,记录x_err,再放到右侧同样距离记录,得到一张像素-厘米表。如果近似线性,就在STM32里乘一个系数K_px,比如K_px=0.8表示1像素偏差对应0.8个转向单位;如果不线性,就分段查表。
| 车体横向偏差 | 记录x_err | 转向输出起始值 | 备注 |
|---|---|---|---|
| 左30cm | -120 | -60 | 大偏差,限幅 |
| 左10cm | -40 | -20 | 线性段 |
| 中心 | 0 | 0 | 死区±3像素 |
| 右10cm | +40 | +20 | 线性段 |
| 右30cm | +120 | +60 | 大偏差,限幅 |
这张表不是让你抄数值,是让你在场地里填。填完后把K_px写进STM32的转向环,x_err乘系数再进PID。注意像素偏差有死区,目标在中心±3像素内不用动,否则车会左右抖。打包代码里ustruct.pack('<hhB', x_err, y_err, flags)的<表示小端,STM32端按小端拼回,两边必须一致。SEQ每帧加1,STM32用它判断是否新帧,别用时间戳,时间戳在两边时钟不同步时更麻烦。
4. STM32底盘闭环:编码器测速、PWM限幅和串级PID整定表
OpenMV给的是方向偏差,STM32要把这个偏差变成左右轮的速度差。很多小车“能识别但跑不稳”,问题出在底盘闭环:编码器没读对、PWM死区没补、积分饱和把车憋死。底盘控制不需要多复杂,关键是测速周期稳定、限幅明确、先内环后外环。下面给的参数是起始量级,不是万能值,换电机、换轮径、换电池电压都要重调。
4.1 编码器测速:定时器计数、M法测速和5ms控制周期
编码器用定时器的编码器模式,常见做法是__HAL_TIM_GET_COUNTER(&htim3)读计数,5ms读一次,算差分。假设编码器线数ENCODER_LINES=13,四倍频后每转52个计数,减速比GEAR_RATIO=30,轮子转一圈就是1560个计数。5ms内差delta个计数,轮速delta / 1560 / 0.005转/秒。代码里把轮径乘进去就是线速度。
#define CTRL_DT 0.005f #define ENCODER_LINES 13.0f #define GEAR_RATIO 30.0f #define WHEEL_DIAMETER 0.065f static int16_t last_cnt = 0; float read_wheel_speed_mps(void) { int16_t now = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); int16_t delta = (int16_t)(now - last_cnt); last_cnt = now; float counts_per_rev = ENCODER_LINES * 4.0f * GEAR_RATIO; float rev_per_sec = (float)delta / counts_per_rev / CTRL_DT; return rev_per_sec * 3.1415926f * WHEEL_DIAMETER; }这段代码的逻辑是M法测速:固定时间读计数差。参数上,CTRL_DT必须和定时器中断周期一致,写5ms就真给5ms,别在中断里做浮点除法和串口打印,否则周期抖动,速度环会抖。ENCODER_LINES和GEAR_RATIO按实际电机填,填错一倍速度就错一倍。delta用int16_t强转,计数溢出时会回绕,短周期内没问题;如果5ms内计数超过32767,说明速度太高或线数填错,先查参数。
M法在低速时分辨率差。5ms内只有几个脉冲,速度算出来一跳一跳。常见做法是低速改用T法测脉冲间隔,或者把测速周期放到10ms,牺牲一点带宽换稳定。小车竞赛速度不会太低,5ms到10ms都有人用,我一般先10ms跑通,再压到5ms看抖动。编码器线不要和电机PWM线捆在一起,否则计数会飞,速度环直接疯。
4.2 PWM限幅与死区:积分抗饱和和电机死区补偿
PWM限幅不是可有可无。电池满电时PID输出可能到100%,电机堵转电流把电压拉低,STM32复位,OpenMV也重启。我一般把PWM上限设到定时器ARR的80%到90%,留余量给电池跌落。比如ARR=9000,PWM_MAX=7200。电机还有死区,PWM小于120时轮子不转,PID会以为输出不够继续加积分,最后积分饱和,车一松就窜出去。
#define PWM_MAX 7200 #define PWM_DEAD 120 int16_t apply_motor_limit(int16_t pwm) { if (pwm > 0 && pwm < PWM_DEAD) pwm = PWM_DEAD; if (pwm < 0 && pwm > -PWM_DEAD) pwm = -PWM_DEAD; if (pwm > PWM_MAX) pwm = PWM_MAX; if (pwm < -PWM_MAX) pwm = -PWM_MAX; return pwm; } void set_motor_pwm(int16_t left, int16_t right) { left = apply_motor_limit(left); right = apply_motor_limit(right); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (uint16_t)(left > 0 ? left : 0)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, (uint16_t)(left < 0 ? -left : 0)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, (uint16_t)(right > 0 ? right : 0)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, (uint16_t)(right < 0 ? -right : 0)); }死区补偿放在限幅之后、写PWM之前,正负都要补。积分抗饱和放在PID里:积分项累加后先限幅到±out_max,再参与输出,输出再限一次。这样电机堵转时积分不会无限涨,松开车不会猛冲。PWM频率常见10kHz到20kHz,太低电机啸叫,太高驱动发热,按驱动板手册选,别频繁改。
4.3 串级PID整定表:先内环后外环的起始参数
串级结构是外环转向、内环速度。外环输入x_err,输出左右轮速度差;内环输入目标轮速,输出PWM。整定顺序必须先内后外:先让单轮速度跟得上阶跃,再关外环调转向。内环P太大嗡嗡振,I太大低速爬行,D在编码器噪声大时先给0。外环P决定打角快慢,D抑制过弯超调,I一般很小,甚至不给。
typedef struct { float kp, ki, kd; float integ; float prev_err; float out_max; } PID; float pid_calc(PID *p, float err, float dt) { p->integ += err * dt; if (p->integ > p->out_max) p->integ = p->out_max; if (p->integ < -p->out_max) p->integ = -p->out_max; float d = (err - p->prev_err) / dt; p->prev_err = err; float out = p->kp * err + p->ki * p->integ + p->kd * d; if (out > p->out_max) out = p->out_max; if (out < -p->out_max) out = -p->out_max; return out; }| 环 | Kp起始 | Ki起始 | Kd起始 | 周期 | 输出限幅 | 观察量 |
|---|---|---|---|---|---|---|
| 速度内环 | 8.0 | 0.5 | 0.0 | 5ms | ±7200 | 轮速阶跃是否等幅振 |
| 转向外环 | 2.5 | 0.02 | 0.8 | 20ms | ±3000 | 过弯是否超调 |
| 丢线搜索 | 1.2 | 0 | 0 | 50ms | ±1500 | 找回目标是否过冲 |
表里的数只是量级,不是抄了就完事。内环先给Kp=8,看轮速能不能在100ms内跟上目标,跟不上加Kp,等幅振就减;Ki从0.5起,消除静差,但低速爬行就减到0.1。外环Kp=2.5,车头摆动大就减,过弯懒就加;Kd=0.8抑制超调,但图像噪声大时D会把噪声放大,先给0.3试。所有参数改一次只改一个,改完至少跑三圈,别一边改PID一边改阈值,最后不知道谁起的作用。
5. 联调排查:图像延迟、串口粘包、电机干扰和电源跌落的翻车记录
小车在台架上跑得好,落地就出问题,多半不是算法错,而是联调边界没查。下面五条是竞赛小车最常见的翻车记录,每条按现象、原因、解决写。排查顺序我一般从电源和串口开始,再查图像,最后动PID;反过来先调PID,往往把机械和电源问题调成“参数玄学”。
5.1 图像延迟导致弯道画龙:曝光、ROI和最新帧策略
现象:直道正常,进弯后车头左右摆,像画龙,弯越急越明显。看串口数据,x_err变化比车体动作晚一截,弯道过了才打到最大偏差。
原因:OpenMV端曝光时间太长,自动曝光在暗处拉到20ms以上,加上find_blobs十几毫秒,一帧就30ms到40ms;STM32端如果还按队列取旧帧,控制周期20ms,实际用的偏差可能滞后两帧。滞后在直道看不出来,弯道相位一差就震荡。
解决:固定曝光到6000微秒左右,ROI缩到目标可能出现区域,find_blobs只算必要色块;STM32端只保留最新帧,g_vision覆盖写,控制任务读fresh标志,读完清零,不排队。如果还慢,把OpenMV分辨率降到QQVGA或把帧率降到30Hz,让每帧处理时间稳定,别让帧率忽高忽低。转向环的D项在延迟大时先减小,D对滞后最敏感。
5.2 串口粘包与CRC错位:环形缓冲的读写下标
现象:小车偶尔突然满舵,串口助手看数据帧有的粘在一起,有的少一个字节;复位后又正常,跑几分钟再犯。
原因:串口中断里直接解析,主循环又在读同一个缓冲区,head和tail竞争;或者中断里做了耗时操作,新字节覆盖旧字节。还有一种情况是CRC计算范围两端不一致,OpenMV算了帧头,STM32没算,平时能过,干扰一来就错。
解决:中断只写环形缓冲,主循环取字节喂状态机;head和tail用volatile,读写时关中断或保证单写单读;缓冲区留足两倍最大帧长。CRC范围写死:LEN+SEQ+payload,两端对照同一张表。加字节间隔超时,2ms没有新字节就复位解析状态,防止半帧卡死。改完用串口助手发乱码测试,确认状态机能自己回到帧头。
5.3 电机PWM干扰OpenMV:走线、共地和复位现象
现象:电机一转,OpenMV图像花屏、颜色跳变,严重时OpenMV重启;电机停就正常。示波器看PWM线上有振铃,图像数据线附近有毛刺。
原因:电机PWM线和大电流线跟摄像头排线平行走,耦合到数据线;电机火花通过地线串进OpenMV供电;共地阻抗大,地电位跳动。OpenMV的USB线如果还插着电脑,地环路更复杂。
解决:PWM线和摄像头排线分开走,能垂直就垂直,不能就拉开距离;电机两端加续流和滤波,PWM线双绞;OpenMV单独一路稳压,地线单点接到电池负极,别和电机地串在一起。复位现象先查供电,再查排线,别一上来改代码。竞赛小车上,线束固定比算法更影响稳定性。
5.4 大电流启动STM32复位:电源路径和电容位置
现象:小车起步或急转时STM32复位,OLED黑屏,串口断;电池电压从8V掉到6V多,电机堵转时更明显。
原因:电池内阻加上细线径,大电流瞬间压降;稳压芯片输入电容太小或离芯片太远,响应不过来;电机和MCU共用一路,电机启动把MCU电压拉低。复位不一定是软件看门狗,先看电源纹波。
解决:电机电源和MCU电源分开走,MCU用独立稳压;稳压输入输出电容靠近芯片引脚,大电容放在电池出口;线径按峰值电流选,别用杜邦线给电机供电;加低压检测,电压低于阈值时降PWM限幅,别硬撑。电容不是越大越好,位置比容量重要,离芯片远的1000uF不如近的100uF。
5.5 换场地阈值翻车:固定曝光后的二次标定
现象:实验室调好的阈值,到赛场一半时间丢目标;同一套代码,上午下午识别率不一样。看IDE里色块LAB值,L通道整体偏移。
原因:场地灯光色温和亮度不同,固定曝光后图像亮度变了,阈值范围没覆盖;目标表面反光,角度一变LAB值跳变;ROI里背景颜色接近目标,面积阈值把背景认进来。
解决:到场后先固定曝光,再重新取阈值,别用实验室值硬跑;阈值范围取目标亮部和暗部的交集,别卡太窄;加ROI限制和面积阈值,背景干扰大时用merge=False;如果只追线,切灰度图减少色温影响。标定花十分钟,比现场调PID半小时有用。
6. 用状态机和在线调参把小车从能跑推到能比赛:一个可复用的调试习惯
状态机是小车从“连续PID”变成“有判断”的关键。我一般分SEARCH、TRACK、ALIGN、STOP四个状态:TRACK用图像偏差做转向,连续丢帧超过5帧进SEARCH,SEARCH用低速原地扫或按最后偏差方向找,找回目标进ALIGN,对齐稳定200ms再回TRACK。切换条件写成表,别散在代码里。
6.1 状态机骨架:SEARCH、TRACK、ALIGN、STOP的切换条件
typedef enum { S_SEARCH, S_TRACK, S_ALIGN, S_STOP } CarState; static CarState state = S_SEARCH; static uint8_t lost_cnt = 0; static uint16_t align_ms = 0; void car_state_task(void) { switch (state) { case S_SEARCH: set_motor_pwm(-800, 800); // 低速原地找 if (g_vision.fresh && (g_vision.flags & 0x01)) { lost_cnt = 0; state = S_ALIGN; } break; case S_TRACK: if (!(g_vision.flags & 0x01)) lost_cnt++; else lost_cnt = 0; if (lost_cnt > 5) state = S_SEARCH; else { int16_t turn = (int16_t)(g_vision.x_err * 0.8f); set_motor_pwm(1500 + turn, 1500 - turn); } break; case S_ALIGN: set_motor_pwm(0, 0); if (++align_ms > 200) state = S_TRACK; break; case S_STOP: set_motor_pwm(0, 0); break; } g_vision.fresh = 0; }这段骨架的重点是lost_cnt和align_ms两个计数器。lost_cnt > 5对应100ms丢帧,避免单帧误判就停车;align_ms > 200让对齐稳定后再走。参数按车重和舵机响应改,轻车可以减到3帧和100ms,重车加到8帧和300ms。状态机里不要做浮点大运算,20ms任务里只做判断和PWM输出。
6.2 在线调参帧:用一条命令改PID和阈值
在线调参能省掉反复烧录的时间。我一般留一个下行命令0xBB 0x66 LEN SEQ CMD DATA CRC8,CMD=0x01改速度环Kp,CMD=0x02改转向Kp,CMD=0x03改OpenMV阈值。STM32收到后写RAM参数并回ACK,掉电不保存,比赛前再固化。注意参数范围限幅,别让一条乱码把Kp写成1000。
| CMD | 数据 | 范围 | 作用 |
|---|---|---|---|
| 0x01 | float Kp | 0到20 | 速度内环Kp |
| 0x02 | float Kp | 0到10 | 转向外环Kp |
| 0x03 | uint8 阈值编号 | 0到7 | 切换预存阈值组 |
我后来养成的习惯是:先看串口波形和电源纹波,再改PID;先固定曝光和ROI,再谈识别率;每次只改一个参数,跑三圈再记录。这样看着慢,实际比反复推翻重来快。希望帮到你。
本文还有配套的精品资源,点击获取