用OpenCV和Flask搭建本地家庭监控系统:从视频流到运动检测
2026/9/17 3:51:55 网站建设 项目流程

简介:基于OpenCV与Flask的家庭监控系统源码包,涵盖视频捕获、图像处理、运动检测与网页端实时预览,适合计算机视觉入门、Python Web开发及智能家居项目参考。压缩包共30个文件、约1.15MB,含10个Python脚本(覆盖核心监控逻辑)、2个HTML页面(用于前端展示)及图片、样式、数据库与说明文档,并有视频流录制子工程,结构清晰便于运行调试。已有135人浏览学习。项目通过OpenCV实现摄像头取流、图像预处理和背景减除运动检测,利用Flask路由与实时推送将画面传至浏览器,读者可掌握前后端实时交互、运动区域告警等落地实现,拿到手即可运行并做二次扩展。

1. 家庭监控为什么需要 opencv+flask 自己搭

市售家用摄像头大多把画面传到云端,运动检测、报警推送全是厂商预设的,想改个灵敏度、接第二路摄像头、把画面和家庭数据联动起来,往往要买会员或刷固件。而我手里的树莓派或者旧电脑,配合一个几十块的 USB 摄像头,用 opencv 做图像识别、flask 起一个网页服务,就能获得一套完全本地运行、数据不外传、规则自己定的家庭监控系统。这个组合的吸引力在于:opencv 负责帧采集、运动检测和人脸框选,flask 负责把视频流发布到浏览器、提供录像回放接口,两层各自成熟、生态文档多,新手照着敲也能跑起来。这篇文章会从最小可运行的视频流讲到运动检测、告警录像,再落到部署时的坑和参数细节,全程给出可复现的代码。

2. 搭建 opencv+flask 的最小监控骨架:摄像头帧采集与视频流输出

2.1 环境准备:opencv-python 与 flask 的安装和版本坑

先安装两个核心依赖。我习惯用虚拟环境隔离,避免把系统 Python 弄乱,树莓派上尤其要这样,因为系统包管理器里的 Python 可能被系统服务依赖。

python3 -m venv monitor-env source monitor-env/bin/activate pip install --upgrade pip pip install opencv-python flask

这里有两个常见的坑:第一,opencv-pythonopencv-contrib-python不能同时装,否则某些函数会报重复符号错误;第二,树莓派上如果直接pip install opencv-python,装的是预编译包,通常没有针对 ARM 的优化,后面章节我会单独讲树莓派的编译方案。装完后用python -c "import cv2; print(cv2.__version__)"验证版本。OpenCV 4.x 的 API 和 3.x 差异不大,但背景建模和视频写入的默认编码行为有变化,建议锁版本时尽量用 4.5 以上。

2.2 Flask 输出 MJPEG 视频流的两个关键写法

2.2.1 帧生成器与 multipart 响应

Flask 本身不提供视频流能力,但浏览器支持multipart/x-mixed-replace响应,服务器不断推送 JPEG 帧,浏览器就把它当成动态图片连续刷新。这是 MJPEG 流的基础,也是局域网监控延迟最低的方案之一。先看核心代码:

import cv2 from flask import Flask, Response, render_template_string app = Flask(__name__) cap = cv2.VideoCapture(0) # 0 表示第一个摄像头,RTSP 流可填 rtsp://... 地址 def generate_frames(): while True: ret, frame = cap.read() if not ret: break ret, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) frame_bytes = buffer.tobytes() yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n' b'Content-Length: ' + str(len(frame_bytes)).encode() + b'\r\n\r\n' + frame_bytes) @app.route('/video_feed') def video_feed(): return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame') @app.route('/') def index(): return render_template_string('<img src="{{ url_for(\'video_feed\') }}" style="width:100%">') if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, threaded=True)

这段代码要说明几点:cv2.imencode把 BGR 帧压缩成 JPEG 二进制,IMWRITE_JPEG_QUALITY控制质量,80 是局域网内清晰度与带宽的折中,调到 60 能看到明显马赛克但延迟更低;呼应头的boundary=frame必须和 yield 里的分隔符完全一致,否则浏览器无法识别帧边界;threaded=True允许 Flask 同时处理视频流和后续要加的报警接口,否则一个客户端占着视频流,别的请求会排队。

