智能车“未加入视觉”阶段:闭环控制、状态机与视觉接口预留的工程实践
最近整理第 21 届智能车项目资料时又看了一遍车模跑“走马观碑”路段的运行视频。画面里车模以中低速稳定通过转向流畅没有明显抖振看上去已经“能跑”了。但视频标题里有个关键信息未加入视觉。很多同学看到这种视频会下意识觉得车都能稳定跑过这一段了视觉识别加不加好像只是锦上添花。这个判断其实不准确。恰恰是这个“能跑但还没加上视觉”的中间版本才是整个项目里最值得保存、也最有技术含量的一版。它证明了底层机械、驱动、测速、闭环控制这些基础设施是可靠的而视觉识别要解决的是另一层问题两者不能互相替代。这篇文章想把这个工程阶段讲透为什么“未加入视觉”本身是一个重要的技术里程碑在这个阶段车模的软硬件架构是怎么搭出来的闭环控制代码怎么写、怎么调以及怎么通过运行视频和录制作业为下一步接入视觉识别留好接口、定好基准。如果你正在做智能车竞赛、机器人小车或者也在经历“先让车跑起来再让它看懂路”的开发过程这篇内容应该能帮你少走不少弯路。1. 为什么“未加入视觉”是一个值得记录的技术里程碑先说结论在没有视觉的情况下车模能稳定跑完“走马观碑”路段至少证明了以下四件事是成立的。第一动力链路是通的。电池、稳压、电机驱动、PWM 输出、电机这一整条链路如果任何一环有问题车是动不起来的更不要说稳定跑完一整段。第二速度闭环是有效的。通过编码器测速加 PID 控制车模能在不同负载和路况下维持目标速度这是后面做视觉识别时最重要的前提。第三路径保持是可用的。无论是靠电磁传感器、灰度传感器还是 IMU 融合车模能沿着赛道线走没有冲出边界。第四状态机框架是可扩展的。当前版本即使没有视觉代码里也要提前预留视觉结果的接入位置否则后面加功能时只能大改。不理解这个阶段价值的人容易犯一个错误把“能跑”当作“完成”。实际上竞赛赛题里的视觉识别任务比如识别路边碑体上的图案或字样涉及的是图像采集、图像预处理、目标检测、结果输出和与车辆控制状态机的联调。这些工作在没有接入视觉之前根本无从验证。但反过来也要清楚没有视觉的版本能验证的是“车本身没问题”不能验证的是“车在识别后还能不能保持正确动作”。识别到目标后要不要减速、要不要变道、要不要停车这些决策逻辑只有在视觉接入后才能完整测试。所以“未加入视觉”不是终点而是把系统分成两层后的第一层验收这个验收越严格第二层接入时越安全。从项目管理的角度看这个阶段也最适合做基线保存。跑一遍、录一段视频、记一组参数之后的每一次改动都可以和这个基线对比。如果没有这个基线等视觉代码加进去之后出了问题你很难判断是控制的问题、图像的问题还是两者联动的问题。2. “走马观碑”元素解读它到底考的是什么“走马观碑”原本是一个成语大意是骑马经过石碑时就能快速看清碑文形容速度快且观察敏锐。智能车竞赛把它用作赛题元素本质上是在高速运动场景下增加一个“观察-理解-决策”的任务这比单纯循迹要难一个维度。放在赛道上这个元素大致可以理解为赛道旁边放置一个预先设定好的“碑体”上面有特定的图案、字样或符号。车模在行驶到该区域时需要在不完全停车或者仅短暂降速的情况下通过摄像头识别出碑体上的内容并根据识别结果做出后续动作。这里的难点不在于“识别”本身而在于“运动中的识别”。为什么说运动中识别很难因为车在运动时摄像头画面每一帧都在变化图像会有运动模糊光照条件随位置变化而改变碑体在画面中的大小和位置也不稳定。如果车模以较高速度通过留给识别的有效帧数可能只有几十帧甚至十几帧算法必须在这些帧里完成检测和判断。这不像离线识别一张图片可以慢慢调参。所以“走马观碑”这个元素实际上把系统分成了三个层次层次任务当前项目状态依赖基础运动层电机驱动、测速、转向、稳定行驶已完成并录制视频验证电池、电机、编码器、主控感知层摄像头采集图像识别碑体图案未加入视觉摄像头、图像算法、算力决策联动层根据识别结果调整车速和路径待感知层完成后联调状态机、识别结果接口这个表格里最关键的是第三层。很多团队在第一层做完之后以为第二层做完就大功告成结果第三层联调时发现识别结果出来得太慢车都已经冲过碑体了。这也是为什么我建议在“未加入视觉”阶段就要把状态机里的视觉接口和降速策略先写好而不是等摄像头到了再临时补。3. 这个阶段的车模系统架构抛开具体型号无视觉阶段的车模系统架构并不复杂但每个模块都有明确职责。主控芯片负责所有逻辑计算常见的选型是 STM32 系列也有团队用更高主频的 MCU 或带 AI 算力的开发板。在主控之外驱动模块接收 PWM 信号并驱动电机编码器反馈实际转速IMU 提供姿态和角速度信息电源模块负责把电池电压稳定到主控和传感器需要的工作电压。这个阶段没有摄像头所以图像链路是空的。从数据流来看整个系统是一个典型的闭环设定速度/路径 → 主控计算 → PWM 输出 → 电机驱动 → 车轮转动 ↑ ↓ ← 编码器/IMU 反馈 ← 车身实际状态 ←这个闭环在无视觉阶段是自洽的。主控知道车跑多快、偏了多少并根据这些信息实时修正输出。摄像机接入后数据流会变成图像作为另一路输入经过识别算法变成“前方是否有目标”或“目标是什么”的高层信息再注入到主控的状态机里影响速度设定和转向逻辑。架构设计上有一个容易被忽略的点视觉结果不应该直接控制电机而应该先进入状态机由状态机决定动作。这样做的好处是当视觉识别出现错误或超时时控制层仍然有兜底策略不会因为一帧误检就让车子猛打方向。无视觉阶段还要确定通信和调试方式。常见做法是串口输出日志或者用无线模块把车模内部状态传到上位机。项目视频里能录到稳定的运行画面通常意味着调试阶段已经把日志系统做好了否则出了问题很难定位。4. 环境准备与硬件平台说明这一节按通用经验整理不限定具体厂商型号。实际项目中请以你手里的板卡和竞赛规则为准这里重点是提供一个完整的最小系统清单。模块作用备注主控板运行控制算法和状态机常见用 STM32 系列需预留摄像头接口电机驱动模块放大 PWM 信号驱动电机注意电流余量和散热直流电机加编码器驱动车轮并反馈转速编码器精度直接影响速度闭环效果IMU 模块提供角速度和加速度用于转向补偿和姿态参考稳压电源输出稳定的 5V/3.3V 等电压电池电压变化时仍需稳定灰度传感器或电磁传感器检测赛道线视具体赛题选择电池整机供电注意电压和放电能力匹配摄像头视觉识别输入当前阶段可先不装但接口要预留软件工具链方面嵌入式端一般使用 Keil MDK、STM32CubeIDE 或 IAR 进行开发代码用 C 语言编写后续如果做视觉可能还会用到 OpenCV、图像采集工具和上位机调试软件。如果你是团队协作建议把代码仓库放在 Git 上每个阶段打一个标签视频素材单独归档。环境准备阶段有几个安全提醒虽然不是技术难点但出了问题代价很大。电池接线时要先断开供电再操作防止短路电机驱动模块在高负载下会发热第一次上电前最好用低速空转测试验证接线车模测试要在空旷、地面平整的场地进行周围不要有易碎物品和人。任何涉及赛道的调试都要提前确认场地授权和测试时间安排。5. 无视觉阶段的核心实现从打通电机到稳定跑完无视觉阶段的工作可以拆成四个步骤电机驱动、速度闭环、路径保持、状态机预留。每一步都有独立的验证标准不要连做三步再一起测试。5.1 电机驱动先让轮子“听话”第一步是让电机能够根据 PWM 占空比转动并且可以控制方向和转速。方向控制依赖驱动模块的 IN 引脚电平组合转速控制依赖 PWM 占空比。这里最常见的坑是 PWM 频率选择。频率太低电机会有明显啸叫频率太高驱动模块可能响应不过来。一般直流电机驱动PWM 频率在 10kHz 到 20kHz 之间比较常见具体看驱动芯片手册。另一个坑是上电瞬间电机意外转动所以初始化代码必须在启动 PWM 之前把占空比设置为 0并且把方向引脚设置为确定状态。验证标准很简单程序里给一个固定占空比电机转速应该稳定切换方向引脚电平电机反转此时如果编码器有反馈读数方向应该跟随变化。这步做不好后面所有闭环都是空谈。5.2 编码器测速与速度闭环编码器把机械转角变成脉冲信号主控通过定时器计数计算转速。测速精度直接影响速度闭环。速度闭环最常用的是 PID 控制器增量式或位置式都可以关键是先调 P再调 I最后加少量 D。P 太小响应慢P 太大容易震荡I 的作用是消除稳态误差但过大的 I 会让系统超调甚至振荡。调试速度闭环时建议用上位机把目标速度和实际速度画成波形。如果实际速度围绕目标速度来回震荡先减小 P如果实际速度和目标速度之间有固定偏差适当增加 I。调参要有耐心一次只改一个参数并记录改动结果。5.3 路径保持传感器循迹与 IMU 融合路径保持的常见方案有两类。一类是灰度传感器阵列循迹通过检测黑线位置计算偏差再把偏差映射到转向。另一类是电磁循迹通过感应导线周围的磁场判断位置。进阶方案是融合 IMU 的角速度数据在车身偏移时进行补偿。无视觉版本里路径保持只需要做到“不冲出赛道”即可但要注意这个阶段速度不能设得太高。因为路径保持的可靠性与速度成反比速度越高留给修正的时间越短。建议从低速开始逐步提高每提高一档都重新观察运行是否稳定。5.4 预留视觉接口的状态机即使没有摄像头控制逻辑也要写成状态机。因为“走马观碑”任务本质上是一系列状态切换正常行驶、接近碑体、识别、决策、继续行驶。如果主循环里全是if嵌套后面接视觉时会非常难维护。状态机要提前定义好各个状态并预留两个关键变量视觉结果标志和视觉就绪标志。在没有摄像头时这两个变量可以被固定为默认值不影响运行接入摄像头后识别线程只需要把结果写进这两个变量控制状态机就会自动切换状态。6. 完整示例代码实现下面以 STM32 HAL 库风格给出几个核心代码片段。注意这不代表你项目的实际工程重点是理解每个模块的职责和接口设计。6.1 电机 PWM 驱动// motor.c —— 电机 PWM 驱动STM32 HAL 示例 #include motor.h void Motor_Init(void) { // 启动 PWM 输出 HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_2); // 初始占空比为 0防止上电瞬间电机转动 __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, 0); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_2, 0); } // speed 范围 -1000 ~ 1000正值为前进负值为后退 void Motor_SetSpeed(int16_t speed) { if (speed 1000) speed 1000; if (speed -1000) speed -1000; if (speed 0) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, speed); } else { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, -speed); } }这段代码的关键点有两个一是限幅防止传入非法值导致占空比越界二是电机的方向和占空比分开控制逻辑清晰便于后面扩展。实际接线中GPIOA 的引脚号和你板卡上的驱动模块输入引脚要一一对应。6.2 增量式 PID 速度闭环// pid.c —— 增量式 PID用于速度闭环 #include pid.h typedef struct { float kp; float ki; float kd; float integral; float last_error; } Pid_t; void Pid_Init(Pid_t *pid, float kp, float ki, float kd) { pid-kp kp; pid-ki ki; pid-kd kd; pid-integral 0.0f; pid-last_error 0.0f; } float Pid_Update(Pid_t *pid, float target, float current) { float error target - current; float output; // 积分限幅防止长时间偏差导致积分饱和 pid-integral error; if (pid-integral 200.0f) pid-integral 200.0f; if (pid-integral -200.0f) pid-integral -200.0f; output pid-kp * error pid-ki * pid-integral pid-kd * (error - pid-last_error); pid-last_error error; return output; }PID 代码本身不复杂复杂的是参数调节。积分限幅这里用了 200 这个经验值它应该根据你的 PWM 最大输出范围来定。如果 PWM 最大是 1000积分限幅取最大输出的五分之一左右是比较稳妥的起点。调参时先从 kp 开始逐步增大出现震荡就回调再加入 ki 消除稳态误差。6.3 运行状态机与视觉接口预留// main_control.c —— 运行状态机预留视觉识别接口 #include main_control.h typedef enum { STAGE_START, // 起步阶段 STAGE_NORMAL, // 正常巡线行驶 STAGE_STELE_APPROACH, // 接近碑体准备降速 STAGE_STELE_WAIT, // 等待视觉识别结果 STAGE_STELE_PASS, // 识别完成通过碑体区域 STAGE_STOP // 停车 } RunStage_t; RunStage_t stage STAGE_START; uint8_t vision_ready 0; // 视觉模块是否就绪 uint8_t vision_result 0; // 视觉识别结果0 未识别到目标1 识别到目标 // 视觉模块结果回调接入摄像头后由识别任务调用 void Vision_OnResult(uint8_t result) { vision_result result; vision_ready 1; } void Control_Loop(void) { switch (stage) { case STAGE_START: TargetSpeed_Set(0.3f); if (Encoder_GetSpeed() 0.25f) { stage STAGE_NORMAL; } break; case STAGE_NORMAL: TargetSpeed_Set(1.2f); // 接近碑体的判断条件可以是里程累计、按键标记或传感器触发 if (Track_Flag_IsSet(TRACK_FLAG_STELE_NEAR)) { stage STAGE_STELE_APPROACH; Track_Flag_Clear(TRACK_FLAG_STELE_NEAR); } break; case STAGE_STELE_APPROACH: // 降速为视觉识别提供稳定的图像采集窗口 TargetSpeed_Set(0.6f); if (vision_ready) { stage STAGE_STELE_WAIT; } break; case STAGE_STELE_WAIT: // 等待视觉结果超时则按未识别处理避免卡死 if (vision_ready vision_result 1) { stage STAGE_STELE_PASS; } else if (Timeout_Check(1000)) { stage STAGE_STELE_PASS; // 超时降级 } break; case STAGE_STELE_PASS: TargetSpeed_Set(1.0f); break; default: break; } }这段状态机代码是无视觉版本和视觉版本之间的“桥”。当前阶段Vision_OnResult不会被调用vision_ready一直为 0所以状态会从STAGE_STELE_APPROACH一直等到超时然后按“未识别”继续执行。这个流程和将来接入视觉后的流程完全一致只是缺少真实的识别结果。这样设计的好处是视觉代码接入后主控逻辑不需要大规模改动。6.4 视觉识别流程骨架预留参考这一小段是给下一步接入视觉做参考的当前版本并未运行。骨架使用 Python 加 OpenCV 描述便于快速验证识别流程。# vision_demo.py —— 后续接入摄像头时的识别流程骨架 import cv2 def send_to_mcu(detected): # 将识别结果发送给主控串口或网络方式按你的方案实现 pass def main(): cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: continue # 1. 裁剪碑体可能出现的区域减少计算量 roi frame[240:480, 0:640] # 2. 灰度化 二值化把目标图案从背景中分离 gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) # 3. 轮廓分析判断是否出现目标图案 contours, _ cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) detected False for cnt in contours: area cv2.contourArea(cnt) if area 500: detected True break # 4. 把结果发给主控 send_to_mcu(detected) cv2.imshow(stele, roi) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这里只演示了最简单的阈值加轮廓方法。真实赛题场景里可能需要对字符、数字或特定图案做更准确的识别后续可以用模板匹配、特征匹配或者轻量级神经网络替换第三步。骨架的价值在于固定输入输出接口识别算法怎么换都可以但发到主控的结果格式保持不变。7. 运行验证与录像方法论“车跑起来了”和“车稳定跑起来了”是两码事。无视觉阶段的验收建议按照下面几条标准来做。第一连续多次运行至少保证在相同参数下 5 次里有 4 次能完整跑完目标路段。如果只有一两次成功说明系统处于临界状态速度快了或电池电压稍微变化就可能失败。第二记录关键运行数据。通过串口把目标速度、实际速度、转向偏差等数据导出来存成文件。这些数据能够在录像之外提供更精确的判断依据。第三录像需要有固定机位和跟随机位。固定机位用来判断车是否跑在赛道线内跟随机位用来观察车身姿态和电机声音是否正常。视频文件名建议包含日期、参数版本、速度档位例如20250601_stele_v1.0_speed1.2.mp4。录制动运行视频时还有一个容易被忽略的细节要在不同的电池电量条件下各录一段。电池电压充足时和快没电时电机响应速度明显不同。如果你只在满电时测试比赛时剩余电量下降可能出现完全不同的车况。视频素材要备注电量状态这是很实用的排查信息。判断成功与否的第一指标是“稳定通过且不触碰赛道边界”第二指标是“速度波动小”。如果录像里车有明显的前后耸动说明速度闭环还没调好如果转向处有剧烈摆动说明路径保持参数需要调整。这两类问题用肉眼看视频就能发现不用等运行数据导出。8. 常见问题与排查思路无视觉阶段看起来简单实际踩坑点不少。下面按问题现象整理了一份排查表。问题现象可能原因排查方式解决方案电机不转驱动模块供电异常或 PWM 未启动检查电池电压、驱动模块电源指示灯重新上电并确认初始化顺序电机只能转不能正反转IN 引脚逻辑接反或代码写反单步调试方向引脚电平核对驱动模块 IN 引脚定义转速有明显抖动PID 参数不合适或编码器读数有毛刺打印编码器原始计数和 PID 输出先调 P再加滤波最后微调 D速度偏差始终存在积分项不足或 PWM 死区观察目标速度与实际速度差值加大 ki 或补偿 PWM 死区转弯时冲出赛道转向灵敏度过高或速度过快分速度档位录制对比视频降低当前档位速度或调整转向系数电池掉电快电机堵转或驱动效率低检查轮胎是否卡滞、转动是否顺畅降低负载并检查电机电流跑几次后表现不一致电池电压下降或机械松动记录电量并检查螺丝和轮胎建立“电量-参数”对照记录串口日志乱码波特率不匹配或地线未共地确认串口参数并检查接线统一波特率并重新接线排查问题的大原则是一次只改一个变量。很多团队在电机不转时同时改代码、换电池、动接线结果问题解决了也不知道是哪个因素起的作用。正确做法是先排除供电再排除初始化再检查逻辑顺着数据流一步步来。9. 从“未加入视觉”到“接入视觉”的落地路径无视觉版本的稳定运行只是第一步接下来真正有挑战的是把视觉识别接进来。很多人以为摄像头一插、代码一烧就能看结果实际工程上要经历五个阶段。第一阶段是摄像头选型和图像采集。要确定摄像头分辨率、帧率、接口类型。分辨率不是越高越好因为图像处理需要时间高分辨率会拉低帧率运动场景反而容易糊。第二阶段是图像预处理。灰度化、二值化、去噪、边缘提取这些操作的目标是把目标图案从复杂背景中分离出来。第三阶段是目标识别。根据赛题要求可能是识别图案、字符或特定形状可以选择传统视觉方法也可以训练轻量级模型但都要考虑主控算力。第四阶段是识别结果与状态机联调。这一阶段要把视觉代码跑起来并把vision_ready和vision_result两个变量真正交给主控。第五阶段是整体调优。识别速度、降速策略、超时处理都要在真实赛道上反复验证。联调阶段最大的风险是时间不同步。视觉识别需要几十毫秒甚至上百毫秒而车可能已经跑出去很远了。所以无视觉阶段为什么要求状态机里设置“接近碑体后降速”这个状态就是为了给视觉争取时间。实际项目中可以在赛道接近碑体的位置用一个传感器触发降速提前降到识别友好速度等识别结果回来后再决定下一步动作。还要考虑识别失败怎么办。视觉算法不可能 100% 正确所以状态机里必须有超时降级策略。超时未识别就按一个默认动作处理而不是让车停在赛道中间。这个策略在无视觉阶段已经写进代码里了只是当时它只是“未来会用到”的兜底逻辑接入视觉后它就变成真正的保命逻辑。如果团队想在开发板上先验证视觉流程建议先用离线图片测算法把识别准确率稳定在一个可接受水平后再放到车上在线测试。在线测试时先在低速下跑稳定后再逐步提速。不要一开始就冲到高速否则图像模糊和转向压力会叠在一起问题极难定位。10. 最佳实践与工程建议这个项目的经验放到任何嵌入式小车项目里都值得复用。参数管理方面所有 PID 参数、速度档位、转向系数都应该集中放在一个配置文件里不要散落在各个源文件中。建议维护一张参数记录表记录日期、参数值、测试结果和运行环境调参时才有据可查。你甚至可以给每组参数取一个版本号比如v1.0_speed1.2_kp18这样录像、日志和代码能一一对应。代码注释方面状态机的每个状态都要写清楚进入条件和退出条件尤其是降级分支。不要觉得“这段代码以后不会变”就不写注释等视觉接入后写注释的人可能已经忘了当时的思路。硬件接线方面驱动模块和电源之间要留保险丝或拨动开关方便紧急断电。所有接插件建议用不同颜色区分电源正负极避免接线错误烧毁模块。调试习惯方面添加日志打印要克制不要每毫秒都输出否则刷屏之后什么都看不清。建议使用分级日志正常状态不打印进入关键状态或异常状态时打印一行。日志内容要包含时间戳和状态名这样能方便地还原现场。团队协作方面这个项目建议至少两个人分工一个人负责运动控制和底层一个人负责视觉算法。两个人在接口处约定好数据格式主控端用vision_ready和vision_result两个变量作为协议视觉端无论用什么算法最终都输出这两个变量的值。接口越简单联调成本越低。安全方面还要再次强调调试电机和电池接线时一定要断电操作。测试过程中如果闻到焦糊味或听到异常声音第一时间切断电源不要强行继续。另外所有代码在烧录前做一次编译检查确认没有明显告警能减少很多不必要的现场调试时间。11. 总结与下一步回到项目视频本身。“未加入视觉”的车模运行视频表面上是项目处于“能跑”阶段的记录实际上是整个系统分层验收的关键证据。它验证了动力链路、速度闭环、路径保持和状态机框架这四层基础能力也为视觉识别接入提供了明确的接口和调试基线。下一步的核心工作是完成摄像头选型、图像算法开发和与现有状态机的联调。如果你现在也处于类似阶段建议先不要急着加视觉代码。把当前版本的车速、PID 参数、状态机逻辑都整理成文档录制一组多电量条件下的运行视频作为基线保存。然后再开始视觉部分的开发一次只加一个新模块每加一个模块都跑一遍原来的测试流程确保没有破坏已经稳定的部分。这个项目真正难的不是某一块技术而是把运动控制、状态管理和感知识别串成一个完整系统的工程能力。先把没有视觉的版本跑扎实后面每一步都会更从容。建议把文章里提到的接口设计和排查表收藏备用等你真正开工接入视觉时应该能少踩几个坑。