🍓 第十五章 硬件接口实验范例(一):树莓派 WiFi 遥控视频车
📄 一句话概括

把三条线程拼成一台车:一条不停抓帧推视频、一条随时接收你的按键、一条把按键变成电机的 PWM——手机浏览器就是方向盘和挡风玻璃。

📝 本节和前面几节的关系

15-1 画了四层图,15-2 让轮子转起来,15-3 让 Flask 能并发接指令;本节把三者接起来,再加上第十章学过的相机。它是本课第一个多线程 + 多子系统的完整项目:采集、控制、执行同时在跑,谁也不能堵住谁。做完这台车,本课【计算机基础 → 操作系统 → 硬件接口 → 网络服务 → AI】的所有线索就在一个实物上汇合了。

一、三条线程,各跑各的

遥控车能跑起来,靠的是三个同时工作的角色:

线程 干什么 起点
相机抓帧线程 循环抓帧、压成 JPEG、放进共享变量 threading.Thread(daemon=True)
Flask 请求线程 视频流响应(持续推帧)+动作响应(改电机状态) threaded=True
电机对象 不占线程,被动接收指令 15-2 的 Motor 对象

为什么抓帧要单独开线程:如果一边推流一边现抓,帧率会被网络拖住(浏览器慢 → 抓帧也慢 → 画面更卡)。抓帧恒定跑、推流按需取最新帧,车才顺。共享变量必须加锁(threading.Lock),否则会读到写了一半的帧,画面撕裂。

二、完整程序:app.py

from flask import Flask, Response, request, jsonify
from gpiozero import Motor
from picamera2 import Picamera2
import cv2, threading, time

app = Flask(__name__)

# ---------- 电机(引脚号按自己的接线改)----------
left  = Motor(forward=17, backward=27, enable=12, pwm=True)
right = Motor(forward=22, backward=23, enable=13, pwm=True)

def drive(action, speed=0.6):
    if action == "forward":
        left.forward(speed);  right.forward(speed)
    elif action == "backward":
        left.backward(speed); right.backward(speed)
    elif action == "left":
        left.backward(speed); right.forward(speed)
    elif action == "right":
        left.forward(speed);  right.backward(speed)
    else:
        left.stop(); right.stop()          # stop 与其他任何未知动作都停车

# ---------- 相机抓帧线程 ----------
frame = None
lock = threading.Lock()

def capture_loop():
    global frame
    cam = Picamera2()
    cam.configure(cam.create_video_configuration(
        main={"size": (640, 480), "format": "RGB888"}))
    cam.start()
    time.sleep(2)                          # 相机启动要时间,别急着抓
    while True:
        arr = cam.capture_array()
        ok, jpg = cv2.imencode(".jpg", arr, [cv2.IMWRITE_JPEG_QUALITY, 70])
        if ok:
            with lock:
                frame = jpg.tobytes()

threading.Thread(target=capture_loop, daemon=True).start()

# ---------- 路由 ----------
@app.get("/")
def index():
    return PANEL_HTML

@app.get("/video_feed")
def video_feed():
    def gen():
        while True:
            with lock:
                cur = frame
            if cur:
                yield (b"--frame\r\n"
                       b"Content-Type: image/jpeg\r\n\r\n" + cur + b"\r\n")
            time.sleep(0.05)               # 约 20 FPS
    return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")

@app.post("/move/<action>")
def move(action):
    speed = float(request.args.get("speed", 0.6))
    drive(action, speed)
    return jsonify({"action": action, "speed": speed})

if __name__ == "__main__":
    try:
        app.run(host="0.0.0.0", port=5000, threaded=True)
    finally:
        drive("stop")

几个不能省的细节:

三、前端:一个页面搞定方向盘

PANEL_HTML = """
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>遥控车</title>
<style>
  body { margin:0; background:#111; color:#eee; font-family:sans-serif;
         text-align:center; user-select:none; -webkit-user-select:none; }
  img  { width:100%; max-width:640px; }
  .pad { display:grid; grid-template-columns:repeat(3,1fr); gap:8px;
         max-width:320px; margin:10px auto; }
  button { padding:18px 0; font-size:20px; border:none; border-radius:10px;
           background:#2a6; color:#fff; touch-action:none; }
  button.stop { background:#c33; grid-column:span 3; }
</style></head>
<body>
<img src="/video_feed">
<div class="pad">
  <span></span><button data-a="forward">前</button><span></span>
  <button data-a="left">左</button><button data-a="stop" class="stop">停</button><button data-a="right">右</button>
  <span></span><button data-a="backward">后</button><span></span>
</div>
<script>
const send = (a) => fetch("/move/" + a + "?speed=0.7", {method:"POST"});
document.querySelectorAll("button").forEach(b => {
  const go = (e) => { e.preventDefault(); send(b.dataset.a); };
  const end = (e) => { e.preventDefault(); send("stop"); };
  b.addEventListener("pointerdown", go);
  b.addEventListener("pointerup", end);
  b.addEventListener("pointerleave", end);
  b.addEventListener("pointercancel", end);
});
</script>
</body></html>
"""