2.2.2 前端页面如何拉流

上面的index路由用一张<img>标签就完成了拉流,不需要 JavaScript,也不需要 video 标签。这看起来太简单,但恰恰是 MJPEG 的优点:任何支持 img 的浏览器都能播放,包括手机浏览器。如果硬要用<video>标签,播放 MJPEG 反而要做额外转封装,得不偿失。注意url_for的写法,避免把硬编码路径写进模板。

2.3 帧率与延迟的取舍:为什么是 MJPEG 而不是 HLS 或 WebRTC

家庭监控最关心的是延迟。我做过一个简单对比,在同一台树莓派 4B、Wi-Fi 网络下,自己的结果如下表:

方案端到端延迟实现复杂度手机浏览器兼容性
MJPEG0.2~0.5 秒低,几十行代码好,img 直接播放
HLS2~5 秒中,需要 ffmpeg 切片好,video 标签播放
WebRTC0.1~0.3 秒高,需要信令服务器与 STUN好,但需处理协商

HLS 的切片机制决定了它至少有切片时长的延迟,不适合看门铃画面,更适合长期录像回放;WebRTC 虽然延迟最低,但要实现 SDP 交换和 ICE 协商,对家庭监控这种小项目来说太重了。MJPEG 的唯一代价是带宽:每帧都是完整 JPEG,720p 下大约需要 3~6 Mbps。在局域网内这不是问题,但后面如果要做公网访问,就必须考虑用 H.264 转码,这个坑我在第五章节详谈。

3. 在视频流上叠加运动检测:帧差法与高斯背景建模的实战对比

3.1 帧差法的实现与参数选择

视频流跑通之后,下一步就是让监控系统“有反应”。最朴素的运动检测是帧差法:把当前帧和上一帧做绝对差,像素变化超过阈值就视为前景运动。

import numpy as np def detect_motion_by_frame_diff(prev_gray, cur_gray, threshold=25): diff = cv2.absdiff(prev_gray, cur_gray) _, thresh = cv2.threshold(diff, threshold, 255, cv2.THRESH_BINARY) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) thresh = cv2.dilate(thresh, kernel, iterations=2) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) motion_boxes = [] for contour in contours: area = cv2.contourArea(contour) if area > 500: # 过滤面积小于500像素的噪声 x, y, w, h = cv2.boundingRect(contour) motion_boxes.append((x, y, w, h)) return motion_boxes

阈值threshold是灰度差分的绝对值,太小会连树叶晃动都触发,太大会漏掉缓慢移动的人。我一般先用 20~30,再根据实际场景调。dilate的作用是把分散的前景点连成块,避免轮廓区域内部出现空洞;iterations=2对 720p 画面比较合适。轮廓面积过滤是第二个降噪点,500 像素在 720p 里大约相当于一个成年人手部的五分之一,具体值可以写成一个配置项。

3.2 OpenCV 的 BackgroundSubtractorMOG2 与参数调优

帧差法在光照突变后会产生大面积误报,比如有人开灯、窗帘被风吹动。更稳妥的是高斯背景建模,OpenCV 里常用cv2.createBackgroundSubtractorMOG2。它给每个像素建模成多个高斯分布,能适应光照渐变,还区分前景和阴影。

bg_subtractor = cv2.createBackgroundSubtractorMOG2( history=500, varThreshold=16, detectShadows=True) def detect_motion_by_mog2(frame): fg_mask = bg_subtractor.apply(frame) # detectShadows=True 时,阴影部分灰度值为127,需要过滤掉 _, fg_mask = cv2.threshold(fg_mask, 200, 255, cv2.THRESH_BINARY) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel, iterations=1) contours, _ = cv2.findContours(fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) ...

参数方面:history越大,背景更新越慢,适合长时间静止的客厅,但有人坐下长时间不动会被吸收成背景;varThreshold是每个像素的方差阈值,16 是默认值,对它调大能降低对阴影和细小物体的敏感度,我实测在 12~24 之间调。detectShadows要打开,不然人的影子会和身体连在一起,导致绘制框时把整个影子包进去,框会明显偏大。阴影像素值是 127,所以后续用threshold强制把 200 以下的灰度变成 0。

