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

把本章前三节的三只【眼睛】(颜色追踪、超声波、车道检测)装到第十五章那台【会走路的底盘】上,再加一层状态机决定此刻听谁的、一层看门狗保证出事就停——这才是一台会自己跑的车。 收官篇真正要教的不是"再写一个功能",而是怎么把多个功能组装成一台可靠的机器:解耦、优先级、安全默认、性能预算。

📝 本节和前面几节的关系

15-1 立的四层架构(感知 / 决策 / 执行 / 供电)在本节第一次被完整填满——前两章各填了一部分,本节是合龙。 16-1、16-3 的感知函数在这里被原样调用,16-2 的比例控制与差速转向在这里成为唯一的执行通道。 11-5 / 12-5 的【第一生存法则】(推理不必每帧都做、感知与取流要解耦)在本节升级成感知与执行整体解耦。 15-4 的安全规矩(finally: stop()、只共地不共电)在这里加上第三道:看门狗——因为自驾车失控的后果比遥控车严重得多。

一、先把架构画出来:四层各放什么

层 本节装配的组件 来自哪一节
感知 CSI / USB 摄像头 + HC-SR04 超声波 10 章、16-2
决策 颜色追踪 / 车道检测 / 避障判断(三选一执行) 16-1、16-2、16-3
执行 驱动板 + 左右两个直流电机(差速转向) 15-2
供电 电池给电机、树莓派独立供电、只共地不共电 15-2、15-4

架构的意义在于【谁坏了会怎样】:感知层坏 → 决策拿不到输入 → 应该停;决策层坏 → 执行收到非法值 → 应该夹住;执行层坏 → 靠物理断电兜底;供电层坏 → 前面三层全部失效,所以它是唯一必须一次做对的一层。

二、决策层:一个四状态的小状态机

不用写得很复杂,四个状态就够把本章所有能力串起来:

状态 什么时候进入 动作
AVOID 超声波读到 < 25cm 停 → 后退 → 转开(最高优先级,一票否决)
TRACK 画面里找到目标色块 比例控制追着色块走
LANE 没色块、但看到车道线 比例控制沿车道中心走
STOP 什么都没看到 停车(这是"安全默认")

两个设计要点:

  1. AVOID 必须写在最前面(16-2 已立的规矩)——安全逻辑一票否决,触发后本轮直接跳过其它判断。
  2. STOP 才是默认状态,不是 LANE——看不见就不动。反过来说,如果默认状态是"继续按上次的动作走",那么摄像头一被遮住,车就会以最后的速度一直冲出去。这个设计选择就是"玩具"和"机器"的分界。

三、性能预算:30fps 意味着每帧只有 33 毫秒

先把账算清楚,才知道哪里该省:

三招省时间(按性价比排序):

① 降分辨率——最划算的一招

分辨率 像素数 相对 640×480
640×480 307200 100%
320×240 76800 25%

像素少 75%,处理时间大致按同比例下降。而车道线检测本来就不需要高分辨率——它看的是边缘梯度,不是纹理细节。320×240 是自驾车这个任务的甜点分辨率。

② 跳帧——控制环不需要 30Hz

车身的机械惯性决定了每秒 10~15 次转向更新就够了。所以可以隔一帧处理一次:

frame_count += 1
if frame_count % 2 != 0:      # 奇数帧只取不算
    continue

CPU 占用直接砍半,而开车的手感几乎没差别。这正是 11-5 / 12-5 那条【推理不必每帧都做】的老规矩。

③ 多线程——把【取帧】和【算帧】分开

单线程里,cap.read() 是阻塞的:它必须等摄像头把下一帧准备好。于是主循环的时间被"等摄像头"和"算图"两件事串起来叠加,帧率上不去。

正解是一个专线程只管取帧,主线程只管算,而且取帧线程只保留最新一帧(旧帧直接扔掉):

class Camera:
    def __init__(self, src=0, w=320, h=240):
        self.cap = cv2.VideoCapture(src)
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, w)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h)
        self.frame = None
        self.lock = Lock()
        self.running = True
        self.t = Thread(target=self._loop, daemon=True)
        self.t.start()

    def _loop(self):
        while self.running:
            ok, f = self.cap.read()
            if ok:
                with self.lock:
                    self.frame = f        # 只存最新一帧,旧的自动被覆盖

    def read(self):
        with self.lock:
            return None if self.frame is None else self.frame.copy()

    def close(self):
        self.running = False
        self.t.join(timeout=1)
        self.cap.release()
