调试记录、赛道视频、还有那台拆了一多半的车模,一直没舍得删。趁着整理工程文件的机会,把这段智能车生涯里真正沉淀下来的东西写成一篇文章,当作这份经历的文字版 Vlog。文章会按“系统架构 → 图像处理 → PID 控制 → 完整工程 → 常见问题 → 工程经验”的顺序展开,内容围绕摄像头寻线智能车展开,覆盖从硬件选型到赛道调试的完整流程。无论你是准备参加大学生智能车竞赛,还是单纯想把一辆摄像头寻线小车从零做起来,这篇文章都能作为一份可复用的技术笔记。
1. 智能车项目:为什么值得投入整个赛季
1.1 智能车到底是什么
很多同学第一次听到“智能车”,第一反应是遥控玩具。实际上,以全国大学生智能汽车竞赛为代表的智能车项目,是一套非常完整的嵌入式系统工程。一辆车模上集成了单片机、摄像头、编码器、舵机、电机驱动、电源管理等多个模块,通过代码让小车自主识别赛道、控制方向、调节速度,最终在复杂的赛道上稳定完赛。
把智能车的工作逻辑拆开,其实就是三个循环往复的步骤:
- 感知:通过摄像头采集赛道灰度图像,提取赛道边界和中心线。
- 决策:判断当前位置是直道、弯道还是十字、环岛等特殊元素,决定目标速度。
- 执行:根据偏差计算舵机转角,控制电机转速。
这三步以固定的控制周期不断执行,就构成了智能车的基本运行机制。
1.2 做智能车能锻炼什么能力
智能车项目的价值,不在于“让一辆小车动起来”,而在于它逼着开发者把嵌入式开发的全链路都走一遍:
- 硬件层面:电路焊接、传感器接线、电源稳定性检查、机械结构安装。
- 软件层面:底层驱动、中断服务、定时器、PWM、ADC、DMA。
- 算法层面:图像二值化、赛道中线提取、赛道元素识别、PID 控制、数据滤波。
- 工程层面:代码版本管理、参数标定、现场调试、团队协作。
这也是很多队伍“推倒重来”很多次的原因。摄像头角度不对、舵机中值没标定、PID 参数反复震荡、环岛识别迟迟不收敛——这些坑基本每支队伍都会踩一遍。但也正是这些反复调试的过程,让这个项目变得特别值得纪念。
1.3 本文的内容范围
这篇文章不是某次比赛的流水账,而是一份围绕“摄像头寻线智能车”的完整技术整理,重点包括:
- 智能车整体架构与常见硬件选型
- 图像二值化、赛道中线提取的原理与代码
- 舵机转向环、电机速度环的 PID 控制实现
- 调试过程中最高频的几类问题
- 可复用的工程化经验与参赛建议
具体的主控型号、摄像头型号,每个学校和每个竞赛组别都不一样。所以文中的代码更偏向“思路级实现”,你拿到自己的工程里替换底层接口即可。
2. 环境准备与整体架构
2.1 智能车系统架构分层
写代码之前,先理解智能车的系统分层。合理的分层能让你在调试时快速定位问题,而不是在庞大的 main 函数里反复翻找。
我从底层到顶层给大家梳理一遍:
- 硬件层:主控 MCU、摄像头、编码器、舵机、电机、电机驱动、电源模块。
- 驱动层:摄像头图像采集、编码器计数、PWM 输出、ADC 采集等底层驱动。
- 感知层:一帧图像的二值化、赛道中线提取、速度反馈计算。
- 控制层:基于偏差的舵机 PID、基于速度误差的电机 PID。
- 决策层:弯道减速、直道加速、特殊元素状态切换、目标速度规划。
调试的时候,一定要按层级去排查。图像是花的,先看驱动层;中线提取不对,先看感知层;舵机抖动,先看控制层参数。层级清晰,排错效率会高很多。
2.2 常见硬件选型说明
智能车竞赛经过多年发展,硬件方案已经相对成熟。我整理了一份常见的选型对照表,供参考:
| 模块 | 常见选型 | 说明 |
|---|---|---|
| 主控 MCU | NXP K60/K66、STM32F4、Infineon TC264 等 | 不同竞赛组别对主控要求不同 |
| 摄像头 | MT9V034(总钻风)、OV7725 等灰度摄像头 | 输出灰度图像,便于二值化 |
| 编码器 | 光电编码器 | 用于测速,构成速度闭环 |
| 舵机 | S3010 等模拟舵机、数字舵机 | 转向执行机构 |
| 电机 | 370、540 直流电机 | 驱动车轮转动 |
| 电机驱动 | BTN7971、DRV8701 | 提供大电流驱动能力 |
| 电源 | 7.2V 镍镉电池/锂电池组加稳压模块 | 供电稳定性非常关键 |
| 调试外设 | OLED、无线串口模块、虚拟示波器 | 把运行数据实时回传 |
需要特别强调,以上只是常见选型,不是标准答案。不同组别对传感器类型、主控平台、车模种类都有明确限制,具体以当年的竞赛规则或你手头的项目文档为准。
2.3 软件开发环境
智能车常用的开发环境,主要看主控平台:
- NXP 系列:IAR、Keil、MCUXpresso IDE。
- STM32 系列:Keil MDK,配合 STM32CubeMX 生成工程,使用 HAL 库或标准库。
- Infineon 系列:AURIX Development Studio 或 ADS。
版本这块,我不建议盲目追求最新版。工具链版本需要根据你手里的主控型号、资料包和现有工程来确定。只要编译下载稳定,工具链旧一点问题不大。相比纠结 IDE 版本,更重要的是建立一套高效的离线验证流程。
这里特别推荐一个习惯:用 Python + OpenCV 在电脑上先处理赛道图片,验证图像算法思路,再移植到单片机上。这样能省下大量现场调车时间,后面实战部分会给出示例代码。
3. 核心技术原理拆解
3.1 图像采集与二值化
摄像头输出的是一幅灰度图像,每个像素点的亮度值范围是 0 到 255。赛道处理的第一步,是把灰度图转换成黑白图,这一步叫做二值化。
二值化的核心逻辑非常简单:
某个像素点亮度 > 阈值 -> 设为白(255),认为是赛道或边界 某个像素点亮度 <= 阈值 -> 设为黑(0),认为是背景阈值的选择有两种常见方式:
- 固定阈值:实现简单,计算量小,但受光照变化影响明显。
- 大津法(Otsu):根据整幅图像的灰度分布自动计算阈值,抗光照变化能力更好,但会增加一定计算量。
下面是一份基础的二值化 C 代码:
// 文件:modules/image_process.c // 输入:gray 灰度图像数组,threshold 分割阈值 // 输出:binary 二值化图像数组 void image_binarize(const uint8_t gray[IMAGE_H][IMAGE_W], uint8_t binary[IMAGE_H][IMAGE_W], uint8_t threshold) { for (uint8_t row = 0; row < IMAGE_H; row++) { for (uint8_t col = 0; col < IMAGE_W; col++) { binary[row][col] = (gray[row][col] > threshold) ? 255u : 0u; } } }其中IMAGE_H和IMAGE_W是图像分辨率,比如 120 行、188 列。实际工程中,这一步一般放在摄像头 DMA 搬运完成之后执行,确保处理的是完整一帧图像。
3.2 赛道中线提取
有了二值化图像之后,下一步是找到赛道中线。这是摄像头寻线最核心的算法逻辑。
基本思路可以概括为:在图像的每一行中,从中间位置向左扫描,找到第一个白点作为左边界;从中间位置向右扫描,找到第一个白点作为右边界。左右边界的中心位置,就是这一行的赛道中线。
示例代码如下:
// 文件:modules/image_process.c // 输出:center 数组,值为 -1 表示该行丢线 void extract_center_line(const uint8_t binary[IMAGE_H][IMAGE_W], int16_t center[IMAGE_H]) { for (uint8_t row = 0; row < IMAGE_H; row++) { int16_t left_edge = -1; int16_t right_edge = -1; // 从中间向左搜索左边界 for (int16_t col = IMAGE_W / 2 - 1; col >= 0; col--) { if (binary[row][col] == 255u) { left_edge = col; break; } } // 从中间向右搜索右边界 for (int16_t col = IMAGE_W / 2; col < IMAGE_W; col++) { if (binary[row][col] == 255u) { right_edge = col; break; } } if (left_edge >= 0 && right_edge >= 0) { center[row] = (left_edge + right_edge) / 2; } else { center[row] = -1; // 丢线标记 } } }这里要特别说明“丢线”的情况。当小车进入曲率很大的弯道时,图像近处可能只能看到一条赛道边界,另一侧找不到白点,这一行就会丢线。丢线不能简单跳过,需要做补偿处理。常见方案包括:
- 使用上一行的有效中线位置做递推。
- 只信任单侧边界,按固定偏移估计中线。
- 结合之前几帧的中线数据做拟合预测。
另外必须提醒,上面是逐行扫描的基础版本。实际赛道还有十字路口、环岛、坡道、路肩等特殊元素,这些元素需要额外的状态机或特征判断,不能只用一套找中线逻辑处理。这也是智能车调试中最花时间的部分。
3.3 PID 控制在智能车里的作用
PID 是智能车控制的主心骨。智能车里最核心的两个控制环:
- 转向环:根据中线偏差计算舵机 PWM。
- 速度环:根据编码器测速与目标速度的误差计算电机 PWM。
位置式 PID 的公式如下:
output = Kp * error + Ki * integral + Kd * derivative下面是一个通用 PID 结构体和计算函数:
// 文件:modules/pid.c typedef struct { float kp; // 比例系数 float ki; // 积分系数 float kd; // 微分系数 float integral; // 积分累计值 float last_error; // 上一次误差,用于计算微分 } pid_t; float pid_calc(pid_t *pid, float error, float dt) { pid->integral += error * dt; float derivative = (error - pid->last_error) / dt; float output = pid->kp * error + pid->ki * pid->integral + pid->kd * derivative; pid->last_error = error; return output; }调 PID 时的经验要点:
- Kp 决定响应速度,调大反应更快,但过大会引起震荡。
- Ki 能消除稳态误差,但积分过大容易超调,甚至导致震荡。
- Kd 提供阻尼,能抑制超调,但对高频噪声敏感,图像类传感器尤其要注意。
- dt 必须是真实控制周期,不能随手填一个数,否则积分和微分的量纲都是错的。
在智能车项目里,我的调参顺序建议是:先整定转向环,让小车能稳定循迹;再整定速度环,让速度平稳;最后把两者联合起来调,因为转向和速度会互相影响。
4. 完整实战:从图像处理到转向控制的闭环
4.1 工程结构规划
实际项目中,我不建议把所有代码堆在 main.c 里。可以参考下面的目录结构,按模块拆分:
smart_car/ ├── user/ │ ├── main.c │ └── isr.c ├── drivers/ │ ├── camera.c │ ├── encoder.c │ ├── motor.c │ ├── servo.c │ └── pwm.c ├── modules/ │ ├── image_process.c │ ├── pid.c │ └── control.c └── config/ └── car_config.h- drivers 存放底层驱动,只负责操作硬件寄存器。
- modules 存放算法和上层控制逻辑。
- config 存放整车参数,比如图像分辨率、舵机中值、PID 初值。
这样做的最大好处是:当某个环节出问题时,你能快速确定问题在哪个文件,而不是在一段几千行的 main 函数里上下翻找。
4.2 图像处理核心代码
首先是二值化和中线提取,代码在前面已经给出。关键是最后要把中线转换成“偏差值”,喂给 PID。
计算偏差的代码可以这样写:
// 文件:modules/control.c // 取距离车体最近的有效行,计算中线与图像中心的偏差 int16_t calc_steering_error(const int16_t center[IMAGE_H]) { int16_t center_col = IMAGE_W / 2; for (uint8_t row = 0; row < IMAGE_H; row++) { if (center[row] >= 0) { return center[row] - center_col; } } return 0; // 整幅图像都丢线时,返回 0 并配合额外保护逻辑 }这里选择“距离车体最近的有效行”,是因为近处的赛道信息最可靠,受摄像头远景畸变影响最小。需要注意的是,如果整幅图像全部丢线,说明小车可能已经冲出赛道或者摄像头被遮挡,这时候不能直接按偏差 0 处理,而应该触发停车或其他保护逻辑。
4.3 舵机转向控制代码
得到偏差后,就可以通过 PID 计算转向控制量,再映射到舵机 PWM 占空比:
// 文件:modules/control.c void steering_control(int16_t error, pid_t *steer_pid) { float pid_out = pid_calc(steer_pid, error, CONTROL_DT); int16_t duty = SERVO_MID + (int16_t)(pid_out * SERVO_SCALE); // 限幅保护,避免超出舵机有效 PWM 范围 if (duty > SERVO_MAX) { duty = SERVO_MAX; } if (duty < SERVO_MIN) { duty = SERVO_MIN; } servo_set_duty(duty); }这里面的SERVO_MID是舵机中值,也就是让前轮保持正向前方的 PWM 占空比。SERVO_SCALE是把 PID 输出转换为 PWM 增量的比例系数,需要在实车上标定。
4.4 电机速度闭环控制代码
速度环以编码器测速结果作为反馈:
// 文件:modules/control.c void speed_control(float target_speed, pid_t *speed_pid) { float current_speed = encoder_get_speed(); // 单位由编码器标定决定 float err = target_speed - current_speed; float output = pid_calc(speed_pid, err, CONTROL_DT); // 将 PID 输出映射到电机 PWM 占空比,映射关系需要实测标定 motor_set_duty((int16_t)output); }注意,这里的target_speed不应该是固定值。直道可以提高目标速度,弯道前要提前减速。目标速度的规划一般放在决策层,根据中线偏差、赛道元素识别结果动态调整。
4.5 主循环整合
把前面几个模块放到主循环里,就构成了完整的控制闭环:
// 文件:user/main.c #include "car_config.h" #include "image_process.h" #include "pid.h" #include "control.h" #include "drivers.h" volatile uint8_t frame_ready = 0; uint8_t camera_image[IMAGE_H][IMAGE_W]; uint8_t binary_image[IMAGE_H][IMAGE_W]; int16_t center_line[IMAGE_H]; int main(void) { board_init(); // 时钟、引脚初始化 camera_init(); // 摄像头与 DMA 初始化 encoder_init(); // 编码器接口初始化 pwm_init(); // 舵机、电机 PWM 初始化 pid_t steer_pid = {2.0f, 0.0f, 0.1f, 0.0f, 0.0f}; pid_t speed_pid = {1.5f, 0.05f, 0.0f, 0.0f, 0.0f}; float target_speed = 1.5f; // 单位按实际标定结果调整 while (1) { if (frame_ready) { frame_ready = 0; // 1. 图像二值化 image_binarize(camera_image, binary_image, 80); // 2. 提取赛道中线 extract_center_line(binary_image, center_line); // 3. 计算转向偏差 int16_t steer_error = calc_steering_error(center_line); // 4. 转向控制 steering_control(steer_error, &steer_pid); // 5. 速度闭环控制 speed_control(target_speed, &speed_pid); } } }这段代码里的底层接口,比如camera_init()、servo_set_duty()、encoder_get_speed(),需要根据你手头主控的 SDK 自行实现。不同平台差异非常大,直接把这段代码拿过去是编译不过的,重点要看控制逻辑本身。
frame_ready一般在摄像头 DMA 中断里置 1,表示一帧图像采集完成。采用“中断置标志,主循环处理”的方式,可以避免图像处理阻塞中断服务函数。
4.6 离线验证图像算法:Python + OpenCV
在实际跑车之前,先用拍下的赛道图片验证图像算法,能节省大量时间。推荐用 Python 写一个离线调试脚本:
# 文件:tools/image_debug.py import cv2 import numpy as np # 读取灰度图 img = cv2.imread("track.jpg", cv2.IMREAD_GRAYSCALE) # 二值化:先用 Otsu 得到初始阈值,再根据效果手动微调 _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) h, w = binary.shape center_points = [] # 逐行提取赛道中线 for row in range(h): row_data = binary[row] left_idx = np.where(row_data[: w // 2] == 255)[0] right_idx = np.where(row_data[w // 2:] == 255)[0] if left_idx.size > 0 and right_idx.size > 0: left_edge = int(left_idx[-1]) right_edge = w // 2 + int(right_idx[0]) center_points.append((row, (left_edge + right_edge) // 2)) # 在原图上画中线 color_img = cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR) for pt in center_points: cv2.circle(color_img, (pt[1], pt[0]), 1, (0, 0, 255), -1) cv2.imwrite("track_result.jpg", color_img) print("处理完成,共提取", len(center_points), "行中线")这段脚本的好处是:你可以用大量真实赛道素材反复测试阈值和中线提取逻辑,确认效果稳定后再同步到单片机。对于环岛、十字等特殊元素,也建议先用图片素材离线验证特征判断逻辑。
5. 常见问题与排查思路
智能车调试过程会遇到的问题非常多,这里整理一份高频问题排查表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 摄像头画面花屏或条纹 | 摄像头配置时序不对,或供电不稳定 | 检查摄像头寄存器配置,单独用稳压模块供电 |
| 二值化图像全黑或全白 | 阈值不合适,或镜头曝光异常 | 改用 Otsu 阈值,检查镜头对焦和进光量 |
| 舵机高速抖动 | Kp 过大,或控制周期不稳定 | 减小 Kp,固定控制频率,检查 PWM 频率是否在舵机范围内 |
| 中线频繁丢线 | 弯道曲率过大,或搜索范围设置不合理 | 增加丢线补偿,使用上一行中线,或改用双边搜索 |
| 电机有 PWM 但轮子不转 | 驱动板使能脚未配置,或电源电流不足 | 检查 EN 引脚电平,确认驱动供电,用串口打印占空比 |
| 车身左右剧烈摇摆 | 转向环和速度环参数不匹配 | 降低目标速度,重新整定 PID,先调转向再调速度 |
| 上电后 MCU 不启动 | 晶振配置错误,复位电路异常 | 先用最小工程确认核心板可运行,再逐个添加外设 |
再展开两个最常见的典型案例。
案例一:图像偶尔整帧丢失
现象:跑车过程中画面突然黑掉一帧,随后又恢复,导致控制量跳变。
原因:最常见的是 DMA 搬运和图像处理同时访问同一块内存缓存,数据被覆盖或读到了半帧数据。
解决:引入双缓冲机制,也就是 ping-pong buffer。摄像头 DMA 写入 buffer A 时,主循环处理 buffer B,处理完成后再交换。这样可以保证 CPU 永远处理完整且稳定的帧数据。
案例二:舵机一上电就嗡嗡响
现象:小车刚上电,舵机就高频抖动并发声,前轮左右乱摆。
原因:PWM 占空比超出了舵机有效范围,或者 PWM 频率与舵机不匹配。
解决:先确认舵机支持的 PWM 频率,常见的是 50Hz,数字舵机一般支持更高频率。然后只给舵机输出中位值,确认前轮能回到正中位置,再叠加 PID 计算出的控制量。
6. 工程化建议与参赛经验
6.1 参数标定要优先完成
很多队伍一开始就急着写控制代码,结果后面所有问题都分不清是算法问题还是“车本身没调好”。参数标定就是地基,主要包括三项:
- 摄像头安装角度:要能同时看到近处较宽的画面和远处的赛道走向。
- 舵机中值标定:让小车静止时前轮方向完全正对前方。
- 编码器速度标定:测出单位时间内脉冲数对应的实际速度值。
这些基础工作不完成,后面的 PID 整定、弯道规划都无从谈起。
6.2 调试工具值得花时间搭建
我见过不少队伍用 OLED 一屏一屏地翻参数,效率很低。更推荐的方案是:
- 无线串口很重要。跑车时把偏差、PID 输出、当前速度实时回传到电脑,能直观看到控制过程是否正常。
- 配合虚拟示波器软件,比如 SerialPlot、VOFA+ 等,把数据画成曲线看,比看 OLED 数字直观很多。
- 每次调参后保存一份参数记录,注明赛道条件、车速、参数值、现象,方便后续回溯。
6.3 代码管理要建立回滚机制
智能车的调参量非常大,而且经常出现“改着改着,原来能跑的版本也回不去了”的情况。几个保守建议:
- 每次大改动之前,保存一个可运行的完整工程副本。
- 用 Git 管理代码,标签可以命名为“可跑直道版”“弯道稳定版”“环岛调试版”。
- 赛道元素逐个新增,一次只改一个变量。多个参数同时变化时,出了问题很难定位。
6.4 团队协作与时间规划
智能车项目很少是一个人能全部搞定的。合理的分工方式可以是:一人负责硬件和电路,一人负责图像算法,一人负责控制调参,一人负责文档和素材整理。分工不是把工作分开就不管了,而是每个接口之间要约定清楚,比如图像输出格式、偏差数据类型、PID 初值范围都要有明确说明。
时间规划上,最怕的是所有问题最后一周才暴露。建议至少提前两周进入完整赛道测试阶段,把机械、供电、算法、代码的问题都暴露在比赛之前。
另外还有一条安全提醒:高速跑车测试时,务必检查车模线束是否牢固、车轮螺丝是否上紧、舵机拉杆是否松动,避免小车高速冲出赛道伤到人或损坏设备。电池充电和烙铁使用时也要注意规范操作。
6.5 调车顺序建议
综合多支队伍的经验,推荐的调车顺序是:
- 先保证小车能正常上电、舵机可控、电机可控。
- 固定低速,先调转向环,让小车能在简单赛道上稳定循迹。
- 加入速度闭环,先调直道加减速。
- 逐个加入弯道、十字、环岛等元素,每次只处理一个。
- 最后做全赛道联调,重点观察元素衔接处的稳定性。
7. 写在最后:这段智能车生涯的总结
整理完这些东西,回头再看那段天天泡在实验室、调参调到深夜的日子,发现最珍贵的不是最终跑得多快,而是面对问题时的排查思路和动手能力。
这篇文章覆盖的关键点包括:
- 智能车的系统分层和硬件选型思路。
- 图像二值化与赛道中线提取的实现方法。
- 舵机转向环和电机速度环的 PID 控制。
- 从 Python 离线验证到单片机移植的完整闭环。
- 高频问题的排查思路和工程化管理经验。
如果你正在准备参赛,下一步可以继续深入学习赛道元素的状态机识别、路径平滑规划、更高级的数据滤波算法,以及惯性导航传感器的融合使用。如果你只是刚接触智能车,建议先照着一台开发板和一辆车模,把“图像采集 → 中线提取 → 舵机转向”这个最小闭环跑通,再逐步加功能。
做智能车的过程注定不会一帆风顺,参数可能反复震荡,小车可能一次次冲出赛道,代码可能推倒重写。但恰恰是这些“不顺利”,才是这个项目真正留给你的东西。
这段智能车生涯可能告一段落了,但工程调试的底子会一直跟着你。如果这篇文章对你有帮助,可以收藏备用,也欢迎在评论区聊聊你做智能车时踩过的坑。