3.3 帧差法与 MOG2 的对比结论与选型建议

对比维度帧差法MOG2
适应光照变化弱,光照突变会大面积误报强,高斯模型按像素更新
检测“静止中的人”无法检测,会瞬间消失能检测,但长时间静止会被融入背景
CPU 占用低,两张图一次差分一次阈值高,每个像素维护多个高斯分布
轮廓完整性容易产生空洞和破碎更完整,但仍需形态学后处理

我的建议是:如果你用的是树莓派 Zero 或老电脑,CPU 很紧张,从帧差法开始;如果是一台闲着的 x86 主机,直接上 MOG2。家庭监控场景里,人并不会在画面里完全静止不动,微微的呼吸和手势都会维持前景,所以“长时间静止被吸收”的问题不大。无论哪种,建议把运动检测放在单独的子线程里计算,不要阻塞主循环的帧采集,否则视频流会卡顿,具体做法看下一章的多路接入。

4. 家庭监控的进阶功能:告警截图、录像回放与多路摄像头接入

4.1 运动事件触发时的截图与消息推送

当检测到运动框时,需要记录证据并通知用户。截图要用原始帧,而不是压缩后的 MJPEG 流中的 JPEG,后者画质已经有损。最简单的通知方式是用钉钉或企业微信群机器人,通过 webhook 发送图片 URL,但家庭监控的图片通常不在公网,所以我一般直接把图片作为 form-data 上传给钉钉机器人。

import requests import time from datetime import datetime def send_alert(frame, boxes): if not boxes: return # 在原始帧上画运动框,再编码保存 for (x, y, w, h) in boxes: cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 0, 255), 2) ts = datetime.now().strftime('%Y%m%d_%H%M%S') img_path = f'captures/{ts}.jpg' cv2.imwrite(img_path, frame) with open(img_path, 'rb') as f: resp = requests.post( 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN', files={'file': f}, headers={'X-User-Id': 'your_id'} # 某些机器人需要这个头 ) # 再发送一条文本消息,附带检测到的运动框数量 requests.post( 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN', json={'msgtype': 'text', 'text': {'content': f'检测到 {len(boxes)} 个运动区域'}} )

注意 webhook 推送有频率限制,如果每帧都触发会把机器人封掉。常见做法是加一个冷静时间窗口,比如 30 秒内只推送一次:用一个全局变量记录上次推送时间戳,在send_alert开头判断。

4.2 录像保存与按时间检索回放

截图只保留一瞬,如果家里进了陌生人,我们更想看到一段录像。OpenCV 的VideoWriter可以把连续帧写成 mp4,但编码器选择很关键。树莓派上cv2.VideoWriter_fourcc(*'mp4v')是软件编码,720p 下也能跑,但 CPU 占用会明显增加;x86 上可以用*'avc1'走 H.264 硬编码,不过兼容性要看编译时是否启用 FFmpeg。

writer = None record_start = None COOLDOWN = 60 # 运动事件后继续录制 60 秒 if boxes: if writer is None: fourcc = cv2.VideoWriter_fourcc(*'mp4v') filename = f'records/{datetime.now().strftime("%Y%m%d_%H%M%S")}.mp4' writer = cv2.VideoWriter(filename, fourcc, 20.0, (frame.shape[1], frame.shape[0])) record_start = time.time() print(f'开始录像: {filename}') writer.write(frame) elif writer is not None and time.time() - record_start > COOLDOWN: writer.release() writer = None

这里的逻辑是:只要有运动框就持续写帧;运动消失后,再录 60 秒再关闭文件,避免把一段完整的入室过程切断成几段。回放页面可以做一个简单的 Flask 路由,列出records/目录下所有 mp4 文件,然后用<video controls>标签直接播放。注意 Flask 开发服务器对视频文件的 Range 请求支持不完整,大文件拖动进度条会失败,生产环境建议交给 Nginx 处理,这也在下一章统一说。

4.3 多路摄像头并发接入的线程模型

