资讯详情

资讯详情

Flask接入RTSP视频流:MJPEG与HLS网页播放方案

1. 浏览器压根不认识 RTSP这个前提得先钉死去年接了个小活帮一个做设备运维的团队把现场那几台网络摄像头的画面搬到网页上看。对方一开始的设想特别朴素网页里放个video标签src直接填rtsp://...不就完事了我在会议室里没直接反驳只是打开浏览器控制台把这段代码跑了一遍——控制台红了一片video元素愣在原地一动不动。这就是大多数刚接触Flask 接入 RTSP 视频流的人要撞的第一堵墙RTSP 从来就不是给浏览器准备的协议。它是一套基于 TCP/UDP 的信令协议负责和摄像头对话——协商传输方式、控制播放暂停、维持会话心跳。全世界的浏览器厂商里没有任何一家把 RTSP 的支持塞进video标签。你在地址栏里输rtsp://打不开在 JS 里fetch也拉不下来甚至 Postman 里都只会给你一个超时。所以FlaskWeb 接入 RTSP 视频流这件事的本质压根不是让网页播放 RTSP而是在服务端把 RTSP 的流拆解、转换再用一种浏览器能理解的形式吐给前端。Flask 在这里的角色是个翻译官兼搬运工它自己不产生画面只负责把摄像头那头的二进制流重新包装一遍。搞不清这个定位后面所有的技术选择都会走偏——你会花大量时间去搜浏览器怎么播放 rtsp然后被一堆互相矛盾的答案绕晕。我写这篇东西是想把这条链路上真正会咬人的地方一次讲透从取流层怎么写、Flask 那一侧怎么拼响应、并发撑不住的时候往哪儿调到最终部署时的反向代理坑。适合手上有 Python 基础、能看懂 Flask 路由但还没真正把一路视频流稳稳跑起来的人。如果你只是想要个 Demo 截图交差照抄第 2、3 章的代码就够如果是要上现场、要长时间跑、要多路并发那第 4、5 章才是重点。1.1 RTSP 和 HTTP 的根本差别一个是会话一个是请求很多人把 RTSP 想象成视频版的 HTTP这个类比只对了一半剩下那一半恰恰是坑的来源。HTTP 是无状态请求-响应模型我问一次你答一次问完连接就可以关掉。RTSP 完全不是这样——它建立的是一个长时存在的会话session。客户端先发DESCRIBE拿到媒体描述SDP再发SETUP协商 RTP 传输通道然后PLAY才开始真正有数据从 RTP 通道流过来。整套流程里RTSP 负责指挥RTP 负责运货两者是分开的。你在浏览器里发一个 HTTP 请求凭什么让摄像头进入 PLAY 状态没人替你发那几条 RTSP 指令图像自然就不会动。理解了这层你就明白为什么服务端必须要有一个常驻的取流进程它替浏览器去和摄像头建立并维持那个会话把 RTP 包拆成帧然后按浏览器能接受的方式重新分发。这个取流进程可以是 OpenCV 的VideoCapture可以是 FFmpeg 子进程也可以是 PyAV 直接调 libav。三条路的取舍下面单独说。注意RTSP 的会话是有超时的。摄像头发不到OPTIONS心跳或者SET_PARAMETER保活几十秒后就会把会话踢掉。很多跑着跑着自己断了的问题根子不在 Flask而在取流层没有做保活。1.2 三条路线摆在台面上先算清楚各自的账在动手写代码之前我把可选方案列成了一张表。这张表后来被我在好几个项目里反复拿出来用因为绝大多数选型纠结本质上就是在这几个维度上做权衡。方案端到端延迟服务端 CPU实现复杂度音频适用场景抽帧 MJPEGmultipart0.3~1 秒中每路持续 JPEG 编码低不支持内部看画面、路数不多FFmpeg 转 HLS5~15 秒可优化到 3~5 秒低转封装几乎不耗 CPU中支持分发给多人、要求稳定WebRTC如 mediamtx 中转200~500 毫秒低到中高要信令、要 TURN支持强交互、要低延迟MJPEG 这条路最土也最直接服务端不停地把最新一帧编码成 JPEG通过multipart/x-mixed-replace这个古老的 HTTP 特性一帧一帧推到浏览器浏览器用最普通的img标签就能显示。优点是延迟低、前端零依赖、断线重连逻辑简单缺点是不支持音频、带宽浪费大每帧都是完整 JPEG帧间冗余完全没压缩、并发多了服务端扛不住。HLS 是苹果那套方案把流切成一个个.ts小分片前端用 hls.js 拉 m3u8 索引按序播放。好处是天然支持 CDN 分发、支持音频、对服务端压力极小-c:v copy转封装时 CPU 占用几乎可以忽略坏处是延迟天生高因为播放器必须攒够几个分片才能开始播等于用延迟换稳定。WebRTC 延迟最低但要在服务端跑一个完整的 WebRTC 端点处理 SDP 交换、ICE 候选、NAT 穿透工程量大概是前两者的三到五倍。我一般不建议在第一版就上除非业务明确要求看到就能立刻反应。1.3 我自己的推进顺序先用最丑的方案跑通再谈优雅这里分享一个我踩过亏之后形成的习惯永远先用最粗糙的方案把整条链路打通再回头做优化。我最早的教训是一上来就盯着 WebRTC觉得延迟低才专业结果三天时间全耗在 ICE 协商失败上连摄像头到底通没通都没验证过。后来改成先用 MJPEG 写二十行代码五分钟看到画面确认了 RTSP 地址、账号密码、网络可达性全都没问题剩下的时间才是纯粹的技术优化心态完全不一样。所以下面第 2、3 章讲的全是 MJPEG 这条最朴素的路因为它能最快帮你排除掉网络不通密码错了摄像头不支持这个编码这类和环境相关的问题。等你能稳定看到画面了第 4 章再讲怎么平滑切到 HLS。2. 取流层OpenCV 把 RTSP 拽进内存的那些暗礁很多人写的第一个版本长这样cap cv2.VideoCapture(rtsp_url) while True: ok, frame cap.read() if not ok: break # 编码成 jpg 推出去能跑但只在你一个人访问、网络特别好的时候能跑。真实环境里这段代码会以各种姿势出问题断流之后read()一直返回 False 但也不抛异常程序变成空转摄像头发包太快导致 OpenCV 内部缓冲积压你看到的画面比现实慢十几秒多开一个页面就多建一条 RTSP 会话超过摄像头并发上限后直接被拒。2.1 VideoCapture 初始化那几个不设就等着吃亏的参数cv2.VideoCapture的构造函数有两个参数第二个是后端选择。不指定的话 OpenCV 会自己猜猜错了就走不到 FFmpeg 后端很多 RTSP 特性用不上。import cv2 cap cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000) cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000)这里有几个点值得掰开说。cv2.CAP_FFMPEG强制走 FFmpeg 后端这是 RTSP 支持最完整的路径。如果你的环境里 OpenCV 编译时没带 FFmpeg这行会直接返回一个空的 captureisOpened()是 False但不会有任何详细报错——这是最容易卡住新手的地方。CAP_PROP_BUFFERSIZE设成 1 是想让内部缓冲只留一帧减少延迟。但这个参数在 FFmpeg 后端上的实际效果很不稳定有些 OpenCV 版本会直接忽略它。我实测下来设了比不设好一点但别指望靠它彻底解决延迟真正的解决办法在 2.2 节的线程模型。OPEN_TIMEOUT_MSEC和READ_TIMEOUT_MSEC是必设项。默认值在部分版本里是无限等摄像头掉线时你的线程会永久卡在read()里整个服务看着还活着但那一路画面再也不动了。设成 5 秒最坏情况也就是 5 秒后发现异常走重连。还有一个隐蔽的坑RTSP 地址里的密码如果带特殊字符必须做 URL 编码。密码里有个FFmpeg 解析时会把它当成用户名和主机的分隔符你会得到一个莫名其妙的主机名解析失败。用urllib.parse.quote(password, safe)处理一下再拼能省掉半天排查。2.2 采集线程和后端解耦只留最新一帧别排队最要命的性能问题出在读和发耦合在一起。一个 Flask 请求进来就在这个请求的上下文里循环cap.read()——看起来没问题实际上每多一个浏览器标签页就多一个线程去抢同一个VideoCapture对象。VideoCapture不是线程安全的多个线程同时read()会导致帧数据错乱甚至段错误。正确做法是一个摄像头对应一个采集线程采集线程只管读读到就覆盖到共享变量上所有请求都从这个共享变量取最新帧。import threading, time, cv2 class StreamReader: def __init__(self, url): self.url url self.frame None self.lock threading.Lock() self.running True self.connected False self.t threading.Thread(targetself._loop, daemonTrue) self.t.start() def _loop(self): while self.running: cap cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000) cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000) if not cap.isOpened(): self.connected False time.sleep(3) continue self.connected True fail_count 0 while self.running: ok, frame cap.read() if not ok: fail_count 1 if fail_count 5: break time.sleep(0.1) continue fail_count 0 ok, jpg cv2.imencode(.jpg, frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) if ok: with self.lock: self.frame jpg.tobytes() cap.release() self.connected False time.sleep(1) def latest(self): with self.lock: return self.frame这个结构里有几个设计决定值得说清楚理由。为什么编码放在采集线程里做因为cv2.imencode是 CPU 密集操作如果放在每个请求的循环里做十个观看者就要编码十次CPU 直接翻十倍。放在采集线程里只编一次所有请求共享同一份 JPEG 字节成本恒定。为什么用覆盖而不是队列队列模型下如果消费者比生产者慢队列会越积越长画面延迟线性增长最后你看到的是几分钟前的画面。覆盖模型下消费者永远只拿到最新一帧慢就慢在掉帧上但延迟是恒定的。实时看画面这件事掉帧远比延迟累积可接受。为什么失败要计数而不是一次就重连网络抖动时read()偶尔返回 False 是正常的一次失败就重建连接会让画面疯狂闪烁。连续五次失败配合 0.1 秒间隔大约半秒再判定为真断这个阈值我试过很多次比较稳。为什么采集线程用 daemonFlask 进程退出时非 daemon 线程会阻塞进程结束你CtrlC之后要等半天才能退出来开发的时候特别烦。2.3 断流重连状态机比一堆 try/except 靠谱上面那段已经内嵌了重连逻辑但如果你要监控多路、要在页面上显示哪一路离线建议把状态显式抽出来。我自己用的是一个很小的三状态机CONNECTING正在连、LIVE正常出帧、OFFLINE连不上退避重试。退避策略一开始我用的是固定 3 秒后来发现摄像头上电启动要十几秒固定 3 秒会导致日志刷屏。改成指数退避3 秒、6 秒、12 秒、24 秒封顶 30 秒连上之后立刻重置回 3 秒。还有一个非常容易被忽略的点摄像头不是无限会话的。大多数网络摄像头同时支持的 RTSP 会话数是 2 到 4 路。如果你的重连逻辑是发现断了就新建一个VideoCapture但旧的没有正确release()会话会越积越多最后摄像头直接返回 453Not Enough Bandwidth或者 401。所以cap.release()必须放在while循环里、cap重建之前而且要用try/finally保证异常时也能释放。3. Flask 这一侧的响应拼装multipart 不是随便 yield 就行画面已经在内存里了接下来要把它推给浏览器。multipart/x-mixed-replace这个 MIME 类型是 1995 年就有的老古董浏览器对它的支持一直保留着img src/video_feed就能把一路 MJPEG 流显示出来连 JavaScript 都不用写。但它的报文格式很挑剔差一个\r\n浏览器就给你显示裂图或者干脆不动。3.1 一个真正能用的 MJPEG 响应长什么样from flask import Flask, Response app Flask(__name__) reader StreamReader(rtsp://user:pass192.168.1.10:554/stream1) BOUNDARY bframe app.route(/video_feed) def video_feed(): def gen(): while True: jpg reader.latest() if jpg is None: time.sleep(0.05) continue yield (b-- BOUNDARY b\r\n bContent-Type: image/jpeg\r\n bContent-Length: str(len(jpg)).encode() b\r\n\r\n jpg b\r\n) return Response( gen(), mimetypemultipart/x-mixed-replace; boundaryframe, headers{Cache-Control: no-cache, no-store, Pragma: no-cache, X-Accel-Buffering: no} )几个必须注意的细节Content-Length头在高版本浏览器里其实可以省略因为每个 part 之间靠 boundary 分隔就够了。但加上它之后Chrome 能够更早地知道这一帧的完整长度渲染更顺滑。省略和加上我都试过加上之后撕裂感明显减少。mimetype里的boundaryframe和代码里 yield 的--frame必须严格对应。少了这两个减号、或者 boundary 大小写不一致浏览器会一直等第一个 part页面就是一片空白。X-Accel-Buffering: no是给 Nginx 看的。不加这个头Nginx 会默认把响应缓冲起来攒够一块再发导致画面变成一顿一顿的抽帧动画。这个头在开发环境直连 Flask看不出效果一上生产就原形毕露我因为这个问题排查过整整一个下午。Cache-Control那一组头是防止中间层缓存住某一帧。虽然理论上 multipart 流不该被缓存但某些企业内网的代理服务器会干这种事加上比较保险。3.2 生成器什么时候该退出客户端断开怎么感知上面那个gen()是while True永久循环。问题来了用户关掉标签页这个循环还在跑吗答案是会退出但可能比你想象的慢。Flask 在客户端断开后下一次 yield 时底层 socket 写入会抛BrokenPipeError或者ConnectionResetErrorWerkzeug 会捕获并结束生成器。所以理论上不用你操心。但是在 gevent 环境下行为不太一样协程被调度到下一次写入时才会发现连接断了。如果你每帧之间只 sleep 50 毫秒那最坏也就 50 毫秒后回收没问题。但如果你在没有新帧的时候 sleep 得很久协程就会一直挂着不释放。所以我习惯把心跳节奏控制在 50 到 100 毫秒之间——没帧的时候time.sleep(0.05)保证协程能周期性地被唤醒去检测连接状态。这个数值没什么玄学就是够短能及时回收够长不空转烧 CPU。另外gen()里千万不要去cap.read()。一旦采集和分发耦合客户端一多就崩。这个前面已经说过了但这里是第二次强调因为这是最多人犯的错。3.3 并发上限Chrome 的六连接限制与 gevent 的正确姿势开发阶段用app.run()一切正常一上线就出问题这个坑我踩了不止一次。第一个坎来自浏览器本身。Chrome 对同一个 domain 的 HTTP/1.1 并发连接数是 6。你页面上挂六路 MJPEG 流前面五路都正常第六路一直转圈等待——因为前五路把连接池占满了。而且这五路是长连接永不结束后面的普通 AJAX 请求全被饿死页面上其他数据也刷不出来。解决办法有两条一是把视频流放到不同的子域名下分散连接池压力二是干脆把六路合成一路服务端做图像拼接或者前端切 tab 只拉当前可见的那一路。我一般选后者因为多路小画面同时看的场景本来就少用户通常只需要看一路大的。第二个坎来自 Flask 的部署方式。app.run()那个内置服务器即使开了threadedTrue每个连接也占一个真实线程。二十路并发就是二十个线程加上每个采集线程线程数直接爆炸。上 gevent 是标准解法gunicorn -k gevent -w 2 --worker-connections 500 \ -b 0.0.0.0:8000 --timeout 120 \ app:app但这里有三个必须搞清楚的坑坑一gevent 必须 monkey patch而且要在最早的地方打。你的采集线程里用的是cv2.VideoCapture和time.sleep前者是 C 扩展的阻塞调用gevent 拦不住它。所以采集一定要放在真正的 Python 线程里跑threading.Thread不要试图用 gevent 的协程去跑采集否则一个协程卡在read()里整个 worker 里的所有协程全部停摆。坑二--timeout对 gevent worker 的行为。默认 30 秒的超时会让长时间没有 yield 新数据的请求被 kill 掉。MJPEG 流如果摄像头卡住了没有新帧你的gen()一直在 sleepworker 会认为请求超时。所以要么把 timeout 拉长上面写的 120要么把 sleep 频率保证在超时阈值以内。我倾向于两个都做。坑三-w的数值不是越大越好。每个 worker 是一个独立进程如果你有 4 路摄像头每路采集线程在每个 worker 里都会启动一份等于采了 4×N 份CPU 直接浪费掉。正确做法是采集线程里用文件锁或者 Redis 标记保证全局只有一份采集或者干脆把采集和分发拆成两个进程用共享内存或本地 socket 传帧。4. 换成 HLS从能看往看得舒服走MJPEG 在路数少、内网环境、只求看画质不求音频的场景下完全够用。但一旦出现下面任何一种情况就该考虑切 HLS 了需要声音、需要给十几个甚至几十个人同时看、需要放到公网带宽受限的环境、需要录下来回放。HLS 的核心是把连续的流切成一个个短小的.ts分片同时维护一个.m3u8播放列表浏览器端的 hls.js 不停地拉列表、下分片、按时间轴播放。好处是每个分片都是独立的 HTTP 文件可以被任意 HTTP 服务器和 CDN 分发服务端不需要维持长连接压力骤降。4.1 转封装还是转码先把 CPU 账算清楚ffmpeg有两种处理模式性能差十倍以上。转封装-c:v copy不改变视频编码格式只是把 RTP 里的 H.264 裸流重新打包成 MPEG-TS。这一步几乎不消耗 CPU就是内存拷贝。前提是摄像头的编码本身就是 H.264而且是 HLS 能接受的 profile一般是 Baseline / Main。转码-c:v libx264真正解码再重新编码。1080p 25fps 的一路在普通服务器上能吃掉 1 到 2 个核。四路就顶满了。绝大多数网络摄像头默认输出 H.264所以先无脑试-c:v copy。如果前端 hls.js 报解码错误那可能是摄像头的 H.264 用了 High profile 或者 B 帧这种时候要么改摄像头的编码配置大部分摄像头后台能改要么就只能老老实实转码。我的建议是优先改摄像头配置别急着转码。把摄像头的编码从 H.265 改成 H.264、profile 改成 Main、B 帧关掉、关键帧间隔设成帧率的 1 到 2 倍。这几步做完转封装基本都能成功省下的 CPU 是实打实的。4.2 FFmpeg 参数切片长度和关键帧对齐是关键ffmpeg -rtsp_transport tcp \ -i rtsp://user:pass192.168.1.10:554/stream1 \ -c:v copy -an \ -f hls \ -hls_time 2 \ -hls_list_size 6 \ -hls_flags delete_segmentsappend_listomit_endlist \ -hls_segment_filename /var/www/hls/cam_%05d.ts \ /var/www/hls/cam.m3u8-rtsp_transport tcp强制走 TCP。UDP 延迟低但丢包会导致花屏和分片损坏在 Wi-Fi 或者跨网段环境里 TCP 稳得多。我默认都走 TCP除非明确知道是局域网千兆有线。-hls_time 2是每个分片的时长。这个值直接决定了延迟下限——播放器至少要攒够hls_list_size里的一部分才能播。设 2 秒配合liveSyncDurationCount2端到端大概能压到 4 到 6 秒。设 1 秒能再降但分片太小会导致请求数暴增服务器要处理大量小文件请求反而容易出问题。2 秒是个实践中的甜点。-hls_list_size 6是列表里保留几个分片。直播场景不用留太多6 个足够播放器追帧和短暂卡顿缓冲。-hls_flags delete_segments会自动删除已经不在列表里的旧分片不加这个你的磁盘一天就满。append_list让重启 ffmpeg 时接着往列表里追加而不是清空避免直播中断。omit_endlist表示这不是一个已结束的流前端会持续轮询刷新列表。最关键的一点-c:v copy模式下切片边界只能落在关键帧上。如果摄像头的关键帧间隔是 4 秒你设hls_time 2也没用实际分片时长会被拉到 4 秒。这就是为什么前面强调要把摄像头的 I 帧间隔改成帧率的 1 到 2 倍。如果摄像头后台改不了可以在 ffmpeg 里加强制关键帧-force_key_frames expr:gte(t,n_forced*2)但注意这个参数在-c:v copy下是无效的——copy 模式没法插入关键帧。所以如果摄像头实在改不了只能转码。4.3 前端 hls.js几个影响首帧时间的配置const video document.getElementById(player); const hls new Hls({ lowLatencyMode: true, liveSyncDurationCount: 2, liveMaxLatencyDurationCount: 4, maxBufferLength: 6, manifestLoadingTimeOut: 5000, manifestLoadingMaxRetry: 4 }); hls.loadSource(/hls/cam.m3u8); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () video.play());lowLatencyMode打开后hls.js 会尝试跟随最新的分片不再死等缓冲区攒满这是把延迟从十几秒压到五秒以内的关键开关。liveSyncDurationCount: 2表示播放点距离直播边缘保持两个分片的距离。设得太小比如 1容易因为网络抖动频繁卡顿设得太大延迟就上去了。maxBufferLength: 6限制缓冲区最长 6 秒防止用户切走再切回来时从很旧的位置开始播。有一个必须处理的事件是Hls.Events.ERROR。直播流一定会断——摄像头重启、网络抖动、ffmpeg 进程被 kill。这时要判断data.fatal如果是网络类的错误就调hls.startLoad()重试如果是媒体解析错误就hls.recoverMediaError()实在不行就hls.destroy()后重建实例。我见过不少人只写了 loadSource 就不管了页面上画面一停就永远停着用户以为系统挂了。5. 现场跑起来之后才会遇到的那些怪问题前面几章讲的都是怎么让它跑起来这一章讲跑起来之后为什么不听话。这些问题在网上搜不到标准答案基本都是我在实际环境里一点点试出来的。5.1 摄像头会话被占满报 401 或者 453现象服务重启之后画面出不来日志里 ffmpeg 报method DESCRIBE failed: 453 Not Enough Bandwidth或者干脆 401。排查过程先 ping 通、再拿 VLC 单独打开这个 RTSP 地址——VLC 能打开说明地址和密码没错。那问题就在会话数上。登进摄像头后台看连接列表发现挂着五六个僵尸会话都是之前进程被强杀时没来得及发TEARDOWN留下的。根因RTSP 会话不会因为 TCP 断开立刻销毁摄像头那边的超时通常是 60 秒左右。你kill -9掉 Python 进程TCP 连接直接断但摄像头还在等那 60 秒超时。这段时间里新连接进来就超了并发上限。解法一是进程退出时用信号处理主动cap.release()二是代码里对 401/453 做特殊处理不要立即疯狂重试等 60 秒再连三是实在急着恢复登摄像头后台手动踢掉僵尸会话。第三种在调试阶段特别有用但生产环境不能靠人工。5.2 画面越看越慢最后落后现实十几秒现象刚打开时正常看个五分钟画面比现场慢十几秒而且越拖越久。排查在采集线程里打时间戳发现read()返回的是缓冲区里的旧帧——FFmpeg 后端内部有一个队列消费速度跟不上生产速度时队列会越积越长read()永远在吐旧数据。根因CAP_PROP_BUFFERSIZE在很多 OpenCV 版本上对 FFmpeg 后端无效设了等于没设。解法我试过三种最后稳定下来的是第三种。第一种是每次取帧前先丢弃几帧即在read()之前连读两次扔掉。这招管用但不优雅而且会白白浪费 CPU 解码。第二种是给 OpenCV 传 FFmpeg 的选项字符串通过环境变量OPENCV_FFMPEG_CAPTURE_OPTIONS设置rtsp_transport;tcp|fflags;nobuffer|flags;low_delay。这招对某些版本有效但依赖编译选项换个环境就不灵了。第三种是换掉 OpenCV 的取流实现直接用 PyAV 或者 subprocess 调 ffmpeg 输出 MJPEG 到管道。PyAV 能精确控制 demux 和 decode 的节奏可以做到每次只拿最新包。我现在的做法是采集层抽象成接口OpenCV 实现用来快速验证稳定版统统换成 PyAV。5.3 多路并发直接打满 CPU现象4 路 1080p 摄像头服务一启动 CPU 直接 100%风扇狂转。排查top -H看线程发现大部分 CPU 耗在cv2.imencode上。4 路 × 25fps × 1080p 的 JPEG 编码每帧大概 8 到 15 毫秒一秒钟就是 400 次编码单核根本扛不住。解法下面几招可以叠加用。第一招降分辨率。看画面而已640×480 完全够编码时间直接降到原来的十分之一。在read()之后加一句cv2.resize或者更好的是让摄像头的子码流输出低分辨率——大部分摄像头都有主码流和子码流两个 RTSP 地址取子码流几乎零成本。第二招降帧率。人眼看实时画面 10 到 15 fps 完全够用采集线程里加个时间判断间隔小于 66 毫秒就跳过当前帧不解码。第三招降 JPEG 质量。IMWRITE_JPEG_QUALITY从默认的 95 降到 70编码时间和体积都能降 40% 左右画质肉眼几乎看不出差别。第四招合并请求。同一路流不管有几个人在看只编码一次这个前面已经做到了这是最本质的优化。5.4 切到别的标签页再切回来画面黑了现象用户开着监控页面切到别的工作十分钟后切回来画面全黑刷新才好。根因两个原因叠加。一是 Chrome 对后台标签页做了资源节流img的 multipart 流在后台可能被挂起或者断开二是服务端在客户端断开后回收了生成器但浏览器这边还持有那个已经死掉的连接。解法监听visibilitychange事件页面重新可见时给img的 src 加一个时间戳参数强制重新发起请求document.addEventListener(visibilitychange, () { if (!document.hidden) { const img document.getElementById(feed); const base img.dataset.base; img.src base ?t Date.now(); } });HLS 那边相对好一些hls.js 自己会检测并恢复但还是要监听ERROR事件手动兜底。5.5 Nginx 反代之后一分钟准断现象直连 Flask 端口一切正常套了 Nginx 之后每一分钟零几秒断一次画面闪一下就重连。排查抓包看是 Nginx 主动发的 FIN。查proxy_read_timeout默认值——60 秒。根因MJPEG 是长连接如果这一分钟内摄像头卡住没出新帧或者你的gen()里 sleep 时间偏长导致没有数据写出Nginx 就认为上游超时了主动断开。解法Nginx 配置里三件套location /video_feed { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; chunked_transfer_encoding on; add_header X-Accel-Buffering no; }proxy_buffering off是最关键的不关的话 Nginx 会攒数据画面一顿一顿。proxy_read_timeout拉长到一小时避免空闲时被切。add_header X-Accel-Buffering no是双保险应用层和 Nginx 层都声明不要缓冲。顺带一提如果你前面挂了 CDN情况会更麻烦——有些 CDN 对 multipart 流根本不支持会把它当成一个超长响应缓存起来。这种场景下 HLS 才是正解因为每个分片都是标准的静态文件CDN 处理起来毫无压力。这也是我在公网分发场景下一律推荐 HLS 的原因。最后分享一个我觉得最省事的小习惯在/health路由里暴露每一路摄像头的状态包括当前连接状态、最近一次成功出帧的时间、采集线程是否存活。页面右上角放个小圆点绿的在跳就是好的灰的就是断了。这个二十行代码的东西后来帮我省掉了无数次用户说画面卡了但我远程看是好的的扯皮——因为我能直接翻日志告诉他是哪一路在几点几分断的断了多久。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →