1. 为什么跑AI的赛道上,还是要从二值化入手
第18届全国大学生智能汽车竞赛的四轮车组,规则核心就一句话:车要沿着赛道线高速跑,谁的稳定性和速度兼顾得好,谁就赢。而这一切的前提,是车得“看得见”赛道。视觉组用的基本都是摄像头,图像处理链路从传感器原始数据到最终能驱动舵机的转向指令,中间每一帧都极其宝贵。
我见过不少新队员,一上来就想着上深度学习、跑YOLO、做语义分割,理由是“显得高级”。我特别理解这种冲动,但我也得泼一盆冷水:智能车上的计算平台,哪怕你用上了高端的工业级开发板,它的算力、内存带宽、功耗限制都摆在那里。一块几十块钱的MCU或者中端SoC,你要让它每帧在20毫秒内完成从采集到输出的全部处理,还要留出CPU给控制环、速度环、无线调试,这种场景下,复杂模型的推理时间根本撑不住。与其上重武器,不如把手里的轻武器打磨到极致。
二值化之所以是智能车图像处理的“基本功中的基本功”,核心逻辑很简单:赛道场景高度结构化。铺设赛道就是一块浅色底布,赛道是深色(或者反过来,不同赛区风格不同),目标就是找出一条边界清晰的暗色/亮色条带。这种背景下,你不需要区分猫和狗,不需要识别红绿灯,你要的就是“哪里是赛道,哪里不是赛道”。既然如此,把灰度图压成二值图,直接得到“前景赛道=255,背景=0”的掩膜,再去算边界、算中线,这不仅是计算量上的极大节省,更是把后续所有逻辑建立在了一个极其简洁可靠的数据结构上。
我用一个简单的数字来说明:一张320×240的灰度图,每个像素1字节,总共76800字节。如果直接对灰度图做边缘检测、霍夫变换,你要在浮点域里处理这七万多个数据点;但如果先二值化,每个像素压缩到1比特,同时形态学上目标区域往往只占画面的20%~30%,那后续处理的候选数据量可能只有一两万个点。同样的算法,在不同数据量上跑,速度差距是数量级的。这就是老队员常说的“先做减法,再做加法”——二值化就是最大的减法。
当然,二值化并不是银弹,它丢掉了纹理、颜色梯度、细节信息。所以这篇文章真正想聊的,不是“二值化有多牛”,而是“在智能车这个具体的工程约束下,二值化、边缘检测、形态学这三板斧如何组合使用,才能既稳定又高效地跑完整个赛场”。适合正在备赛的四轮组、越野组队员,也适合刚入门嵌入式视觉、想在资源受限设备上做图像处理的朋友。我会从底层原理讲到实际调参,再给出代码层面的优化思路和典型的踩坑记录。
2. 二值化选型与调参:从固定阈值到大津法的进化之路
2.1 不同二值化方法的适用边界,直接决定你的调参工作量
二值化的本质,是给每个像素找一个分类边界:小于阈值的归为一类,大于阈值的归为另一类。看似简单,但阈值怎么取,学问很大。
最简单的做法是固定阈值。写一行代码,if (pixel > 120) pixel = 255; else pixel = 0;,完事。在光线恒定、赛道颜色不变的实验室环境下,固定阈值完全够用,甚至稳定性很好。但问题是,赛场条件千变万化:晴天太阳斜射、灯光色温不同、跑道上有阴影、有反光、有轮胎印。固定阈值在某种光线下调到完美,换到另一个时段上场,可能整个赛道都淹没在背景里。我当年第一次参加分区赛,赛前调好的固定阈值,到了正式场地因为灯光比实验室亮很多,图像几乎全白,那真是欲哭无泪。
所以稍微成熟一点的队伍都会用大津法(Otsu's Method)。它的思路是,自动计算一个阈值,使得分类后两类像素的类间方差最大。说白了,就是算法自己去找一个让“赛道”和“背景”两类像素内部都尽量均匀、两类之间差异最大的分界点。好处很明显,基本做到自适应光照变化;坏处是,当画面里赛道占比很小、或者背景杂乱时,大津法会找错方向。比如画面大部分是浅色的背景板,只有底部一小条是赛道,大津法求出来的阈值很可能把所有东西都归为一类,二值化结果惨不忍睹。
这里还有一类是自适应局部阈值,它对每个像素以其邻域的灰度特征动态计算阈值,能很好应对光照不均匀,但计算量成倍增加,而且容易把大面积平滑区域撕裂成黑白噪点。我个人的经验是,在智能车这个场景下,局部阈值基本用不上,杀鸡用牛刀,还容易误伤。
三种方法我整理成一个对比表,方便你按自己的硬件条件和赛场环境选型:
| 方法 | 计算开销 | 抗光照变化能力 | 典型适用场景 |
|---|---|---|---|
| 固定阈值 | 极低 | 弱 | 室内恒定光源、测试阶段 |
| 大津法 | 低(单次直方图扫描) | 中 | 光照整体变化、但画面场景相对单一 |
| 自适应局部阈值 | 高(每个像素邻域运算) | 强 | 光照严重不均匀、但目标区域纹理简单 |
我自己的倾向是,主用大津法,同时保留一个手动微调的偏移量。具体做法后面章节我会详细讲。
2.2 大津法源码级解析:为什么它稳定,又为什么偶尔会抽风
大津法的实现思路不复杂,但很多同学只是调库,出了问题不知道怎么排查。我建议每个人都能自己写一遍。核心步骤分三步:
- 统计灰度直方图,计算每个灰度级出现的概率。
- 从0到255遍历每个可能的阈值t,把像素分成两类C0(灰度≤t)和C1(灰度>t)。
- 计算两类之间的类间方差σ² = ω0 × ω1 × (μ0 - μ1)²,其中ω是类占比,μ是类内灰度均值。取σ²最大时对应的t作为最终阈值。
下边是我在嵌入式C代码里常用的大津法实现,去掉了浮点运算,用定点方式处理,跑在MCU上也不吃力:
uint8_t otsu_threshold(uint8_t *gray_img, uint16_t width, uint16_t height) { uint32_t hist[256] = {0}; uint32_t total = width * height; for (uint32_t i = 0; i < total; i++) { hist[gray_img[i]]++; } uint32_t sum_all = 0; for (uint16_t i = 0; i < 256; i++) { sum_all += i * hist[i]; } uint32_t sum_back = 0; // 背景类灰度累加 uint32_t weight_back = 0; // 背景类像素数 double max_var = -1.0; uint8_t threshold = 0; for (uint16_t t = 0; t < 256; t++) { weight_back += hist[t]; if (weight_back == 0) continue; uint32_t weight_fore = total - weight_back; if (weight_fore == 0) break; sum_back += t * hist[t]; double mean_back = (double)sum_back / weight_back; double mean_fore = (double)(sum_all - sum_back) / weight_fore; double diff = mean_back - mean_fore; double var = (double)weight_back * weight_fore * diff * diff; if (var > max_var) { max_var = var; threshold = t; } } return threshold; }这段代码在我的赛车上跑一遍,320×240的图像,大约耗时1~2毫秒,完全可接受。但请注意,这里有个关键问题:大津法计算时是全图参与,如果画面顶部有大面积的空旷背景(比如远处的墙壁、观众席),这些像素会对直方图产生强烈干扰。我遇到过一次典型的翻车:画面顶部有大面积白色墙壁,大津法把阈值拉得很高,结果赛道本身的灰色部分也被判成背景,赛道直接消失。
解决办法其实很简单:只对感兴趣区域(ROI)做阈值计算。我一般会裁剪掉图像顶部30%,只统计赛道可能出现的中下部区域。这不仅能提高鲁棒性,还能进一步减少计算量。赛道图像中,赛道的边缘往往在画面中下部,顶部是天空或背景,裁掉不仅没损失,还去掉了干扰源。
2.3 手动偏移量:为什么我在大津法基础上加了“人工干预”
大津法虽然自适应,但它追求的是“两类差异最大”,并不等于“赛道和背景分得最符合人的预期”。尤其是当赛道颜色与背景布颜色比较接近,或者赛道上有一层轻微反光时,大津法给的阈值可能偏向中间值,导致二值化后赛道边界出现很多毛刺,或者在赛道内部出现空洞。
我的做法是给大津法加入一个可调的偏移量:
final_threshold = otsu_threshold(img, w, h) + bias;这个bias可以是正数也可以是负数,具体正负取决于你的赛道颜色是深色还是浅色。我们当时的赛道是深蓝色,地面是浅灰色,目标是把深色赛道提取出来,所以bias通常取负值,让阈值降一点,保证赛道完整,宁可背景混进来一点,也不能让赛道断掉。因为在后续处理里,背景的零散噪声可以用形态学腐蚀去掉,但赛道断裂就得用更复杂的补线逻辑来处理,代价高得多。
这个“宁可多,不可少”的思路,其实贯穿整个图像处理流程。我在后边的边缘检测部分还会反复提到。很多新手喜欢追求完美的二值化结果,视觉上好看,但实际运行不稳定;老手反而会主动留出冗余,让后续算法去兜底。
3. 边缘检测的真正价值:二值化丢了什么,边缘就补什么
3.1 为什么二值化都做好了,还要再做边缘检测
这是很多队员问过我的问题:“我二值化之后已经能清楚看到赛道了,直接找边界不就行了吗,为什么还要用Canny?”
答案是:二值化的“边界”和边缘检测的“边缘”,本质上不是一回事。
二值化之后得到的赛道区域,只是一个像素块。如果直接逐行扫描像素块左边界和右边界,你会发现这个边界非常“脆”——它可能在第100行是第50列,到第101行变成了第58列,再往下又回到第52列。这种锯齿状的抖动,对转向控制来说就是致命的:它会让你的转向指令高频抖动,车在直道上也会左摇右晃。
而真正的边缘检测,尤其是Canny算子,它做的核心事情有两件:一是计算灰度梯度,找到图像中灰度变化最剧烈的像素点;二是通过非极大值抑制,把边缘细化成单像素宽。这带来的直接好处是,边缘线的位置是亚像素级别的稳定,不会因为光照微变、噪点干扰而剧烈抖动。
另外还有一个非常实际的原因:边缘检测能帮你在二值化失效的时候“兜底”。比如在进入停车场区域时,赛道线可能变成虚线,甚至有一段完全消失;又比如在十字路口,二值化后的赛道区域会突然变宽,这时候仅仅靠二值化的边界做判断,你的中线计算就会飘。但是,经典赛道边缘依然存在,边缘检测的结果能提供额外的几何约束,帮你判断赛道走向。
一句话总结:二值化给你的是“赛道的面积”,边缘检测给你的是“赛道的线条”。面积信息适合做区域判断,线条信息适合做方向计算。两者配合,才能又稳又准。
3.2 Canny边缘检测在嵌入式平台上的工程化裁剪
完整的Canny流程是:高斯模糊 -> 计算梯度幅值和方向 -> 非极大值抑制 -> 双阈值检测 -> 滞后边界跟踪。每一步在PC上跑都很快,但在嵌入式平台上每一步都是性能黑洞。
我的建议是:该偷懒的地方大胆偷懒,该保留的地方一步都不能少。
先说高斯模糊。Canny第一步用高斯核对图像做卷积,目的是去噪,但代价很大——一个3×3的高斯核在320×240的图像上就要做大约69万次乘法运算。在嵌入式平台上,我通常的做法是跳过高斯模糊,用中值滤波替代,或者干脆直接在第2章的ROI裁剪后不做平滑。因为我们的图像来源是工业摄像头,噪声水平本身就比普通摄像头低,加上赛道场景高对比度,梯度计算受噪声影响并不严重。用大津法二值化的结果做一个掩膜,只对掩膜内的像素计算梯度,能去掉大量无意义的背景梯度。
然后是Sobel算子。计算X方向梯度和Y方向梯度,标准做法是两个3×3卷积核。这里有一个优化技巧:用绝对值之和代替平方和开根号。标准梯度幅值公式是G = sqrt(Gx² + Gy²),在嵌入式上开根号非常慢。工程上直接取G = |Gx| + |Gy|,误差在可接受范围内,速度快了一个数量级。
我对Canny做过的最大改动在双阈值环节。经典Canny需要你设两个阈值:高阈值和低阈值,高阈值决定强边缘,低阈值决定弱边缘,然后通过滞后算法把与强边缘相连的弱边缘保留下来。这个滞后算法(递归追踪)在嵌入式上非常麻烦,因为它涉及递归调用和访问模式的不规则性。
我的做法是:把滞后追踪这一步直接砍掉,改为高阈值检测强边缘,然后用形态学膨胀把强边缘向外扩展一小段,等效替代弱边缘的连接效果。视觉上可能略有区别,但对智能车赛道这种边缘连续性强、无复杂纹理的场景,实际效果几乎无差异,而且省掉了最烧CPU的递归操作。这就是典型的“用空间的复杂度,换时间的复杂度”。
下边是我在实际工程中使用的一段简化版Canny核心代码:
void edge_detect(uint8_t *gray, uint8_t *edge_map, uint16_t w, uint16_t h) { int16_t *gx = (int16_t *)malloc(w * h * sizeof(int16_t)); int16_t *gy = (int16_t *)malloc(w * h * sizeof(int16_t)); uint8_t *mag = (uint8_t *)malloc(w * h); // 1. Sobel梯度 for (uint16_t y = 1; y < h - 1; y++) { for (uint16_t x = 1; x < w - 1; x++) { int32_t dx = -gray[(y-1)*w + x-1] - 2*gray[y*w + x-1] - gray[(y+1)*w + x-1] + gray[(y-1)*w + x+1] + 2*gray[y*w + x+1] + gray[(y+1)*w + x+1]; int32_t dy = -gray[(y-1)*w + x-1] - 2*gray[(y-1)*w + x] - gray[(y-1)*w + x+1] + gray[(y+1)*w + x-1] + 2*gray[(y+1)*w + x] + gray[(y+1)*w + x+1]; gx[y*w + x] = (int16_t)dx; gy[y*w + x] = (int16_t)dy; int32_t mag_val = abs(dx) + abs(dy); mag[y*w + x] = (mag_val > 255) ? 255 : (uint8_t)mag_val; } } // 2. 简化版非极大值抑制(只比较梯度方向上的两个邻域点) for (uint16_t y = 1; y < h - 1; y++) { for (uint16_t x = 1; x < w - 1; x++) { int32_t dx = gx[y*w + x]; int32_t dy = gy[y*w + x]; if (mag[y*w + x] < 40) { // 低幅值直接丢弃 edge_map[y*w + x] = 0; continue; } // 判断梯度方向,只在对应方向上比较左右/上下邻点 uint8_t suppressed = 0; if (abs(dx) > abs(dy)) { int16_t left = mag[y*w + x - 1]; int16_t right = mag[y*w + x + 1]; if (mag[y*w + x] < left || mag[y*w + x] < right) suppressed = 1; } else { int16_t upper = mag[(y-1)*w + x]; int16_t lower = mag[(y+1)*w + x]; if (mag[y*w + x] < upper || mag[y*w + x] < lower) suppressed = 1; } edge_map[y*w + x] = suppressed ? 0 : 255; } } free(gx); free(gy); free(mag); }这段代码在200MHz的MCU上跑完约8~10毫秒,如果配合二值化掩膜做ROI限制,可以压到5毫秒以内。但注意,我把梯度方向的分桶做得很粗糙,没有区分对角线方向,边缘在某些斜向区域可能会有细小断裂,这种断裂我会在接下来讲的形态学膨胀操作里补上。
3.3 边缘断裂的工程妥协:先膨胀,后细化,再拟合
不管Canny怎么优化,在真实赛道上边缘断裂都是逃不掉的问题。原因很多:反光导致局部灰度梯度不足、赛道灰度和背景接近导致梯度值偏低、Sobel算子的离散误差等。
面对断裂,我的处理链条是:边缘检测 -> 膨胀(连接断口) -> 骨架化(细化回单像素) -> 提取边界点集。
膨胀的作用是让边缘线“变粗”,把断口处的空隙补上。这一步需要用结构元素大小适中的膨胀,我一般用3×3全1结构元素膨胀一次或两次,膨胀次数太多会让边缘位置偏移,影响中线精度。膨胀之后边缘线粗了好几倍,但断口基本连上了,这时候再用一次骨架化(比如简单的形态学细化算法),把粗线缩回单像素宽度。这样既保持了边缘线的连续性,又不会因为线太粗导致边界位置不确定。
很多同学没用骨架化,直接用膨胀后的边缘算中线,结果就是转向严重抖动,因为粗边缘的“中心”到底在哪一列,每次算都不太一样。骨架化是一笔性价比极高的计算投入。
4. 形态学处理:尺寸、顺序、结构元素大小,一个都不能马虎
4.1 先腐蚀还是先膨胀?顺序错了结果完全不同
形态学处理里的膨胀和腐蚀,不仅仅是用来看的,它们是整个图像处理流程里的“决策工具”。不同顺序的组合,带来完全不同的效果,我在这里把几种组合方式的适用场景掰开揉碎讲清楚。
先腐蚀后膨胀,叫开运算,作用是消除小的亮噪点,同时保持大的目标区域形状基本不变。在我把二值化阈值故意调低(为了保证赛道完整)之后,背景里混进来的小噪点很常见,这时候先做一次开运算,效果立竿见影。经验之谈,开运算的处理结果会让赛道边界略微平滑,但不会改变赛道整体的宽度分布。
先膨胀后腐蚀,叫闭运算,作用是填充目标区域内部的小洞和缝隙。当赛道上有轮胎印、水渍导致二值化后赛道内部出现黑色空洞时,闭运算能把洞填上,让赛道区域完整。
我在实际中常用的组合是:第一步闭运算(补洞),第二步开运算(去噪)。顺序不要反过来,因为如果先开运算去噪,再去闭运算,理论上也能做,但开运算会放大目标区域边缘的不规则性,闭运算再补齐时可能把一些噪点又膨胀回来,效果不如先闭后开稳定。
这里有个非常重要的细节:结构元素的大小。很多同学不管三七二十一,直接用5×5或者更大的结构元素。结构元素越大,运算量越大,同时对目标形状的破坏也越严重。在智能车赛道这个场景里,赛道宽度在图像里通常有几十个像素,3×3结构元素的开闭运算已经足够。只有在处理大面积空洞时,我才考虑用5×5的闭运算,但后续一定会用骨架化修正位置偏移。
4.2 连通域筛选:为什么我不用漫水填充,而是用三次遍历
开闭运算做完,二值图上依然可能存在一些面积较大的非赛道区域,比如赛区外面的深色地毯装饰、观众席暗色区域。这时候需要连通域筛选,把面积最大的连通域(或者最符合赛道形态的连通域)保留下来。
常见的做法有漫水填充(Flood Fill)和轮廓检测。但在嵌入式平台上,我推荐一个更简单粗暴的方法:两遍扫描法(Two-pass),配合并查集做连通域标记。这方法在内存占用和计算速度上都很均衡,而且代码实现不复杂。
流程是这样:
- 第一遍扫描,对每个前景像素,根据其左上方相邻像素的标签判断是否属于已有连通域,否则给它新标签,同时记录标签之间的等价关系。
- 利用并查集合并所有等价标签。
- 第二遍扫描,把每个像素的标签替换为其根标签。
- 统计每个标签的像素数量,按面积排序筛选。
我在嵌入式平台上更偏好一个更轻量的变种:只统计每个连通域的“外接矩形宽度”和“面积”,不保留完整的标签图,因为赛道区域的特征就是宽度相对稳定、面积最大且呈长条状。按这个特征筛选后,直接生成一张干净的赛道掩膜:
uint32_t find_largest_component(uint8_t *binary, uint8_t *mask, uint16_t w, uint16_t h) { int32_t *labels = (int32_t *)malloc(w * h * sizeof(int32_t)); memset(labels, 0, w * h * sizeof(int32_t)); int32_t parent[4096]; // 最多允许4096个连通域 int32_t count[4096] = {0}; for (int32_t i = 0; i < 4096; i++) parent[i] = i; // 第一遍扫描,简化版(略去完整并查集代码,仅示意) int32_t next_label = 1; for (uint16_t y = 0; y < h; y++) { for (uint16_t x = 0; x < w; x++) { if (binary[y*w + x] == 0) continue; int32_t left = (x > 0) ? labels[y*w + x - 1] : 0; int32_t up = (y > 0) ? labels[(y-1)*w + x] : 0; if (left == 0 && up == 0) { labels[y*w + x] = next_label++; } else if (left != 0 && up == 0) { labels[y*w + x] = left; } else if (left == 0 && up != 0) { labels[y*w + x] = up; } else { labels[y*w + x] = (left < up) ? left : up; if (left != up) parent[up] = left; // 等价关系合并 } } } // 第二遍扫描,路径压缩+计数(完整实现略) // 找到面积最大的label后,生成mask输出 free(labels); return best_label; }这套流程跑完一次大约3毫秒,相比漫水填充动辄10毫秒以上的耗时,在40ms一帧的处理周期里节省出的时间非常可观。
4.3 中线提取的边界条件:在边界缺失时用“双线互推”兜底
拿到干净的赛道二值图之后,下一步就是提取中线。最基础的思路是逐行扫描:找到每行第一个和最后一个白色像素,它们的中点就是该行的赛道中线点。这个方法在赛道完整时非常好用,但一旦某一行只有一侧边缘(比如因为透视关系,赛道弯道内沿被车头遮挡),中点计算就出问题了。
我的解决思路是双线互推:如果某一行检测不到左边界,就用上一行的赛道宽度作为参考,从右边界反推左边界位置;如果连续多行都缺左边界,就启动“丢线处理”——记录当前赛道走向,用上一次有效的曲率外推这一段缺失的中线。这个处理逻辑写起来不复杂,但能极大提高弯道中的稳定性,防止中线突然跳变导致舵机猛打方向。
5. 性能优化实战:从一帧30毫秒压到5毫秒的完整过程
5.1 先定位瓶颈,再动手优化:别让感情用事浪费你的时间
每次看到新队员一上来就盯着代码东改改西改改,我就头疼。性能优化的第一原则永远是:先测量,再优化。我用的是逐段计时法,在每一段处理函数的前后都加上一个微秒级的时间戳,然后通过串口打印出来,观察到底是哪一段吃掉了主要时间。
在一台Cortex-A7内核的赛用开发板上,我测到过一批典型数据:
| 处理模块 | 优化前耗时 | 瓶颈原因 | 优化后耗时 |
|---|---|---|---|
| 灰度化 | 4.2ms | 逐像素RGB转灰度,浮点运算 | 0.8ms(查表法) |
| 大津法二值化 | 1.5ms | 直方图统计需要全图遍历 | 1.0ms(ROI裁剪后) |
| 边缘检测 | 12.6ms | 浮点平方根+递归滞后跟踪 | 4.8ms(定点近似+去递归) |
| 形态学处理 | 3.5ms | 多次完整图遍历 | 1.8ms(ROI限定+3×3核拆分) |
| 中线提取 | 5.0ms | 逐行扫描全图宽度 | 2.1ms(只在赛道区域行扫描) |
这张表是团队跑了两个星期才总结出来的。你可以看到,边缘检测是大头,其次是中线提取。很多人一上来先优化灰度化,其实灰度化占的时间比例没那么夸张。先测再调,是最省钱的方式。
5.2 查表法、定点化、内存复用:三个已优化的朋友
查表法的原理很朴素:把复杂的计算提前在启动时算好,存成数组,运行时直接用下标取结果。
灰度化的标准公式是gray = 0.299R + 0.587G + 0.114B,如果直接在循环里做浮点乘法,320×240图像要跑七万多次浮点运算。优化的做法是启动时生成一张uint8_t gray_lut[32][32][32],对RGB取前5位作为索引,查表得到近似灰度值。因为RGB值本身就只有8位,用5位索引做近似,灰度误差只有2到3个灰度级,对后续二值化和边缘检测影响微乎其微,但速度能提升5倍以上。同理,Sobel梯度幅值里的绝对值加法替代平方根,也是一种定点化近似。
内存复用这个思路很简单:不要在每一帧里反复malloc/free图像缓冲区,而是在初始化时分配好所有的中间图像内存(梯度图、边缘图、二值图、掩膜),整场比赛反复使用。malloc/free在嵌入式平台上的性能开销非常大,还可能引入内存碎片,导致一段时间后系统变慢。好的做法是写一个简单的内存池,所有图像缓冲区都从里面分配。
最后是一个容易忽略的细节:图像数据在内存中的访问模式。遍历图像时,尽量按行连续访问(先y后x的顺序),不要按列跳着访问。因为CPU缓存以行为单位加载数据,按列访问会导致缓存命中率极低,速度可能慢3到5倍。这个优化不需要改任何算法逻辑,只要把循环顺序写对,效果立竿见影。
5.3 帧率与稳定性的权衡:不是越快越好
很多队伍拼了命把图像处理压到1毫秒以内,然后追求200帧的摄像头采集频率。但我个人觉得,在嵌入式平台上盲目追高帧率不见得是好事。
一方面,高帧率意味着CPU大部分时间都在处理图像,留给控制环的时间就少了。智能车控制不仅仅需要图像信息,还需要编码器数据、陀螺仪数据、速度PID计算。如果图像处理抢占太多CPU时间,控制环的周期稳定性就会被破坏,转向和速度控制变得毛躁。
另一方面,图像处理结果的输出频率和控制频率不需要完全一致。我的做法是图像处理以50Hz(20ms每帧)运行,控制环以200Hz(5ms每周期)运行,图像数据通过一个带时间戳的共享缓冲区传递给控制环。控制环在两次图像更新之间,用上一次的有效赛道曲率做线性外推,这样既保证了控制的实时性,又不会因为图像数据抖动导致控制不稳。
这个“降频处理、高频控制、插值外推”的架构,是我在所有参赛经历中验证过最稳定的方案。强烈建议新队伍不要一上来就追求极限帧率,先把控制稳定,再慢慢往上提图像处理频率。
6. 实战疑难排查:从“图像全白”到“边界毛刺”的诊断思路
6.1 图像全白/全黑:先怀疑曝光,再怀疑阈值,最后查代码
智能车比赛的现场光照,永远不会和你的实验室一样。你在实验室调好的参数,到现场大概率要重新调。遇到图像全白或全黑,最常见的三个原因按概率排序是:
第一,摄像头曝光和增益设置不合理。很多摄像头默认自动曝光,在强光下会把整个画面提亮到全白,在暗光下会压到全黑。解决方法是把摄像头设为手动曝光,然后在调试界面上实时调整曝光值、增益值、对比度,直到画面中赛道和背景的灰度差值最大。我一般会在每个比赛场地留出至少半小时专门调这三个参数。
第二,大津法因画面主体结构突变而失效。这种情况多是ROI设置不合理,比如画面顶部有大片亮区干扰了直方图分布。重新检查ROI裁剪区域,确保ROI内赛道和背景占比大体均衡。
第三,代码本身的bug。比如图像数据格式搞错,摄像头输出的是YUYV格式,你按RGB格式解析,或者DMA传输的缓冲区和图像处理访问的内存不一致。这类bug在更换摄像头型号后特别容易冒出来,排查时需要先用串口把一帧原始图像数据dump出来,用上位机软件(比如VOFA+)图像化显示,确认原始数据本身是否正常,再去查处理逻辑。
6.2 边界毛刺和抖动:从“结果不好看”到“找到根因”的排查路径
边界毛刺是另一个让人抓狂的问题。现象是赛道的二值化结果或者边缘检测结果在实时画面上看着边缘坑坑洼洼,导致转向指令高频小幅抖动。排查思路我建议按以下顺序走:
- 检查二值化阈值是否接近赛道灰度和背景灰度的中间值。如果阈值正好落在边界过渡带上,一个像素的亮度波动就会导致边界来回跳动。对策是把阈值向赛道色那一侧偏移,让边界判断更“迟钝”一些。
- 检查是否有环境光频闪。工频50Hz的荧光灯,在以60fps或更高帧率采集的摄像头下,会出现光条纹或明暗周期性波动。对策是提高曝光时间的整周期匹配,或者改用无频闪的LED光源。
- 检查边缘检测的非极大值抑制环节是否真的生效。如果跳过了非极大值抑制,边缘是“粗线”,边界位置天然不稳定。回到第3章,确认这一步骤没有被你的优化策略误伤。
- 检查形态学处理的顺序是否正确。开闭运算的次序不对时,噪声没有被有效滤除,边界自然毛糙。
我有一段时间就是卡在边界毛刺上,所有阈值和曝光都调过了,还是抖。最后发现是摄像头的垂直同步信号不稳定,某些帧采集到的图像只有半帧是新的,下半帧是旧图像,导致图像错位,产生的“边界跳动”。这个问题在示波器上看摄像头PCLK和VSYNC信号才排查出来,属于比较冷门但确实存在的硬件坑。
6.3 边缘断裂的终极兜底:在控制层做容错
不管你图像处理做得多好,赛场上总会出现一些极端情况:一个急弯处摄像头视野被车头遮挡太多、赛道线被路肩遮住、反光导致整段边缘丢失。我不相信有任何队伍能在所有情况下保持图像处理结果完美,所以控制层的容错设计是最后一道防线。
我的控制逻辑里有一个“赛道置信度”的概念。每一帧图像处理完成后,统计有效边界点数、连续丢失行数、中线曲率变化量,综合计算一个0到100的置信度。置信度低于某个阈值时,控制器切换到“保守模式”:
- 速度强制降低到当前速度的60%以下;
- 转向角不再直接使用当前帧的中线,而是用前10帧中线做加权平均的外推值;
- 丢线超过一定行数时,按上一次有效曲率保持一个固定转向角,同时延迟给刹车留出反应时间。
这套容错设计的核心思想是:宁可让车慢一点,也不能让它冲出去。很多队伍在比赛中失控,不是因为图像处理不好,而是因为某一帧丢线后转向角出现了离谱的跳变,舵机瞬间打死,车直接失控。加一个置信度判断和输出限幅,能过滤掉绝大多数这种致命跳变。
7. 串起全流程:一个完整、可复用的处理链代码骨架
行文到这里,我把各个模块的原理和优化思路都聊了一遍。最后,我给出一个整合了所有环节的处理链伪代码,便于你在自己的平台上迁移:
void image_process_pipeline(uint8_t *raw_frame, uint16_t w, uint16_t h) { uint32_t t0 = get_tick_us(); // 1. 灰度化(查表法) rgb_to_gray_lut(raw_frame, gray_img, w, h); // 2. ROI裁剪统计 + 大津法阈值计算 uint8_t otsu_th = otsu_threshold_roi(gray_img, w, h, ROI_START_Y, ROI_END_Y); uint8_t final_th = otsu_th + threshold_bias; // 3. 二值化 + 形态学闭运算 + 开运算 + 连通域筛选 binary_threshold(gray_img, binary_img, final_th, w, h); morphological_close(binary_img, binary_img, w, h, 3); morphological_open(binary_img, binary_img, w, h, 3); uint8_t *clean_img = filter_largest_component(binary_img, w, h); // 4. 边缘检测(简化Canny) edge_detect(gray_img, edge_img, w, h); // 5. 边缘与二值掩膜融合:(可选)用二值掩膜约束边缘范围 for (uint32_t i = 0; i < w * h; i++) { if (clean_img[i] == 0) edge_img[i] = 0; } // 6. 形态学膨胀+细化连接断口 morphological_dilate(edge_img, edge_img, w, h, 3); morphological_skeleton(edge_img, edge_img, w, h); // 7. 中线提取(双线互推 + 丢线外推) extract_center_line(clean_img, edge_img, center_line, &confidence, w, h); // 8. 输出置信度和中线数据到控制环 set_control_data(center_line, confidence); }这个流程的每一步,我都在前面章节里解释了为什么要做、怎么做、有什么坑。按这个骨架去实现,你的图像处理帧率应该能稳定在40~50fps,给控制环留出充足的时间余量。
最后再分享一点我的个人体会:智能车竞赛的图像处理,比的不只是算法先进程度,更是工程化的稳定性和针对性。二值化、边缘检测这些经典算法,在PC视觉领域可能被视为“入门基础”,但在嵌入式赛车上,它们就是整个系统赖以生存的根基。把这些基础做到极致,比浅尝辄止地堆叠几个高级模型有用得多。希望这篇文章能帮你在备赛路上少踩几个坑,把更多时间花在调参、试跑、优化车辆动力学上,毕竟那才是最终决胜的地方。