把三条线程拼成一台车:一条不停抓帧推视频、一条随时接收你的按键、一条把按键变成电机的 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")
几个不能省的细节:
daemon=True:主程序退出时抓帧线程跟着死,不然 Ctrl+C 后相机句柄不释放,下次启动报设备忙;time.sleep(2):相机启动有延迟,早抓会拿到空数组或报错;finally: drive("stop"):程序退出必须停车。没有这行,Ctrl+C 之后车会以最后的速度一直跑下去——这是本章最危险的一条;- 动作路由用 POST:15-3 讲过的理由,刷新页面不会误触发。
三、前端:一个页面搞定方向盘
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>
"""
前端三个关键设计:
pointerdown/pointerup(而不是 click):按下即动、抬起即停,配合下面的【抬起必发 stop】实现【松手就停】;pointerleave也发 stop:手指滑出按钮范围时同样停车,防止【按着滑走导致失控】;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 常被嫌弃【不省流量】,算一笔账就知道它在局域网里完全够用:
- 640×480 的 JPEG(质量 70)约 40KB/帧;
- 20 FPS 时带宽 = 40KB × 20 × 8 = 6.4 Mbps;
- 单帧传输时间 ≈ 320kbit ÷ 6400kbps = 50 ms;
- 家用 2.4GHz WiFi 实测通常有 20~40Mbps 可用——余量 3~6 倍。
对比另外两条路:HLS 压缩率高(H.264)但延迟 5~15 秒——开遥控车延迟 5 秒等于撞墙;WebRTC 延迟最低(亚秒级)但需要信令服务器,复杂度陡增。局域网遥控车的正路就是 MJPEG:延迟低(100~300ms 观感)、零额外依赖、任何浏览器都认。
想更省带宽:把分辨率降到 320×240(约 10KB/帧,带宽降到 1.6Mbps)——遥控车看路况,低分辨率反而更好开(延迟更低)。
六、老教程里已失效的写法
raspistill/raspivid抓帧 —— 已删除(第十章已讲),现在一律picamera2;picamera(1.x)库 —— 已淘汰,用picamera2;系统预装,apt 装是python3-picamera2;app.run()不加threaded=True—— 老教程只有一次请求的示例,搬到视频流上必挂;@app.before_first_request—— Flask 3.0 移除(15-3 表格),初始化写进应用工厂或with app.app_context():。
七、常见误区
- Ctrl+C 退出后车还在跑 —— 没写
finally: drive("stop");程序异常退出时也走 finally,这是唯一可靠的自动停车点。 - 【停】按钮做成 click 就够了 —— 触屏上 click 触发时机和长按行为都不可靠;按住走、松手停的遥控语义必须用 pointerdown/up 组合。
- 画面卡就拼命提高帧率 —— 卡的原因通常是带宽或 WiFi 信号,不是帧率不够;正确做法是降分辨率(320×240)或降 JPEG 质量,把单帧变小。
- 用树莓派自身当画面显示 —— 车上的树莓派要抓帧、编码、跑服务,再开个浏览器看自己的流只会抢资源;画面在手机上看,树莓派专心干活。
- 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。
八、收束:三条线在这里合流
回头看这台车是谁拼出来的:
- 10 章的相机给眼睛(感知层);
- 15-2 的电机与驱动板给腿(执行层);
- 15-3 的 Flask给神经(控制通道);
- 13-2 的 GPIO-on-Web 原型是这一切的起点;
- 7 章的 PWM藏在速度滑杆里;
- 而 11~14 章的 AI——把 COCO-SSD 或手势模型接到
/move路由上,这台车立刻就有【看到人自动停下】【手势指挥】的可能。AIoT 的最后一公里,就是本章这套骨架。
带着走的一句话:硬件项目的难点从来不在代码,而在【谁给谁供电、谁和谁共地、谁在什么时候必须停下】——把这三件事想清楚,剩下的都是顺序问题。
- ☑ 第十五章 完结 —— 教材这一章已读完四节:认清智能车四层结构与三块板卡的分工、用 gpiozero 把直流电机转起来(守住电源隔离与 ENA 跳线帽两条铁律)、把 Flask 升级成能并发伺候视频流与动作指令的遥控台,最后以 MJPEG + 多线程 + 前端按键拼出第一台 WiFi 遥控视频车——本课首次实现采集、控制、执行三系统同时运转
- ☑ 第十六章 硬件接口实验范例(二):树莓派 AI 自驾车 —— 已完结:打造树莓派 AI 自驾车(颜色追踪+超声波避障+车道检测三只眼,配上状态机与看门狗,拼成一台会自己找路、看不见路就停下的车)
相关笔记
- 树莓派的直流电机控制 —— 执行层:本节
drive()调用的仍是那两个 Motor 对象 - 再谈 Python 的 Flask 框架 —— 控制通道:[threaded 与 POST 的规矩]全在本节兑现
- 在树莓派建立视频串流 —— 感知层的另一条路(VLC/MPEG-TS)与本节 MJPEG 的分工
- AIoT 实验范例:Node-RED 与 COCO-SSD —— 给这台车装上眼睛与大脑的下一个方向
- OpenCV 色彩检测与追踪 —— 下一章起点:让这台车第一次自己读画面、自己判断
- 打造树莓派 AI 自驾车 —— 下一章小结:把本章底盘与三只眼装配成自走的车
- 🍓 树莓派 · 索引 —— 全课路线图