简介:基于树莓派图像识别技术实现的智能循迹避障小车项目,包含电脑端与树莓派端完整 Python 源码,面向计算机视觉、嵌入式控制方向的学习者,可作为毕业设计、课程设计或工程实训的参考实现。压缩包共 14 个文件,核心是 4 个 Python 脚本,分别对应电脑端驱动、树莓派控制、视频流与超声波模块;另有 3 个 OpenCV 级联分类器文件用于标识牌检测,3 个说明文档提供环境配置与使用说明,整体仅 208KB,结构精简、便于按需阅读。目前已有 164 人学习,适合具备一定 Python 与 OpenCV 基础、希望动手调试完整小车的读者。实现上,道路检测通过图像上下区域灰度中心偏差判断直行与左右转弯,标识牌检测基于 OpenCV 自带训练网络;资源附有主要模块调用说明,可帮助理解树莓派与电脑端的分工协作,并在此基础上扩展深度学习算法或调整循迹策略。
1. 从“能跑”到“会看”:树莓派图像识别循迹避障小车到底在做什么
循迹避障小车不是一个新话题,但绝大多数教程里用的是灰度传感器、红外对管或者超声波模块,它们对场地颜色、环境光线和赛道材质极其敏感——换一块地板、换一个教室开灯,之前的标定就全部失效。而基于树莓派图像识别的做法,是把摄像头画面直接变成决策依据:用 OpenCV 识别赛道线、判断障碍物位置,再通过 GPIO 输出 PWM 波驱动电机。这个方案最大的特点是“调整逻辑而不是调整硬件”:黑板赛道变白纸赛道时,改一行 HSV 阈值就能继续跑。本文不依赖某个现成项目,而是把一套常见的双端架构拆开讲清楚——树莓派端负责采集、识别、控制,电脑端负责远程预览、调参与录像回放,完整覆盖循迹、避障、调试三个硬需求。适合已经会基础 Python、手上有树莓派 4B 和 L298N 驱动板、准备把“会跑的小车”升级成“会看的小车”的人。
2. 树莓派端图像识别与循迹避障的架构与选型
2.1 为什么是图像识别而不是灰度传感器:光线与地形的“鲁棒性”账
灰度传感器的本质是反射率检测:它发射红外光,根据反射强度判断黑线还是白底。这个方案的问题在于它只有“对”或“不对”两个分量,一旦环境光从窗口照进来,或者赛道本身是灰色地砖,传感器的阈值就必须重新拧电位器。更麻烦的是,传感器装在车底,只能看到车头前方一两厘米的地面,车速稍快就没有提前量,车身会左右画龙。
图像识别方案把传感器换成 OV5647 摄像头模块,通过 CSI 接口把画面送进树莓派。好处有两个:第一是空间范围大了,摄像头能看到车前方 30 到 50 厘米的赛道走向,转向决策可以提前一拍;第二是信息量足了,除了黑白之外还能识别颜色,所以赛道可以用红色、蓝色、绿色做标记,障碍物用另一种颜色或轮廓面积来判断,这让“循迹”和“避障”可以在同一个图像处理流程里完成。代价是需要计算,树莓派 4B 跑 OpenCV 的常规图像分割算法,320x240 分辨率下基本能稳定拿到 15 到 30 帧,这对小车控制来说已经够了。
2.2 树莓派端源码的分层:采集、推理、控制三线程
树莓派端 Python 源码常见的组织方式不是单线程循环,那样做帧率会被图像识别拖死,电机响应就会顿挫。我一般会把程序拆成三个线程:采集线程只做摄像头读帧和缩放,推理线程对最新一帧做 HSV 分割和轮廓提取,控制线程根据识别结果计算转向和速度并输出 PWM。三个线程之间用queue.Queue传递数据,采集线程放帧,推理线程取帧,控制线程消费最新的识别结果。
import threading import queue import cv2 import RPi.GPIO as GPIO import time frame_queue = queue.Queue(maxsize=2) # 最多存2帧,旧帧直接丢弃 result_queue = queue.Queue(maxsize=1) # 只保留最新识别结果 def capture_loop(camera): while True: ret, frame = camera.read() if not ret: continue frame = cv2.resize(frame, (320, 240)) if frame_queue.full(): frame_queue.get() # 丢掉最旧的一帧 frame_queue.put(frame) def inference_loop(): while True: frame = frame_queue.get() mask = process_frame(frame) # 图像分割,见第3章 result = compute_track_info(mask) if result_queue.full(): result_queue.get() result_queue.put(result) def control_loop(): while True: if not result_queue.empty(): result = result_queue.get_nowait() steering = calc_steering(result['error']) speed = calc_speed(result['obstacle']) set_motor(speed, steering) time.sleep(0.02) # 控制周期50Hz线程的分离是为了避免“图像处理耗时导致电机失控”的连锁问题。摄像头帧率是 30fps,但 HSV 分割加找轮廓可能耗时 40 毫秒,如果所有逻辑串在一个循环里,控制周期就会在 33 到 80 毫秒之间随机抖动,小车提速后尤其明显。拆成三个线程后,控制线程保持固定周期运行,推理慢一点只是让识别结果更新慢一点,不会直接让电机停转。注意result_queue里只保留最新结果,控制线程永远读取“此刻该依据的最新一帧”,而不是排队等着一堆过期帧。
2.3 摄像头选型与 OpenCV 安装:OV5647 与 CSI 接口的实战选择
树莓派常见摄像头方案有三种:CSI 接口的 OV5647 原厂摄像头、USB 免驱摄像头、以及串口或 SPI 接口的摄像头模块。做循迹避障小车首推 CSI 接口的 OV5647,因为它的帧率稳定、CPU 占用低、延迟小。USB 摄像头虽然即插即用,但传输走 USB 总线会占用 CPU,而且部分免驱摄像头的自动曝光没法关闭,画面亮度一变,HSV 阈值就会失效。
安装 OpenCV 有两个层面:py 包和系统依赖。
sudo apt update sudo apt install -y python3-opencv python3-picamera2python3-opencv是官方打包的预编译版,省去源码编译的时间。树莓派 4B 上不要用pip install opencv-python默认安装的版本,它自带许多树莓派用不到的模块,安装体积大且运行时可能触发浮点性能问题。直接装python3-opencv就行,它的底册是优化过 ARM Neon 指令集的。
启用 CSI 摄像头还需要在树莓派系统里配置:
sudo raspi-config # 选择 Interface Options -> Camera/CSI,启用后重启 vcgencmd get_cameravcgencmd get_camera返回supported=1 detected=1才说明摄像头硬件正常。读取画面用 Picamera2,因为树莓派新版系统把原来的picamera模块废弃了。
from picamera2 import Picamera2 import cv2 picam2 = Picamera2() config = picam2.create_preview_configuration( main={"size": (640, 480), "format": "RGB888"} ) picam2.configure(config) picam2.start() frame = picam2.capture_array()这里把分辨率设为 640x480,但实际推理前会缩放到 320x240,这样既保证预览清晰,又避免高分辨率下逐像素遍历的耗时。format指定RGB888,这样拿到的frame可以直接传给 OpenCV,不需要再走cv2.cvtColor转一次颜色空间。Picamera2 的capture_array()和旧picamera的capture()不同,记得看返回值的形状是不是(height, width, 3),如果多了一个维度,说明配置的是RAW流。
3. 循迹与避障的 Python 实现:从颜色分割到 PID 控制
3.1 图像预处理:HSV 阈值提取赛道线与障碍物
循迹小车最常用的赛道方案是用红、蓝、绿三种不同颜色的胶带贴出车道线。OpenCV 读取的默认是 BGR 颜色空间,但它对光照变化非常敏感,同一种红色在阴影下可能变成暗红、在强光下变成粉红,直接用 BGR 范围抠色很容易漏检或误检。所以要先把 BGR 转成 HSV,把“颜色”和“亮度”分开,再对色相(H)和饱和度(S)设阈值,这样白天和傍晚都能稳定识别。
| 对象 | H 范围 | S 范围 | V 范围 | 说明 |
|---|---|---|---|---|
| 红色线 | 0-10 或 156-180 | 70-255 | 60-255 | 红色在 HSV 里会跨 0 和 180 两段 |
| 蓝色线 | 100-124 | 70-255 | 60-255 | 蓝色胶带的 H 通常在 110 左右 |
| 障碍物 | 20-80 | 80-255 | 80-255 | 这里以黄色纸箱为例 |
转换和抠色的核心代码:
import cv2 import numpy as np def extract_mask(frame, lower_hsv, upper_hsv): hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, lower_hsv, upper_hsv) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 去掉小噪点 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 补全断线 return maskMORPH_OPEN是先腐蚀再膨胀,删掉孤立的噪点,比如地面反光产生的小白点。MORPH_CLOSE是先膨胀再腐蚀,把线中间几像素宽的断口连起来。这两个形态学操作在循迹场景里直接影响提取质量;如果赛道线特别细,可以把核从(5, 5)降到(3, 3),否则线会被腐蚀掉一截,中心点计算就会偏移。HSV 颜色范围的 V 值(亮度)建议放宽到 60-255,因为小车行进过程中光照变化最大的就是亮度,而色相和饱和度相对稳定。
3.2 循迹线中心点计算与转向决策
拿到赛道线掩码后,下一步是找出线的几何中心。这里不用铺满整幅图像的模板匹配,而是用轮廓信息:
def compute_track_info(mask): contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: return {"error": None, "obstacle": False, "area": 0} # 取面积最大的轮廓,通常是当前视野里的主赛道线 max_contour = max(contours, key=cv2.contourArea) M = cv2.moments(max_contour) if M["m00"] == 0: return {"error": None, "obstacle": False, "area": 0} cx = int(M["m10"] / M["m00"]) cy = int(M["m01"] / M["m00"]) frame_center_x = 160 # 320x240 画面的横向中心 error = cx - frame_center_x return {"error": error, "x": cx, "y": cy, "area": M["m00"]}update_error = error_from_image是后来给 PID 用的核心变量。error的单位是像素,正值表示赛道线在视野中心右侧,小车需要右转;负值需要左转。把cy(中心点的 y 坐标)也返回是因为它可以用来判断赛道线离小车远近:cy越大说明线越靠近画面底部,也就是车头附近。
转向决策里常用 P 控制或 PD 控制,偏置比例就足够让小车沿直线走:
def calc_steering(error, kp=0.4, kd=0.1, last_error=0): if error is None: return 0 p = kp * error d = kd * (error - last_error) return p + d这里kp是比例系数,默认 0.4,error 为 100 像素时转角 40。kd是微分系数,用于抑制转向抖动;如果整车左右摇摆,就把kd调到 0.15 或 0.2。注意calc_steering没有积分项,因为循迹是一个不断跟随时变目标的过程,误差不会长期单一方向累积,积分反而会让转向滞后。
3.3 避障逻辑:单目视觉下的距离判断与避让策略
没有深度摄像头的条件下,避障只能靠单目视觉的几何线索:同一障碍物在画面中占据的面积越大,它就离小车越近。这个思路在固定高度的障碍物(纸箱、水瓶、立柱)上足够可靠。
OBSTACLE_AREA_THRESHOLD = 8000 # 320x240 画面中障碍物占据的像素面积 def detect_obstacle(frame_hsv): obstacle_mask = extract_mask(frame_hsv, (20, 80, 80), (80, 255, 255)) contours, _ = cv2.findContours(obstacle_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return False max_area = max(cv2.contourArea(c) for c in contours) return max_area > OBSTACLE_AREA_THRESHOLD阈值 8000 表示障碍物大约占据画面 10% 的面积,这个数值需要按实际场地标定。如果小车离障碍物很远就触发避障,说明阈值设低了,需要调高;如果撞上了才触发,阈值设太高了。避障策略有两种常见做法:一种是检测到障碍物后全停原地等待,适合演示;另一种是让小车左转或右转绕过障碍物并继续循迹。我更推荐第二种,因为它更接近真实场景,实现也不复杂:检测到障碍物后,设置一个状态机——先向左转 800 毫秒,再向前直行 600 毫秒,然后恢复循迹。转向时间和直行时间就是绕障碍物的“盲目动作”,为了避免绕过之后找不到赛道线,可以用“恢复循迹”的一个简单准则:当掩码中再次存在赛道线轮廓时,立刻切回循迹模式。
3.4 PWM 波输出与电机驱动:树莓派 GPIO 引脚配置
树莓派本身不直接驱动电机,电流不够。需要接 L298N 或者 TB6612 电机驱动模块。L298N 的接线比较简单:它的 IN1/IN2 控制左侧电机方向,IN3/IN4 控制右侧电机方向,ENA/ENB 接树莓派的 GPIO 引脚用来调速。以下是常见接线表:
| 树莓派引脚 | L298N 接口 | 功能 |
|---|---|---|
| GPIO 17 | IN1 | 左电机方向 A |
| GPIO 18 | IN2 | 左电机方向 B |
| GPIO 22 | IN3 | 右电机方向 A |
| GPIO 23 | IN4 | 右电机方向 B |
| GPIO 24 | ENA | 左电机速度(PWM) |
| GPIO 25 | ENB | 右电机速度(PWM) |
Python 端使用RPi.GPIO输出 PWM:
import RPi.GPIO as GPIO from time import sleep GPIO.setmode(GPIO.BCM) GPIO.setup([17, 18, 22, 23], GPIO.OUT) GPIO.setup([24, 25], GPIO.OUT) left_pwm = GPIO.PWM(24, 50) # ENA 接 GPIO24 right_pwm = GPIO.PWM(25, 50) # ENB 接 GPIO25 left_pwm.start(0) right_pwm.start(0) def set_motor(left_speed, right_speed): left_speed = max(-100, min(100, left_speed)) right_speed = max(-100, min(100, right_speed)) # 左电机 if left_speed >= 0: GPIO.output(17, GPIO.HIGH) GPIO.output(18, GPIO.LOW) else: GPIO.output(17, GPIO.LOW) GPIO.output(18, GPIO.HIGH) left_pwm.ChangeDutyCycle(abs(left_speed)) # 右电机 if right_speed >= 0: GPIO.output(22, GPIO.HIGH) GPIO.output(23, GPIO.LOW) else: GPIO.output(22, GPIO.LOW) GPIO.output(23, GPIO.HIGH) right_pwm.ChangeDutyCycle(abs(right_speed))GPIO.PWM(24, 50)里的 50 是 PWM 频率,单位 Hz。频率太低会听到电机“嗡嗡”声,转速不均匀;频率太高(比如 1kHz)会让 L298N 的开关损耗增大,电机反而更容易发热。50Hz 对普通直流减速电机是比较中庸的取值。这里用left_speed和right_speed的差值实现转向:直行时两者都是 50,左转时left_speed=30, right_speed=50,原地转时一正一负。注意ChangeDutyCycle接受的参数是 0-100 之间的浮点数,所以正反转都用绝对值输出占空比,方向交给 IN 引脚控制。
4. 电脑端与树莓派端源码的协同:SSH 远程调试与视频回传
4.1 电脑端源码结构:图像预览与 HSV 参数实时调节
树莓派端代码经常要调 HSV 阈值、PID 系数和避障面积阈值,如果每改一次参数就要插拔 HDMI 线、接键盘显示器,实际调试效率会非常低。所以电脑端源码通常不写业务逻辑,而是做一个“可视化调参面板”:通过局域网从树莓派接收图片流,在电脑端显示识别结果,用滚轮或者滑块实时修改 HSV 范围和 PID 系数,修改后的参数通过网络发回树莓派端热加载。
电脑端最简单的实现是直接用 OpenCV 自带的高GUI:
import cv2 import numpy as np def on_change(value): pass cv2.namedWindow("tuning") cv2.createTrackbar("H_min", "tuning", 0, 180, on_change) cv2.createTrackbar("H_max", "tuning", 30, 180, on_change) cv2.createTrackbar("S_min", "tuning", 70, 255, on_change) cv2.createTrackbar("S_max", "tuning", 255, 255, on_change) cv2.createTrackbar("V_min", "tuning", 60, 255, on_change) cv2.createTrackbar("V_max", "tuning", 255, 255, on_change) while True: frame = receive_frame_from_pi() # 从树莓派获取的画面 if frame is None: continue h_min = cv2.getTrackbarPos("H_min", "tuning") h_max = cv2.getTrackbarPos("H_max", "tuning") lower = np.array([h_min, cv2.getTrackbarPos("S_min", "tuning"), cv2.getTrackbarPos("V_min", "tuning")]) upper = np.array([h_max, cv2.getTrackbarPos("S_max", "tuning"), cv2.getTrackbarPos("V_max", "tuning")]) mask = cv2.inRange(cv2.cvtColor(frame, cv2.COLOR_BGR2HSV), lower, upper) cv2.imshow("original", frame) cv2.imshow("mask", mask) if cv2.waitKey(1) == ord("q"): break这段代码的价值在于把“识别结果”和“参数调整”放在同一个画面里:左边看原始图,右边看掩码图,调到掩码只保留赛道线而滤掉地面阴影时,再把参数写进树莓派端配置文件。cv2.createTrackbar的滑块范围要跟 HSV 各通道的实际最大值对齐,H 最大 180,S 和 V 最大 255。如果红色赛道线识别不稳定,这里会发现 H 范围必须跨两个区间的坑——0-10 和 156-180 两段都要设,代码里可以用两次inRange再bitwise_or合并。
4.2 通过 Socket 桥接双端源码:控制指令与图像数据流
双端通信最常见的方案是 Socket,树莓派作为 TCP 服务端,电脑作为客户端,这样手机或另一台 PC 都能连过来访问。由于图像数据量较大,不能直接裸传 JPEG 流,要定义帧格式。
树莓派端发送图像:
import socket, cv2, numpy as np server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind(("0.0.0.0", 8000)) server_socket.listen(1) conn, addr = server_socket.accept() def send_frame(conn, frame): # 压缩成 JPEG,质量85 ret, encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) data = encoded.tobytes() size_bytes = len(data).to_bytes(4, "little") conn.sendall(size_bytes + data)电脑端接收:
def receive_frame(sock): size_bytes = sock.recv(4) if len(size_bytes) < 4: return None size = int.from_bytes(size_bytes, "little") buffer = b"" while len(buffer) < size: chunk = sock.recv(size - len(buffer)) if not chunk: return None buffer += chunk frame = cv2.imdecode(np.frombuffer(buffer, np.uint8), cv2.IMREAD_COLOR) return frame发送端先传 4 字节的长度,再传对齐的 JPEG 二进制,接收端根据长度循环收包直到完整。这里使用 4 字节 little-endian 表示长度,能描述最大 4GB 的帧数据,对 320x240 的 JPEG 帧来说绰绰有余。压缩质量 85 是平衡画质和带宽的常用值;如果局域网不好,降到 70 也够识别。电脑端还可以在收到图像后发送转向调整指令,比如键盘方向键控制速度、空格急停,这就是把电脑端变成遥控手柄。指令用 1 字节字符表示,而不是发送 JSON 字符串,避免粘包处理麻烦。
4.3 树莓派修改软件源与 Python 环境准备
树莓派端需要安装opencv-python、picamera2等依赖库,系统默认源在部分地区访问慢,所以拿到新系统后第一件事是修改软件源。修改源之前先备份:
sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak sudo cp /etc/apt/sources.list.d/raspi.list /etc/apt/sources.list.d/raspi.list.bak然后编辑/etc/apt/sources.list,把deb.debian.org换成国内镜像地址,比如清华源或阿里云源。编辑完执行sudo apt update。调整完源之后再装 Python 依赖,可以避免把大把时间耗在下载超时上。
Python 环境建议创建虚拟环境,不要直接用系统 Python:
python3 -m venv car_env source car_env/bin/activate pip install numpy opencv-python pip install picamera2使用虚拟环境的好处是安装包不会污染系统环境,而且换 SD 卡迁移源码时,只需要把requirements.txt里列出的版本重新装一遍。树莓派端源码里的 Python 解释器路径要注意,如果是在虚拟环境里运行,要用/home/pi/car_env/bin/python car.py,而不是sudo python car.py。sudo会切换成 root 环境,可能找不到虚拟环境里的包。
5. 把调试时间缩短 80% 的离线视频重放调参法
5.1 先用树莓派录制现场视频,再在电脑端重放调参
很多人拿到小车代码后直接搭赛道、放车上跑,发现转向抖动就改 PID,改一次跑一次,一个下午只试出两组参数。更省事的做法是先在树莓派上录制视频,把现场环境完整存下来,然后回到电脑端对录像反复调参。
from picamera2 import Picamera2 import cv2 picam2 = Picamera2() picam2.configure(picam2.create_preview_configuration( main={"size": (640, 480), "format": "RGB888"})) picam2.start() out = cv2.VideoWriter("track.avi", cv2.VideoWriter_fourcc(*"MJPG"), 20, (640, 480)) while True: frame = picam2.capture_array() out.write(frame) if cv2.waitKey(1) & 0xFF == ord("q"): break out.release()录视频的时候要把小车放在赛道的不同地方:直道、弯道、障碍物正前方各录 10 秒,这样调参时才覆盖到所有动作。然后在电脑端用 VideoCapture 读录像,循环执行extract_mask、compute_track_info,把每一帧的error和obstacle打印出来。这能让你不用弯腰看小车就搞清楚:到底是 HSV 阈值把地面阴影误判成了赛道线,还是 PID 参数导致过冲。
5.2 用 Replay 数据验证转向死区和避障阈值
调参的顺位建议是先调 HSV,再调转向kp,最后调速度。摆弄kp之前先要确认error在直线行驶时是否正确归零:如果小车正对直线但error显示 20 像素,多半是摄像头安装角度歪了,而不是转向问题。把录制画面暂停在直线帧,观察掩码中心是否和画面中心重合,这一步能筛掉一半“代码没问题但车跑不直”的情况。
避障面积阈值也可以离线验证:对录像中障碍物从远到近的那几帧,打印轮廓最大面积,就能直观看到触发值设在哪个范围。如果障碍物离小车 30 厘米时面积到 9000,你可以把阈值设到 8500 给一点提前量,但不要设到 4000,否则过弯时墙壁也会被误判成障碍物。
5.3 从离线参数到实车参数的转换要点
离线视频和实车运行有一个不可忽略的差异:视频帧率是固定的 20fps,蓝牙或无线遥控的输入延迟也会不同,所以离线调好的 PID 在实车上不一定完全稳定。常见办法是把离线调好的参数作为初值,实车上只微调kd:离线收敛良好但实车抖动,调高kd到抖振消失;实车响应太肉、转不过弯,调低kd。最终判断标准不是“看起来不抖”,而是小车在直线赛段左右偏差持续小于 5 个像素,在弯道不会冲出赛道。树莓派端没有 SDL 桌面环境时,可以通过电脑端收到的图像流同步做实时视觉化验证:把error画成一条竖线显示在画面上,观察它是否平滑地跟在画面中心附近。这样整个项目从录制、离线调参到实车微调,就形成了一个闭环,源码里剩下的问题都会在调试过程中暴露出来。
本文还有配套的精品资源,点击获取