⚠️ 为什么是【只保留最新一帧】,而不是排一个队列

如果用队列把每一帧都排进去,处理速度只要比拍摄速度慢一点点,队列就会越积越长——表现是画面延迟从 50ms 慢慢涨到 2 秒,车早就撞上去了,屏幕上还在放三秒前的位置。 只保留最新一帧就等于"跟不上就丢帧",延迟恒定。对实时控制来说,一帧新鲜的画面永远比一串过期的画面值钱。

四、动手 Demo:完整可跑的骨架

先在场地上贴两条平行胶带当车道线(黑胶带贴在浅色地面上效果最好),把摄像头朝下前方、能看见前方 1 米左右的路面。第一版务必把 BASE 调小。

import time
from time import sleep
from threading import Thread, Lock
import cv2
import numpy as np
from gpiozero import Motor, DistanceSensor

# ================= 配置 =================
W, H = 320, 240
BASE = 0.45            # 巡航基速:第一版一定要慢(0.4 ~ 0.5)
KP = 0.6               # 比例系数
DEAD_ZONE = 18         # 死区:偏差小于 18 像素就不修(防抖)
OBSTACLE_CM = 25       # 避障阈值
STALE_SEC = 1.0        # 看门狗:超过 1 秒没成功处理就停车

# ================= 执行端(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)


def clamp(v):
    return max(-1.0, min(1.0, v))


def set_wheels(lv, rv):
    left.value = clamp(lv)
    right.value = clamp(rv)


def stop():
    left.stop()
    right.stop()


# ================= 感知端:摄像头线程 =================
class Camera:
    def __init__(self, src=0, w=W, h=H):
        self.cap = cv2.VideoCapture(src)
        self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, w)
        self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h)
        self.frame = None
        self.lock = Lock()
        self.running = True
        self.t = Thread(target=self._loop, daemon=True)
        self.t.start()

    def _loop(self):
        while self.running:
            ok, f = self.cap.read()
            if ok:
                with self.lock:
                    self.frame = f

    def read(self):
        with self.lock:
            return None if self.frame is None else self.frame.copy()

    def close(self):
        self.running = False
        self.t.join(timeout=1)
        self.cap.release()


