🍓 第十六章 硬件接口实验范例(二):树莓派 AI 自驾车
📄 一句话概括

把两条本来各自独立的能力装到同一台车上——超声波负责【别撞上】(硬安全),色彩追踪负责【往目标去】(软目标);前者是一根绝对优先级的安全绳,后者是一个会自己找方向的意愿。 本节的真正新东西不是接线,而是从【开关式控制】换到【比例控制】——这一步决定了车是【左右剧烈摆动】还是【走得像回事】。

📝 本节和前面几节的关系

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 = 距离

公式与数字(这一节请把数算清楚,后面调参全靠它):

验算一遍这个 58 的来历:1cm 往返 = 2cm ÷ 0.0343 cm/μs ≈ 58.3 μs。记住了它,就能反问【我的读数为什么跳】——温度每变化 10°C,声速大约变 0.6%,对 1 米处的测距来说就是约 6 毫米的漂移。所以超声波本来就不是精密仪器,别指望毫米级。

⚠️ 4 米是它的物理上限,也决定了你的循环节奏

最远 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 就是这个组合)。

⚠️ 三条保命规矩
  1. 上电前用万用表确认:黑笔接 GND、红笔接【进 GPIO 的那个节点】,读数应在 3.3V 附近。读到 5V = 接反了,立刻断电。
  2. 别省这一步,也别想着【就接一下下】——GPIO 烧掉是不可逆的。
  3. 想彻底免掉分压,可以换成 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 上,gpiozero 必须走 lgpio 后端

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 → 右轮快 → 大幅左转,方向和力度都对。

⚠️ 速度必须夹在 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 内 → 车先停、再后退、再转开,然后继续找目标。

读结果:

七、30 秒实验:看一眼 queue_len 到底在干什么

把 queue_len=5 改成 queue_len=1,重跑第一层 Demo,手掌静止不动,盯着读数:

原因:queue_len 让 gpiozero 连续采 5 次、取中值再给你——而树莓派跑的是非实时系统,一次读数可能被系统调度打断,单次读数的抖动主要来自操作系统而不是传感器。所以两个数都有用:要平稳用 5,要最快反应(比如车开得很快)用 1 或 2。

八、常见误区

  1. 【HC-SR04 的 Echo 直接接 GPIO 就行】——它输出的是 5V 高电平,直接进 3.3V 的 GPIO 会打坏引脚。必须 1k/2k 分压(或改用 3.3V 原生模块)。这是本节唯一不可妥协的一条。
  2. 【超声波能测到毫米级】——声速随温度漂;20°C 到 0°C 之间就有约 3% 的差。±1~2cm 是它的正常水平,别拿它做精密测量。
  3. 【避障就是"到距离就停"】——原地停死会把车卡在那儿,之后永远出不来。正路是 停 → 后退 → 转开 → 继续,四步一个循环。
  4. 【追踪用 if-else 就够,反正最后也能到】——开关式控制只有【全力】和【不转】两档,在现场的表现就是车左右剧烈摆动。要稳就必须用比例控制。
  5. 【转向得靠舵机,那才叫自动驾驶】——差速转向(左右轮转速差)是小车底盘的常规做法,结构更简单、无额外舵机;只有"前轮转向"的车型才需要舵机打方向。
  6. 【读一次 distance 就是一个可信的数】——单次读数受操作系统调度影响会跳。用 queue_len 平滑,并接受"它是便宜的传感器"这个事实。
  7. 【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 以内才反应**,刹车距离全靠运气。安全逻辑必须**一票否决**、写在最前,触发后本轮直接跳过追踪。

相关笔记