前端三个关键设计:

  1. pointerdown / pointerup(而不是 click):按下即动、抬起即停,配合下面的【抬起必发 stop】实现【松手就停】;
  2. pointerleave 也发 stop:手指滑出按钮范围时同样停车,防止【按着滑走导致失控】;
  3. touch-action:none + user-select:none:手机浏览器默认会把手势当成滚动或放大,不加这两条,按按钮时页面会乱动。

四、动手 Demo:启动与访问

hostname -I              # 先查树莓派的局域网 IP,例如 192.168.1.42
python3 app.py           # 启动服务

然后同一 WiFi 下的手机浏览器打开 http://192.168.1.42:5000——画面出现,按钮能开车。

看到什么算成功:页面上视频画面流畅(约 20 FPS 观感),按住【前】车前进、松手立刻停;按【左】【右】能原地转向;按红色【停】立即刹车。

五、带宽与延迟:为什么这个方案刚刚好

MJPEG 常被嫌弃【不省流量】,算一笔账就知道它在局域网里完全够用:

对比另外两条路:HLS 压缩率高(H.264)但延迟 5~15 秒——开遥控车延迟 5 秒等于撞墙;WebRTC 延迟最低(亚秒级)但需要信令服务器,复杂度陡增。局域网遥控车的正路就是 MJPEG:延迟低(100~300ms 观感)、零额外依赖、任何浏览器都认。

想更省带宽:把分辨率降到 320×240(约 10KB/帧,带宽降到 1.6Mbps)——遥控车看路况,低分辨率反而更好开(延迟更低)。

六、老教程里已失效的写法

  1. raspistill / raspivid 抓帧 —— 已删除(第十章已讲),现在一律 picamera2;
  2. picamera(1.x)库 —— 已淘汰,用 picamera2;系统预装,apt 装是 python3-picamera2;
  3. app.run() 不加 threaded=True —— 老教程只有一次请求的示例,搬到视频流上必挂;
  4. @app.before_first_request —— Flask 3.0 移除(15-3 表格),初始化写进应用工厂或 with app.app_context():。

七、常见误区

  1. Ctrl+C 退出后车还在跑 —— 没写 finally: drive("stop");程序异常退出时也走 finally,这是唯一可靠的自动停车点。
  2. 【停】按钮做成 click 就够了 —— 触屏上 click 触发时机和长按行为都不可靠;按住走、松手停的遥控语义必须用 pointerdown/up 组合。
  3. 画面卡就拼命提高帧率 —— 卡的原因通常是带宽或 WiFi 信号,不是帧率不够;正确做法是降分辨率(320×240)或降 JPEG 质量,把单帧变小。
  4. 用树莓派自身当画面显示 —— 车上的树莓派要抓帧、编码、跑服务,再开个浏览器看自己的流只会抢资源;画面在手机上看,树莓派专心干活。
  5. WiFi 覆盖不用管 —— 车一开出路由器覆盖范围立刻失联,且失联时车保持最后的速度继续跑(这也是必须用 finally 停车的原因之一);出车前先实测遥控距离。
自测三问(点开对答案) 1. 抓帧为什么要单独开一个线程,而不是在 `/video_feed` 里现抓现推? 现抓现推会被网络速度拖住(浏览器慢→抓帧也慢→画面更卡);独立线程让抓帧保持恒速,推流只取最新帧,共享变量加锁避免读到半帧。 2. `finally: drive("stop")` 为什么不能省? 程序正常或异常退出、以及 Ctrl+C 时都会走 finally;没有它,车会以最后的速度一直跑下去,是本章最危险的一条。 3. 画面卡顿时,该升帧率还是降分辨率? 降分辨率(或降 JPEG 质量)。卡顿九成来自带宽/信号,不是帧率;单帧变小才是对症下药(320×240 约 10KB/帧)。

30 秒实验

在电脑浏览器里单独打开 http://<树莓派IP>:5000/video_feed(不打开控制页面):

你会看到一张不停刷新的纯图片流,浏览器地址栏不变、页面不刷新——这就是 MJPEG 的本质:一个永不结束的 HTTP 响应,边界符 --frame 把一帧帧 JPEG 隔开。再把它嵌进 <img src="/video_feed">,一行标签就完成了【视频播放器】的全部工作——没有播放器、没有插件、没有额外协议。这就是为什么 10-5 讲的 VLC 那条路只在需要跨网络时用,而局域网项目都选 MJPEG。

八、收束:三条线在这里合流

回头看这台车是谁拼出来的:

带着走的一句话:硬件项目的难点从来不在代码,而在【谁给谁供电、谁和谁共地、谁在什么时候必须停下】——把这三件事想清楚,剩下的都是顺序问题。

相关笔记