# ================= 感知端:两个视觉函数 =================
KERNEL = np.ones((5, 5), np.uint8)
LOWER = np.array([40, 70, 70])          # 绿色(16-1)
UPPER = np.array([80, 255, 255])


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 detect_lane_center(frame):
    """返回车道中心横坐标;看不到车道线返回 None(16-3)"""
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    edges = cv2.Canny(blur, 50, 150)

    pts = [(0, H), (W, H), (W // 2, int(H * 0.4))]
    polygon = np.array([pts], np.int32)
    roi_mask = np.zeros_like(edges)
    cv2.fillPoly(roi_mask, polygon, 255)
    edges = cv2.bitwise_and(edges, roi_mask)

    lines = cv2.HoughLinesP(edges, 1, np.pi / 180,
                            threshold=50, minLineLength=40, maxLineGap=20)
    if lines is None:
        return None

    left_fit, right_fit = [], []
    for ln in lines:
        x1, y1, x2, y2 = ln[0]
        if x2 == x1:
            continue
        slope = (y2 - y1) / (x2 - x1)
        if abs(slope) < 0.5:
            continue
        intercept = y1 - slope * x1
        (left_fit if slope < 0 else right_fit).append((slope, intercept))

    if not left_fit and not right_fit:
        return None

    def bottom_x(fit):
        slope, intercept = fit
        return int((H - intercept) / slope)

    if left_fit and right_fit:
        lx = bottom_x(np.average(left_fit, axis=0))
        rx = bottom_x(np.average(right_fit, axis=0))
        return (lx + rx) // 2
    if left_fit:
        return bottom_x(np.average(left_fit, axis=0)) + W // 2
    return bottom_x(np.average(right_fit, axis=0)) - W // 2


# ================= 决策 + 主循环 =================
def proportional(base, center, ref):
    """偏差点 center(目标/车道中心)相对 ref(画面中心)算出左右轮速度"""
    if abs(center - ref) < DEAD_ZONE:
        turn = 0.0
    else:
        turn = KP * (center - ref) / (W / 2)      # 归一化后乘 KP
    return base + turn, base - turn                # 左快 = 右转(16-2)


cam = Camera()
mode = "STOP"
last_ok = time.time()
frame_count = 0

try:
    while True:
        # ---- ① 看门狗:处理不动了就停 ----
        if time.time() - last_ok > STALE_SEC:
            stop()
            mode = "STOP"

        # ---- ② 避障:最高优先级,一票否决 ----
        if sonar.distance < OBSTACLE_CM / 100.0:
            mode = "AVOID"
            stop()
            sleep(0.15)
            set_wheels(-0.35, -0.35)      # 后退
            sleep(0.30)
            set_wheels(0.40, -0.40)       # 原地右转(左正右负 = 右转)
            sleep(0.35)
            stop()
            last_ok = time.time()
            continue

        # ---- ③ 取帧(跳帧:奇数帧不算) ----
        frame = cam.read()
        frame_count += 1
        if frame is None or frame_count % 2 != 0:
            sleep(0.01)
            continue

        # ---- ④ 先找色块,再找车道 ----
        cx = find_target_cx(frame)
        if cx is not None:
            mode = "TRACK"
            lv, rv = proportional(BASE, cx, W // 2)
            set_wheels(lv, rv)
        else:
            lane = detect_lane_center(frame)
            if lane is None:
                mode = "STOP"             # 安全默认:看不见就不动
                stop()
            else:
                mode = "LANE"
                lv, rv = proportional(BASE, lane, W // 2)
                set_wheels(lv, rv)

        last_ok = time.time()
        sleep(0.02)

except KeyboardInterrupt:
    pass
finally:
    stop()                    # ★ 15-4 的规矩,绝不能省
    sonar.close()
    cam.close()
    cv2.destroyAllWindows()

看到什么算成功:

场地:贴两条平行胶带的浅色地面,车放在车道中央
期望:车沿车道走一段不压线;手掌挡在车前 25cm 内 ->
      停车、后退、转开,然后继续找车道
地面无胶带/摄像头被遮 -> 车停下(进入 STOP),而不是乱冲

读结果与首调顺序(按这个顺序,别跳步):

  1. 车完全不动 → 先查 sonar.pin_factory 不是 MockFactory、left.value/right.value 有没有越界、BASE = 0.45 能不能推动车(15-2 的静摩擦)。
  2. 车在车道里画龙(左右猛摆) → 降 KP(0.6 → 0.35),或加大死区(18 → 28)。先动这两个,别去改视觉参数。
  3. 车总是往一边偏 → 摄像头装歪了或镜头畸变。用一个常数补偿(在 proportional 里给 turn 加个固定偏移),比反复调视觉参数有效。
  4. 明明有车道线却进入 STOP → 打开 16-3 的可视化窗口看 roi_edges,先确认 Canny 和 ROI 正常,再怀疑车道线本身(对比度太低?胶带颜色太接近地面?)。
  5. 跑一段后突然失控 → 大概率是电源(15-4 的老账:电机一转树莓派就重启)或过热降频。先量电压,再看代码。

五、30 秒实验:速度与容错,只能选一个

把 BASE = 0.45 改成 BASE = 0.8,其余一切不动,在场地上重跑。

预期:车明显更容易冲出车道、画龙幅度更大、避障后回到车道的时间变长——参数一个没改,只是快了。

为什么:从"看见偏差"到"轮子真正改变车速"之间存在总延迟(取帧 + 计算 + 串口/GPIO + 电机响应),基速越快,这段延迟内车已经跑出去的距离越长,而比例控制修正的是"当前看到的误差",等它生效时车早就偏更多了——偏差被放大,于是振荡。

结论一句话:自驾车的第一版必须慢。 想跑快,要先把延迟压下来(降分辨率、跳帧、提高 KP 的稳定裕度),或者上更高级的控制(PID 里的 D 项就是专门用来对付这种滞后振荡的)。

六、安全兜底:三层,一层都不能少

层 手段 防的是什么
① 代码层 finally: stop() Ctrl+C/异常退出后车还在跑
② 逻辑层 STOP 是默认状态 + 看门狗 摄像头掉了/处理卡死/看不见路,车按上次动作一直冲
③ 物理层 电池开关/拔线 前两层都失灵时的最后手段

做完任何一次上车实验,都要亲手测一遍这三层:按 Ctrl+C 看它停不停、用手遮住摄像头看它停不停、拍一下电池开关看它断电。测试停车比测试开车重要。

七、常见误区

  1. 【把三个感知全塞进一个大循环里一起算】——CPU 只有那么多,三个全开的结果是帧率掉到个位数,车反应迟钝。按场景选一个主感知(场上跑赛道就用车道检测),其余当兜底。
  2. 【功能越"聪明"越好】——本章的每一种感知都有失效场景(16-3 明确列过)。可靠性来自"失效时知道要停",不来自"功能清单很长"。
  3. 【摄像头掉了没关系,继续按上次的动作走】——这是最危险的写法。默认状态必须是 STOP,任何"拿不到输入"的分支都要通向停车。
  4. 【BASE 拉满才叫性能好】——基速越快,同样的延迟带来的偏差越大,越容易振荡失控(第五节实验亲测)。第一版一律慢速。
  5. 【能跑起来就算成功】——要验证的是【沿车道跑完一段不压线、遇障能停】,而不是【轮子会转】。验收标准要写清楚,否则你会以为"能动"就是"做好了"。
  6. 【调参全凭感觉乱改】——顺序很重要:先修机械与装配(摄像头角度、电源),再调 KP 与死区,最后才碰视觉参数(Canny/Hough)。反着来的话,你会在视觉参数上白耗一整天。
  7. 【状态机是多余的心智负担】——恰恰相反。没有状态机,四件事会互相打架(避障时还在追色块、看不见路还在按上次速度跑)。状态机就是把"此刻该听谁的"写成代码,它是最便宜的安全装置。

这本教材到此,全部完结。 🎉

十六章节链路(回头看一眼自己走过的路):

章 主题 留下的那一句话
1 认识树莓派 一块几十块钱的板子,是一台完整的电脑
2 购买、安装与设置 装系统=把镜像写进 SD 卡
3 Raspberry Pi OS 基本使用 在 Linux 里,一切皆文件
4 Linux 系统管理 用户、权限、软件包,是系统的骨架
5 架设服务器 树莓派天生就是一台服务器
6 开发 Python 程序 Python 是树莓派的母语
7 GPIO 硬件接口 软件第一次伸出【手】,摸到物理世界
8 树莓派遇到 Arduino 分工:一个动脑、一个动手
9 Pico 与 MicroPython 小到一块芯片也能跑 Python
10 相机模块与视频串流 它有了眼睛
11 OpenCV + YOLO 它开始认得世界
12 MediaPipe + CVZone + TFLite 它开始看得懂动作
13 IoT 与 Node-RED 它把数据送上了网
14 Node-RED + TensorFlow.js 流程与智能画在同一张画布上
15 WiFi 遥控视频车 感知、决策、执行第一次合体
16 AI 自驾车 它终于会自己看路、自己决定

三个【带得走的】东西(比任何一个库都值钱):

  1. 先断电的习惯——接线之前先断电,是所有硬件实验的第零步;
  2. 先核实再照抄的习惯——教材永远比软件老,老教程的每一条命令都先问一句【现在还这样吗】;
  3. 默认安全的习惯——任何会动的东西(车、电机、灯)都要有它自己的【停下来】,拿不到可靠输入就不动。
自测三问(点开对答案) 1. **为什么 `STOP` 必须是默认状态,而不是"继续上一次的动作"?** 因为"继续上次动作"意味着**任何一次感知丢失都会让车继续冲出去**——摄像头被遮、处理卡死、看不见车道都会变成事故。把 `STOP` 设为默认,就等价于【拿不到可靠输入就不动】,这是自驾车最基本的安全原则。配合**看门狗**(超过 1 秒没成功处理就停车)覆盖"程序卡住"的情形。 2. **取帧线程为什么【只保留最新一帧】,而不是把所有帧排进队列?** 因为处理速度只要比拍摄速度慢一点点,队列就会**越积越长**,画面延迟从几十毫秒涨到几秒——**屏幕上还在显示三秒前的位置,车早就撞了**。只保留最新一帧等于"跟不上就丢帧",延迟**恒定**,对实时控制来说这才是有用的。 3. **`BASE` 从 0.45 调到 0.8,为什么车反而更容易冲出去?** 因为从"看到偏差"到"轮子真正改变速度"之间存在**总延迟**(取帧+计算+GPIO+电机响应)。基速越快,这段延迟里车已经跑出去的距离越长,**比例控制修正的是过期的误差**,偏差被放大,于是出现振荡甚至冲出车道。**想快必须先压延迟**(降分辨率、跳帧)或引入微分项(PID 的 D)。

相关笔记