家里往往不止一个摄像头,门外一个、客厅一个、阳台一个。如果每路摄像头都在主线程里VideoCapture.read(),一路卡死的 RTSP 流会拖垮整个 Flask 进程。我在实践中把每一路摄像头封装成独立的采集线程,线程内循环读取帧,并把最新帧放到一个全局FrameStorage里,Flask 路由只从存储中取最新帧。

import threading class CameraThread(threading.Thread): def __init__(self, camera_id, source): super().__init__() self.camera_id = camera_id self.cap = cv2.VideoCapture(source) self.storage = {} # 由外部传入字典 self._stop = False def run(self): while not self._stop: ret, frame = self.cap.read() if not ret: self._reconnect() continue self.storage[self.camera_id] = frame time.sleep(0) # 交还 GIL 给其他线程 def stop(self): self._stop = True self.cap.release()

time.sleep(0)很有必要,否则在 Python 的 GIL 下,这个线程会霸占解释器,导致 Flask 的响应不及时。视频流生成器改为从storage读取对应 camera_id 的帧即可。多路摄像头的 Flask 路由写成/video_feed/<int:camera_id>,前端可以嵌多个<img>标签组成监控墙。

5. 家庭部署与优化:从本地到远程的排错和安全清单

5.1 摄像头打开失败的常见原因:RTSP 参数与权限

在最后阶段,很多问题出现在“摄像头打不开”而不是代码逻辑。USB 摄像头有时因为权限或接口占用失败,先确认ls /dev/video*能看到设备,再检查进程有没有占用。RTSP 摄像头更麻烦,OpenCV 打开某些厂家的 RTSP 流会返回 false,常见做法是加传输协议和缓冲参数:

rtsp://user:pass@192.168.1.100:554/stream1?tcp&buffer_size=1024

在代码里可以设置cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)减少内部缓冲,否则播放延迟会越积越大。我遇到过树莓派上 OpenCV 打开 RTSP 超时的问题,最后在VideoCapture前面加一行cv2.setUseOptimized(True)并降低分辨率到 640x480 才稳定。如果还不行,就交给 ffmpeg 拉流再转成管道路径,但那属于重方案,不展开。

5.2 树莓派部署 opencv 的优化:分辨率、模型与编码

树莓派安装 opencv 是个老话题:预编译包快但没优化,自己编译即使加-j4也要两小时。我的建议是安装opencv-python-headless,体积小、不依赖 GUI 库,节省内存。运行时的优化优先级比编译更高:把原始帧cv2.resize到 640x480,运动检测的 MOG2 也在这个低分辨率上做,只在需要截图时用原分辨率。H.264 录像如果 CPU 占用太高,就降低录制帧率到 15 FPS,或者干脆只在运动事件时录像,静止时只跑 MJPEG 流。

5.3 用 HTTP Basic Auth 保护视频流,别裸奔

监控画面直接通过端口暴露在局域网里,任何一个能连 Wi-Fi 的设备都能访问,在家庭环境其实不安全。至少加一层认证。我用 Flask 的functools.wraps写一个简单的 Basic Auth 装饰器,几行代码就够,不用引入重量级登录框架。

import functools from flask import request, Response, redirect def require_basic_auth(func): @functools.wraps(func) def wrapper(*args, **kwargs): auth = request.authorization if not auth or not (auth.username == 'admin' and auth.password == 'your_password'): return Response( 'Authentication required', 401, {'WWW-Authenticate': 'Basic realm="Monitor"'} ) return func(*args, **kwargs) return wrapper @app.route('/video_feed') @require_basic_auth def video_feed(): return Response(...)

浏览器遇到 401 会自动弹出用户名密码框,手机浏览器也兼容。如果真的要外网访问,我会在 Nginx 那一层再加 SSL 证书,并把 Flask 的 5000 端口只监听 127.0.0.1,让 Nginx 作为唯一入口。这样做还有额外好处:Nginx 支持 MJPEG 流的反向代理缓冲,避免多个客户端同时访问时 Flask 生成器被反复消耗。最后留一个自查技巧:用curl -u admin:password http://127.0.0.1:5000/video_feed | head -c 100查看响应头,如果出现multipart/x-mixed-replace就说明流是通的,剩下的问题都出在网络链路上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询