资讯动态

树莓派图像识别循迹避障小车:OpenCV+PID控制实战指南

发布时间:2026/9/11 21:33:48 来源:尧图企业网站定制
简介基于树莓派图像识别技术实现的智能循迹避障小车项目包含电脑端与树莓派端完整 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(maxsize2) # 最多存2帧旧帧直接丢弃 result_queue queue.Queue(maxsize1) # 只保留最新识别结果 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返回supported1 detected1才说明摄像头硬件正常。读取画面用 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-18070-25560-255红色在 HSV 里会跨 0 和 180 两段蓝色线100-12470-25560-255蓝色胶带的 H 通常在 110 左右障碍物20-8080-25580-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, keycv2.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, kp0.4, kd0.1, last_error0): if error is None: return 0 p kp * error d kd * (error - last_error) return p d这里kp是比例系数默认 0.4error 为 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 17IN1左电机方向 AGPIO 18IN2左电机方向 BGPIO 22IN3右电机方向 AGPIO 23IN4右电机方向 BGPIO 24ENA左电机速度PWMGPIO 25ENB右电机速度PWMPython 端使用RPi.GPIO输出 PWMimport 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_speed30, right_speed50原地转时一正一负。注意ChangeDutyCycle接受的参数是 0-100 之间的浮点数所以正反转都用绝对值输出占空比方向交给 IN 引脚控制。4. 电脑端与树莓派端源码的协同SSH 远程调试与视频回传4.1 电脑端源码结构图像预览与 HSV 参数实时调节树莓派端代码经常要调 HSV 阈值、PID 系数和避障面积阈值如果每改一次参数就要插拔 HDMI 线、接键盘显示器实际调试效率会非常低。所以电脑端源码通常不写业务逻辑而是做一个“可视化调参面板”通过局域网从树莓派接收图片流在电脑端显示识别结果用滚轮或者滑块实时修改 HSV 范围和 PID 系数修改后的参数通过网络发回树莓派端热加载。电脑端最简单的实现是直接用 OpenCV 自带的高GUIimport 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 最大 180S 和 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 环境建议创建虚拟环境不要直接用系统 Pythonpython3 -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画成一条竖线显示在画面上观察它是否平滑地跟在画面中心附近。这样整个项目从录制、离线调参到实车微调就形成了一个闭环源码里剩下的问题都会在调试过程中暴露出来。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价