本期核心:灰度图像怎么变成车能用的信息?二值化、噪声滤波、左右边线提取、跳跃触发识别、视觉偏航修正——一帧 120×188 的图像,如何驱动偏航控制。
1. 先想清楚:摄像头能给我们什么
摄像头在车上的角色是**“眼睛”——提供 GPS 给不了的局部、高精度**信息:
- GPS 精度是米级,赛道线是厘米级——循线必须靠视觉;
- 跳跃台阶的位置,也要靠视觉提前发现。
但摄像头给的是一帧灰度图像,不是"线在哪"。中间要经过一条完整的图像处理流水线:
原始灰度 → 二值化 → 噪声滤波 → 逐行边线扫描 → 中线提取 → 偏差→偏航修正这套流水线在main.c里由三个函数接力完成:
if(camera_driver_frame_ready()){constuint8_t*raw=camera_driver_get_image();image_binarize(image_bin,(constuint8_t(*)[188])raw,car.img_threshold);/* 二值化 */image_noise_filter(image_bin);/* 去噪 */track_detect_process(&car,image_bin);/* 提取边线 */camera_driver_clear_flag();}三个函数,一帧图,各干一件事。每帧图像只在主循环处理,不阻塞 1ms 控制中断——实时性由调度保证(第1期讲过)。
2. 第一步:二值化 —— 把灰度变成黑白
原始图像每个像素是 0~255 的灰度。循线场景下,我们只关心"是白还是黑"(白 = 赛道,黑 = 背景)。于是设定一个阈值:
voidimage_binarize(uint8_tout[120][188],constuint8_tin[120][188],intthreshold){for(inti=0;i<CAMERA_H;i++){for(intj=0;j<CAMERA_W;j++){out[i][j]=(in[i][j]>=threshold)?IMG_WHITE:IMG_BLACK;}}}就这么简单:>= 阈值 → 白,否则 → 黑。全图遍历一遍,O(H×W)。
阈值本身就是个可调参数(car.img_threshold),存进 Flash、可以在菜单里调、开机从 Flash 读回(第4期讲过)。
二值化的意义:把 256 级灰度的浮云变成"是/否"的布尔判断,后面所有算法瞬间变简单。
3. 第二步:噪声滤波 —— 3×3 邻域投票
摄像头画质不完美,图像里有椒盐噪声(孤立的黑白点)。用 3×3 邻域"投票"滤掉:
voidimage_noise_filter(uint8_timg[120][188]){for(inti=1;i<CAMERA_H-1;i++){for(intj=1;j<CAMERA_W-1;j++){/* 统计 8 邻域 + 自身的白色点数 */uint8_twc=0;wc+=(img[i-1][j-1]==IMG_WHITE);.../* 8 邻域 */wc+=(img[i][j]==IMG_WHITE);if(img[i][j]==IMG_WHITE&&wc<=2)img[i][j]=IMG_BLACK;/* 孤立白点→黑 */elseif(img[i][j]==IMG_BLACK&&wc>=6)img[i][j]=IMG_WHITE;/* 孤立黑点→白 */}}}思路:看邻居投票。一个白点周围全是黑的,说明它是噪声,翻成黑;反过来黑点周围一片白,翻成白。
这是最朴素的滤波:O(H×W×8),简单可靠。真正复杂的场景才需要中值/均值/形态学,循线这种对比度高的图,投票法够了。
4. 第三步:逐行边线扫描 —— 从图像里"抠"出赛道边界
这是视觉模块的核心。思路是从下往上逐行扫描,每行在左右方向上找"黑白跳变点":
intstart_x=CAMERA_W/2;for(inti=CAMERA_H-1;i>=0;i--){/* 从图像底部往上扫 */intraw_left=0,raw_right=CAMERA_W-1;if(img[i][start_x]==IMG_BLACK)start_x=CAMERA_W/2;for(intj=start_x;j>=0;j--)/* 向左找第一个黑点 */if(img[i][j]==IMG_BLACK){raw_left=j;break;}for(intj=start_x;j<CAMERA_W;j++)/* 向右找第一个黑点 */if(img[i][j]==IMG_BLACK){raw_right=j;break;}/* 宽度约束:上一行知道赛道多宽,这行偏差过大就矫正 */intpw=s->right_line[i+1]-s->left_line[i+1];if(rji>5&&lji<5)raw_right=raw_left+pw;/* 右边界缺失→按宽度补 */...s->left_line[i]=raw_left;s->right_line[i]=raw_right;s->center_line[i]=(raw_left+raw_right)/2;start_x=s->center_line[i];/* 下一行从本行中线附近开始搜 */}三个关键工程细节
- 从下往上:图像底部离车最近、最可靠,先扫出底部中线,作为上面行的起始搜索点;
- 宽度约束:如果这一行只找到一个边,就用上一行的赛道宽度补出另一边——防止断线导致边线突变;
- 逐行推进:每行用上一行的中线作为起点往左右找,"路径连续性"让扫描又快又稳。
这套"从底部往上、宽度约束、连续推进"的扫描策略,是所有循线算法的基础套路。你的项目可以照抄这套骨架,换成别的摄像头、别的赛道也一样。
5. 第四步:中线偏差 → 视觉偏航修正
拿到每行的左右边线,就能算中线。中线偏离图像中心多少,就是车"偏了"多少:
/* 取有效区段(剔除底部遮挡/顶部噪声) */for(inti=CAMERA_H-1-IGNORE_BOTTOM_ROWS;i>=30;i--){if((right_line[i]-left_line[i])>MIN_VALID_WIDTH){if(valid_bottom==-1)valid_bottom=i;valid_top=i;}}if(valid_bottom!=-1&&(valid_bottom-valid_top)>10){/* 取约 40% 高度处的中线做误差 */intlar=valid_top+(int)((valid_bottom-valid_top)*0.4f);floatavg=5行中线平均;/* 抗噪 */floaterror=avg-(CAMERA_W/2);/* 中线 vs 图像中心 *//* PD 修正量,叠加到目标偏航 */s->vision_yaw_correction=-(KP_YAW_VISION*error+KD_YAW_VISION*(error-last_err));last_err=error;}else{s->vision_yaw_correction=0.0f;/* 看不到线,修正归零 */}这里的精髓
- 只用 40% 高度、5 行平均:不用整幅图像,取"代表性的一行"做误差——用局部信息代表全局,省算力还抗噪;
- PD 修正:比例项快速跟线,微分项抑制左右摆;
- 看不线就归零:摄像头丢线时(进入跳跃/出界)修正量为 0,不会把车带偏。
6. 视觉和 GPS 怎么配合:修正量叠加
关键问题:视觉循线和 GPS 路径跟踪会不会打架?
答案在balance.c的目标偏航合成里——视觉修正量是叠加上去的:
floatcur_yaw=s->yaw+s->gps_yaw_bias;floattarget=normalize_deg(s->target_yaw+s->vision_yaw_correction);floatyaw_err=normalize_deg(cur_yaw-target);PID_Calc(&s->pid_yaw,yaw_err,0);最终目标偏航 = GPS路径的目标偏航 + 视觉偏航修正量设计思路:GPS 管"宏观方向"(走哪条路),视觉管"微观纠偏"(贴哪条线)。视觉修正量是一个小量,叠加在 GPS 目标上做局部微调,两者不冲突。
这就是多传感器融合最简单的形态:“主从叠加”。主源定方向,从源做修正。不用卡尔曼也能让两个传感器协同工作。
7. 视觉的另一个任务:跳跃触发识别
摄像头不只是循线,还负责在跳跃任务里识别台阶/障碍,触发跳跃(第3期讲过跳跃状态机)。
voidtrack_detect_process(CarState_t*s,constuint8_timg[120][188]){if(s->task_flag==1){if(s->jump_cnt==0||s->jump_cnt==2){/* 在图像中下方找"白块"(台阶/路障)底部 */for(inti=CAMERA_H-5;i>=0;i--){...统计中线附近白色像素数...if(wc>=MIN_STEP_WIDTH){step_bottom_y=i;break;}}}elseif(s->jump_cnt==1){/* 找黑块(跃过障碍后的路面) */...}/* 触发条件:台阶高度到达阈值行,或累计行驶距离到阈值 */if(s->jump_cnt==0&&step_bottom_y>=s->jump_trigger_row_1)s->jump_flag=1;elseif(s->jump_cnt==1&&s->all_distance>=s->jump_distance_1)s->jump_flag=1;elseif(s->jump_cnt==2&&s->all_distance>=s->jump_distance_2)s->jump_flag=1;}...}触发跳跃的组合判据:
- 视觉判据:台阶的"行位置"(越往下越近)到达
jump_trigger_row阈值; - 里程判据:累计行驶距离
all_distance到达jump_distance阈值(用于后续跳跃的等间距触发)。
视觉 + 里程双判据,互为冗余、互相兜底——一个判据丢失,另一个还能触发。这是"传感器冗余"的简单实践。
8. 完整视觉流水线回顾
原始灰度帧 │ 二值化(阈值可调) ▼ 黑白图像 │ 3×3 投票去噪 ▼ 干净二值图 │ 从底部向上逐行扫描(宽度约束) ▼ 左右边线 → 中线 │ ├──▶ 视觉偏航修正 ──叠加──▶ 目标偏航(贴线) └──▶ 台阶检测 ──▶ jump_flag(跳跃触发)每一步都是"图进图出/图进数出"的纯函数,可以单独调试、单独可视化。
9. 本期小结
- 二值化:阈值切分,灰度变黑白;
- 投票滤波:3×3 邻域去椒盐噪声;
- 边线扫描:从底部往上、宽度约束、连续推进;
- 中线偏差 → PD 修正:40% 高度 + 5 行平均,丢线归零;
- 视觉 + GPS 叠加:GPS 定方向,视觉做微调;
- 视觉 + 里程双判据:跳跃触发互为冗余。
10. 下期预告
算法闭环基本齐了,最后一期讲人机交互与调参经验:
LoRa 遥控的摇杆映射、按键/菜单系统怎么设计,以及整套系统在实车调试时踩过的坑和心得。
欢迎关注,持续连载中。