把两条本来各自独立的能力装到同一台车上——超声波负责【别撞上】(硬安全),色彩追踪负责【往目标去】(软目标);前者是一根绝对优先级的安全绳,后者是一个会自己找方向的意愿。 本节的真正新东西不是接线,而是从【开关式控制】换到【比例控制】——这一步决定了车是【左右剧烈摆动】还是【走得像回事】。
16-1 算出的中心坐标 cx 在这里变成转向输入;15-2 的 Motor 对象和 drive() 函数在这里是输出端;15-4 立的电源铁律(只共地、不共电)在这里一步都不能破——本节比上一章多用了一个 5V 传感器,电源账要重新算一遍。
与 15-4 的分界:15-4 的车【谁动由人决定】,本节的车【动由程序决定】——这是本课从【遥控】走向【自主】的分水岭。
7-2 的 gpiozero 在这里第一次用于输入型器件(前面几章多是输出),而7-5 讲过的【树莓派没有 ADC】在这里不构成障碍——超声波走的是数字接口(一个时间脉冲),不需要模数转换。
一、这台车要同时干两件事,得先排优先级
先把任务拆清楚,否则代码会写成一团:
| 任务 | 靠什么 | 输出 | 性质 |
|---|---|---|---|
| 避障 | HC-SR04 超声波 | 【前方有东西 → 停/退/让】 | 硬安全,一票否决 |
| 追踪 | 16-1 的彩色目标 | 【目标在左 → 左转】 | 软目标,可以被打断 |
优先级怎么写进代码:避障先判、追踪后判;避障一旦触发,追踪这一轮直接跳过。
每一轮循环:
1. 读前方距离
2. 距离 < 20cm ? -> 执行避障动作,continue(本轮不追踪)
3. 否则 -> 找色块,算出 cx -> 比例控制转向
顺序反过来会出什么事:如果先追踪后避障,车会先朝目标猛冲一段再停车——每次都在离障碍 20cm 以内才反应,刹车距离全靠运气。安全逻辑永远写在最前面。
二、超声波怎么把【距离】变成【一个数】
HC-SR04 面板上两只"眼睛":一只发射、一只接收。工作流程只有一句话——发一声音,听它什么时候回来:
① 给 Trig 脚一个至少 10 微秒的高电平脉冲 -> "喊一声"
② 模块把 40kHz 的超声波打出去
③ 声波撞到障碍物反射回来
④ 模块把 Echo 脚拉高,高电平持续的时间 = 声波往返的总时间
⑤ 程序量出这段脉宽,乘声速再除以 2 = 距离
公式与数字(这一节请把数算清楚,后面调参全靠它):
- 20°C 空气中的声速约 343 m/s = 0.0343 cm/μs
- 声波是往返的,所以单程要 除以 2
- 合并起来:距离(cm) = Echo 脉宽(μs) ÷ 58
验算一遍这个 58 的来历:1cm 往返 = 2cm ÷ 0.0343 cm/μs ≈ 58.3 μs。记住了它,就能反问【我的读数为什么跳】——温度每变化 10°C,声速大约变 0.6%,对 1 米处的测距来说就是约 6 毫米的漂移。所以超声波本来就不是精密仪器,别指望毫米级。
最远 4m 时,声波往返需要 2 × 4 ÷ 343 ≈ 23.3 ms——在这 23ms 里你根本不可能拿到下一次读数。所以轮询频率的上限大约是每秒 40 次,工程上取 4Hz(每 0.25 秒读一次)就够了,也留出余量给电机控制和图像处理。
别写 while True: print(sensor.distance) 不加延时——你会读到大串重复值甚至异常,而且把 CPU 白烧掉。
三、⚠️ 本节最危险的一条:Echo 脚的 5V 必须分压
HC-SR04 是 5V 器件:VCC 要 5V 供电,而它的 Echo 脚输出的高电平也是 5V。树莓派的 GPIO 是 3.3V 逻辑,直接把 5V 灌进 GPIO = 打坏那根引脚(轻则该脚报废、重则连带芯片)。
正解是分压:Echo 脚先过一个分压网络,把 5V 降到 3.3V 以下再进 GPIO。
| 接法 | 电阻 | Echo 到 GPIO 的电压 |
|---|---|---|
| VCC(5V) - 1kΩ - 节点 - 2kΩ - GND | 1k / 2k | 5 × 2 ÷ (1+2) ≈ 3.33V ✅ |
| VCC(5V) - 2kΩ - 节点 - 1kΩ - GND | 2k / 1k | 5 × 1 ÷ (2+1) ≈ 1.67V ❌ 太低 |
验算:节点电压 = 5V × R下 ÷ (R上 + R下)。1kΩ 在上、2kΩ 在下 得到 3.33V,正好压在 3.3V 逻辑的容许线上(略高但可接受,实际器件通常接 1k/2k 就是这个组合)。
- 上电前用万用表确认:黑笔接 GND、红笔接【进 GPIO 的那个节点】,读数应在 3.3V 附近。读到 5V = 接反了,立刻断电。
- 别省这一步,也别想着【就接一下下】——GPIO 烧掉是不可逆的。
- 想彻底免掉分压,可以换成 3.3V 原生的超声波模块(如 ME007YS、RCWL-1601):VCC 和 Echo 都是 3.3V,代码一行都不用改,代价是贵几块钱。新手建议直接上 3.3V 版本,省下的是烧板子的风险。
四、gpiozero 的 DistanceSensor:一行代码替你算完
gpiozero 把上面那一整套【发脉冲→量宽度→除 58】封装成了一个类:
from gpiozero import DistanceSensor
sensor = DistanceSensor(echo=24, trigger=23, # 注意顺序是 echo 在前
max_distance=4.0, # 超过 4m 就算【超量程】
queue_len=5) # 取最近 5 次的中值,抹平抖动
| 用法 | 得到 | 说明 |
|---|---|---|
sensor.distance |
距离,单位米(浮点) | 想要厘米就 * 100 |
sensor.value |
归一化的距离(0.0 ~ 1.0) | 0=贴着,1.0=超量程(读不到回波) |
sensor.max_distance |
设定的上限(米) | |
sensor.close() |
释放引脚 | 退出时最好调一次 |
sensor.value 是个妙用:value == 1.0 就说明【没有任何回波】——比数值本身更有信息量,因为超量程和"真的很远"是两回事。
Pi 5 换了新 I/O 芯片(RP1),老的 RPi.GPIO 已经失效(见 7-2)。gpiozero 在 Pi 5 上会自动选 lgpio,但前提是系统里真的有 lgpio:
sudo apt update
sudo apt install -y python3-gpiozero python3-lgpio
自查方法:在代码里 print(sensor.pin_factory),看到 LGPIOFactory 才对;看到 MockFactory 说明没装真后端——代码能跑但针脚毫无反应,这是最容易被误判成"传感器坏了"的情形。
接线(BCM 编号):
| HC-SR04 | 接到 |
|---|---|
| VCC | 树莓派 5V(物理脚 2 或 4) |
| GND | 树莓派 GND |
| Trig | 任意 GPIO,例 BCM 23 |
| Echo | 经 1k/2k 分压后接 BCM 24 |
五、从【偏差】到【转向】:开关式 vs 比例控制
这是本节真正要讲透的一段。同一个偏差,有两种用它的方式:
开关式(if-else)——最简单,也最难看:
if cx < 160:
turn_left_at_full_speed()
elif cx > 160:
turn_right_at_full_speed()
问题在于它只有【全力】和【不转】两档。结果就是:目标偏一点点 → 全力猛转 → 一下转过头 → 再全力猛转回来。画面上看就是车在左右剧烈摆动,像喝醉了一样。
比例控制(P 控制)——把偏差按比例变成转向力度:
error = (cx - width / 2) / (width / 2) # 归一化到 -1.0(最左) ~ +1.0(最右)
turn = kp * error # kp 是比例系数,从 0.8 左右试起
偏差越大转得越猛、偏差小就轻轻修正——车就稳了。这就是比例控制的全部思想:输出与误差成正比,而不是与误差的正负号成正比。
再加上一个死区(dead zone),专治【正对目标还在微抖】:
if abs(cx - width / 2) < 20: # 中心 ±20 像素以内视为【已经对准】
turn = 0.0 # 不再修正,免得原地画抖
转向怎么落到轮子上——差速转向:
这里有个极容易搞反的地方,先把这张表刻住——左轮转得快,车向右拐(左侧行程更长,车头被推向右;这跟"踩左边油门"的直觉相反,却是刚体运动学的必然结果——想右转就加快左边的轮子):
| 左轮速度 | 右轮速度 | 车的动作 |
|---|---|---|
| 快 | 慢 | 右转(左侧行程更长) |
| 慢 | 快 | 左转 |
| 一样快 | 一样快 | 直行 |
| 正转 | 反转 | 原地打转 |
于是约定 turn > 0 表示【向右修正】,代码就写成:
left_speed = base + turn # turn>0 -> 左轮加速
right_speed = base - turn # -> 右轮减速,合起来就是右转
验算一次:画面宽 320,目标出现在 cx = 80(偏左)。error = (80-160)/160 = -0.5;取 kp = 0.8 → turn = -0.4(负 = 向左修正);基速 base = 0.5 →
左轮 = 0.5 + (-0.4) = 0.1、右轮 = 0.5 - (-0.4) = 0.9。右轮 0.9、左轮 0.1 → 右轮快 → 大幅左转,方向和力度都对。
gpiozero 的 Motor 用 speed 0.0 ~ 1.0,上面算出的 0.9 没问题,但偏差更大时会算出 1.3、-0.2 这种越界值。一定要夹:
left_speed = max(0.0, min(1.0, base - turn))
right_speed = max(0.0, min(1.0, base + turn))
负数会直接抛异常(ValueError: speed must be between 0 and 1),不是静默忽略——所以别指望它自己兜住。
还有一层物理现实:0.9 能不能真的让轮子转起来取决于电压和负载(15-2 讲过静摩擦)。先把 base 设在 0.5 以上,否则算出来的低速可能推不动车,看起来像"程序没跑"。
六、动手 Demo:先验证,再接电机
分层验证的老规矩:先只跑传感器、确认读数合理,再接电机。
第一层:超声波读数
from gpiozero import DistanceSensor
from time import sleep
sensor = DistanceSensor(echo=24, trigger=23, max_distance=4.0, queue_len=5)
print("pin factory =", sensor.pin_factory) # 必须不是 MockFactory
try:
while True:
if sensor.value == 1.0:
print("超量程:前方 4 米内没有回波")
else:
print("distance = {:.1f} cm".format(sensor.distance * 100))
sleep(0.25) # 4Hz,留出 23ms 的物理余量
except KeyboardInterrupt:
pass
finally:
sensor.close()
看到什么算成功:
pin factory = LGPIOFactory
distance = 42.3 cm
distance = 41.8 cm <- 手掌慢慢靠近,数字连续变小
distance = 12.6 cm
超量程:前方 4 米内没有回波 <- 手拿开、探头朝空处
读结果:数字连续变化、靠近时变小就对;若是乱跳(比如 40 → 300 → 15)通常有两个原因:① 探测面是斜的、软材质(吸音)——换个硬纸板对着试;② queue_len 太小,改成 5 或 7。若 pin factory 是 MockFactory,先去装 python3-lgpio,别怀疑传感器。
第二层:接上电机,跑完整逻辑
from gpiozero import DistanceSensor, Motor
import cv2
import numpy as np
from time import sleep
# --- 执行端:15-2 的电机对象(引脚按你的接线改) ---
left = Motor(forward=17, backward=18, enable=12, pwm=True)
right = Motor(forward=22, backward=23, enable=13, pwm=True)
# --- 感知端一:超声波 ---
sonar = DistanceSensor(echo=24, trigger=25, max_distance=4.0, queue_len=5)
# --- 感知端二:摄像头 ---
cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)
W = 320
lower = np.array([40, 70, 70])
upper = np.array([80, 255, 255])
kernel = np.ones((5, 5), np.uint8)
def clamp(v):
return max(0.0, min(1.0, v))
def find_target_cx(frame):
"""返回目标中心横坐标;找不到返回 None(逻辑与 16-1 一致)"""
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, lower, upper)
mask = cv2.erode(mask, kernel, iterations=1)
mask = cv2.dilate(mask, kernel, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
biggest = max(contours, key=cv2.contourArea)
if cv2.contourArea(biggest) <= 500:
return None
x, y, w, h = cv2.boundingRect(biggest)
return x + w // 2
def drive(left_speed, right_speed):
left.value = left_speed
right.value = right_speed
def stop():
left.stop()
right.stop()
try:
while True:
# ===== 1. 避障优先:一票否决 =====
if sonar.distance < 0.20: # 20cm 以内
stop()
sleep(0.2)
drive(-0.5, -0.5) # 退一点
sleep(0.3)
drive(0.5, -0.5) # 原地转开
sleep(0.3)
stop()
continue # 本轮不追踪
# ===== 2. 追踪:比例控制 =====
ok, frame = cap.read()
if not ok:
break
cx = find_target_cx(frame)
if cx is None:
stop() # 看不见目标 -> 停住等
else:
error = (cx - W / 2) / (W / 2) # -1.0 ~ +1.0
if abs(cx - W / 2) < 20: # 死区:对准了就别修
turn = 0.0
else:
turn = 0.8 * error # kp = 0.8,turn>0 表示向右修正
base = 0.5
drive(clamp(base + turn), clamp(base - turn)) # 左快=右转
sleep(0.05)
except KeyboardInterrupt:
pass
finally:
stop() # ★ 绝不能省
sonar.close()
cap.release()
cv2.destroyAllWindows()
看到什么算成功:把绿色物体放在车前约 1 米处并缓慢左右移动 → 车头跟着摆;拿手挡在车前方 20cm 内 → 车先停、再后退、再转开,然后继续找目标。
读结果:
- 车在目标正前方轻微左右摆 → 死区 20 像素太大或
kp太大,把kp降到 0.5、死区提到 30 试。 - 车朝目标冲过去但没到就停 → 那个"目标"其实是把别的东西误判成了绿色,去看掩膜(16-1 的调试窗口)。
- 完全不动 → 先查
sensor.pin_factory与min(1.0, ...)之后的base值;0.5 以下可能推不动(15-2 的静摩擦)。 finally: stop()不是可选项:没有它,Ctrl+C 之后车会以最后的速度一直跑下去——这是 15-4 立下的规矩,本节照样适用。
七、30 秒实验:看一眼 queue_len 到底在干什么
把 queue_len=5 改成 queue_len=1,重跑第一层 Demo,手掌静止不动,盯着读数:
queue_len=1:数字后两位疯跳(42.3 / 39.8 / 44.1 / 41.0),幅度能到 ±3cm;queue_len=5:读数明显平稳,跳动收到 ±1cm 内。
原因:queue_len 让 gpiozero 连续采 5 次、取中值再给你——而树莓派跑的是非实时系统,一次读数可能被系统调度打断,单次读数的抖动主要来自操作系统而不是传感器。所以两个数都有用:要平稳用 5,要最快反应(比如车开得很快)用 1 或 2。
八、常见误区
- 【HC-SR04 的 Echo 直接接 GPIO 就行】——它输出的是 5V 高电平,直接进 3.3V 的 GPIO 会打坏引脚。必须 1k/2k 分压(或改用 3.3V 原生模块)。这是本节唯一不可妥协的一条。
- 【超声波能测到毫米级】——声速随温度漂;20°C 到 0°C 之间就有约 3% 的差。±1~2cm 是它的正常水平,别拿它做精密测量。
- 【避障就是"到距离就停"】——原地停死会把车卡在那儿,之后永远出不来。正路是 停 → 后退 → 转开 → 继续,四步一个循环。
- 【追踪用 if-else 就够,反正最后也能到】——开关式控制只有【全力】和【不转】两档,在现场的表现就是车左右剧烈摆动。要稳就必须用比例控制。
- 【转向得靠舵机,那才叫自动驾驶】——差速转向(左右轮转速差)是小车底盘的常规做法,结构更简单、无额外舵机;只有"前轮转向"的车型才需要舵机打方向。
- 【读一次 distance 就是一个可信的数】——单次读数受操作系统调度影响会跳。用
queue_len平滑,并接受"它是便宜的传感器"这个事实。 - 【
sensor.value和sensor.distance差不多】——value是归一化到 0~1 的值,distance是米;而且value == 1.0表示【超量程没回波】,这个判断只能靠value做。
自测三问(点开对答案)
1. **HC-SR04 的 Echo 脚为什么不能直接接树莓派 GPIO?怎么解决?** 模块是 5V 器件,Echo 输出的高电平是 5V,而树莓派 GPIO 是 3.3V 逻辑,**直接接会打坏引脚**。解决办法:用 **1kΩ(上)+ 2kΩ(下)分压**,节点电压 `5 × 2 ÷ 3 ≈ 3.33V`;或者直接换 **3.3V 原生**的超声波模块(ME007YS / RCWL-1601),代码完全不变。 2. **开关式控制和比例控制的区别是什么?为什么前者会让车乱摆?** 开关式只看偏差**符号**(偏左就全力左转、偏右就全力右转),**只有两档**,于是必然转过头、再转回来,形成摆动。比例控制让**转向力度与偏差大小成正比**(`turn = kp × error`),偏差小就轻修、偏差大才猛转,所以走得稳。再加上**死区**(中心 ±20 像素内不修正)治抖动。 3. **为什么避障判断必须写在追踪之前?** 因为避障是**硬安全**、追踪是**软目标**。若先追踪,车会朝目标全速冲一段、**每次都在离障碍 20cm 以内才反应**,刹车距离全靠运气。安全逻辑必须**一票否决**、写在最前,触发后本轮直接跳过追踪。- ☐ 下一节(16-3 车道自动检测系统)
相关笔记
- OpenCV 色彩检测与追踪 —— 本节
find_target_cx()用的就是那一套流水线 - 树莓派的直流电机控制 —— 执行端:
Motor对象、speed 0~1、起步阈值 - 打造树莓派 WiFi 遥控视频车 —— 电源铁律与
finally: stop()的出处 - Python 的 GPIO 模块 —— gpiozero 与 pin factory(在 Pi 5 上必须是 LGPIOFactory)
- 模拟输入 —— 对照:超声波走数字接口,所以树莓派没有 ADC 也不影响它
- 🍓 树莓派 · 索引 —— 全课路线图