YOLOv8 模型训练完,很多人的第一反应是打个 API 跑单张图片,但真实项目里最常被问的却是另一件事:怎么把摄像头连续的视频流在网页里实时看到检测结果。这里绕不开两个东西——视频帧要在后端持续处理,处理完要低延迟推到浏览器,我的选择是 Flask/FastAPI + WebSocket,这两个组合是目前 Web 端视频流展示最顺手的技术栈。这篇内容适合正在做目标检测落地、想把检测结果从命令行挪到网页上的开发者,也适合第一次接触 WebSocket 推送视频帧、不知道从哪下手的同学。我把从框架选型到代码实现、再到性能调优的完整链路串一遍,看完你至少能跑通一个像样的 Demo,而不是只停留在“模型能出框”的阶段。
1. 为什么 Web 端视频流展示必须用 WebSocket
1.1 三种推送方案横向对比
做 Web 端视频流,最容易想到的方案其实是 MJPEG 流。OpenCV 里自带 VideoWriter 的 MJPG 编码,Flask 的响应流配合 multipart/x-mixed-replace 就能把视频推到浏览器。这个方案写起来确实快,十几行代码就能看到画面,但它有几个硬伤:浏览器兼容性本来就越来越不友好,很多现代浏览器对 multipart 流的渲染策略改了又改;而且它本质上是一个长连接的 HTTP 响应,后端推流线程要一直挂着,同时在线几个人就开几个推流线程,摄像头和模型资源被反复占用。
另一个常见方案是 HTTP 轮询,前端每隔几百毫秒去拉一次最新检测结果。这个方案的问题在于“实时性”是假的,轮询间隔设短了请求太频繁,设长了画面卡顿,而且每次请求都要重建连接,HTTP 头部的开销在视频这种高频场景下大得惊人。
WebSocket 能解决这两个方案的核心痛点:它是全双工的长连接,服务端可以主动往浏览器推数据,延迟是毫秒级的;连接建立后就一直复用,没有反复握手和头部开销;更重要的是协议本身是双向的,浏览器可以向后端发“开始/停止/切换摄像头”这类控制指令,这正好是视频流展示场景里的刚需。
提示:MJPEG 方案在小范围单路预览时依然有它的价值,毕竟零前端依赖。但一旦牵扯到检测结果的结构化推送、控制指令回传、多路并发,WebSocket 就是正确的技术路线。
1.2 Flask 与 FastAPI 的取舍逻辑
选了 WebSocket,下一个问题就是框架。Flask 配 Flask-SocketIO,FastAPI 原生支持 WebSocket,两者都能干活,但背后的线程模型差异很大。
Flask-SocketIO 默认是线程模式,一个连接进来会开一个后台线程跑推流循环,代码写起来非常直觉化,前后端事件名也清晰,适合中小规模项目。但线程模型的瓶颈在于并发:如果同时有几十个浏览器页面在接收视频流,每个连接一个线程,加上模型推理线程和采集线程,线程数量一多,GIL 和上下文切换的成本就上去了。
FastAPI 走的是 asyncio 协程模型,单个事件循环可以hang住大量连接,内存和线程开销低得多。但要注意:YOLOv8 的推理本身是同步阻塞的,不能直接扔进协程里跑,否则整个事件循环会被卡死。正确做法是让推理跑在线程池里,协程通过 await 去取结果,这个后续实操部分我会详细写。
我的建议很直接:单机小并发、快速验证、团队对 Python 同步编程更熟,选 Flask-SocketIO;要面对多路视频流、长连接数量大、对延迟敏感,选 FastAPI。两者代码形态差异不大,核心思路都是“视频处理 + WebSocket 推送”,框架只是外壳。
2. 后端架构核心设计:生产者-消费者三线程模型
2.1 采集、检测、推流为什么要拆开
很多人第一次做 Web 端视频流,会天真地写一个死循环:读帧 → 推理 → 推给前端 → 再读帧。这个写法在小 Demo 里能跑,但稍微加点逻辑就出问题——读摄像头本身就有阻塞,推理更是吃 CPU/GPU 的大户,任何一个环节慢下来,推流就跟着卡。
我习惯把链路拆成三个角色:采集线程负责从摄像头或视频文件里持续读帧,只做一件事就是取最新帧;检测线程负责把采集到的帧交给 YOLOv8 推理,生成标注后的画面和结构化检测结果;推流协程/线程负责把结果序列化成 JPEG 或 JSON,通过 WebSocket 发到浏览器。三个角色之间用队列把数据松耦合地串起来。
这个模式的好处是各环节可以独立调速。采集跟不上可以丢帧,检测来不及可以跳过部分帧,推流慢不会卡住采集和检测。视频流的本质是“当前这一刻的画面”,它不像普通消息系统要求不丢不重,恰恰相反,实时视频流的正确策略是允许丢帧,优先保证“最新帧能最快送达”。
2.2 帧缓冲队列与丢帧策略
队列在视频流链路里扮演的角色是“缓冲带”,不是“仓库”。我见过不少新手把队列长度设成 100、几百,结果就是延迟像滚雪球一样越来越大,因为前端一直在消费积压的旧帧。实时场景下队列长度超过 5 就已经是一种事故了。
正确的做法是:队列长度尽量短,满的时候直接把最旧的帧扔掉。比如我常用 queue.Queue(maxsize=2),生产者塞不进去就清空再塞。这个策略的本质是“延迟优先于完整性”——上一帧还没被消费完,新帧已经到了,说明当前处理速度跟不上采集速度,与其让新帧排队等旧帧处理完,不如直接把旧帧干掉,把算力留给最新画面。
检测线程和推流线程之间也遵循同样的原则。检测结果本身是一个结构体,包含标注框坐标、类别、置信度以及编码后的画面帧。如果推流速度没跟上,就直接丢弃积压的检测结果,保证前端拿到的永远是最近一次推理的输出。
2.3 检测结果序列化结构
WebSocket 推送的数据得有个约定。我的做法分两部分:一张 base64 编码的 JPEG 帧,用于展示画面;一个 JSON 结构,用于存储检测结果。前端拿到这两部分后,把图片画到 canvas 上,再把 JSON 里的检测框叠加绘制上去。这样前端逻辑干净,后端也容易扩展——以后要加追踪 ID、计数结果,只需要在 JSON 里加字段。
头像框和标签的 JSON 结构大概是:
{ "frame_id": 1024, "timestamp": 1712133456.789, "width": 960, "height": 540, "objects": [ {"cls": "person", "conf": 0.92, "box": [120, 80, 260, 420]}, {"cls": "car", "conf": 0.87, "box": [400, 220, 720, 380]} ] }box 用 [x1, y1, x2, y2] 的整数坐标,前端画 rect 时不用做任何缩放换算。JPEG 帧则单独走一个消息类型推送,不要和 JSON 串在同一个字符串里,否则前端解析还得分段处理。
3. Flask-SocketIO 实现:从摄像头上屏到浏览器
3.1 后端完整代码与逐段讲解
Flask-SocketIO 的接入分为三块:摄像头视频流管理、YOLOv8 模型封装、SocketIO 事件处理。下面这份代码是完整的可运行版本,我拆开讲每一块在干什么。
import base64 import json import queue import threading import time import cv2 import torch from flask import Flask, render_template from flask_socketio import SocketIO, emit from ultralytics import YOLO app = Flask(__name__) app.config["SECRET_KEY"] = "video-stream-demo" socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*") socketio.engineio_logger = False model = YOLO("yolov8n.pt") class VideoStreamer: def __init__(self): self.capture_thread = None self.running = False self.frame_queue = queue.Queue(maxsize=2) self.result_queue = queue.Queue(maxsize=2) self.lock = threading.Lock() def start(self, source=0): if self.running: return self.running = True self.capture_thread = threading.Thread(target=self._capture_loop, args=(source,), daemon=True) self.capture_thread.start() threading.Thread(target=self._detect_loop, daemon=True).start() threading.Thread(target=self._push_loop, daemon=True).start() def stop(self): self.running = False def _capture_loop(self, source): cap = cv2.VideoCapture(source) if not cap.isOpened(): print("摄像头打开失败") return while self.running: ok, frame = cap.read() if not ok: time.sleep(0.01) continue if self.frame_queue.full(): try: self.frame_queue.get_nowait() except queue.Empty: pass self.frame_queue.put(frame) cap.release() def _detect_loop(self): while self.running: try: frame = self.frame_queue.get(timeout=0.5) except queue.Empty: continue results = model(frame, verbose=False)[0] annotated = results.plot() objects = [] if results.boxes is not None: boxes = results.boxes.xyxy.cpu().numpy() confs = results.boxes.conf.cpu().numpy() clss = results.boxes.cls.cpu().numpy() names = results.names for box, conf, cls in zip(boxes, confs, clss): x1, y1, x2, y2 = [int(v) for v in box] objects.append({ "cls": names[int(cls)], "conf": round(float(conf), 2), "box": [x1, y1, x2, y2], }) ok, jpg = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 80]) if ok: payload = { "frame_b64": base64.b64encode(jpg.tobytes()).decode("ascii"), "objects": objects, } if self.result_queue.full(): try: self.result_queue.get_nowait() except queue.Empty: pass self.result_queue.put(payload) def _push_loop(self): while self.running: try: payload = self.result_queue.get(timeout=0.5) except queue.Empty: continue socketio.emit("detect_frame", payload) streamer = VideoStreamer() @socketio.on("connect") def handle_connect(): print("浏览器连接上来了") if not streamer.running: streamer.start() emit("ack", {"msg": "connected"}) @socketio.on("stop_stream") def handle_stop(): streamer.stop() @app.route("/") def index(): return render_template("index.html") if __name__ == "__main__": # 预热模型,避免第一帧推理特别慢 dummy = torch.zeros(1, 3, 640, 640) model(dummy, verbose=False) socketio.run(app, host="0.0.0.0", port=5000)这段代码里有几个细节值得说明。frame_queue 和 result_queue 都用 maxsize=2,并采用“塞不进去就丢掉旧帧”的策略,这是前面讲的延迟优先原则的落地。三个线程分别跑采集、检测、推送,互不阻塞。detect 的结果只保留 JSON 结构和 JPEG 编码,没有把原始帧存在内存里,这对长运行的服务很重要。
注意:_push_loop 里用 socketio.emit 推给所有连接,Demo 阶段没问题。但要记住,如果多个浏览器同时打开页面,每个浏览器都会收到同一份推流,业务上如果有“每个连接看不同摄像头”的需求,需要按房间(room)隔离,emit 改成 room 维度。
3.2 前端页面:canvas 渲染检测画面
后端推的是 base64 图片 + 检测框 JSON,前端的核心逻辑就是建立 SocketIO 连接、监听 detect_frame 事件、把图片画上 canvas 再画框。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>YOLOv8 Web 视频检测</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script> <style> body { background: #1a1a1a; color: #eee; font-family: Consolas, monospace; margin: 20px; } canvas { border: 2px solid #333; width: 100%; max-width: 960px; } #info { margin: 10px 0; font-size: 14px; } .btn { padding: 8px 18px; background: #2563eb; color: white; border: none; cursor: pointer; } .btn.stop { background: #dc2626; } </style> </head> <body> <h2>YOLOv8 Web 端视频流检测</h2> <div id="info">等待连接...</div> <button class="btn" id="startBtn">开始检测</button> <button class="btn stop" id="stopBtn">停止</button> <canvas id="viewer" width="960" height="540"></canvas> <script> const canvas = document.getElementById("viewer"); const ctx = canvas.getContext("2d"); const info = document.getElementById("info"); const socket = io(); const img = new Image(); img.onload = () => { ctx.drawImage(img, 0, 0, canvas.width, canvas.height); drawBoxes(window.currentObjects || []); }; function drawBoxes(objects) { ctx.lineWidth = 2; ctx.strokeStyle = "#22c55e"; ctx.font = "14px sans-serif"; objects.forEach(obj => { const [x1, y1, x2, y2] = obj.box; ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); const label = `${obj.cls} ${obj.conf}`; ctx.fillStyle = "rgba(34,197,94,0.8)"; ctx.fillText(label, x1, Math.max(18, y1 - 4)); }); } socket.on("connect", () => { info.textContent = "WebSocket 已连接"; }); socket.on("detect_frame", data => { window.currentObjects = data.objects; img.src = "data:image/jpeg;base64," + data.frame_b64; }); document.getElementById("startBtn").onclick = () => socket.emit("start_stream"); document.getElementById("stopBtn").onclick = () => socket.emit("stop_stream"); </script> </body> </html>这段前端代码里,最值得注意的一点是 img 对象的 onload 回调。socket 推帧的速度往往比浏览器解码图片的速度快,如果每来一帧就直接抢着 drawImage,会出现画面闪烁、撕裂。用 img.src 赋值配合 onload 绘制,浏览器会自动排队解码,我们再在 onload 里画图,画面就稳定很多。
检测框叠加用的是 canvas 的 strokeRect 和 fillText,没有额外依赖。坐标直接使用后端传回来的原始像素坐标,canvas 的 width 和 height 与模型输入尺寸保持一致,这样框和画面不会有错位。
3.3 必须处理好的几个边界条件
第一,模型不能在 connect 回调里才加载。YOLOv8 第一次推理会把权重载入显存/内存,耗时一两秒是常有的事。我把预热推理放在了 main 里面,用一个 640x640 的占位张量跑一次,后面对所有连接的首帧响应都很快。
第二,浏览器断开时,后台线程会一直空转。Demo 里我没加自动清理,生产环境应该在 disconnect 事件里统计连接数,连接数为 0 时就停掉采集、检测、推送三个线程,避免摄像头一直被占用、CPU 白白空转。
第三,Flask-SocketIO 的异步模式。如果在实例化 SocketIO 时不指定 async_mode,它可能为了兼容 Werkzeug 而选一些并不合适的模式。显式加上 async_mode="threading" 最稳,因为我们用的就是多线程模型。
4. FastAPI + WebSocket 实现:异步链路的高效版
4.1 异步架构:采集与检测如何协作
FastAPI 版本的思路完全不同——整个服务跑在 asyncio 事件循环里,WebSocket 长连接也是异步的。但 YOLOv8 推理是同步操作,如果直接放在 async 函数里,模型推理期间整个事件循环会被阻塞,所有 WebSocket 连接全部卡住。
我的方案是:摄像头采集和模型推理继续跑在线程里,结果放进一个 asyncio.Queue 或者一个线程安全的容器里,由事件循环侧的后台任务把结果取出来,推给所有已连接的 WebSocket。这样既享受了 asyncio 高并发的长处,又避开了阻塞模型推理的坑。
具体来说,FastAPI 版本我用一个全局的 asyncio.Queue 作为“检测结果集散地”。YOLOv8 的检测线程把结果塞进这个队列,WebSocket 连接建立后,为每个连接创建一个订阅任务,从队列里取结果并 send。如果多个连接同时订阅同一个队列,那就需要广播模式:一个后台消费者拿数据,再分发给所有连接。
4.2 后端代码示例
下面是一个简洁但完整的 FastAPI 实现:
import asyncio import base64 import json import threading import time import cv2 from fastapi import FastAPI, WebSocket, WebSocketDisconnect from ultralytics import YOLO from io import BytesIO app = FastAPI() model = YOLO("yolov8n.pt") frame_queue = asyncio.Queue(maxsize=2) websocket_clients = set() def video_and_detect_worker(source: int = 0): cap = cv2.VideoCapture(source) if not cap.isOpened(): print("摄像头打开失败") return frame_count = 0 skip_frames = 2 while True: ok, frame = cap.read() if not ok: time.sleep(0.01) continue frame_count += 1 if frame_count % skip_frames != 0: continue results = model(frame, verbose=False)[0] annotated = results.plot() objects = [] if results.boxes is not None: for box, conf, cls in zip( results.boxes.xyxy.cpu().numpy(), results.boxes.conf.cpu().numpy(), results.boxes.cls.cpu().numpy(), ): x1, y1, x2, y2 = [int(v) for v in box] objects.append({ "cls": results.names[int(cls)], "conf": round(float(conf), 2), "box": [x1, y1, x2, y2], }) ok, jpg = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 80]) if not ok: continue payload = { "frame_b64": base64.b64encode(jpg.tobytes()).decode("ascii"), "objects": objects, } try: frame_queue.put_nowait(payload) except asyncio.QueueFull: try: frame_queue.get_nowait() frame_queue.put_nowait(payload) except Exception: pass async def broadcast_worker(): while True: try: payload = await asyncio.wait_for(frame_queue.get(), timeout=1.0) except asyncio.TimeoutError: continue if websocket_clients: message = json.dumps(payload) dead = [] for client in websocket_clients: try: await client.send_text(message) except Exception: dead.append(client) for client in dead: websocket_clients.discard(client) @app.on_event("startup") async def startup(): threading.Thread(target=video_and_detect_worker, daemon=True).start() asyncio.create_task(broadcast_worker()) @app.get("/") async def root(): with open("templates/index_fastapi.html", "r", encoding="utf-8") as f: return f.read() @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() websocket_clients.add(websocket) try: while True: await websocket.receive_text() except WebSocketDisconnect: websocket_clients.discard(websocket)前端与 Flask 版本几乎一致,只把 SocketIO 换成原生 WebSocket:
const ws = new WebSocket(`ws://${location.host}/ws`); ws.onmessage = (event) => { const data = JSON.parse(event.data); window.currentObjects = data.objects; img.src = "data:image/jpeg;base64," + data.frame_b64; };FastAPI 版本的线程结构比 Flask 版简单一些,因为采集和检测合并成了一个 worker 线程,推流交给了 asyncio 的广播任务。两个死循环各自跑在自己的“地盘”里,互不干扰。
4.3 更优做法:画面与检测结果分开推送
我在优化阶段还尝试过另一种推送方式:把压缩后的 JPEG 帧和检测结果拆成两个 WebSocket 消息,或者干脆用两条不同的通道。前端的绘制逻辑变成:收到帧就画画面,收到 JSON 就画框。这样处理有个好处——如果检测结果算得慢一点,画面还在持续更新,用户体验更接近“流畅的视频”,而不是“一卡一顿的幻灯片”。
不过这种方式对前端代码的复杂度提升很明显,两个事件的到达顺序不保证,容易出现“框和画面错位半拍”的视觉误差。我的建议是:单机单路视频场景用一体的 payload 就够了;多路视频、需要混合展示实时画面与历史结构化数据的场景,再考虑分离推送。
5. 性能参数调优与常见坑位速查
5.1 核心参数对照表
Web 端视频流展示的性能瓶颈不在 WebSocket,而在整条链路的吞吐。几个最关键的参数我整理了一张表,都是我实际调过的经验值:
| 参数 | 推荐取值范围 | 影响与说明 |
|---|---|---|
| 推理间隔/跳帧数 | 每 2~5 帧推理一次 | 帧率高于模型 fps 时,跳帧避免队列积压 |
| 模型输入尺寸 | 640 或 960 | 越大精度越高、速度越慢,Web 展示 640 足够 |
| JPEG 压缩质量 | 70~85 | 太低画质差,太高带宽和编码开销剧增 |
| 帧缓冲队列长度 | 1~2 | 保持“最新帧优先”,延迟不明显变大 |
| WebSocket 心跳间隔 | 20~30 秒 | 过长会因代理超时导致连接假死 |
| canvas 尺寸 | 960x540 或 1280x720 | 过大浏览器解码压力大,过小看不清检测框 |
5.2 我踩过但文档没写清楚的几个坑
坑一:base64 传输开销。base64 会把二进制数据膨胀 33%,在低带宽环境里非常致命。我自己在局域网内测试时没感觉,一旦放到公网服务器上,延迟立刻翻倍。优化方向是 WebSocket 的 send_bytes 直接送二进制 JPEG 数据,前端用 Blob + FileReader 解析。代码改动不大,收益立竿见影。
坑二:Flask-SocketIO 默认会做消息压缩。压缩对 JSON 文本还有一定收益,但对已经是 JPEG 的图片数据基本是浪费 CPU。实测开了压缩,CPU 占用能多出 15% 左右。在传输图片的场景,建议显式关闭压缩。FastAPI 原生 WebSocket 没有这个烦恼。
坑三:模型推理线程数和 CPU 核数打架。PyTorch 默认会开满所有物理核心,如果你在推流服务里和其他业务共享同一台机器,模型推理线程会把 CPU 打满。用 torch.set_num_threads(2) 或者环境变量 OMP_NUM_THREADS 限制一下,服务整体稳定很多。
坑四:浏览器标签页切到后台,img 解码会被浏览器节流。看起来像是 WebSocket 断了,其实只是浏览器偷懒了。这时 reconnect 机制会反复触发,后端日志刷一堆重连记录。前端的解决思路是监听 document.visibilitychange,页面恢复可见时主动重置 WebSocket 连接。
坑五:多路摄像头 push 到同一队列时,顺序错乱。如果希望每个摄像头的检测结果独立推送给指定页面,消息里必须带一个 stream_id 字段,前端做过滤,后端按 stream_id 做队列隔离。否则所有摄像头的结果混在一起,前端画面和检测框完全对不上。
5.3 典型故障排查表
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 页面黑屏但无报错 | 前后端未连接或帧推送路径不通 | 先看浏览器 Network 面板确认 WebSocket 状态;再确认后端帧队列是否在持续 put |
| 延迟越来越高 | 帧队列积压 | 检查队列长度是否过长;检测线程是否落后于采集线程;把跳帧参数调大 |
| CPU 满载 | 推理线程数未限制、base64 编码过频 | torch.set_num_threads 限制核数;降低 JPEG 质量;关闭 SocketIO 压缩 |
| 画面卡顿但 CPU 不高 | 网络带宽瓶颈,或前端 canvas 过大 | 改 send_bytes 二进制传输;缩小 canvas 尺寸;降低推送帧率 |
| WebSocket 频繁断开后重连 | 心跳超时或浏览器标签页后台节流 | 服务端加 ping_interval;前端监听 visibilitychange 手动恢复 |
| 内存持续增长 | 队列未清理或 img 对象累积 | 检查是否有对象一直被引用;确保帧队列满时丢弃旧帧 |
6. 从 Demo 到工程的几点收尾经验
代码能跑通只是第一步,真正要在生产环境里扛住并发,还有几件事值得提前做。
连接管理一定要做成可观测的。我在广播 worker 里加过一种简单的指标收集:每 5 秒统计一次 websocket_clients 的数量、帧队列积压量、平均推送间隔,打印到日志里。这些数据不花什么成本,但排查问题的时候比任何文档都管用。当积压量长时间大于 1,就该考虑加跳帧或缩小输入尺寸了。
另一个容易忽视的点是错误隔离。检测线程如果因为某帧数据异常抛了异常,整个 worker 线程直接挂掉,所有浏览器同时黑屏。我在 worker 里习惯用 try/except 包住单帧处理,单帧失败就跳过,绝不能让整条链路崩掉。这个习惯救过我很多次。
最后说一个玄学但实用的建议:WebSocket 推流服务不要和业务 API 混在同一个端口。视频流流量大、长连接多,一旦某个浏览器卡住不消费消息,可能拖累其他 API 请求。Nginx 转发时把 /ws 路径单独分流,或者干脆起两个服务进程,业务 API 归业务,视频流归视频流,出了事也好定位。我在实际项目里吃过一次这个亏才学会的,希望你不用再踩一遍。