资讯动态

智能车摄像头图像处理实战:最长白列法提取赛道边线

发布时间:2026/9/24 12:35:51 来源:尧图企业网站定制
每年智能车准备季都有大量同学卡在摄像头组的第一步图像到底怎么处理边线到底怎么提很多人拿到 MT9V03X 摄像头灰度图调出来了却对着满屏的白色赛道和黑色边界发呆——逐行扫描试了半天反光一打、阴影一遮边线直接乱飞。如果你也正在这个阶段我建议直接试试“最长白列法”。它的核心思路非常简单代码量很小但对反光、阴影、白色噪点的容忍度比逐行扫描好不少而且它天然自带“丢线处理”的参考坐标对新手来说极其友好。这篇文章我会把整个方法掰开揉碎讲清楚先说赛道图像处理的完整链路再说最长白列法的思路为什么成立然后给 MT9V03X 摄像头的寄存器配置和完整实战代码最后把反光、十字、单边丢线这些经典大坑一次性讲透。内容从零开始适合第一次接触智能车的新手也能给正在调车的老手一些不同角度的参考。1. 先说清楚摄像头组的图像处理到底要解决什么问题1.1 从一个画面到一个转向量智能车摄像头组看起来玄乎其实核心链路相当明确摄像头拍到赛道画面想办法把“车相对于赛道的位置和朝向”算出来然后输出给转向舵机和驱动电机。这中间最关键的环节就是图像处理而图像处理的最终目标只有一个——把每一帧图像变成若干个“边线点”再把这些边线点变成误差量。你可以这么理解整条链路摄像头采集灰度图原始数据就是一圈亮度值。对灰度图做二值化把赛道和背景分开常见做法是把浅色赛道变白、深色背景变黑或反过来。在二值图上提取左右边线也就是找出赛道的轮廓边界。由左右边线算中线由中线和图像物理中心的差值算出偏差。偏差送给转向控制速度也由偏差大小来决定。这五步里第2步和第3步最容易出问题也是所有摄像头组调车时花时间最多的地方。二值化如果做得不好后面边线全是鬼影边线提取如果不够稳转向控制再漂亮也没用。很多队伍在逐行扫描上死磕改来改去最后发现其实是方法选得不够聪明。1.2 赛道边线为什么难提先泼一盆冷水真实赛道的灰度图远没有教科书里那么理想。你以为拍出来是“白赛道、黑背景、边界清晰”实际上拍出来往往是这样的近处赛道因为光照强白得刺眼甚至反光到整片过曝。远处赛道因为像素密度低边界糊成一片灰色渐变。日光灯、窗外阳光会造成局部亮斑赛道边界的黑色胶带可能被阴影覆盖。车模自身的投影、旁边队友的走动都会在画面里留下时有时无的暗区。这些因素叠加起来会让“边线”这个概念变得模糊。逐行扫描法那种“每行从左边开始扫扫到第一个黑白跳变就当左边线”的粗暴思路在这种画质下很容易把噪声当边线、把阴影当赛道。你要是不做滤波车一跑起来边线就在左右横跳要是滤波做多了远处边线又被滤没了。所以我们需要一种先定位赛道整体位置再精细找边线的方法。最长白列法就是干这个的。2. 最长白列法的整体思路2.1 一句话理解“最长白列”先把图像想象成一个二维数组宽 W高 H。每一列就是数组里一个固定横坐标 x 下的一整竖条从第0行到第H-1行。二值化之后这竖条上的像素要么是白、要么是黑。统计一下每一列有多少个白色像素白点数量最多的那一列就是“最长白列”。为什么叫“最长”因为赛道在画面里是一整片纵向白色区域白色像素在某一列上往往连成一条长竖线。白点数最多的列意味着这一列上白色区域的纵向延伸长度最大、最完整所以叫“最长白列”。这个列号代表了什么代表赛道主体在画面中的横向位置。比如图像宽 120最长白列在第 60 列说明赛道大致位于画面中央如果最长白列在第 80 列说明赛道偏右车需要向右转修正。这个信息本身就够用但它更大的价值是作为后续边线提取的“启动坐标”。2.2 为什么比逐行扫描更稳我把逐行扫描法和最长白列法放在一起对比你看完就明白差距在哪了。对比维度逐行扫描法最长白列法基本原理每一行从图像边缘开始扫描找黑白跳变点作为边线先统计各列白色像素总量定位赛道横向位置再以此为中心找边线抗噪能力局部噪声直接造成假边线需要加很多滤波列统计天然“投票”零星噪声对列总数影响极小计算量每一行都从边缘扫到中心W 次统计阶段每列扫一遍提取阶段只需在赛道附近小范围搜索丢线后的表现丢线后下一行恢复边线容易乱跳丢线后仍可借最长白列恢复边线平滑小白上手难度逻辑简单但调参痛苦思路略绕但一旦理解调车极其顺滑最核心的区别在于逐行扫描是在“一无所知”的情况下找边线而最长白列法先告诉你“赛道大概在中间这个区域”然后你只需要在这个区域附近做精细搜索。这就好比你找东西一个是满屋子乱翻另一个是先在门口看一眼确认东西大概率在客厅然后直奔客厅翻。这带来的直接好处是即使在远处像素模糊、甚至有噪点干扰的行上你的搜索起点也离真实赛道不远不会发生那种“噪声拉走边界整条边线飞出去”的事故。2.3 整套算法流程速览在进入代码之前先把你脑子里该有的流程理顺。最长白列法整个图像处理链路分五步采集灰度图根据需要裁剪出有效区域。用固定阈值或大津法OTSU做二值化生成 0/1 的二值图。只统计图像下半部分离车近的区域每一列的白点数量找出白点最多的列作为参考列。从图像底部向上以参考列为起点逐行向左右两侧寻找白色区域的边界记录左右边线。由左右边线计算中线再计算中线与图像中心的偏差交给控制。这个流程看起来平淡无奇但每一步都有细节可以挖。下面我结合实际摄像头参数和代码把每一步讲透。3. MT9V03X 摄像头与图像预处理3.1 MT9V03X 是个什么样的摄像头MT9V03X 是智能车竞赛里非常常见的一类数字图像传感器通常指 MT9V032 和 MT9V034 这两款。它们的最大分辨率是 752x480但在智能车场景里几乎不会用到这么高常见做法是设置一个窗口比如 188x120然后单片机上再做缩放处理最终参与算法的是 60x120 甚至 40x80 这种小尺寸图像。和手机摄像头、普通模拟摄像头相比MT9V03X 最大的优势是全局曝光。什么意思简单说就是传感器所有像素在同一时刻开始感光、同一时刻结束感光拍出来的画面是“冻结”的一瞬间。而普通卷帘曝光摄像头是一行一行扫描感光运动状态下画面会斜切变形俗称“果冻效应”。智能车高速跑起来卷帘快门拍到的赛道边线会歪掉全局快门的 MT9V03X 就没有这个烦恼。MT9V03X 通过 DVP 并行接口把数据传给单片机配合 DMA 可以做到“摄像头数据边采边存CPU 同时做别的事”。底层的采集驱动不同开发板差异较大但最终都会把一帧灰度图摆在一个 uint8_t 数组里。本文后面的算法代码就从“你手上已经有了灰度图数组”这一步开始。3.2 关键寄存器配置与采集要点MT9V03X 的配置是通过类似 I2C 的 SCCB 时序写寄存器完成的。具体寄存器地址不同厂家模块略有差异但以下几个方向是通用的你拿到任何例程都应该先确认这几项配置项作用建议窗口大小决定采集多少行多少列影响视野和帧率先开 188x120后续再缩到处理尺寸曝光时间控制传感器感光时长进光量越大画面越亮越容易过曝从默认值往下调直到赛场地面不发白增益AGC增益越高画面越亮但噪点也会放大建议关闭自动增益固定一个偏低的值帧率每秒能出多少帧图像影响控制实时性一般在 50~80 帧满足大部分场景实际调车时我建议先把曝光调到一个“赛道上不反光、背景不发灰”的状态然后锁死增益和自动曝光不要让它自己变。自动曝光在室内灯光下会来回拉亮度画面上看就是一会儿亮一会儿暗二值化阈值跟着飘边线自然就不稳。下面给一段寄存器初始化的伪代码只是示意结构具体地址请查你手上模块的数据手册或厂家例程void mt9v03x_init(void) { // 设置窗口为 188x120 write_reg(MT9V03X_COLUMN_START, 0); write_reg(MT9V03X_ROW_START, 0); write_reg(MT9V03X_WINDOW_WIDTH, 188); write_reg(MT9V03X_WINDOW_HEIGHT, 120); // 关闭自动增益固定一个低增益 write_reg(MT9V03X_AGC_ENABLE, 0); write_reg(MT9V03X_GAIN_GREEN1, 0x20); // 关闭自动曝光手动设置曝光时间 write_reg(MT9V03X_AEC_ENABLE, 0); write_reg(MT9V03X_EXPOSURE_TIME, 0x0100); }注意MT9V03X 一帧数据进来后你手上是一张灰度图。在用 DMA 采集时大多数例程会开启两帧交替缓冲也就是这一帧在传输、上一帧在运算避免图像撕裂。这个细节很重要否则你算法很稳画面却像撕开一样边线照样乱跳。3.3 二值化动态阈值用大津法二值化就是把灰度图变成 0 和 1 的图。最简单的是固定阈值灰度大于 120 就是白否则就是黑。但这个方案很脆弱因为光线一变最优阈值就变了。你要是把阈值调到“正对灯光时不反光”一旦车走到赛场边缘光照暗一点赛道整体灰度下降固定阈值就会把大片赛道判成黑色直接翻车。更好的办法是大津法OTSU。它按灰度直方图自动寻找一个阈值使得白、黑两类之间的“类间方差”最大。大白话就是程序自己找一条分界线让分界线两边的灰度尽可能各自集中、彼此离得远。大津法代码不复杂适合小白直接抄。下面这个函数输入灰度图指针和像素总数输出一个阈值int otsu_threshold(uint8_t *gray, int total) { uint32_t hist[256] {0}; uint32_t sum_all 0; uint32_t sum_back 0; uint32_t weight_back 0; uint32_t weight_fore 0; double var_max 0.0; int threshold 0; // 1. 统计灰度直方图 for (int i 0; i total; i) { hist[gray[i]]; } // 2. 所有像素灰度之和用于后续计算前景/背景均值 for (int i 0; i 256; i) { sum_all i * hist[i]; } // 3. 遍历每个灰度值计算类间方差 for (int t 0; t 256; t) { weight_back hist[t]; // 背景像素数 if (weight_back 0) continue; 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 * (double)weight_fore * diff * diff; if (var var_max) { var_max var; threshold t; } } return threshold; }拿到阈值之后二值化就是一整层循环的事for (int i 0; i IMG_H * IMG_W; i) { binary_image[i / IMG_W][i % IMG_W] (gray_image[i] threshold) ? 1 : 0; }大津法每次都要全图扫描一遍算直方图在单片机上会有一定开销但智能车图像处理用的分辨率很低60x120 一共 7200 个像素复杂度完全可接受。你可以在 DMA 中断里把灰度图同时拷一份放到处理缓冲区然后主循环里跑大津法时间完全够。4. 最长白列法核心代码实战4.1 找最长白列从最底下那段开始统计先定义图像尺寸和数据结构。以实用分辨率 60 行 120 列为例这个尺寸对边线提取和实时性都很友好太大浪费算力太小远处信息不够。#define IMG_H 60 #define IMG_W 120 uint8_t gray_image[IMG_H][IMG_W]; // 灰度图 uint8_t binary_image[IMG_H][IMG_W]; // 二值图1白 0黑 uint16_t col_white_count[IMG_W]; // 每一列的白点总数找最长白列时有个重要技巧只统计底部附近的行。为什么因为图像底部离车最近画面最清楚二值化最可靠远处容易受光照、像素模糊影响白点数量不稳定。统计区域建议取图像下半部分比如从第 30 行到第 59 行共 30 行。这样既保留了赛道整体位置信息又避免了远处图像干扰。int find_max_white_col(void) { int max_col IMG_W / 2; // 默认取图像中心 uint16_t max_count 0; // 只统计底部 30 行 for (int col 0; col IMG_W; col) { col_white_count[col] 0; for (int row IMG_H / 2; row IMG_H; row) { if (binary_image[row][col] 1) { col_white_count[col]; } } if (col_white_count[col] max_count) { max_count col_white_count[col]; max_col col; } } return max_col; // 返回最长白列所在列号 }这段代码干的事就是遍历每一列数一数底部 30 行里有多少个白点把白点最多的列的列号记录下来。为什么可以信任它因为车在赛道上时正前方一定是赛道主体赛道主体在画面中间白色像素在中间的列上连成大片而背景在画面两侧白点数量远少于赛道区域。就算背景里有一些白色噪点单独某列的白点数量也不可能超过赛道所在列。这个统计过程本身就是个“投票机制”能自动忽略小面积噪声。4.2 从最长白列出发提取左右边线拿到参考列之后接下来就是从图像底部向上逐行提取左右边线。这里有个细节非常关键不要每一行都从最长白列开始往两边扫因为弯道里赛道会弯曲远处行的赛道中心会偏离参考列你要是每行都从同一列出发弯道深处的边线会找错。正确做法是保留上一行的左右边线中点作为当前行的搜索起点。这样边线是逐行“跟过去”的天然贴合赛道曲率。而参考列只在起始行和丢线恢复时用。看代码#define NO_LINE 0xFF // 表示该位置边线无效 typedef struct { uint8_t left[IMG_H]; // 每一行的左边线列号 uint8_t right[IMG_H]; // 每一行的右边线列号 uint8_t mid[IMG_H]; // 每一行的中线列号 uint8_t valid; // 整帧是否有效 } LaneInfo; LaneInfo lane; void extract_lane(int ref_col) { int last_left ref_col; int last_right ref_col; int row_lost_cnt 0; for (int row IMG_H - 1; row 0; row--) { int left_edge -1; int right_edge -1; int search_col; // 取上一行左右边线的中点作为搜索起点 if ((last_left ! NO_LINE) (last_right ! NO_LINE)) { search_col (last_left last_right) / 2; } else { search_col ref_col; // 上边线失效回到参考列 } // 如果搜索起点落在白区内说明这一行有赛道 if (binary_image[row][search_col] 1) { // 向左搜索找到最后一个白点 int col search_col; while (col 0 binary_image[row][col] 1) { col--; } left_edge col 1; // 向右搜索找到最后一个白点 col search_col; while (col IMG_W binary_image[row][col] 1) { col; } right_edge col - 1; } else { // 搜索起点不在白区可能是噪声或者丢线 // 尝试从 ref_col 出发再找一次 if (binary_image[row][ref_col] 1) { int col ref_col; while (col 0 binary_image[row][col] 1) col--; left_edge col 1; col ref_col; while (col IMG_W binary_image[row][col] 1) col; right_edge col - 1; } } // 记录本行结果 if (left_edge 0 right_edge 0) { lane.left[row] (uint8_t)left_edge; lane.right[row] (uint8_t)right_edge; lane.mid[row] (uint8_t)((left_edge right_edge) / 2); last_left left_edge; last_right right_edge; row_lost_cnt 0; } else { lane.left[row] NO_LINE; lane.right[row] NO_LINE; lane.mid[row] NO_LINE; // 连续丢线超过 5 行说明远处已经看不到赛道停止向上扫描 row_lost_cnt; if (row_lost_cnt 5) { break; } } } }这段代码的核心思想是先进去找白色区域然后从白色区域内部往两侧摸边界。你从上一行中点出发如果你的中点已经在赛道内部那么向左扫到第一个黑点就是左边线向右扫到第一个黑点就是右边线。这个方式比“从左往右找第一个白点”更稳定因为它天然对搜索起点做了限制不会把画面边缘的白色噪点误判成赛道。丢线处理也写进去了连续好几行找不到赛道说明前面的赛道已经接近消失直接停止扫描没必要继续往上看全是噪声的远处行。这个“提前止损”的逻辑能让边线数组的远端部分保持 NO_LINE方便你后面判断前瞻距离。4.3 由边线算偏差给转向一个参考量边线数组出来后计算偏差就很简单了。常用做法是取底部某一个固定行比如第 50 行的中线减去图像物理中心列号归一化后作为转向误差。float get_steering_error(void) { int row IMG_H - 10; // 取底部往上 10 行 int mid lane.mid[row]; if (mid NO_LINE) { return 0.0f; // 该行无效返回 0 让车直行或维持上次状态 } // 归一化到 -1.0 ~ 1.0 float error (float)(mid - IMG_W / 2) / (float)(IMG_W / 2); return error; }然后这个误差送给转向 PDfloat kp 0.6f; float kd 1.2f; static float last_error 0.0f; float error get_steering_error(); float steer kp * error kd * (error - last_error); last_error error; // steer 映射到舵机 PWM set_servo_pwm(STEER_MID (int)(steer * STEER_RANGE));PD 参数不要照抄每家车的机械结构、舵机响应都不一样。你从 kp0.5、kd1.0 左右开始试先把车放赛道低速跑看它是不是左右画龙再一点点调整。注意 kd 不要太大否则直道上一抖动就出锯齿状轨迹。这些参数调到最后你会发现边线提得稳参数就好调边线提得抖参数调爆了也没用。5. 调车路上的坑常见问题与排查实录5.1 反光二值化碎裂怎么治这个坑我几乎见每支队伍都踩过。赛道的浅色表面在灯光直射下会形成大片反光反光区域的灰度值冲到 250 以上看起来就是一片白光和白色赛道“融为一体”但赛道上如果有深色痕迹或者黑色胶带反光会把它盖掉导致二值化后赛道中间出现黑斑、边线断裂。解决反光从三个层面下手硬件层面降低曝光时间让整体画面亮度降下来反光区域就不会过曝。MT9V03X 的曝光可以调得比较低你调到画面里反光区域不再是一片死白就行。还可以在镜头前加偏振片对消除表面反光效果明显。如果经费有限用一张偏光太阳镜片剪成圆形贴镜头前也有用。算法层面用大津法动态阈值而不是固定阈值。反光造成的是局部过亮不是全局变亮大津法会在整幅图范围内找到一个折中阈值至少不会像固定阈值那样整片失效。后处理层面如果二值图上还是出现了小范围黑斑可以在边线提取循环里加一个“白点连续段长度判断”比如某一行白色区域中间出现了一个宽度小于 3 像素的黑点就忽略它把它当成反光噪点。我最初写边线提取时不理解为什么要做这个处理直到在真实赛道上看到中间碎的边线才明白。5.2 阴影和零散噪点怎么滤反光是“过亮”阴影是“过暗”。比赛场地里灯架、裁判、你自己都会在赛道旁留下阴影。阴影落到赛道上时该区域灰度会明显下降二值化后可能直接变黑导致某一行边线突然多出一个缺口。处理阴影最长白列法天然有优势。因为你的搜索起点是上一行中点或者最长白列阴影造成的局部黑区通常不会覆盖整条赛道。真正需要做的是在找左右边线时限制搜索宽度不要允许某一行白色区域无限膨胀。比如你可以给搜索半径设个上限从 search_col 出发最多找 40 列超过就认为这一行丢线。这样可以防止画面边缘的浅色背景被误判为赛道。零散白色噪点相对好办。二值化之后你可以对每一列的白点数量做判断如果某一行在搜索起点左侧扫到的“白色段”总长度小于 2 个像素就直接忽略它不认为是边线。这个阈值别设太大否则远处真正的赛道边界也被滤掉了。5.3 丢线单边丢、全白丢、全黑丢丢线是智能车摄像头组最经典的话题。最长白列法虽然稳但遇到极端场景还是会丢关键要分情况处理。单边丢线常见于大弯、圆环、贴边行驶时赛道的一侧边线超出摄像头视野。处理策略是用另一侧边线和预估赛道宽度推算丢线位置。比如右侧边线还在左边线丢了那么可以假设赛道宽度和上一行差不多if (lane.left[row] NO_LINE lane.right[row] ! NO_LINE) { uint8_t width lane.right[row] - lane.right[row - 1]; // 用上几行算赛道宽度 lane.left[row] lane.right[row] - width; lane.mid[row] (lane.left[row] lane.right[row]) / 2; }预估赛道的宽度最好取过去若干行的平均值不要只看上一行否则弯道里宽度波动大会把边线推飞。全白丢常见于十字路口几段赛道在画面里连成一大片白色左右边线都找不到了。这种情况我是这么处理的全白时边线实际上仍然是可用的只是“宽度”突然暴增。你可以把每一行的白区宽度都记录一下当宽度超过正常赛道宽度的 1.8 倍就认为遇到十字或者大面积白区此时不急着算偏差而是保持上一帧的偏差输出或者根据两侧白色区域的边界估算一个中间线。很多老队伍会专门做十字识别这里给个临时应对方案就够了。全黑丢说明摄像头前方一片黑可能是车冲出赛道、压到边界也可能是图像异常。全黑时我会把整帧数据判定为无效帧转向保持上一次的值同时适当降速。如果连续好多帧全黑直接停车等待人工干预别让车继续乱跑。5.4 最长白列选错怎么办最长白列法也会翻车最常见的原因是参考列选到了画面边缘的白色干扰上。比如赛场旁边有一扇白墙或者路边有白色标志物这些都会在图像两侧制造大量白点干扰统计结果。预防手段有三个只统计底部 ROI离车最近的那 30 行通常最干净。画面边缘的白墙再白也很难在底部区域形成一整列白点。加宽度连续性判断统计白点最多的列之后检查这一列的左右邻近列是否也有不少白点。如果只有孤零零一列特别白周围都黑那基本是噪声不是赛道。此时回退用上一帧的参考列。对参考列做低通滤波int ref_col_now find_max_white_col(); ref_col (int)(0.7f * ref_col 0.3f * ref_col_now);这样参考列不会突然跳变边线整体也就更稳。滤波系数可以自己调核心思想是让参考列“慢变”避免单帧偶发错误毁掉整条边线逻辑。5.5 常见问题速查表整理了一份极简排查表比赛现场遇到问题可以直接对号入座现象可能原因优先排查方案二值化图大面积黑/白曝光或增益不对关自动曝光手动调曝光边线每隔几帧跳一下自动增益/自动曝光在飘锁死增益大津法阈值确认大弯道内边线乱飞每行都从同一列出发改用上一行中点做搜索起点十字路口偏差突变全白导致中线算错检测白区宽度突变保持上帧偏差画面撕裂、图像一半新一半旧DMA 缓冲区没做乒乓启用双缓冲ROI 底部有干扰物白墙/标志物在画面边缘缩小统计列范围或裁剪 ROI车冲出赛道后无法恢复全黑连续丢线状态没恢复连续无效帧停车重置这些坑我当年调车时几乎一个不落踩过一遍。最浪费时间的是第二个当时一直以为是边线算法有问题最后发现是自动增益在偷偷调亮度排查了一整天。所以你在调车时遇到边线不稳先花十分钟盯住灰度图和二值图确认图像本身稳了再怀疑算法。6. 关于这项技术我最后想说的几点心得最长白列法本质上是一个很朴素的思路先用全局统计确定“赛道大致在哪”再局部精细搜索。它能成为很多智能车队伍的入门首选不是因为它炫而是因为它简单、稳、好调。我个人在实际调车中的体会是图像处理最怕的不是算法不够聪明而是图像基础没打好就盲目上复杂方案。你先把二值化做稳、把最长白列参考列用明白、把边线丢线策略写清楚车就能稳稳跑完一圈这比任何花哨算法都实在。最后再分享一个小技巧比赛现场光线变化剧烈最好在正式发车前做一个简单的“阈值自适应校准”——车停在赛道上连续采集 20 帧把大津法算出的阈值平均一下作为基础阈值再叠加上一个固定的偏移量。这样能应对绝大多数室内灯光的突变避免一发车就翻。如果你也是刚接触智能车没多久建议别急着上各种“高级”算法。先把最长白列法跑通跑稳再考虑动态前瞻、赛道分类、圆环识别这些进阶内容。想在图像处理上继续深入的话B站和公众号上很多学长都有非常详细的开源例程像“余心乐智能车”“雁过留痕”这些账号都值得去看看配合本文的思路一起理解会发现摄像头组远没有想象中那么难。先把边线提稳你已经赢了一半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价