
简介一份基于OpenCV与Flask构建的家庭监控系统项目面向Python开发者、物联网爱好者及初入计算机视觉的读者帮助理解如何将视频流采集、运动检测与Web实时展示结合起来。资源共30个文件其中10个Python脚本实现摄像头读取、帧处理、运动检测、路由与视频流推送等核心逻辑另有HTML、CSS、JS文件构成前端监控界面图片文件用于界面展示或测试整体压缩包仅1.15MB结构清晰便于快速上手。已有135人学习下载。通过该项目可以掌握cv2.VideoCapture捕获视频、帧差法与MOG2背景减除实现运动检测以及Flask路由和WebSocket实时通信等关键知识点并理解完整监控系统的目录组织与部署思路适合作为课程设计、毕设参考或家庭安防原型。1. 家庭监控的需求多数人用成品摄像头或云平台 SDK 解决。其实一个几十块的 USB 摄像头加上 OpenCV 做图像采集与移动侦测、Flask 做网页视频流就能在本地局域网搭出一套完全自治的方案。它的优点是不依赖第三方推送通道、录像格式可控、检测逻辑随时改代码适合懂 Python、想用手头闲置硬件的人。OpenCV 负责画面采集、预处理和目标轮廓提取Flask 负责把处理后的帧以 MJPEG 方式输出并提供事件查询接口。这条链路从 VideoCapture 开始到浏览器实时画面结束每一环都能独立验证。2. OpenCVFlask 架构分层采集线程、检测线程与 Web 服务怎么协同2.1 单线程读摄像头为什么必然卡顿监控系统里最常见的错误是把摄像头读取、算法处理、HTTP 响应放在同一条执行流里。一个/video_feed请求占住逻辑后其他接口全部排队。更严重的是cv2.VideoCapture.read()是阻塞调用当浏览器因缓冲暂停消费帧时生成器不再被拉取摄像头 buffer 会持续堆积最终拿到的是滞后好几秒的旧画面。正确做法是把采集、检测、Web 三个环节解耦。OpenCV 的 VideoCapture 在一个后台线程里不断 read()把最新帧放进带锁的共享变量检测模块从共享变量拿帧做计算再把标注矩形画到副本上Flask 的 streaming generator 以固定节奏往外吐帧。三者通过线程安全变量衔接任何一环变慢其他环节不会直接阻塞。这里有一个常见误区把 Python 多线程等同于并行。GIL 会让纯 Python 逻辑彼此串行但 OpenCV 的底层解码、MOG2 前景计算、imencode 压缩都在 C 侧完成这部分能真正多核执行。采集线程读帧、主线程做检测两个环节在 CPU 密集部分可以同时跑。常见的实测结果是从单线程改成双线程结构后720P 分辨率下每帧处理耗时下降 30% 上下分辨率越高这种解耦收益越明显。2.2 Flask 做流媒体服务省掉了哪些基础设施视频流协议最朴素的实现是 MJPEG服务端不断输出--frame分隔的 JPEG 图片浏览器 img 标签天然支持 multipart/x-mixed-replace 响应不需要 WebSocket、不需要 HLS 分片前端只有三行代码。Flask 的 Response 对象直接接一个生成器把 live 流和事件 API 放在同一个 Web 服务里不用额外起流媒体组件。与 Node.js 或 Django 方案相比Flask 的差距体现在高并发上但家庭监控的并发量通常不会超过两个终端。Flask 开发服务器在threadedTrue模式下能支撑 3 到 5 路并发视频流单看这一条就够用。后期就算要接入外网瓶颈也通常在路由器端口映射和上行带宽而不在 Web 框架本身所以前期选 Flask 不需要背性能包袱。2.3 最小模块划分与 config 集中管理我习惯把监控系统拆成四个文件加一个模板目录各文件职责单一motion_monitor/ ├── config.py ├── camera.py ├── detector.py ├── app.py ├── templates/ │ ├── index.html │ └── events.html └── recordings/config.py 里集中管理所有参数。contourArea阈值、高斯模糊核大小、触发后录制时长这类参数后期调优频率很高集中在一个文件里改起来最快# config.py # 摄像头与画面 CAMERA_SRC 0 # Linux 下 /dev/video0 对应 0 FRAME_WIDTH 640 FRAME_HEIGHT 480 CAP_FPS 15 # 采集帧率 # 检测 BG_HISTORY 25 # 背景建模参考帧数 MOTION_THRESHOLD 25 # 像素灰度差阈值 MIN_AREA 800 # 目标最小面积 BLUR_KERNEL (9, 9) # 高斯模糊核 # 存储 RECORD_DIR ./recordings TRIGGER_SECONDS 8 # 触发后连续录像秒数CAP_FPS 是一个容易产生误解的设置。它告诉摄像头驱动“期望帧率”但 USB 摄像头在光线不足时会自动掉到 8 到 10 帧H264 摄像头同样如此。下游代码不应该假设固定帧率而要用真实时间戳计算帧间隔否则录像文件的时间和实际时间会逐渐偏移。常见的做法是录制时用视频流经过的秒数做索引而不用采集帧数除以设定的 FPS。参数取值范围调大后效果调小后效果MOTION_THRESHOLD15~50更抗噪误报少触发更灵敏MIN_AREA300~3000只捕捉大目标小飞虫也触发BG_HISTORY15~100背景适应慢但更稳定背景更新快但易有残影这三个参数是家庭监控里最常见的调优点。屋里光线稳定、没有人窗外行走的场景可以把 MOTION_THRESHOLD 调小换更高召回率门口走廊这种容易有小型动物经过的地方MIN_AREA 建议放到 1500 以上否则每次飞虫经过都会写入一段录像。3. OpenCV 视频采集与移动侦测VideoCapture、MOG2 与事件录像3.1 后台线程 VideoCapture 封装摄像头不适合被反复 open/close。每开一次会重新做 USB 枚举和驱动层的传感器初始化在 Linux 上轻则 200ms、重则 1 秒以上。我一般把它包装成一个常驻线程持续 read() 并只保留最新一帧# camera.py import cv2 import threading import time class CameraStream: def __init__(self, src, width, height, fps): self.cap cv2.VideoCapture(src) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) self.cap.set(cv2.CAP_PROP_FPS, fps) if not self.cap.isOpened(): raise RuntimeError(f无法打开摄像头 {src}) self.stopped False self.frame None self._lock threading.Lock() self._thread threading.Thread(targetself._update, daemonTrue) def start(self): self._thread.start() return self def _update(self): while not self.stopped: ok, frame self.cap.read() if ok: with self._lock: self.frame frame else: # 读取失败时等待避免死循环占满 CPU time.sleep(0.05) def read(self): with self._lock: return None if self.frame is None else self.frame.copy() def stop(self): self.stopped True self.cap.release()read() 返回的是拷贝而不是引用这里有约 900KB 的内存复制开销640x480 BGR但换来的是调用方可以放心持有帧对象做异步处理不会被后台线程的下一轮覆盖。stop() 里不加 join 是因为采集线程可能阻塞在 read() 上join 会卡住主流程数秒daemonTrue 能保证进程退出时线程自动终止。3.2 帧差法与 MOG2 的选择依据帧差法是最容易想到的方案上一帧与当前帧逐像素相减超过阈值就算运动。问题在于它没有背景概念光照渐变时会产生鬼影而且当帧间隔很短时缓慢移动的人可能连续两帧画面几乎一致直接被漏检。监控场景里目标移动速度变化范围很大帧差法需要额外维护参考帧淘汰策略复杂度并不比背景建模低。MOG2 是自适应高斯混合背景建模算法每个像素位置维护多个高斯分布同时表示静态背景和动态前景。OpenCV 里对应的接口是createBackgroundSubtractorMOG2。室内走廊、阳台这类背景基本固定的场景MOG2 的准确率和稳定性都在可接受范围单帧计算量比帧差法高约 1.5 倍但 640x480 分辨率下用纯 CPU 也能跑到每秒 20 帧以上。# detector.py import cv2 class MotionDetector: def __init__(self, history, var_threshold, min_area, blur_kernel): self.min_area min_area self.blur blur_kernel self.bg cv2.createBackgroundSubtractorMOG2( historyhistory, varThresholdvar_threshold, detectShadowsFalse, ) def detect(self, frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, self.blur, 0) fgmask self.bg.apply(gray) # MOG2 前景像素是 255阴影默认是 127先二值化避免阴影干扰 _, fgmask cv2.threshold(fgmask, 200, 255, cv2.THRESH_BINARY) fgmask cv2.dilate(fgmask, None, iterations2) contours, _ cv2.findContours(fgmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) triggered False for contour in contours: if cv2.contourArea(contour) self.min_area: continue x, y, w, h cv2.boundingRect(contour) cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) triggered True return frame, triggereddetectShadowsFalse 在家庭监控里是常规选择。开启阴影检测后运动目标在地面上的影子会被标记为灰色的 127直接用 findContours 会把影子边缘并入目标矩形导致矩形面积比真实物体大一圈。关掉阴影检测后前景只有 0 和 255 两种取值轮廓提取干净很多。GaussianBlur 的核大小决定空间平滑程度。核越大衣物纹理和树叶晃动的干扰越弱但目标边缘也会被磨掉一层轮廓位置会有 2 到 3 个像素的偏移对报警位置判断影响不大。室内常用 (9,9)室外强风环境可以加大到 (15,15)。异常表现原因排查方向轮廓频繁闪断目标面积在 MIN_AREA 阈值边缘抖动降低 MIN_AREA 或增大膨胀迭代次数固定物体被标成前景背景历史帧数不足增大 BG_HISTORY观察 fgmask 灰度变化画面卡顿但 CPU 不高摄像头实际帧率低于设定值检查 CAP_FPS 实际取值调整读取间隔3.3 触发窗口内的录像写入detect() 返回 triggeredTrue 后系统进入录像窗口。用最后一次触发时间戳作为基准超过 TRIGGER_SECONDS 没有新触发就关闭写入器。这样人来时录满 8 秒人持续活动就持续录直到人离开 8 秒后自动收口# recorder.py import time import cv2 from pathlib import Path class Recorder: def __init__(self, record_dir, trigger_seconds8, fps15): self.record_dir Path(record_dir) self.record_dir.mkdir(parentsTrue, exist_okTrue) self.writer None self.last_trigger 0.0 self.trigger_seconds trigger_seconds self.fps fps def update(self, frame, triggered, nowNone): now now if now is not None else time.time() if triggered: self.last_trigger now if now - self.last_trigger self.trigger_seconds: if self.writer is None: filename self.record_dir / fevent_{now:.0f}.avi fourcc cv2.VideoWriter_fourcc(*XVID) h, w frame.shape[:2] self.writer cv2.VideoWriter( str(filename), fourcc, self.fps, (w, h)) self.writer.write(frame) else: if self.writer is not None: self.writer.release() self.writer None录像以event_Unix时间戳.avi命名文件名里包含触发时刻查询接口可以直接解析。注意 VideoWriter 的 fps 参数和实际帧率可能不一致这里传的 15 只是容器信息播放器根据它推算时长如果实际喂进来的帧率是 10播放出来的动作会比现实慢。磁盘清理也要提前做。一台长期运行的设备每天 30 次触发、每次 8 秒 640x480 AVI约占 30MB保留 7 天约 200MB 出头32GB 存储卡够跑几个月。实际操作时我会写一个定时任务扫描 RECORD_DIR只保留最近 7 天的文件。4. Flask 端实时视频流与事件查询接口4.1 MJPEG 流的生成器实现MJPEG 的核心是把 HTTP 响应的 content-type 设成 multipart/x-mixed-replace然后在响应体里不断输出以 boundary 分隔的 JPEG 数据。浏览器 img 标签会持续渲染新帧不需要 JS 去刷新图片地址。放到 Flask 里的完整实现# app.py import time import cv2 from flask import Flask, Response, render_template, jsonify from camera import CameraStream from detector import MotionDetector app Flask(__name__) camera CameraStream(src0, width640, height480, fps15).start() detector MotionDetector(history25, var_threshold25, min_area800, blur_kernel(9, 9)) def generate_frames(): while True: frame camera.read() if frame is None: time.sleep(0.1) continue annotated, triggered detector.detect(frame) ok, encoded cv2.imencode( .jpg, annotated, [cv2.IMWRITE_JPEG_QUALITY, 80]) if not ok: continue yield (b--frame\r\n bContent-Type: image/jpeg\r\n\r\n encoded.tobytes() b\r\n) app.route(/video_feed) def video_feed(): return Response(generate_frames(), mimetypemultipart/x-mixed-replace; boundaryframe)JPEG 质量取 80 是监控场景里比较平衡的选择。480P 画面在这个质量下单帧约 60KB15fps 就是约 900KB/s 带宽局域网轻松承载质量调到 95 时单帧接近 120KB带宽涨一倍但肉眼在监控画面里几乎看不出差异。这里可以做区分保存录像单独用高质量直播流用 80。generate_frames() 是生成器当浏览器断开连接时Flask 在准备向下一次 yield 写入时会抛出 GeneratorExit响应自动终止。Python 的生成器在 GC 时释放持有的引用不需要额外清理函数。4.2 事件查询 API 与前端轮询录像文件名里已经带 Unix 时间戳。查询接口把 recordings 目录下的文件按时间倒序读取返回时间、文件名和大小。用 pathlib 处理后不同平台上的路径拼接没有斜杠问题app.route(/events) def events(): record_dir Path(./recordings) items [] for path in sorted(record_dir.glob(*.avi), reverseTrue)[:50]: ts int(path.stem.split(_)[1]) items.append({ time: datetime.fromtimestamp(ts).isoformat(), file: path.name, size: path.stat().st_size, }) return jsonify(itemsitems)前端页面用原生 fetch 轮询这个接口5 秒一次。事件页不需要秒级实时性5 秒延迟在移动侦测场景里完全可接受。页面渲染用模板字符串拼 HTML不引入前端框架监控后台的复杂度配开发框架属于负资产!-- templates/events.html -- !DOCTYPE html html langzh-CN head meta charsetutf-8 title事件记录/title /head body h1移动侦测事件/h1 table idevent-table theadtrth时间/thth文件/thth大小/th/tr/thead tbody/tbody /table script function loadEvents() { fetch(/events) .then(res res.json()) .then(data { const tbody document.querySelector(#event-table tbody); tbody.innerHTML data.items.map(item tr td${item.time}/td td${item.file}/td td${(item.size / 1024).toFixed(1)} KB/td /tr).join(); }); } setInterval(loadEvents, 5000); loadEvents(); /script /body /html4.3 线程模式与启动入口运行入口记得显式开启 threaded 模式if __name__ __main__: app.run(host0.0.0.0, port5000, threadedTrue)旧版本 Flask 开发服务器默认单线程新版本默认开启 threaded但显式声明能消除环境差异。不设置时第一个 video_feed 生成器一旦启动第二个请求的事件页就会排队等前面的响应结束表现是页面一直 pending。threadedTrue 后每个请求由独立线程处理MJPEG 流和事件 API 可以同时工作。这里有一个常被误解的地方给视频流加移动侦测逻辑会把 Web 服务拖慢。实际上 OpenCV 检测计算在 C 层完成MJPEG 流的瓶颈是 JPEG 编码和 socket 写入两者关联很小。真正拖慢 Web 服务的是在生成器里做文件 IO 或网络 IO所以录像写入不要放在视频流生成器里做应当由独立检测循环负责。5. 部署到树莓派和旧笔记本时固定三条自查路径5.1 树莓派上 OpenCV 安装和参数起点家里闲置硬件上跑这套系统最合适。树莓派 4B 安装 OpenCV 时经常面对编译时间长的选择系统自带的 python3-opencv 版本偏旧而要用 gstreamer 扩展又往往需要源码编译。我一般直接 pip 安装 opencv-python-headless省掉 GUI 依赖在 4GB 内存的树莓派上 640x480 分辨率的 MOG2 处理能稳定跑到每秒 15 帧左右。安装后先在 Python 交互环境里执行import cv2确认版本避免后续出现ModuleNotFoundError: No module named cv2这类路径问题。5.2 亮度突变防误报的检测策略MOG2 对缓慢光照变化有自愈能力但室内开灯、拉窗帘那一瞬间全画面像素梯度突变会被当成大规模运动。白天实测不明显晚上下班回家开灯最容易触发一串假报警。我在实现里加了亮度变化检查先算当前灰度帧的全局均值和上一帧对比如果变化超过 25就把当前帧直接喂给背景模型做更新但不做轮廓和报警判断。等背景稳定后再恢复检测相当于给监控加了一个瞬间失明的保护# 放在 MotionDetector.detect 内最前面 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) mean_brightness gray.mean() if abs(mean_brightness - self.last_brightness) 25: self.last_brightness mean_brightness self.bg.apply(gray) return frame, False self.last_brightness mean_brightness这个阈值 25 对多数家庭场景可用可根据开灯瞬间的亮度差调整。家里用暖黄色护眼灯时亮度差小阈值可以放到 15客厅大功率白炽灯亮度差大阈值设到 35 更稳。5.3 把验证动作固定成一个脚本改动任何参数后我建议固定执行三组检查curl -I看/video_feed响应头是否 multipart/x-mixed-replacels -lh看 recordings 目录是否有新文件且不为 0最后在镜头前挥手 3 秒对比事件时间戳与实际时间差。把这三个动作整理成脚本放到项目根目录# check_stream.sh curl -I http://localhost:5000/video_feed ls -lh recordings/ | tail -3/video_feed响应正常但录制目录却是空的说明检测模块的 triggered 状态从来没变成 True用 debug 模式把 detector.detect 里每个轮廓的 area 值打印出来就能判断是 MIN_AREA 设得过大还是背景模型还没收敛。这三条路径固定下来之后每次调参回归只需一分钟确认整个 OpenCVFlask 链路没有在改参数时被意外破坏。本文还有配套的精品资源点击获取