安徽省冠,再见安大。
这篇博客不想写成感言,而是一次正经的技术复盘。
标题里写的“安徽省冠”,是过去一年我们在安徽大学实验室里从零开始做的竞赛项目拿到的成绩。项目本身不是开源框架,而是一套完整的机器人竞赛解决方案:视觉识别、底盘运动控制、机械臂抓取、任务状态调度,全部自己写。从第一版“能跑就不错”,到赛场上稳定输出,中间踩的坑基本可以写成一本小册子。
如果你准备参加机器人类竞赛,或者正在做自动化、嵌入式视觉相关的课程设计,这篇文章可以直接收藏。下面会把系统架构、环境准备、代码部署、各模块测试方法、性能优化和故障排查完整过一遍,重点讲清楚一件事:怎样让一个多模块联动的机器人系统,在赛场上稳定完赛,而不是在实验室里“偶尔能跑通”。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | 机器人竞赛整体技术方案,覆盖感知、决策、执行三层 |
| 主要功能 | 自动循迹、视觉识别物料、机械臂抓取与定点投放、任务流程调度 |
| 软件语言 | Python、C++,可组合使用 |
| 视觉方案 | OpenCV 颜色/轮廓识别,ArUco 定位,可扩展轻量分类网络 |
| 决策框架 | 任务状态机 + 行为列表,可编排多阶段任务 |
| 通信方式 | 上位机与下位机之间使用串口/ROS Topic 通信 |
| 推荐硬件 | Jetson Nano / 树莓派 4B 作为上位机,STM32 作为下位机 |
| 支持平台 | Ubuntu 20.04/22.04,Windows 可通过 WSL 或远程调试 |
| 是否支持批量任务 | 支持,任务列表可预先编排并自动执行 |
| 是否支持接口 API | 模块可被命令行调用,也可封装为本地 HTTP/ROS 服务 |
| 适合场景 | 机器人竞赛、课程设计、产学研项目原型、自动化产线 Demo |
需要说明,本文是参赛项目的技术复盘,不提供完整成品代码包。但每个模块的设计思路和测试方法足够通用,可以按自己的硬件参数复刻。
2. 赛题分析与系统方案设计
比赛类项目最容易犯的错,是一上来就写代码。真正该做的第一件事,是把赛题规则拆成技术需求。
2.1 赛题关键信息拆解
以我们参赛的赛题为例,核心动作包括:
- 小车从起点出发,沿场地白线或色带自动循迹。
- 经过物料区,通过视觉识别指定颜色的物料。
- 机械臂抓取物料,移动到目标投放区。
- 投放完成后返回,继续下一个任务点。
- 全程不允许人为遥控,必须自主完成。
把规则翻译成技术语言,就是:
| 赛题要求 | 技术需求 |
|---|---|
| 自动循迹 | 底盘运动控制 + 线检测/路点导航 |
| 识别物料颜色 | 视觉识别模块,输出目标类别和坐标 |
| 机械臂抓取 | 坐标变换 + 机械臂逆解 + 抓取控制 |
| 定点投放 | 位置标定 + 状态确认 |
| 多任务连续执行 | 任务状态机,保证状态流转和异常恢复 |
2.2 系统分层设计
我们最终把系统分成了三层:
- 感知层:负责图像采集、目标识别、位置估计。
- 决策层:负责任务编排、状态切换、异常处理。
- 执行层:负责底盘运动、机械臂动作、传感器读取。
三层之间的数据流是:
摄像头 -> 感知层(识别结果) -> 决策层(任务状态) -> 执行层(速度指令/舵机指令)这里最重要的设计原则是:层与层之间通过结构化消息通信,不允许直接调用内部变量。比如感知层输出的是统一的识别结果结构体,决策层不关心它是用颜色阈值还是YOLO实现的。
这样做的好处是后期调试方便。某个模块出问题,可以直接单独测试,不用把整个机器人跑起来。
3. 环境准备与硬件清单
竞赛项目必须在真实硬件上跑,所以环境准备比普通软件项目更复杂。
3.1 硬件清单
| 部件 | 作用 | 备注 |
|---|---|---|
| 上位机主控 | 跑视觉、决策算法 | Jetson Nano 或树莓派 4B |
| 下位机主控 | 电机控制、传感器采集 | STM32 或 Arduino |
| 摄像头 | 图像采集 | USB 摄像头即可,注意帧率和分辨率 |
| 直流电机 + 编码器 | 底盘驱动 | 编码器用于里程计 |
| 舵机/机械臂 | 物料抓取与投放 | 建议使用单独供电 |
| 电源模块 | 供电 | 上位机与电机必须分开供电 |
电源分开供电这条非常关键。电机启动瞬间电流很大,如果和主控共用电源,很容易导致主控重启。
3.2 软件依赖
上位机建议使用 Ubuntu 20.04 或 22.04,并安装以下依赖:
# 基础工具 sudo apt update sudo apt install -y python3-pip git cmake v4l-utils # Python 视觉与计算依赖 pip install numpy opencv-python pyserial # 如果需要 ROS 方案,按 ROS 版本安装对应环境 # 这里以 ROS Noetic 为例 sudo apt install -y ros-noetic-ros-base下位机开发推荐 STM32CubeIDE 或 PlatformIO。如果没有硬件开发条件,也可以先用串口助手模拟下位机指令,提前联调上位机逻辑。
3.3 目录规划
建议项目目录按以下结构组织:
robot_project/ ├── config/ # 所有参数配置 │ ├── camera.yaml │ ├── pid.yaml │ └── task_list.yaml ├── perception/ # 感知模块 │ ├── camera.py │ └── detect.py ├── decision/ # 决策模块 │ └── state_machine.py ├── control/ # 执行模块 │ ├── serial_comm.py │ └── motion.py ├── tests/ # 各模块独立测试脚本 ├── logs/ # 运行日志 └── main.py # 主入口把参数放到配置文件里,而不是写死在代码中,是这次项目复盘里最值得强调的一点。比赛现场调整参数非常多,如果每次调参都要改代码,很容易引入新的 bug。
4. 软件部署与启动流程
4.1 代码启动流程
主入口启动流程设计为:先加载配置,再初始化各模块,最后进入任务循环。
# main.py 简化示例 import yaml from perception.camera import Camera from perception.detect import Detector from decision.state_machine import StateMachine from control.serial_comm import SerialComm def main(): # 1. 加载配置 with open("config/camera.yaml", "r") as f: camera_config = yaml.safe_load(f) with open("config/task_list.yaml", "r") as f: task_config = yaml.safe_load(f) # 2. 初始化模块 camera = Camera(camera_config) detector = Detector() comm = SerialComm(port="/dev/ttyUSB0", baudrate=115200) state_machine = StateMachine(task_config) # 3. 启动任务循环 while True: image = camera.read() detections = detector.run(image) state = state_machine.update(detections) cmd = state_machine.get_command() comm.send(cmd) if __name__ == "__main__": main()4.2 启动检查清单
首次启动不要直接跑完整车测试,按顺序做以下检查:
- 摄像头能否正常打开,帧率是否达标。
- 串口设备节点是否存在,权限是否正确。
- 电机驱动板是否能响应速度指令。
- 视觉识别模块能否识别测试物料。
- 状态机能否按预设流程正常切换。
每一步都有独立的测试脚本,确认通过后再进入下一步。
5. 核心模块设计与功能测试
5.1 视觉识别模块
视觉识别在比赛场景中承担两个任务:一是识别目标物料的颜色和位置,二是辅助定位。
5.1.1 方案选型
没有直接上 YOLO 这类目标检测网络,原因很简单:比赛场地环境相对固定,目标物料的颜色和形状是提前知道的,用传统视觉方案已经能满足需求,而且 CPU 推理速度更快,实时性更稳定。
最终使用的方案是:
- HSV 颜色阈值过滤目标颜色。
- 轮廓提取获取物料候选区域。
- 面积和形状过滤排除误检。
- 计算目标中心点作为抓取坐标。
5.1.2 核心代码示例
import cv2 import numpy as np class ColorDetector: def __init__(self, hsv_lower, hsv_upper, min_area=500): self.hsv_lower = np.array(hsv_lower) self.hsv_upper = np.array(hsv_upper) self.min_area = min_area def detect(self, frame): hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, self.hsv_lower, self.hsv_upper) mask = cv2.erode(mask, None, iterations=2) mask = cv2.dilate(mask, None, iterations=2) contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) results = [] for cnt in contours: area = cv2.contourArea(cnt) if area < self.min_area: continue x, y, w, h = cv2.boundingRect(cnt) cx, cy = x + w // 2, y + h // 2 results.append({"center": (cx, cy), "area": area}) return results5.1.3 测试方法与预期结果
测试目的:验证不同光线条件下,颜色识别稳定性和目标中心点输出准确性。
操作步骤:
- 准备红、蓝、绿三种颜色的物料各一个。
- 分别在不同角度、不同距离下采集图像并运行识别。
- 检查输出中心点与人工标注中心点之间的偏差。
预期结果:
- 目标颜色识别准确率在 95% 以上。
- 中心点像素误差在 10 像素以内。
- 误检率低于 5%。
如果识别不稳定,优先调整 HSV 阈值,而不是直接改算法。前期花时间采集场地光照下的样本,把范围调准,比后期加后处理更有价值。
5.2 运动控制模块
底盘控制使用差速模型。上位机下发线速度和角速度,STM32 接收后换算成左右电机 PWM 输出。
5.2.1 串口通信协议设计
通信协议要足够简单且抗干扰。我们使用的是以下格式:
帧头(0xA5) + 数据类型(0x01) + 线速度(int16) + 角速度(int16) + 校验和(1字节)对应 Python 发送代码:
import struct import serial def send_velocity(ser: serial.Serial, linear: float, angular: float): linear_int = int(linear * 1000) angular_int = int(angular * 1000) data = struct.pack("<BBhhB", 0xA5, 0x01, linear_int, angular_int, 0) # 实际校验和需要按协议计算 ser.write(data)5.2.2 测试方法与预期结果
测试目的:验证小车能否按指令直线行驶、原地旋转和闭环巡线。
操作步骤:
- 先下发固定线速度,观察小车走 1 米后的横向偏移。
- 再下发固定角速度,观察小车旋转 90 度的误差。
- 最后开启视觉巡线,测试场地白线跟踪。
预期结果:
- 直线 1 米横向偏移小于 5 厘米。
- 旋转 90 度误差小于 3 度。
- 巡线速度在 0.3 m/s 时能稳定过弯。
常见失败原因:电机左右不对称、编码器安装松动、PID 参数未调好。
5.3 任务调度与状态机
比赛中最容易出问题的是任务流程混乱。比如物料抓取没成功,但程序直接进入投放状态,最后整场比赛丢分。
我们用状态机来解决这个问题。核心思想是:每个任务步骤都是独立状态,进入下一个状态前必须满足退出条件。
5.3.1 状态定义
| 状态 | 说明 | 进入条件 | 退出条件 |
|---|---|---|---|
| IDLE | 初始状态 | 系统启动 | 收到开始信号 |
| SEARCH | 搜索物料 | 收到开始信号 | 识别到目标 |
| GRASP | 抓取物料 | 目标确认 | 机械臂闭合,抓取成功 |
| CARRY | 携带物料移动 | 抓取成功 | 到达投放点 |
| RELEASE | 投放物料 | 到达投放点 | 机械臂张开,确认投放 |
| RETURN | 返回起点 | 投放成功 | 回到起点 |
| FINISH | 完成全部任务 | 所有任务完成 | 无 |
5.3.2 状态机伪代码
class StateMachine: def __init__(self, tasks): self.tasks = tasks self.current_state = "IDLE" def update(self, detection_result): if self.current_state == "SEARCH": if detection_result: self.current_state = "GRASP" elif self.current_state == "GRASP": if self.check_grasp_success(): self.current_state = "CARRY" # 其余状态逻辑省略 return self.current_state5.3.3 测试方法与预期结果
测试目的:验证多任务连续执行时,状态切换是否准确,异常条件下能否恢复。
操作步骤:
- 先跑单次抓取投放全流程。
- 再跑 5 次连续任务,统计成功率。
- 人为制造失败场景,比如抓取时物料滑落,观察状态机是否进入重试逻辑。
预期结果:
- 单次任务成功率 100%。
- 连续任务成功率 80% 以上。
- 失败场景下 3 秒内恢复或进入安全状态。
状态机是这次项目里收益最高的模块。它让整个系统的逻辑变得非常清楚,也让多人协作开发变成了可能。每个人只需要负责自己那个状态节点。
6. 比赛现场的稳定性优化
实验室跑得好,赛场崩掉,是竞赛项目最常见的结局。这里的差距不在算法本身,而在工程细节。
6.1 光线变化问题
比赛场地的灯光和实验室不一样,摄像头曝光和色温会有明显偏差。我们做了三件事:
- 采集多个时间点的场地图像,建立阈值参数集。
- 启动时自动读取当前帧的平均亮度,选择匹配的参数组。
- 视觉处理前先做白平衡补偿。
6.2 通信稳定性
USB 摄像头和串口在机器人上同时工作,容易出现通信延迟或丢包。排查后发现是电磁干扰和带宽冲突导致的。
解决方案:
- 给串口线加磁环,减少干扰。
- 摄像头降低分辨率到 640x480,帧率保持 30。
- 所有指令增加超时重发机制。
6.3 任务状态卡死问题
最严重的一次事故,是机械臂抓取动作完成后,传感器信号没有及时返回,状态机卡在“GRASP”状态,整台车停在原地。
改进方案:除了传感器信号确认,增加时间超时判断。如果机械臂动作超过 2 秒,即使没有收到传感器信号,也认为是动作完成,进入下一个状态。这是典型的工程容错思路。
7. 性能观察与资源占用
竞赛小车算力有限,必须把性能开销控制在合理范围。
7.1 观察方法
上位机使用htop观察 CPU 使用率,使用jtop(Jetson 平台)观察 GPU 使用率。
# CPU 内存观察 htop # Jetson 平台查看 GPU 占用 jtop7.2 性能瓶颈分析
这次项目中最耗性能的是图像处理环节。直接对整个画面做 HSV 过滤和轮廓提取,CPU 占用会明显偏高。
针对这个问题做了两个优化:
- 将图像处理区域裁剪到 ROI,只处理画面中央的物料区。
- 降低处理分辨率,从 1280x720 降到 640x480,识别精度几乎没有损失,但 CPU 占用大幅下降。
7.3 延迟分配参考
| 环节 | 耗时 | 说明 |
|---|---|---|
| 图像采集 | 约 15ms | 640x480 @ 30fps |
| 颜色识别 | 约 20ms | 取决于 ROI 大小 |
| 状态机决策 | 小于 1ms | 状态判断非常快 |
| 串口指令发送 | 约 5ms | 115200 波特率 |
| 总循环周期 | 约 40ms | 满足 25Hz 控制频率 |
这里的数值是我们项目实测量级,不同硬件和分辨率会有差异,实际以本机测试为准。但优化思路通用:先定位瓶颈,再压缩耗时最长的环节。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 摄像头打不开 | 设备节点冲突或权限不足 | 检查/dev/video*列表 | 添加用户到 video 组,或指定正确设备节点 |
| 串口指令无响应 | 串口号错误或波特率不一致 | 用串口助手发送测试帧 | 确认设备路径与波特率,重新打开串口 |
| 视觉识别抖动 | RGB 阈值不稳或曝光剧烈变化 | 打印实时 HSV 值 | 采用 ROI 约束,启用自动曝光补偿 |
| 机械臂抓不到物料 | 目标中心坐标标定偏差 | 检查像素坐标到物理坐标的映射 | 重新标定相机与机械臂坐标系 |
| 状态机卡死 | 缺少超时机制 | 查看日志中状态切换记录 | 为每个状态增加超时退出逻辑 |
| 车轮左右偏 | 电机 PWM 基准不同 | 测量空载转速 | 在控制层增加左右电机速度补偿 |
| 比赛中途重启 | 电源供电不足 | 测量电机启动时电压跌落 | 上位机与电机分开供电,选用大电流电源 |
| 投放位置偏移 | 里程计累计误差 | 观察里程计读数与真实位置偏差 | 增加视觉辅助定位,或加终点传感器校正 |
9. 工程化与团队协作建议
竞赛项目做了半年,最大的收获不是奖项本身,而是学会了一套协作开发方式。这里整理几条对团队最有用的经验。
9.1 配置与代码分离
所有可调参数都放到配置文件中。比赛现场调参时,只需要修改 yaml 文件,不需要重新部署代码。这个习惯大幅降低了现场操作的出错率。
9.2 日志必须覆盖关键节点
每个状态切换、每次识别结果、每条下发的控制指令都要记录到日志。线上跑崩了,第一件事不是猜问题,而是看日志定位是哪个环节出了问题。
日志示例:
2025-05-10 14:23:05 [STATE] SEARCH -> GRASP 2025-05-10 14:23:05 [DETECT] color=red, center=(320, 180), area=2345 2025-05-10 14:23:07 [ACT] gripper close, success=True 2025-05-10 14:23:07 [STATE] GRASP -> CARRY9.3 测试要分等级
- 单元测试:单独验证视觉函数、串口编码函数。
- 模块联调:视觉 + 机械臂一起测试。
- 整车测试:完整流程跑一遍。
- 模拟赛:连续跑 10 次完整任务,统计稳定率。
不要跳过单元测试直接整车联调。省下的时间最终会在赛场上以更高倍数还回来。
9.4 版本管理
Git 分支结构:
main分支只放稳定可发布版本。dev分支是日常开发。- 每个模块拆成独立目录,禁止互相调用内部实现。
比赛前一周冻结代码,只允许修改配置参数。这个约束虽然严格,但保证了赛前系统稳定性。
10. 从比赛到工程项目的经验沉淀
比赛结束后再看这套系统,有些模块可以直接沉淀成通用组件:
- 颜色识别模块可以抽成独立的视觉工具库,配合不同场景的阈值配置即可复用。
- 串口通信协议可以固化成通用的上位机与下位机通信框架。
- 状态机决策框架可以复用大部分代码,只需要替换具体状态节点。
这其实是最有价值的部分。竞赛项目的时间窗口很短,很多代码为了赶进度牺牲了可维护性。但凡是认真做了接口设计的模块,赛后都能继续使用。
如果你们也在备赛,建议从一开始就按“可复用组件”的标准来写代码。宁愿前期多花两天设计接口,也不要到最后靠熬夜改状态逻辑续命。
安大的三年,最后换来的不只是省冠的名字,而是一套可以继续打磨的技术底座。暂时画一个句号,但技术这条路还长。