机器人大赛搬运车控制程序源码解析:视觉识别与状态机实战
简介一份围绕2019中国机器人大赛光电车型搬运赛的国一控制程序源码及学习说明适合计算机、数学、电子信息等专业学生备战机器人竞赛、研究视觉定位与运动控制算法时参考。包内含12个文件主要以Python源码脚本、调试日志txt、Word学习说明docx、项目架构图png与README说明构成压缩包仅195KB结构紧凑便于快速对照代码与理论。内容覆盖光电车型搬运策略、位置标定、基于图像的坐标获取、调车流程等关键环节并配有调车日志、分步说明等辅助文档详细记录了调试过程中的参数调整与问题排查思路能帮助读者理解国一方案的完整实现路径掌握从图像识别到运动控制的全流程调试方法。目前已有165人学习适合具备一定编程基础、愿意深入钻研的竞赛选手作为借鉴模板尤其对准备参加同类机器人竞赛的团队有较高参考价值。1. 光电车型搬运赛的控制程序为什么值一个国一2019 中国机器人大赛光电车型搬运赛的国一控制程序源码第一眼看上去不像一个“获奖作品”detect.py、std_pos_from_array.py、std_pos_from_pic.py 三个脚本命名随意调车日志.txt 里全是临时参数和现象记录README 写得像备忘录。但把它当学习资料逐行读下来会发现这套控制程序源码里几乎没有多余步骤颜色阈值分割解决“物料在哪”区域坐标标定解决“往哪放”状态机把抓取和搬运串成流水线。对准备大学生竞赛、或者正在做视觉搬运小车的开发者来说它的价值在于展示了一台比赛车在有限时间里怎么把代码调得可用。zip 解压后可以直接读、直接改适合能看懂代码、愿意拿场地数据重新标定的人。2. 视觉识别与坐标标定detect.py 和 std_pos 脚本的协作逻辑2.1 搬运赛的视觉坐标为什么要分两步走光电车型在搬运赛里要解决的核心矛盾是物料在图像里但机械爪要落在场地上。摄像头装在车体前方detect.py 输出的只是画面里的像素位置而电机控制需要的是“车体该往哪个方向转、该走多远”。把感知和映射拆开做是这类竞赛控制程序常见的分层方式。从这套源码的文件命名能还原当时的方案detect.py 负责“看到物料”std_pos_from_array.py 和 std_pos_from_pic.py 负责“记住标准位置”std_pos 是 standard position 的简写。这样换场地或者调整摄像头安装角度时只需要重跑标定脚本不需要改检测逻辑反过来光照环境变了检测不稳定也只需要在 detect.py 里调阈值不用动位置数据。比赛现场最缺的就是重调整个链路的时间这种解耦能省掉大量排错成本。2.2 detect.py 的检测管线HSV 阈值、形态学滤波与质心输出从文件名和调车日志的内容看detect.py 处理的是“当前帧图像 → 物料质心像素坐标”这是搬运赛视觉模块最核心的一步。常见的实现是颜色阈值分割加轮廓分析下面这段代码的结构和这套源码的检测思路一致# detect.py 核心流程简化版 import cv2 import numpy as np # HSV 阈值调车日志里改的就是这组值 COLOR_RANGES { red: ((0, 100, 80), (8, 255, 255)), blue: ((95, 120, 80), (125, 255, 255)), } MIN_AREA 500 # 小于该面积的轮廓视为反光噪点 def detect_centers(frame): hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) centers {} for name, (lower, upper) in COLOR_RANGES.items(): mask cv2.inRange(hsv, np.array(lower), np.array(upper)) # 开运算去掉细小噪点光照不稳时 kernel 大小影响很大 mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)) cnts, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not cnts: continue # 取面积最大的轮廓作为目标物料 c max(cnts, keycv2.contourArea) if cv2.contourArea(c) MIN_AREA: continue M cv2.moments(c) if M[m00] 0: centers[name] (int(M[m10] / M[m00]), int(M[m01] / M[m00])) return centers这段代码里有三个参数是现场调试的高频修改点。第一是 HSV 阈值区间HSV 比 BGR 对光照更稳定但红色在色相环上跨越 0 度只写 (0, 8) 会漏掉另一段接近 170 的红色区域所以实际调车时红色往往要用两个区间取并集。第二是开运算的 kernel 尺寸场地反光点在形态学处理后会被滤掉kernel 调太大会把细长物料也抹掉。第三是 MIN_AREA 面积下限它同时过滤掉远处误检的小块颜色区域反过来如果物料离摄像头较远导致轮廓变小这个值也要跟着下调。视觉模块的输出是整个控制的状态输入。后续状态机不看原始图像只看 centers 字典里的质心坐标这保证电机控制代码和摄像头安装细节互相独立也方便以后把摄像头换成其他传感器控制层代码基本不用动。2.3 std_pos_from_array 与 std_pos_from_pic两种标定方式的取舍源码里两个 std_pos 脚本解决的是同一个问题把装卸区和物料区的标准位置固化下来。区别只在标定输入的来源。标定方式输入适用场景换场地后的操作std_pos_from_array.py手填坐标数组已知场地尺寸按比例换算改数组里的数字std_pos_from_pic.py场地俯拍图只有现场照片鼠标点选重新点选存成 JSONstd_pos_from_pic.py 的常见实现思路是读取一张场地俯视图用鼠标事件收集点击位置再把结果写到 JSON 文件里供主程序加载。# std_pos_from_pic.py 点选标定逻辑示意 import cv2 import json def collect_points(image_path): img cv2.imread(image_path) if img is None: raise FileNotFoundError(fcannot load {image_path}) picks [] def on_click(event, x, y, flags, param): if event cv2.EVENT_LBUTTONDOWN: picks.append((x, y)) cv2.circle(img, (x, y), 4, (0, 0, 255), -1) cv2.imshow(calib, img) cv2.namedWindow(calib) cv2.setMouseCallback(calib, on_click) cv2.imshow(calib, img) cv2.waitKey(0) cv2.destroyAllWindows() return picks if __name__ __main__: pts collect_points(field_top.jpg) with open(std_pos.json, w, encodingutf-8) as f: json.dump({points: pts}, f, ensure_asciiFalse, indent2)这段代码通过鼠标回调收集点击坐标每点一次在图上画一个红点关掉窗口后把点集写盘。竞赛现场用这种方式标定比手动量尺寸填数组快得多代价是要求场地照片的拍摄角度和实际运行时尽量一致否则像素坐标和场地坐标的比例关系会偏。2.4 像素到场地坐标的换算边界detect.py 输出质心像素位置std_pos 脚本输出装卸区的标准坐标两者之间还需要一个变换关系。从文件结构看这套源码的定位是“固定摄像头视角加固定场地”车体到位后图像变化范围有限所以用简单的比例换算就能把像素差转换成转向量。车体停靠位置与装卸区保持固定距离时横向像素误差和实际横向偏移近似线性直接用比例系数换算就够了距离变化大时才需要引入透视变换cv2.getPerspectiveTransform 加 warpPerspective甚至里程计融合复杂度立刻上升。对搬运赛这类小场地任务先确认车体停靠位置再固定映射关系比盲目上视觉 SLAM 更实际。3. 搬运控制状态机从像素坐标到电机指令3.1 搬运赛规则对程序结构的约束搬运赛的任务是从物料区把带颜色的物料搬到对应装卸区按成功搬运数量和用时计分。这意味着控制程序至少要覆盖“找物料、对准、抓取、运送、放置”五个环节而且每完成一次搬运后还要判断是否还有下一个任务。如果把这五个环节写成顺序执行的脚本一旦某个环节失败比如抓空或者对准超时整个流程就会卡死。竞赛代码的常见做法是引入状态机每个环节是一个状态状态之间写清迁移条件和超时保护。这样某个状态失败时可以退回上一状态重试而不是断电重启。3.2 状态定义与迁移条件从源码的调车日志和主控脚本结构来看这套代码的状态划分大致如下状态进入条件主要动作退出条件IDLE上电复位等待开始信号收到启动指令DETECT启动指令采集图像调用 detect.py检测到目标物料质心ALIGN质心有效PID 计算转向量下发电机指令横向误差小于阈值GRASP对准完成直线前进、闭合夹爪夹爪到位或超时CARRY夹爪闭合朝目标装卸区移动装卸区坐标进入视野PLACE到达装卸区打开夹爪、后退物料已放置状态表里每个状态只做一件事退出条件都对应一个可检测的事件这是避免逻辑混乱的关键。实际比赛中 DETECT 和 ALIGN 之间往往是循环关系第一帧检测不到物料就继续转摄像头检测到但误差大就继续转向只有误差小于设定值才进入 GRASP。# 主控状态机骨架示意 STATE IDLE while running: if STATE IDLE: if start_signal(): STATE DETECT elif STATE DETECT: centers detect_centers(camera.read()) if red in centers: target centers[red] target_px_err target[0] - IMG_CENTER_X if abs(target_px_err) ALIGN_TOL: STATE GRASP else: STATE ALIGN elif STATE ALIGN: # 通过 PID 计算转向速度并下发 steer pid.update(target_px_err, dt) set_motor(LEFT, BASE_SPEED - steer) set_motor(RIGHT, BASE_SPEED steer) if abs(target_px_err) ALIGN_TOL: STATE GRASP elif STATE GRASP: if gripper_closed(): STATE CARRY elif timeout(): STATE DETECT # 抓取失败回到识别重新来这个骨架的关键在于 GRASP 状态的超时分支夹爪没有在预期时间内闭合说明刚才的“对准”其实没对准回到 DETECT 重新检测比原地重试效率更高。代码里的 ALIGN_TOL 是图像中心与物料质心的像素误差阈值调车日志里通常会记录它的调整过程阈值太小车会一直微调不前进阈值太大则大概率抓偏。3.3 PID 转向与串口指令下发从像素误差到电机转速中间需要一个闭环控制算法。竞赛小车常用的是横向一维 PID只把横向像素误差作为输入输出左右轮速差车体一边前进一边修正方向。# 一维 PID控制左右轮速差 class PID: def __init__(self, kp, ki, kd, max_integral20.0): self.kp kp self.ki ki self.kd kd self.max_integral max_integral self.last_err 0.0 self.integral 0.0 def update(self, err, dt): self.integral err * dt # 积分限幅防止在装卸区等待时积分饱和 self.integral max(-self.max_integral, min(self.max_integral, self.integral)) derivative (err - self.last_err) / dt if dt 0 else 0.0 self.last_err err return self.kp * err self.ki * self.integral self.kd * derivativePID 的输出通过串口下发给底层单片机常见指令格式是L100R95G\nL 和 R 分别是左右轮 PWM 值G 表示执行这次转向。注意积分限幅 max_integral 在比赛里必须写小车在等待夹爪闭合时如果横向误差一直存在积分项会持续增大等夹爪闭合同一个 PID 会让车猛地一窜。把积分限幅控制在 20 以内即使卡住几秒也不会产生失控急转。3.4 状态机里最容易漏掉的超时保护搬运赛单轮限时几分钟任何一个状态卡死都会直接消耗掉整轮时间。我调试这类车时习惯给每个状态加一个超时时间超时后要么重试当前状态要么放弃当前物料进入下一个目标。源码的调车日志里如果出现“卡在 GRASP”或者“DETECT 一直没输出”之类的记录对应的多半是超时保护没有覆盖到那个状态。超时值不能统一写死DETECT 可以给长一点因为摄像头转动需要时间GRASP 要短因为夹爪动作是毫秒级的迟迟不闭合说明机构卡住或对准失败应当快速退回重试。4. 调车日志的读法现场调参到底在调什么4.1 调车日志里最常见的内容格式源码包里那份调车日志.txt 的作用不是记录代码变更而是记录每次试跑时“改了什么参数、场上发生了什么”。这是比赛现场调车的真实习惯代码逻辑基本不动动的是阈值、速度、PID 系数和标定坐标。日志里出现的记录项通常包括时间与场次用来对应比赛轮次HSV 阈值和 MIN_AREA 的当前值左右轮基础速度 BASE_SPEEDPID 的 kp、ki、kd 和 ALIGN_TOL失败现象描述比如“抓偏”“冲过头”“红检不到”把这些记录按时间排序能还原出一条调参路径。比如开始红色阈值是 (0,100,80)下午阳光斜射后连续三次“红色丢失”日志里大概率能看到阈值被改成 (0,100,120)或者增加第二段红色区间。这条路径就是这套控制程序在实际场地上的进化史比任何注释都有价值。4.2 光照变化与阈值、面积下限的关系比赛场地通常在白炽灯或自然光下光照强度变化直接改变物料在画面里的饱和度与反光面积。HSV 的 V 通道和 S 通道对光照最敏感所以调车日志里调得最多的就是这两个值。常见问题是红色物料在强光下偏粉、在弱光下偏暗单一阈值难以同时覆盖两种环境。这时有两个处理方向。一个是在检测前加适度高斯模糊降低边缘反光干扰另一个是把 MIN_AREA 下调并引入双区间让检测更宽容。注意不要为了覆盖光照把阈值区间拉得很宽否则地面上的红色贴纸、轮胎印都会被误识别成物料。日志里出现“识别到两个红点”往往是区间过宽的信号这时优先收窄第二个区间而不是改代码逻辑。4.3 三个典型失败现象的排查顺序失败现象优先检查项具体操作物料在画面里但检测为空HSV 区间、MIN_AREA开 HSV 调试窗口实时看 mask 漏了哪块对准时来回摆动不前进kp、积分限幅先减半 kp还摆就检查积分是否饱和到装卸区附近停偏 5cm 以上STD_POS、车轮回程差重跑标定脚本检查左右轮直行是否走偏调车现场最容易犯的错是直接改代码逻辑。按排查顺序走八成问题出在阈值和标定上。加一个 HSV 调试滑条窗口往往比改十行代码更快这也是日志里出现频率最高的现场操作。5. 把国一代码改造成自己的控制框架5.1 按功能拆分目录结构原资源包以脚本文件平铺功能完整但复用和维护成本高。我会先把它拆成四个目录chassis_ctrl/ ├── main.py # 入口启动相机和控制循环 ├── vision/ │ ├── detect.py # 颜色阈值检测保留原算法 │ └── calibrate.py # 标定脚本的统一入口 ├── motion/ │ ├── pid.py # PID 控制器 │ └── state_machine.py # 状态机迁移逻辑 ├── config/ │ ├── colors.yaml # HSV 阈值参数外置 │ └── std_pos.json # 标定坐标文件 └── logs/ └── run_tuning.log # 调试日志把阈值和坐标外置到配置文件后现场调参不再需要改 Python 文件改 YAML 和 JSON 即可。比赛环境下手改代码最容易引入缩进或变量名错误外置参数能把这部分风险降到最低。5.2 视觉与运动解耦从顺序调用到队列原代码里 detect 和 control 在一个循环内顺序执行摄像头帧率不高时整体节奏会被拖慢。我一般会开两个线程视觉线程按固定频率更新物料坐标控制线程以更短的周期读取最新坐标并计算 PID。两者之间用一个共享变量或 deque 传递数据控制线程永远拿最新的检测结果即使某一帧检测超时也不会阻塞电机指令下发。5.3 用调车日志做参数回归验证把历史调车日志里的参数整理成一组回归用例每次改完代码后用同一批保存的场地图片跑一遍 detect.py跟日志里记录的现象逐条对照日志说“能稳定识别”新的输出就必须稳定日志说“红色漏检”改动后的输出必须比当时更好。这样调整视觉算法时不会把以前调好的场景又调坏。上场地前跑完这组回归再上车试跑能省掉大半无效调试时间。本文还有配套的精品资源点击获取