简介:围绕树莓派3B四轮驱动小车的双线寻迹项目设计报告,面向自动化、机器人及嵌入式视觉方向的课程设计者、竞赛选手与自学者,聚焦用OpenCV+Python在树莓派上完成车道线识别与小车自动循迹。资源包仅含1个docx文档,约1.37MB,以项目报告形式组织,便于按问题背景、系统配置、转向调试、图像处理与代码逻辑逐节阅读。报告记录了烧录新版系统、升级Python 3.9.2与OpenCV 4.5的环境配置过程,也分析了L298N无法反转时采用单侧车轮不动、另一侧高速转动实现转弯的替代方案,并通过降低主函数嵌套复杂度解决运行死机。图像处理部分涵盖高斯模糊、灰度化、膨胀、二值化及阈值调整,还说明摄像头斜向下安装对减少环境干扰的作用。已有250人学习,适合需要完整双线寻迹案例、环境搭建参考和故障排查思路的读者。
1. 树莓派双线寻迹:用 OpenCV + Python 让四轮小车自己跑八字
摄像头斜向下固定、两条黑线之间留白、树莓派 3b 加一块 L298N,这套配置听起来像入门玩具,实际跑起来第一晚就能把人劝退。我拆的这个项目,核心就是用 OpenCV + Python 在树莓派上做视觉双线寻迹——不靠红外对管,而是让摄像头看路、算偏差、发指令,让四轮小车在两条黑线中间稳定行驶并完成八字绕环。它解决的是「视觉循迹怎么从单线走到双线」这个卡点,适合已经玩过红外寻迹、想上手图像处理闭环的自动化专业学生和嵌入式爱好者。整个流程里,最耗时的不是算法,而是环境配置和电机控制这两个坑,后面逐章拆。
2. 环境配置:树莓派 3b 上的 Python 3.9 与 OpenCV 4.5 落地
项目组最初想直接用现成系统,结果撞上了老版本 Python 的墙:python2 停在 2.9,python3 停在 3.4,找匹配的 .whl 文件极难,好不容易装上 OpenCV 又因为 cmake 版本过旧编译不过,还缺一堆依赖库,现场又不能联网,逐个下载编译不现实。这个血泪经验很典型——在树莓派上做 OpenCV 项目,环境这一关过不去,后面全是空谈。
2.1 为什么选「重烧系统」而不是硬修旧环境
旧系统的根本问题不是缺一个包,而是整条工具链都停在几年前:Python 3.4 对应的 OpenCV 轮子基本绝迹,cmake 版本撑不起新源码编译,依赖库版本互相打架。算一下成本:继续修旧系统要逐个找 .whl、手动编译、解决依赖冲突,且没有网络兜底;重烧最新系统则一次性把 Python 拉到 3.9.2,OpenCV 用 4.5 有现成轮子,依赖库也能批量装。项目组前后花了 4 天在这上面,说明选错路线的代价很高。我的判断是:只要旧系统 Python 低于 3.6,直接重烧,别犹豫。
2.2 烧录、联网与 SSH 的完整步骤
树莓派没有显示器时,联网和远程登录是第一步。常见做法是烧录系统后在 boot 分区放两个配置文件再把卡插回板子。
# 1) 烧录官方系统镜像到 TF 卡后,在 boot 分区新建空文件 ssh,开启 SSH 服务 touch /media/xxx/boot/ssh # 2) 配置无线网络:在 boot 分区新建 wpa_supplicant.conf cat > /media/xxx/boot/wpa_supplicant.conf <<'EOF' country=CN ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev update_config=1 network={ ssid="你的热点名称" psk="你的热点密码" key_mgmt=WPA-PSK } EOF # 3) 插卡上电,等 30 秒左右,从路由器或热点管理页查树莓派 IP,然后 SSH 登录 ssh pi@192.168.1.100逻辑说明:ssh空文件是官方系统识别「允许远程登录」的开关;wpa_supplicant.conf里的country=CN决定射频参数,缺这行在部分环境下会连不上;key_mgmt=WPA-PSK对应最常见的 WPA2 热点。参数上,ssid和psk必须与热点完全一致,大小写和空格都算。项目里用手机热点给树莓派供网,这一步解决的是「小车无法联网装包」的死结。
登录后先确认 Python 版本,再装 OpenCV 和依赖:
python3 --version # 期望输出 Python 3.9.2 sudo apt update sudo apt install -y python3-pip python3-numpy # 优先用系统包管理器装 OpenCV,省去源码编译 sudo apt install -y python3-opencv python3 -c "import cv2; print(cv2.__version__)" # 验证是否可用参数说明:python3-opencv是发行版打包好的轮子,装完即可import cv2,比pip install opencv-python在树莓派上更省事——后者在 ARM 上常常没有预编译轮子,会触发本地编译,耗时长且容易因内存不足失败。如果系统源里的 OpenCV 版本低于 4.5,再考虑pip install opencv-python==4.5.x,但要先pip3 install --upgrade pip并确认有 swap 空间。
提示:装任何包之前先
sudo apt update,并且确认树莓派已联网。联网失败时先ping网关,再看wpa_supplicant.conf的缩进和引号是否被编辑器改坏。
2.3 摄像头与电机的接线确认
视觉寻迹的前提是图像能进来、轮子能听话。摄像头用 CSI 排线接树莓派,raspi-config里开启 Camera 接口后,用libcamera-hello或 OpenCV 读一帧验证。电机侧,L298N 的 IN1/IN2 控制左侧前后轮、IN3/IN4 控制右侧前后轮,ENA/ENB 接 PWM 调速,电源地和树莓派地必须共地,否则电平信号没有参考、电机动作会飘。
import cv2 cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240) ret, frame = cap.read() print("取帧成功" if ret else "取帧失败,检查 CSI 排线和 Camera 接口是否开启") cap.release()这里把分辨率压到 320×240 是有意的:树莓派 3b 的算力有限,分辨率越高,每帧高斯模糊和阈值处理越慢,控制回路的延迟越大,小车在八字弯道上就更容易冲出去。参数CAP_PROP_FRAME_WIDTH/HEIGHT需要和摄像头实际支持的分辨率匹配,设置不支持的值可能被驱动忽略。
3. 图像处理管线:从原始帧到黑线中心点
双线寻迹和单线的本质差别在于:单线只需要判断「线在左还是右」,双线要同时盯住左右两条边界,再取中间车道。所以图像处理的目标不是找到一条线,而是算出车道中心相对画面中心的横向偏差,这个偏差就是转向的依据。
3.1 高斯模糊、灰度、膨胀、二值化的处理顺序
项目给的顺序是高斯模糊、灰度处理、模糊、膨胀、二值化。这个顺序基本合理:先去噪再降维,最后分割。高斯模糊压掉摄像头噪点,灰度化把三通道压成单通道减少计算量,二次模糊进一步平滑,膨胀让黑线在二值图上更连续,二值化把灰度图切成黑白两色。需要提醒的是,膨胀放在二值化之前还是之后效果不同——先膨胀再二值化,模糊的边界会被一起放大;先二值化再膨胀,黑色区域会向外扩。项目选择先膨胀后二值化,实际调试时如果发现黑线变粗侵占车道,就改成先二值化后膨胀。
import cv2 import numpy as np def preprocess(frame): # 1) 高斯模糊去噪,核大小取奇数 blur = cv2.GaussianBlur(frame, (5, 5), 0) # 2) 灰度化,减少后续计算量 gray = cv2.cvtColor(blur, cv2.COLOR_BGR2GRAY) # 3) 二次模糊,进一步平滑 blur2 = cv2.GaussianBlur(gray, (5, 5), 0) # 4) 膨胀,让黑线区域更连续 kernel = np.ones((3, 3), np.uint8) dilate = cv2.dilate(blur2, kernel, iterations=1) # 5) 二值化:低于 70 判为黑色轨道 _, binary = cv2.threshold(dilate, 70, 255, cv2.THRESH_BINARY) return binary逻辑说明:GaussianBlur的核(5,5)是 5×5 邻域加权平均,值越大越模糊,5 是树莓派上延迟和去噪的折中;cvtColor转灰度后单像素只占 1 字节,阈值处理快一倍以上;dilate用 3×3 全 1 核,让断开的黑线连起来;threshold的第二个参数 70 就是项目调出来的黑线判定阈值,低于 70 的像素判为黑色(赋 255 还是 0 取决于后续统计方式,若统计黑线像素则取反)。
注意:阈值 70 不是通用值。项目最初用 50,结果地面影子和反光被误判成轨道边界,小车直接跑偏。改到 70 后影子被排除在黑色之外,效果才稳定。换场地必须重调这个值。
3.2 阈值与中心点参数:50 到 70、250 与 100 的边界
两个参数决定了小车的行为:二值化阈值决定「什么算黑线」,中心点阈值决定「什么时候转向」。项目最终设定是二值化阈值 70,黑色像素中心值大于 250 时左转、小于等于 100 时右转。这里的 center 是图像处理后黑线区域的横向重心,在 320 宽度下,画面中心约 160,250 和 100 分别落在右侧和左侧的偏置区间。理解方式:重心明显偏右(>250)说明车道整体在右边,车要往左修;重心明显偏左(≤100)则往右修;中间区间直行。这两个阈值本质是转向死区,设得太窄小车会左右抖,设得太宽弯道修不回来。
| 参数 | 项目取值 | 作用 | 调整方向 |
|---|---|---|---|
| 二值化阈值 | 70 | 判定黑色轨道的灰度分界 | 影子多则调高,黑线浅则调低 |
| 左转触发值 | 250 | 中心点超过此值左转 | 弯道修不过来则调小 |
| 右转触发值 | 100 | 中心点低于此值右转 | 抖动严重则调大 |
| 图像宽度 | 320 | 参与计算的水平像素数 | 算力不足则降低 |
| 膨胀核 | 3×3 | 连通黑线 | 线断则加大,线粗则减小 |
3.3 用黑线中心点算横向偏差
有了二值图,统计黑色像素的横向分布就能得到中心点。项目代码里有个容易忽略的细节:只统计画面下半部分。因为摄像头斜向下安装时,画面上半部分拍的是远处背景,参与统计只会引入干扰。
def find_center(binary): h, w = binary.shape # 只取下半部分,减少远端干扰 roi = binary[h // 2:, :] # 黑色像素在二值图中值为 0,取反后统计 black_pixels = np.where(roi == 0) if len(black_pixels[1]) == 0: return -1 # 没找到线,交给上层做丢线处理 center = int(np.mean(black_pixels[1])) return center def decide_action(center): if center == -1: return "stop" # 丢线,停车等下一帧 if center > 250: return "left" elif center <= 100: return "right" else: return "forward"逻辑说明:binary[h//2:, :]切掉上半部分,np.where返回所有黑色像素的坐标,np.mean(black_pixels[1])对列坐标取均值得到横向重心。参数上,250 和 100 是按 320 宽度调出来的经验值,如果换了分辨率必须等比换算,比如宽度改成 640,阈值大致翻倍到 500 和 200 再微调。丢线返回 -1 是必要的保护,否则np.mean空数组会报错或返回 NaN,程序直接崩。
4. 运动控制:L298N 转向与死机问题的四个坑
图像算出偏差只是前半程,后半程是把「左/右/直行」翻译成电机动作。这个项目在控制环节踩的坑最集中,也最有参考价值——很多视觉寻迹教程只讲图像不讲电机,结果车一动就出问题。
4.1 电机只能正转:为什么一侧不动也能转弯
项目组按 L298N 手册接线,IN1/IN2 给高低电平,理论上左侧前后轮应该正转,反过来给电平应该反转。实操发现无论怎么设,电机都只正转,左右轮分别反转实现原地转向的思路直接作废。排查结论是驱动模块或接线层面的限制,短时间内改不了。他们的解法是利用小车质量轻的特点:转弯时一侧车轮停转、另一侧高速正转,靠速度差把车头带过去。这个方案不优雅但有效,是硬件受限下的工程妥协。
import RPi.GPIO as GPIO IN1, IN2, IN3, IN4 = 17, 18, 22, 23 ENA, ENB = 12, 13 GPIO.setmode(GPIO.BCM) for pin in (IN1, IN2, IN3, IN4, ENA, ENB): GPIO.setup(pin, GPIO.OUT) pwm_a = GPIO.PWM(ENA, 1000) # 左轮调速 pwm_b = GPIO.PWM(ENB, 1000) # 右轮调速 pwm_a.start(0) pwm_b.start(0) def forward(speed=60): GPIO.output(IN1, GPIO.HIGH); GPIO.output(IN2, GPIO.LOW) GPIO.output(IN3, GPIO.HIGH); GPIO.output(IN4, GPIO.LOW) pwm_a.ChangeDutyCycle(speed) pwm_b.ChangeDutyCycle(speed) def turn_left(speed=60): # 左轮停转,右轮高速正转,靠速度差转向 GPIO.output(IN1, GPIO.LOW); GPIO.output(IN2, GPIO.LOW) GPIO.output(IN3, GPIO.HIGH); GPIO.output(IN4, GPIO.LOW) pwm_a.ChangeDutyCycle(0) pwm_b.ChangeDutyCycle(speed) def turn_right(speed=60): GPIO.output(IN1, GPIO.HIGH); GPIO.output(IN2, GPIO.LOW) GPIO.output(IN3, GPIO.LOW); GPIO.output(IN4, GPIO.LOW) pwm_a.ChangeDutyCycle(speed) pwm_b.ChangeDutyCycle(0) def stop(): pwm_a.ChangeDutyCycle(0) pwm_b.ChangeDutyCycle(0)逻辑说明:PWM频率 1000Hz 对直流减速电机足够,占空比控制转速;转弯函数把一侧占空比设为 0、另一侧保持速度,就是「单侧驱动转向」。参数speed建议从 50 到 60 起步,八字弯道半径小时速度太高会直接冲出去。这段代码是项目方案的最小复现,如果手里的 L298N 能正常反转,把转弯改成左右轮反向驱动会更稳,但要注意反转瞬间电流冲击。
4.2 嵌套 while 改 if:死机问题的定位过程
小车跑起来后频繁死机,尤其做完左右转向后必卡。单独跑图像处理没问题,单独跑电机也没问题,说明是两者耦合导致的。检查主函数发现里面套了两层while,内层循环在一帧内反复执行,时间和空间开销被放大,主循环跟不上摄像头帧率。把内层while改成if后,死机消失。这个改动的实质是让每一帧只做一次决策,而不是在一次决策里反复空转。
# 反例:内层 while 会在一帧内反复执行,拖垮主循环 while True: ret, frame = cap.read() binary = preprocess(frame) center = find_center(binary) while center != -1: # 问题所在 action = decide_action(center) execute(action) break # 正确写法:每帧只判断一次,决策完立即回到取帧 while True: ret, frame = cap.read() if not ret: break binary = preprocess(frame) center = find_center(binary) action = decide_action(center) execute(action)逻辑说明:原代码内层while配合break实际只执行一次,但写法让人误以为要循环,后续维护时容易把break去掉导致死循环,而树莓派单核性能有限,一旦某帧处理超时,PWM 信号就被阻塞,表现就是死机。改成if后控制流一目了然,每帧耗时可控。这类「玄学死机」我见过多次,排查顺序应该是:先看循环结构,再看阻塞调用,最后看电源。
4.3 摄像头安装角度:垂直安装为什么是错的
项目组一开始把摄像头垂直于小车固定,结果画面里塞满远处背景和天花板,有效轨道信息占比很低,干扰大到算法失效。改成斜向下固定后,画面下半部分稳定覆盖车前一段轨道,干扰明显减少。这个细节值得单拎出来讲:视觉寻迹的摄像头不是「能看到路」就行,而是要「看到的每一像素都对决策有用」。斜向下的俯角决定前视距离,俯角太小看太远、弯道信息滞后,俯角太大看太近、来不及转向。常见做法是让画面下边缘落在车前 20 到 30 厘米,上边缘覆盖弯道入口。
4.4 避坑与排查:五个让小车跑不直的常见问题
现象一:小车在直道上左右画龙。原因:转向死区太窄,中心点小波动就触发转向。解决:把左转阈值往上调、右转阈值往下调,或者对中心点做几帧滑动平均再判断。
现象二:进弯不转,直接冲出轨道。原因:速度太高或死区太宽,等中心点越过阈值时车头已经出界。解决:弯道前降速,或者把阈值往中间收,同时提高帧率——降低分辨率比换摄像头更直接。
现象三:地面上有影子时乱转。原因:二值化阈值偏低,灰色影子被当成黑线。解决:阈值从 50 调到 70 这类更高值,同时在预处理里去掉膨胀或减小膨胀核,避免影子被扩成边界。
现象四:运行时随机死机。原因:主循环里有阻塞或嵌套循环,或者电源供电不足导致树莓派欠压。解决:按 4.2 把循环结构理顺,同时用独立电源给电机供电、和树莓派共地,别从树莓派 5V 直接带电机。
现象五:丢线后程序报错退出。原因:np.mean对空数组求均值。解决:统计前先判空,返回 -1 并让上层执行停车或保持上一动作,别让异常冒到主循环。
5. 八字绕环调参法:把偏差映射成差速的三步验证
八字绕环是这个项目最难的验收项,因为它同时考验直道稳定性和双向弯道响应。很多人卡在这里不是算法不行,而是调参没有章法,改一个参数车变一个样,最后不知道哪个值对。我的做法是把整个过程拆成三步,每步只动一个变量。
第一步,静态验证图像管线。把小车架空,摄像头对着打印好的黑白轨道,用下面的脚本把二值图和中心点画出来看。
import cv2 import numpy as np cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if not ret: break binary = preprocess(frame) center = find_center(binary) # 在原图上画出中心点和分界线,肉眼确认是否对准车道 vis = frame.copy() if center != -1: cv2.line(vis, (center, 0), (center, vis.shape[0]), (0, 0, 255), 2) cv2.line(vis, (100, 0), (100, vis.shape[0]), (0, 255, 0), 1) cv2.line(vis, (250, 0), (250, vis.shape[0]), (0, 255, 0), 1) cv2.imshow("binary", binary) cv2.imshow("center", vis) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这一步的意义是排除「算法和实际画面对不上」的低级错误。红线是算出来的中心,绿线是 100 和 250 两条阈值线,车在直道时红线应该落在两绿线之间,进弯时偏向一侧。如果直道上红线就在阈值线附近晃,说明死区设得太窄,先调阈值再谈跑车。
第二步,低速直线标定。把速度压到 40 左右,只跑直道,观察小车是否稳定居中。这一步调的是二值化阈值和摄像头俯角,不碰转向参数。判断标准:小车能连续直行 3 米不碰线。
第三步,单弯到八字。先跑单个弯道确认转向方向正确、回正及时,再上八字。八字的关键是左右弯切换时的响应延迟,如果切换瞬间冲出去,说明速度还是偏高,或者中心点没有做平滑。常见做法是对最近 3 帧的中心点取中位数,抑制单帧噪声,代码如下:
from collections import deque history = deque(maxlen=3) def smooth_center(center): if center == -1: return history[-1] if history else -1 history.append(center) return int(sorted(history)[len(history) // 2]) # 取中位数参数说明:maxlen=3是平滑窗口,窗口越大越稳但响应越慢,八字绕环建议 3 到 5;取中位数而不是均值是为了抗单帧突变,比如偶发的影子误判。history[-1]的兜底逻辑是丢线时沿用上一帧中心,避免停车抖动,但如果连续多帧丢线,应该直接停车而不是继续沿用。
最后给一个我自己的习惯:每次调参前先把当前参数和对应现象记在纸上,改完只跑同一段轨道做对比。视觉寻迹的参数是耦合的——阈值影响中心点、中心点影响动作、动作影响车的位置、车的位置又反过来影响画面,所以一次只动一个变量、跑固定路线对比,是唯一可靠的收敛方式。从那以后我每次碰这类视觉闭环项目,都强制先做静态图像验证再上车跑,宁可多花半小时看二值图,也不在路上追着车跑。希望帮到你。
本文还有配套的精品资源,点击获取